package handler import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/agentmail/gateway/internal/db" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/repo" "github.com/google/uuid" ) /* `GET /api/v1/agent/session/default` —— 非邮件轮次的合法 session_id 出口(2026-10-02)。 # 为什么需要它 `AgentMayReadSession` 收严后,未声明 session_id 的读信一律 403。而桥在 **非邮件驱动轮次**(对话里自主调 read_mail / read_thread)手里没有任何会话可声明 —— `session_id` 是 AgentMail 会话的 UUID,进程 cwd 给不出它 (这与 workspace 不同:workspace 能回落到 cwd,那条我今天已修过一次)。 ⇒ 没有这个出口,`15e4fe9` 上线后 homeagent 在对话里就再也读不到信了。 # 三条语义(本组判据钉的就是它们) ① **纯只读**:没通信过就返回 `session_id: null`,**绝不建会话**。 GET 有副作用是坏味道 —— 它会被桥每轮调一次,预建会话等于每轮留一条垃圾。 (我第一版调 `FindOrCreateDefaultSessionCreated`,判据当场抓到「每次都新建」。) ② **不是万能钥匙**:它只回答「你自己 `name@/tmp` 的默认会话是哪条」, 不授予读任何会话的能力。拿到 id 后读别的会话照样 403。 ③ 默认落点是 `/tmp`:非邮件轮次没有真实工作目录可依,而 `UnreadWorkspaces` 要求 workspace 形如 `/%`(要能被寻址补投),/tmp 满足。 */ func defaultSessionGET(t *testing.T, agent, query string) *httptest.ResponseRecorder { t.Helper() if err := repo.CreateOrUpdateAgent(context.Background(), agent, "k-"+agent, "test", nil); err != nil { t.Fatalf("建 agent %s: %v", agent, err) } req := httptest.NewRequest("GET", "/api/v1/agent/session/default"+query, nil) req = req.WithContext(context.WithValue(context.Background(), middleware.AgentNameKey, agent)) rr := httptest.NewRecorder() AgentDefaultSession(rr, req) return rr } // seedDefaultSession 在 ws 造一条该 agent 真实通信过的会话(满足 EXISTS mails 条件)。 func seedDefaultSession(t *testing.T, agent, ws string) uuid.UUID { t.Helper() ctx := context.Background() sid, err := repo.CreateSession(ctx, nil, agent, "默认会话", ws) if err != nil { t.Fatal(err) } if _, err := repo.CreateMail(ctx, sid, nil, agent, "", agent, "", "自寄", "内容", nil); err != nil { t.Fatal(err) } return sid } type defaultSessionResp struct { SessionID any `json:"session_id"` Workspace string `json:"workspace"` Exists bool `json:"exists"` SelfAddress string `json:"self_address"` } // ① 从未通信过 ⇒ exists=false / session_id=null,且**不新建**。 func TestDefaultSessionPureQueryWhenAbsent(t *testing.T) { setupPermissionHandlerDB(t) rr := defaultSessionGET(t, "probeagent", "") if rr.Code != http.StatusOK { t.Fatalf("应 200,实际 %d:%s", rr.Code, rr.Body.String()) } var out defaultSessionResp if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { t.Fatal(err) } if out.Exists || out.SessionID != nil { t.Fatalf("★ 从未通信过就不该凭空造会话:exists=%v id=%v", out.Exists, out.SessionID) } if out.Workspace != DefaultFallbackWorkspace { t.Fatalf("默认落点应是 %s,实际 %q", DefaultFallbackWorkspace, out.Workspace) } } // ② 通信过 ⇒ 问出那条会话,且带可用的 self_address(桥自己拼容易 404)。 func TestDefaultSessionReturnsExisting(t *testing.T) { setupPermissionHandlerDB(t) want := seedDefaultSession(t, "probeagent", DefaultFallbackWorkspace) rr := defaultSessionGET(t, "probeagent", "") var out defaultSessionResp if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { t.Fatal(err) } if !out.Exists { t.Fatal("存在默认会话时 exists 应为 true") } if got, _ := out.SessionID.(string); got != want.String() { t.Fatalf("应问出 %s,实际 %v", want, out.SessionID) } if out.SelfAddress == "" { t.Fatal("应给出 self_address(桥自己拼容易错,而 404 会被误读成「那封信不存在」)") } } // ★③ 不是万能钥匙:拿默认会话的 id 去读**别人的**会话仍须 403。 // 少了这一格,这个端点就是「声明一个合法会话然后读遍全场」的后门。 func TestDefaultSessionIsNotUniversalKey(t *testing.T) { setupPermissionHandlerDB(t) ctx := context.Background() other, err := repo.CreateSession(ctx, nil, "someoneelse", "别人的线索", "/home/other") if err != nil { t.Fatal(err) } mailID, err := repo.CreateMail(ctx, other, nil, "someoneelse", "", "anotheragent", "", "别人的信", "机密正文", nil) if err != nil { t.Fatal(err) } seedDefaultSession(t, "probeagent", DefaultFallbackWorkspace) rr := defaultSessionGET(t, "probeagent", "") var out defaultSessionResp json.Unmarshal(rr.Body.Bytes(), &out) scope, _ := out.SessionID.(string) req := httptest.NewRequest("GET", "/api/v1/agent/mail/"+mailID.String(), nil) req = req.WithContext(context.WithValue(context.Background(), middleware.AgentNameKey, "probeagent")) rr2 := httptest.NewRecorder() id := uuid.MustParse(scope) canReadSession(rr2, req, "probeagent", &id, other) if rr2.Code != http.StatusForbidden { t.Fatalf("★ 默认会话 id 不得成为万能钥匙:读别人的信应 403,实际 %d %s", rr2.Code, rr2.Body.String()) } } // ④ 连问 3 次不新增会话(我第一版每次都建一条,这条判据当场抓到)。 func TestDefaultSessionQueryHasNoSideEffect(t *testing.T) { setupPermissionHandlerDB(t) ctx := context.Background() seedDefaultSession(t, "probeagent", DefaultFallbackWorkspace) var before int db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions`).Scan(&before) var first string for i := 0; i < 3; i++ { rr := defaultSessionGET(t, "probeagent", "") var out defaultSessionResp json.Unmarshal(rr.Body.Bytes(), &out) got, _ := out.SessionID.(string) if i == 0 { first = got } else if got != first { t.Fatalf("第 %d 次问拿到不同会话 %q(第一次 %q)", i+1, got, first) } } var after int db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions`).Scan(&after) if after != before { t.Fatalf("★ 连问 3 次会话数 %d → %d(查询必须零副作用)", before, after) } } // ⑤ per-agent:不同 agent 的默认会话互不相同(共享会让各 Agent 的私信箱串一起)。 func TestDefaultSessionIsPerAgent(t *testing.T) { setupPermissionHandlerDB(t) seedDefaultSession(t, "agent-a", DefaultFallbackWorkspace) seedDefaultSession(t, "agent-b", DefaultFallbackWorkspace) var a, b defaultSessionResp json.Unmarshal(defaultSessionGET(t, "agent-a", "").Body.Bytes(), &a) json.Unmarshal(defaultSessionGET(t, "agent-b", "").Body.Bytes(), &b) sa, _ := a.SessionID.(string) sb, _ := b.SessionID.(string) if sa == "" || sb == "" { t.Fatalf("两侧都该问得出:a=%q b=%q", sa, sb) } if sa == sb { t.Fatal("★ 默认会话必须 per-agent(共享会让各 Agent 的非邮件轮次邮件互相可见)") } } // ⑥ workspace 必须绝对路径(与 checkWorkspace 同一规矩,别开第二个口子)。 func TestDefaultSessionRejectsRelativeWorkspace(t *testing.T) { setupPermissionHandlerDB(t) rr := defaultSessionGET(t, "probeagent", "?workspace=../../etc") if rr.Code != http.StatusBadRequest { t.Fatalf("相对路径应 400,实际 %d:%s", rr.Code, rr.Body.String()) } }