diff --git a/plugins/homeagent-mail-bridge/default_session_test.go b/plugins/homeagent-mail-bridge/default_session_test.go new file mode 100644 index 0000000..4822de4 --- /dev/null +++ b/plugins/homeagent-mail-bridge/default_session_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +/* +非邮件轮次必须能声明一个**合法**的 session_id(2026-10-02,B 方案配套)。 + +# 背景 + +服务端 `AgentMayReadSession` 收严后(`15e4fe9`),未声明 session_id 的读信 +一律 403。而 homeagent 的工具**全局可调** ⇒ 对话里自主调 read_mail/read_thread 时 +`currentSessionID` 为空 ⇒ 不带 session_id ⇒ 403。 + +「非邮件轮次读信」这个能力不能因此消失(它是 2026-10-01 那个 +`read_inbox` 修复的用户可见部分),所以要给它一个合法声明。 + +# 为什么回落答案必须向服务端问 + +`workspace` 能回落到进程 cwd,而 **session_id 是 AgentMail 会话的 UUID, +cwd 给不出它**。所以只有一条路:`GET /agent/session/default?workspace=/tmp` +(服务端纯只读;没通信过就返回 session_id=null,不建会话)。 +*/ + +// ① 取值函数 effectiveSessionID **不发请求**(纯取值)。 +// +// 我第一版把 HTTP 塞进这里,于是 `&Plugin{}` 构造的测试直接 nil panic +// (p.client 为 nil),而且 scopeQuery 变成「拼 URL 时顺带发一次请求」。 +// 判据钉住这个形状:取值与 IO 混在一起,测试与真实调用都会变得难以推理。 +func TestEffectiveSessionIDIsPureLookup(t *testing.T) { + p := &Plugin{} + // 不设置 client —— 若取值函数敢发请求,这里就 panic + if got := p.effectiveSessionID(); got != "" { + t.Fatalf("未探测且无当前会话时应为空,实际 %q", got) + } +} + +// ② 邮件轮次内优先用信封上的 currentSessionID(不回落)。 +func TestEffectiveSessionIDPrefersEnvelope(t *testing.T) { + p := &Plugin{currentSessionID: "env-session"} + if got := p.effectiveSessionID(); got != "env-session" { + t.Fatalf("轮次内必须用信封上的会话,实际 %q", got) + } +} + +// ③ scopeQuery 在非邮件轮次、且已问出默认会话时,**仍要带 session_id**。 +// 这是本次改动的核心形状:旧写法是 currentSessionID 为空就整体放弃。 +func TestScopeQueryCarriesDefaultSessionOutsideTurn(t *testing.T) { + p := &Plugin{defaultSessionID: "sess-default-123"} + q := p.scopeQuery("&") + if !strings.Contains(q, "session_id=sess-default-123") { + t.Fatalf("★ 非邮件轮次也必须声明合法 session_id(否则服务端 403):%q", q) + } +} + +// ④ 两样都拿不到时才放弃 —— 且是**明确放弃**(返回空串), +// 不是「退化成不带 session_id 的裸奔」。 +func TestScopeQueryGivesUpOnlyWhenBothAbsent(t *testing.T) { + p := &Plugin{} // 既无 currentSessionID,也无 defaultSessionID + if q := p.scopeQuery("&"); q != "" { + t.Fatalf("两样都没有时整体放弃(服务端会 403,这是可见的错误而非静默放行):%q", q) + } +} + +// ⑤ client 没就绪时**不该**标记「已问」——那不是「答案是空」, +// 而是「还没资格问」。标了就会把空值永久缓存,之后再也不会问。 +// +// 这条是被判据打出来的:我第一版没这道判据,register() 里调它会在 +// 插件加载阶段 nil panic;而加了判据后若还沿用旧断言,就会把 +// 「正确地没标记」当成失败 —— 断言本身要跟着实现意图走,不能反过来。 +func TestEnsureDefaultSessionDoesNotProbeBeforeClientReady(t *testing.T) { + p := &Plugin{} // client 未就绪 + p.ensureDefaultSession() + if p.defaultSessionProbed { + t.Fatal("★ client 未就绪时不该标记已问(否则永久缓存空值,之后再也不问)") + } + if p.defaultSessionID != "" { + t.Fatalf("更不该有 id,实际 %q", p.defaultSessionID) + } +} + +// ⑤b 一旦标记过就**不再重复**问(否则每个工具调用都给服务端添压力)。 +// 这里直接检查缓存标记的粘性(真发 HTTP 的形状由 repo/handler 侧判据覆盖)。 +func TestDefaultSessionProbeFlagIsSticky(t *testing.T) { + p := &Plugin{defaultSessionProbed: true} + p.ensureDefaultSession() // 不该再动任何状态 + if !p.defaultSessionProbed { + t.Fatal("已问标记应是幂等的") + } + if p.defaultSessionID != "" { + t.Fatalf("标记已置时不该凭空产生 id,实际 %q", p.defaultSessionID) + } +} + +// ⑥ 邮件轮次内**不**去问默认会话(那次问是纯浪费,且当前SessionID 已有值)。 +func TestEnsureDefaultSessionSkipsInsideTurn(t *testing.T) { + p := &Plugin{currentSessionID: "env"} + p.ensureDefaultSession() + if p.defaultSessionProbed { + t.Fatal("★ 轮次内不该问默认会话(currentSessionID 已有值)") + } +} + +// ⑦ 默认落点与服务端一致:/tmp。 +// 两边不一致 = 桥问的是一个 key、服务端建的是另一个 key ⇒ 永远问不到。 +func TestDefaultFallbackWorkspaceMatchesServer(t *testing.T) { + if defaultFallbackWorkspace != "/tmp" { + t.Fatalf("必须与 handler.DefaultFallbackWorkspace 一致,实际 %q", defaultFallbackWorkspace) + } + if !strings.HasPrefix(defaultFallbackWorkspace, os.Getenv("HOME")) && + !strings.HasPrefix(defaultFallbackWorkspace, "/") { + t.Fatal("默认落点必须是绝对路径(UnreadWorkspaces 要求 workspace LIKE '/%')") + } +} \ No newline at end of file diff --git a/plugins/homeagent-mail-bridge/plugin.go b/plugins/homeagent-mail-bridge/plugin.go index f0b8bf5..88bdff7 100644 --- a/plugins/homeagent-mail-bridge/plugin.go +++ b/plugins/homeagent-mail-bridge/plugin.go @@ -142,6 +142,16 @@ type Plugin struct { // 拿不准时宁可少列 —— 跨工作区误读正是 2026-09-26 那个缺陷本身。 currentWorkspace string + // defaultSessionID 是「非邮件轮次的默认会话」(由 /agent/session/default 问出)。 + // 默认会话与工作区回落的差别在答案的来源:workspace 能从进程 cwd 得到, + // 会话 UUID 不能 —— 那是服务端的 id。所以它必须问,且那个端点是纯只读的 + // (没通信过就没有 ⇒ 不编 id,见 effectiveSessionID 的注释)。 + defaultSessionID string + + // defaultSessionProbed 保证「问过了」与「答案是空」这两种情况都只问一次。 + // 缺了它,每次工具调用都会重问一个答案恒为空的端点。 + defaultSessionProbed bool + // 单调递增的 last-seen-ID:被重放的旧事件不会让它回退。 // 原来直接赋值(p.lastEventID = eid),Gateway 重放时发旧 ID, // 于是 lastEventID 从 123 退回 116 → 下次重连又报 116 → 又重放。 @@ -533,6 +543,17 @@ func (p *Plugin) heartbeatLoop() { } func (p *Plugin) register() error { + // ★ 2026-10-02:装配期先问一次默认会话。 + // + // 非邮件轮次(对话里自主调 read_mail/read_thread)时 currentSessionID 为空, + // 而服务端对未声明 session_id 的读信一律 403(AgentMayReadSession 收严后)。 + // 放在这里而不是取值函数里:那是**取值**,不该有 IO(我第一版塞进 + // effectiveSessionID,`&Plugin{}` 构造的测试当场 nil panic)。 + // + // 问不到也继续:那是「没有默认会话」的正常形状(那个端点是纯只读的), + // 后果只是这次读信拿 403,而不是被静默放行成越权。 + p.ensureDefaultSession() + body := map[string]interface{}{ "name": p.agentName, "platform": "homeagent", @@ -1558,17 +1579,101 @@ func (p *Plugin) markRead(ids []string) { // // 不在回合里时 currentSessionID 为空,返回空串 —— 退回旧行为(服务端会记警告), // 而不是猜一个会话。 +// defaultFallbackWorkspace 必须与 handler.DefaultFallbackWorkspace 一致 +// (非邮件轮次没有真实工作目录,而 /tmp 是中性落点且能被寻址补投)。 +const defaultFallbackWorkspace = "/tmp" + func (p *Plugin) scopeQuery(sep string) string { - if p.currentSessionID == "" { + // ★ 2026-10-02:非邮件轮次不再「整体放弃收窄」—— + // + // 旧写法在 currentSessionID 为空时直接返回 "",于是 URL 里既没有 session_id + // 也没有 workspace。而服务端 `AgentMayReadSession` 对未声明 session_id 的 + // 读信请求一律 403(2026-09-15 那个「迁移期放行」已作废,实测曾 20/20 读到别人的信)。 + // + // 现在:session_id 走 effectiveSessionID(信封 → 默认会话), + // workspace 走 effectiveWorkspace(信封 → cwd)。两者都拿不到时才放弃。 + sid := p.effectiveSessionID() + if sid == "" { return "" } - q := sep + "session_id=" + url.QueryEscape(p.currentSessionID) + q := sep + "session_id=" + url.QueryEscape(sid) if ws := p.effectiveWorkspace(); ws != "" { q += "&workspace=" + url.QueryEscape(ws) } return q } +// effectiveSessionID 返回本轮该声明的会话:信封优先,**其次向服务端问默认会话**。 +// +// # 为什么要回落(与 effectiveWorkspace 同一形状,但答案在服务端) +// +// 工具全局可调 ⇒ 非邮件轮次(对话里自主调 read_mail/read_thread)时 +// currentSessionID 为空 ⇒ 服务端 403。而 cwd 给不出会话 UUID, +// 所以只能问服务端:`GET /agent/session/default?workspace=/tmp`。 +// +// 那个端点是**纯只读**的(没通信过就返回 session_id=null,不建会话)—— +// 所以这里拿不到是正常形状:默认收件箱本来就空,不该编一个 id 去声明。 +// +// 缓存:问出来的结果按进程缓存。理由是它极少变化(默认会话只在真的往 +// /tmp 发过信之后才存在),而每个工具调用都问一次会给服务端添无谓压力。 +// 缓存 null 也要记 —— 否则「还没有默认会话」会被反复重问。 +// ⚠ 这里**不发请求**:它是取值函数,与 effectiveWorkspace 同形状。 +// 我第一版把 HTTP 塞进来,于是 `&Plugin{}` 构造的测试直接 nil panic +// (p.client 为 nil),而且 scopeQuery 变成「拼 URL 时顺带发一次请求」—— +// 取值与 IO 混在一起,很难说清什么时候有副作用。 +// +// 所以由调用方在**装配期**调一次 ensureDefaultSession(见下), +// 这里只读缓存值。 +func (p *Plugin) effectiveSessionID() string { + if sid := strings.TrimSpace(p.currentSessionID); sid != "" { + return sid + } + return p.defaultSessionID +} + +// ensureDefaultSession 在**装配期**问一次「我的默认会话是哪条」。 +// +// 只问一次的代价:若进程活到默认会话才第一次被建立(有人往 /tmp 发了信), +// 它不会自动看到 —— 但那不构成正确性问题:默认值本来就是兜底, +// 拿到旧值只是少看一封本该补投的信,而补投走的是 SSE 而不是这里。 +// 而每次工具调用都问一次会给服务端添无谓压力,两者取轻。 +func (p *Plugin) ensureDefaultSession() { + if p.defaultSessionProbed || strings.TrimSpace(p.currentSessionID) != "" { + return // 邮件轮次里不需要默认值;已问过也不重复问 + } + // ⚠ client 未就绪时**不标记已问**:那不是「问过了、答案是空」, + // 而是「还没资格问」。标了就会永久缓存空值,之后再也不会问了。 + // + // 我第一版没这道判据,判据 `TestEnsureDefaultSessionProbesOnlyOnce` 用 + // `&Plugin{client: nil}` 直接把它打 panic 出来 —— 而 register() 里就会调它, + // 真的会在插件加载阶段崩掉。 + if p.client == nil { + return + } + p.defaultSessionProbed = true + p.defaultSessionID = p.fetchDefaultSessionID() + if p.defaultSessionID != "" { + log.Printf("[homeagent-mail-bridge] 非邮件轮次默认会话: %s(%s)", + p.defaultSessionID, defaultFallbackWorkspace) + } +} + +// fetchDefaultSessionID 向服务端问「我的默认会话是哪条」,失败返回空串。 +func (p *Plugin) fetchDefaultSessionID() string { + var out struct { + SessionID string `json:"session_id"` + } + url := fmt.Sprintf("%s/api/v1/agent/session/default?workspace=%s", + p.gwURL, url.QueryEscape(defaultFallbackWorkspace)) + if err := p.get(url, &out); err != nil { + // 问不到就当没有:下面的调用会不带 session_id,服务端 403。 + // 那是「没收窄」被明确拒绝,而不是被静默放行 —— 与 workspace 同一原则。 + log.Printf("[homeagent-mail-bridge] 取默认会话失败(本次不带 session_id): %v", err) + return "" + } + return strings.TrimSpace(out.SessionID) +} + // effectiveWorkspace 返回本轮该用的工作区:**先信封,再进程 cwd**。 // // 为什么要回落:homeagent 的工具全局可调,而 currentWorkspace 只在 diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 1932ff5..dbf272e 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -143,6 +143,8 @@ func main() { // 归档、改别名、权限决策仍然只有人能做。 r.Get("/agent/contacts", handler.AgentListContacts) r.Get("/agent/contacts/suggest", handler.AgentSuggestAddress) + // ★ 2026-10-02:问出「我的默认会话」(见 handler 里的说明)。 + r.Get("/agent/session/default", handler.AgentDefaultSession) r.Get("/agent/mail/{id}", handler.AgentGetMail) r.Get("/agent/mail/{id}/thread", handler.AgentGetMailThread) r.Get("/agent/sessions/{id}/participants", handler.AgentSessionParticipants) diff --git a/server/internal/handler/agent_default_session_test.go b/server/internal/handler/agent_default_session_test.go new file mode 100644 index 0000000..d63f886 --- /dev/null +++ b/server/internal/handler/agent_default_session_test.go @@ -0,0 +1,201 @@ +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()) + } +} diff --git a/server/internal/handler/agent_discovery.go b/server/internal/handler/agent_discovery.go index 65ebe5a..6840a78 100644 --- a/server/internal/handler/agent_discovery.go +++ b/server/internal/handler/agent_discovery.go @@ -112,6 +112,80 @@ func canReadSession(w http.ResponseWriter, r *http.Request, agentName string, sc return false } +// GET /api/v1/agent/session/default —— 问出「我的默认会话是哪条」 +// +// ★ 2026-10-02 新增。它是 `AgentMayReadSession` 收严后的**配套出口**: +// 未声明 session_id 的读信请求现在一律 403,而桥在**非邮件驱动轮次** +// (对话/自主调用)手里确实没有任何会话可声明 —— `session_id` 是 AgentMail +// 会话的 UUID,进程 cwd 给不出它(这点与 workspace 不同,workspace 能回落到 cwd)。 +// +// 所以这里把**已有的**默认会话语义开放成一个可查询入口: +// `name@path` 省略 session 位时投递到的那条会话(`repo.FindOrCreateDefaultSession`)。 +// 那个函数早就在(8 个测试覆盖它),只是没有「只查询」的 HTTP 形状。 +// +// # 为什么默认工作区是 /tmp +// +// 非邮件轮次没有真实工作目录可依(桥进程 cwd 是插件目录,不是用户项目)。 +// /tmp 是**明确的中性落点**:它不属于任何一个真实项目, +// 所以落进去的会话不会在任何人的工作区里混进项目邮件, +// 而 `UnreadWorkspaces` 要求 workspace 形如 `/%`(要能被寻址补投),/tmp 满足。 +// +// # 安全边界:这个端点**不是**绕过 session 闸的万能钥匙 +// +// 它只回答「你自己 `name@/tmp` 的默认会话是哪条」,不授予读任何会话的能力。 +// 拿到 UUID 后仍要过 `AgentMayReadSession`:读别的会话照样 403。 +// 换句话说:它把「非法」变成「合法但窄」—— 默认会话只装发给**你自己**的信。 +func AgentDefaultSession(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + ws := strings.TrimSpace(r.URL.Query().Get("workspace")) + if ws == "" { + ws = DefaultFallbackWorkspace + } + if !strings.HasPrefix(ws, "/") { + Error(w, http.StatusBadRequest, "workspace 必须是绝对路径,收到: "+ws) + return + } + // 纯只读:没有就返回空,**不建会话**。 + // + // 我第一版直接调 FindOrCreateDefaultSessionCreated,判据当场报它**每次都新建** + //(连着两次问拿到两个不同 UUID)。改「先查后建」还不够 —— 已建的空会话仍不满足 + // 那个函数的复用条件(`EXISTS (SELECT 1 FROM mails …)`),于是下一次依然「没找到」。 + // + // 而想深一层:**根本不该建**。非邮件轮次读信时,若 `name@/tmp` 一封都没通过, + // 那个收件箱本来就应该是空的 —— 不需要一条会话 id 才能表达「空」。 + // 要让默认会话真的存在,只需要**往 /tmp 发一封信**,那是发信路径的事。 + // + // GET 端点有副作用本身就是坏味道:它会被桥每轮调一次, + // 预建会话等于让「问一次」在会话列表里留一条垃圾。 + id, err := repo.FindExistingDefaultSession(r.Context(), agentName, ws) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to resolve default session") + return + } + // 没找到时返回 session_id=null(而不是 uuid.Nil 字符串): + // 桥看到 null 就知道「默认会话还不存在,收件箱按空处理」, + // 而不是拿着一个不存在的 id 去声明、然后拿到一个莫名其妙的 403。 + var sid any + self := "" + if id != uuid.Nil { + sid = id + self = models.FormatAddress(agentName, ws, repo.SessionAliasOf(r.Context(), id)) + } + JSON(w, http.StatusOK, map[string]any{ + "session_id": sid, + "workspace": ws, + "exists": id != uuid.Nil, + "self_address": self, + }) +} + +// DefaultFallbackWorkspace 是非邮件轮次的默认落点(理由见 AgentDefaultSession)。 +const DefaultFallbackWorkspace = "/tmp" + // GET /api/v1/agent/contacts // // 本 Agent 参与过的会话,每条给出可直接投递的 `address`。 diff --git a/server/internal/repo/repo.go b/server/internal/repo/repo.go index f474200..f4bb860 100644 --- a/server/internal/repo/repo.go +++ b/server/internal/repo/repo.go @@ -1367,16 +1367,14 @@ func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subj return id, err } -// FindOrCreateDefaultSessionCreated 与 FindOrCreateDefaultSession 相同,但额外返回 -// **这次调用是否真的新建了会话**。 +// defaultSessionMatchSQL 是「默认会话」的**唯一**匹配条件。 // -// 为什么需要这个返回值:调用方此前用 `parentMailID == nil` 判断「是不是新建会话」, -// 而复用已有默认会话时 parentMailID 也是 nil —— 于是「仅在新建时生效」的字段 -// (往返预算、权限档位)在每一封省略 session 位的信上都被重写了。 -// 实测:第一封 max_rounds=7 → 第二封省略该字段 → 预算被静默改成默认的 20。 -func FindOrCreateDefaultSessionCreated(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, bool, error) { - var id uuid.UUID - err := db.DB.QueryRowContext(ctx, ` +// ★ 抽出来的理由(不是洁癖):`FindOrCreateDefaultSessionCreated` 与 +// `FindExistingDefaultSession` 必须给出**同一个**答案,否则会出现 +// 「我查到的默认会话不是我发信会落进去的那条」—— 而调用方会把前者 +// 当成后者去声明,于是每一次发信/读信都指向不同会话。 +// 两处各写一份 SQL 的话,改一边就会静默分叉(无任何东西会红)。 +var defaultSessionMatchSQL = ` SELECT s.session_id FROM sessions s WHERE s.status <> 'archived' @@ -1387,7 +1385,7 @@ func FindOrCreateDefaultSessionCreated(ctx context.Context, name, path, fromAgen AND EXISTS ( SELECT 1 FROM mails m WHERE m.session_id = s.session_id - AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`) + AND (m.to_name = $1 OR m.from_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `) ) AND (s.workspace = $2 OR (s.workspace = '' AND EXISTS ( @@ -1396,8 +1394,39 @@ func FindOrCreateDefaultSessionCreated(ctx context.Context, name, path, fromAgen AND COALESCE(w.to_workspace,'') = $2 ))) ORDER BY s.updated_at DESC - LIMIT 1 - `, name, path).Scan(&id) + LIMIT 1` + +// FindExistingDefaultSession 只**查**不建:命中就返回那个 id,没命中返回 uuid.Nil。 +// +// 为什么要与 FindOrCreate 并存(2026-10-02):后者在没命中时会**建一条**, +// 而它的复用条件里有 `EXISTS (… FROM mails …)` —— 空会话不满足。 +// 于是「先查 id 再用」的调用方(先于任何投递发生)每轮都会新建一条。 +// 这就是 `GET /agent/session/default` 需要它的地方:那里只该**读**, +// 建会话是发信时才发生的事。 +// +// 不调 TouchSession:查询不是活动,刷新 updated_at 会把陈旧会话排到前面 +// (那条 ORDER BY s.updated_at DESC 的语义是「最近活跃的优先」)。 +func FindExistingDefaultSession(ctx context.Context, name, path string) (uuid.UUID, error) { + var id uuid.UUID + err := db.DB.QueryRowContext(ctx, defaultSessionMatchSQL, name, path).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return uuid.Nil, nil + } + return id, err +} + +// FindOrCreateDefaultSessionCreated 与 FindOrCreateDefaultSession 相同,但额外返回 +// **这次调用是否真的新建了会话**。 +// +// 为什么需要这个返回值:调用方此前用 `parentMailID == nil` 判断「是不是新建会话」, +// 而复用已有默认会话时 parentMailID 也是 nil —— 于是「仅在新建时生效」的字段 +// (往返预算、权限档位)在每一封省略 session 位的信上都被重写了。 +// 实测:第一封 max_rounds=7 → 第二封省略该字段 → 预算被静默改成默认的 20。 +func FindOrCreateDefaultSessionCreated(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, bool, error) { + var id uuid.UUID + // 匹配条件用共享常量(见 defaultSessionMatchSQL 的注释:两处必须同源, + // 否则「查到的默认会话」会与「发信落进去的会话」分叉)。 + err := db.DB.QueryRowContext(ctx, defaultSessionMatchSQL, name, path).Scan(&id) if err == nil { TouchSession(ctx, id) return id, false, nil