From be0693821b08d7f5ee892787ed9c4fc3e714672b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 12:07:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(agents):=20=E5=9B=9B=E5=AE=B6=E6=A1=A5?= =?UTF-8?q?=E7=9A=84=20read=5Finbox=20=E4=B8=80=E5=BE=8B=E6=8C=89=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=94=B6=E7=AA=84=EF=BC=88dsh/opencode/zcode/homeagen?= =?UTF-8?q?t=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:「你还是没修好不同 session agent 收件箱隔离的问题」。上一轮我只修了 **pi**, 另外四家还漏着 —— 它们是**每一家各自实现** read_inbox,不修就还是漏。 ## 缺陷 列表按 Agent 列(整个收件箱),而 read_inbox 按契约把**列出来的都标成已读** ⇒ A 会话的回合会把 B 会话的未读标掉 ⇒ B 之后按 `?status=unread` 补投时 再也看不到那封信(静默丢信,不是"少看一封")。用户是在别的 Agent 上看到它的。 ## 四家的修法(各自平台能力不同,但都要"并发安全") | 桥 | 会话来源 | 为什么这样做 | |---|---|---| | dsh | 工具第二参数 `exec.agent.id` → `reverseMap` | 平台就在上下文里给了会话;**不能用模块级"当前会话"变量**(同进程可能同时跑多条会话的回合,会互相覆盖) | | opencode | 工具第二参数 `context.sessionID` → `reverseMap` | 同上 | | zcode | `AGENTMAIL_SESSION_ID`(在**调用时**读) | 一轮一个进程,驱动本来就注入它给授权钩子用;调用时读,避免将来复用进程拿到旧值 | | homeagent | `p.currentSessionID`(回合开始设、结束清) | Go 插件,本来就有这个状态 | 取不到会话一律**退回整体收件箱**(历史行为),不猜 —— 猜错就是静默丢信。 ## 判据 - 服务端语义:`server/internal/repo/session_scope_test.go`(读 A 不动 B、列表收窄、 计数与列表口径一致)。 - 桥侧接线:dsh 4 条、opencode 3 条、zcode 3 条、homeagent Go 1 条 (`TestInboxURLScopedBySession`,直接断言拼出来的 URL)。 每家都带**判据自检**:拿旧写法喂进来必须判红;dsh/opencode 还专门断言 "不得用模块级当前会话变量"。 - **部署件**(不是仓库):四家的部署快照里都能 grep 到 `session_id=`。 - **线上实测**:用 opencode 自己的 Agent 身份请求收窄列表 —— 会话 A 3 封、 会话 B 0 封、两者无交集、且都是全量的子集。 套件:opencode **331**、dsh **381**、zcode **385**、homeagent ok,全绿。 四家桥已重新部署(dsh/opencode/zcode 快照切换 + homeagent 新 plugin.bin 并重启), 四个服务均 active。 --- plugins/dsh-mail-bridge/src/index.ts | 26 ++++++++++- .../test/inbox-session-scope.test.mjs | 44 ++++++++++++++++++ plugins/homeagent-mail-bridge/plugin.go | 23 +++++++++- .../plugin_session_scope_test.go | 45 +++++++++++++++++++ plugins/opencode-mail-bridge/index.js | 12 ++++- .../test/inbox-session-scope.test.mjs | 31 +++++++++++++ plugins/zcode-mail-bridge/lib/tools.mjs | 12 ++++- .../test/inbox-session-scope.test.mjs | 32 +++++++++++++ 8 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 plugins/dsh-mail-bridge/test/inbox-session-scope.test.mjs create mode 100644 plugins/homeagent-mail-bridge/plugin_session_scope_test.go create mode 100644 plugins/opencode-mail-bridge/test/inbox-session-scope.test.mjs create mode 100644 plugins/zcode-mail-bridge/test/inbox-session-scope.test.mjs diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 84a1fa6..3aa25d2 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -822,6 +822,18 @@ export function apply(ctx: any, config: PluginConfig): void { * 推不出来,重启后确实无法定位热更新 —— 已知取舍;下次投递时会按邮件里 * 带的 permission_mode 重新 apply,档位不会丢。 */ + /** + * 从工具运行上下文里取"这次调用属于哪条**邮件**会话"。 + * + * 取不到返回空串(= 退回整个 Agent 的收件箱)。宁可退回旧行为,也不猜 —— + * 猜错会把别人会话的未读标掉,那是静默丢信。 + */ + function mailSessionOf(exec: any): string { + const dshSessionId = String(exec?.agent?.id ?? ''); + if (!dshSessionId) return ''; + return reverseMap.get(dshSessionId) ?? ''; + } + function findLiveDshSession(mailSessionID: string): { id: string; agent: any } | undefined { const bound = sessionMap.peek(mailSessionID); if (bound) { @@ -1256,10 +1268,20 @@ export function apply(ctx: any, config: PluginConfig): void { schema: { type: 'string' }, render: (_args: any, value: string) => [{ type: 'text', text: value }], }, - async execute(args: any): Promise { + // ★ 第二个参数是平台给的运行上下文:`exec.agent` 就是这次调用所属的 DSH 会话。 + // 桥里有 reverseMap(DSH 会话 → 邮件会话),因此可以做到**并发安全**的收窄 —— + // 不能用模块级"当前会话"变量(同一进程里可能同时有多个会话的回合在跑, + // 那个变量会被互相覆盖)。 + // + // 缺陷(用户报的):「不同 session 的 agent 都可以看到全部邮件」: + // 列表按 Agent 列且 read_inbox 会把列出的都标已读 ⇒ A 会话标掉 B 会话的未读 + // ⇒ B 之后按 ?status=unread 补投时再也看不到那封信(静默丢信)。 + async execute(args: any, exec?: any): Promise { const status = args.status || DEFAULT_INBOX_STATUS; + const mailSessionID = mailSessionOf(exec); + const scope = mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : ''; const { mails } = await client.get( - `/mail/inbox?status=${status}&limit=${args.limit || DEFAULT_INBOX_LIMIT}` + `/mail/inbox?status=${status}&limit=${args.limit || DEFAULT_INBOX_LIMIT}${scope}` ); // 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关, diff --git a/plugins/dsh-mail-bridge/test/inbox-session-scope.test.mjs b/plugins/dsh-mail-bridge/test/inbox-session-scope.test.mjs new file mode 100644 index 0000000..f37075b --- /dev/null +++ b/plugins/dsh-mail-bridge/test/inbox-session-scope.test.mjs @@ -0,0 +1,44 @@ +/** + * read_inbox 必须收窄到**自己那条会话**(用户:「你还是没修好不同 session agent + * 收件箱隔离的问题」)。 + * + * 缺陷:列表按 Agent 列,且 read_inbox 按契约把列出的都标已读 ⇒ A 会话的回合把 + * B 会话的未读标掉 ⇒ B 之后按 `?status=unread` 补投时再也看不到那封信(静默丢信)。 + * + * 这里只验**接线**(工具把会话 id 传出去了、会话来源是平台上下文而不是模块级变量); + * 服务端语义由 server/internal/repo/session_scope_test.go 负责。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(join(HERE, '..', 'src', 'index.ts'), 'utf8'); + +test('read_inbox 的 URL 会拼上会话收窄', () => { + assert.match(src, /mail\/inbox\?status=\$\{status\}&limit=\$\{args\.limit \|\| DEFAULT_INBOX_LIMIT\}\$\{scope\}/); + assert.match(src, /session_id=\$\{encodeURIComponent\(mailSessionID\)\}/); +}); + +test('★ 会话来源是平台上下文(并发安全),不是模块级变量', () => { + // 工具签名必须接住第二个参数 + assert.match(src, /async execute\(args: any, exec\?: any\): Promise/); + assert.match(src, /exec\?\.agent\?\.id/, '要从 exec.agent.id 取 DSH 会话'); + assert.match(src, /reverseMap\.get\(dshSessionId\)/, '再经 reverseMap 换成邮件会话'); + // 反向对照:不能引入"当前会话"这种模块级可变状态来做这件事 + const bad = /let\s+currentMailSessionID|let\s+activeMailSession/; + assert.ok(!bad.test(src), '不得用模块级"当前会话"变量(同进程多会话会互相覆盖)'); +}); + +test('取不到会话时退回整体收件箱(不猜)', () => { + assert.match(src, /if \(!dshSessionId\) return ''/); + assert.match(src, /const scope = mailSessionID \? `&session_id=[^`]*` : ''/); +}); + +test('★ 判据自检:不带收窄的旧写法必须判红', () => { + const old = '`/mail/inbox?status=${status}&limit=${args.limit || DEFAULT_INBOX_LIMIT}`'; + assert.ok(!/\$\{scope\}/.test(old), '旧写法没有 scope ⇒ 会判红'); + assert.ok(!/async execute\(args: any, exec\?: any\)/.test('async execute(args: any): Promise {')); +}); diff --git a/plugins/homeagent-mail-bridge/plugin.go b/plugins/homeagent-mail-bridge/plugin.go index 36b7738..615806a 100644 --- a/plugins/homeagent-mail-bridge/plugin.go +++ b/plugins/homeagent-mail-bridge/plugin.go @@ -10,6 +10,7 @@ import ( "log" "mime/multipart" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -1071,7 +1072,14 @@ func (p *Plugin) handleReadInbox(args map[string]interface{}) (interface{}, erro limit = int(v) } - url := fmt.Sprintf("%s/api/v1/mail/inbox?status=%s&limit=%d", p.gwURL, status, limit) + // ★ 会话收窄:只列**当前正在处理的那条会话**的邮件。 + // + // 不带 session_id 时这是整个 Agent 的收件箱,而 read_inbox 按契约会把列出来的 + // 都标成已读 ⇒ A 会话的回合会把 B 会话的未读标掉,B 之后按 ?status=unread + // 补投时就再也看不到那封信(静默丢信)。currentSessionID 在回合开始时设置、 + // 结束时清空(见 handleNewMail/handleThread 的 InjectInputSync 前后), + // 所以这里读到的就是"这次在替谁干活"。 + url := p.inboxURL(status, limit) var result map[string]interface{} if err := p.get(url, &result); err != nil { return nil, err @@ -1472,3 +1480,16 @@ func (p *Plugin) markRead(ids []string) { req.Header.Set("Authorization", "Bearer "+p.key) p.client.Do(req) } + +// inboxURL 拼收件箱地址。单独抽出来是为了能被单测直接断言 —— +// 会话收窄这种"少了个参数就静默丢信"的改动,必须有判据盯着 URL 本身。 +// +// session_id 只在**正在处理某条会话**时带上;不在回合里(例如桥启动时的自检) +// 就是整个 Agent 的收件箱,这也是历史行为。 +func (p *Plugin) inboxURL(status string, limit int) string { + scope := "" + if p.currentSessionID != "" { + scope = "&session_id=" + url.QueryEscape(p.currentSessionID) + } + return fmt.Sprintf("%s/api/v1/mail/inbox?status=%s&limit=%d%s", p.gwURL, status, limit, scope) +} diff --git a/plugins/homeagent-mail-bridge/plugin_session_scope_test.go b/plugins/homeagent-mail-bridge/plugin_session_scope_test.go new file mode 100644 index 0000000..e7c6c5e --- /dev/null +++ b/plugins/homeagent-mail-bridge/plugin_session_scope_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "net/url" + "strings" + "testing" +) + +/* +会话收窄:一个 Agent 同时在多条会话里干活时,收件箱列表必须只列**当前这条**。 + +缺陷(用户报的):「不同 session 的 agent 都可以看到全部邮件」。列表按 Agent 列, +且 read_inbox 按契约把列出来的都标已读 ⇒ A 会话的回合标掉 B 会话的未读 ⇒ +B 之后按 `?status=unread` 补投时再也看不到那封信(静默丢信)。 + +homeagent 原先根本没有这个参数(另外三家桥也是),所以这里不只测"带上了", +也测"不在回合里时不带"(那是历史行为,桥启动自检要用整箱)。 +*/ +func TestInboxURLScopedBySession(t *testing.T) { + p := &Plugin{gwURL: "http://127.0.0.1:8180"} + + outside := p.inboxURL("unread", 5) + if strings.Contains(outside, "session_id=") { + t.Fatalf("不在回合里时不应带 session_id:%s", outside) + } + + p.currentSessionID = "593988da-0000-0000-0000-000000000001" + inside := p.inboxURL("unread", 5) + if !strings.Contains(inside, "session_id=593988da-0000-0000-0000-000000000001") { + t.Fatalf("回合中必须带上当前会话:%s", inside) + } + if !strings.Contains(inside, "status=unread") || !strings.Contains(inside, "limit=5") { + t.Fatalf("原有参数不能被改坏:%s", inside) + } + + // 会话 id 里不可能出现的东西也要被转义(否则拼出坏 URL,网关会 400) + p.currentSessionID = "a b&c" + esc := p.inboxURL("all", 1) + if strings.Contains(esc, "&c") { + t.Fatalf("session_id 未转义:%s", esc) + } + if _, err := url.Parse(esc); err != nil { + t.Fatalf("拼出的 URL 不合法:%v", err) + } +} diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index c3b9ecf..94fcb4f 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -261,10 +261,18 @@ const readInboxTool = { filter: z.enum(["unread", "all"]).optional().describe("过滤条件,默认 unread"), limit: z.number().optional().describe("返回数量,默认 5"), }, - async execute(args) { + // ★ 第二个参数是平台给的上下文:`context.sessionID` 是这次调用所属的 opencode 会话, + // 经 reverseMap 换成邮件会话(并发安全,不依赖模块级"当前会话")。 + // + // 缺陷(用户报的):「不同 session 的 agent 都可以看到全部邮件」:列表按 Agent 列, + // 且 read_inbox 会把列出的都标已读 ⇒ A 会话标掉 B 会话的未读 ⇒ B 之后按 + // ?status=unread 补投时再也看不到那封信(静默丢信)。 + async execute(args, context) { const filter = args.filter || DEFAULT_INBOX_STATUS; const limit = args.limit || DEFAULT_INBOX_LIMIT; - const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}`); + const mailSessionID = reverseMap.get(String(context?.sessionID ?? "")) || ""; + const scope = mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : ""; + const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}${scope}`); // 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关, // 各平台插件必须一致(见该文件里每条规则对应的错误行为)。 diff --git a/plugins/opencode-mail-bridge/test/inbox-session-scope.test.mjs b/plugins/opencode-mail-bridge/test/inbox-session-scope.test.mjs new file mode 100644 index 0000000..be2ee10 --- /dev/null +++ b/plugins/opencode-mail-bridge/test/inbox-session-scope.test.mjs @@ -0,0 +1,31 @@ +/** + * read_inbox 必须收窄到**自己那条会话**(用户:「你还是没修好不同 session agent + * 收件箱隔离的问题」)。 + * + * 缺陷:列表按 Agent 列,且 read_inbox 按契约把列出的都标已读 ⇒ A 会话的回合把 + * B 会话的未读标掉 ⇒ B 之后按 `?status=unread` 补投时再也看不到那封信(静默丢信)。 + * 服务端语义由 server/internal/repo/session_scope_test.go 负责,这里只验接线。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'index.js'), 'utf8'); + +test('read_inbox 的 URL 会拼上会话收窄', () => { + assert.match(src, /mail\/inbox\?status=\$\{filter\}&limit=\$\{limit\}\$\{scope\}/); + assert.match(src, /session_id=\$\{encodeURIComponent\(mailSessionID\)\}/); +}); + +test('★ 会话来源是平台上下文(并发安全),不是模块级变量', () => { + assert.match(src, /async execute\(args, context\)/, '工具要接住第二个参数'); + assert.match(src, /reverseMap\.get\(String\(context\?\.sessionID/, '经 reverseMap 换邮件会话'); + assert.ok(!/let\s+currentMailSessionID/.test(src), '不得用模块级"当前会话"变量'); +}); + +test('★ 判据自检:不带收窄的旧写法必须判红', () => { + const old = '`/mail/inbox?status=${filter}&limit=${limit}`'; + assert.ok(!/\$\{scope\}/.test(old)); +}); diff --git a/plugins/zcode-mail-bridge/lib/tools.mjs b/plugins/zcode-mail-bridge/lib/tools.mjs index 343a0d0..9bdcca2 100644 --- a/plugins/zcode-mail-bridge/lib/tools.mjs +++ b/plugins/zcode-mail-bridge/lib/tools.mjs @@ -114,8 +114,18 @@ export function buildTools({ client, agentName }) { const a = obj(args); const status = str(a.status) || DEFAULT_INBOX_STATUS; const limit = Number.isFinite(a.limit) ? a.limit : DEFAULT_INBOX_LIMIT; + // ★ 会话收窄:驱动每轮都会把**本轮邮件会话**注入 AGENTMAIL_SESSION_ID + // (授权钩子本来就用它),MCP 子进程继承同一个 env ⇒ 这里直接读即可, + // 而且天然并发安全(一轮一个进程)。 + // + // 缺陷(用户报的):「不同 session 的 agent 都可以看到全部邮件」:列表按 Agent 列, + // 且 read_inbox 会把列出的都标已读 ⇒ A 会话标掉 B 会话的未读 ⇒ B 之后按 + // ?status=unread 补投时再也看不到那封信(静默丢信)。 + // 在调用时读(而不是 import 时读死),避免未来复用同一进程时拿到旧值。 + const mailSessionID = process.env.AGENTMAIL_SESSION_ID || ''; + const scope = mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : ''; const { mails } = await client.get( - `/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}` + `/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}${scope}` ); const listed = renderInbox(mails, BODY_LIMIT, agentName); diff --git a/plugins/zcode-mail-bridge/test/inbox-session-scope.test.mjs b/plugins/zcode-mail-bridge/test/inbox-session-scope.test.mjs new file mode 100644 index 0000000..8a373b8 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/inbox-session-scope.test.mjs @@ -0,0 +1,32 @@ +/** + * read_inbox 必须收窄到**自己那条会话**(用户:「你还是没修好不同 session agent + * 收件箱隔离的问题」)。 + * + * zcode 的特殊之处:一轮一个进程,驱动已经把本轮邮件会话注入 AGENTMAIL_SESSION_ID + * (授权钩子本来就用它)⇒ 直接读 env 即可,天然并发安全。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const tools = readFileSync(join(HERE, '..', 'lib', 'tools.mjs'), 'utf8'); +const index = readFileSync(join(HERE, '..', 'src', 'index.mjs'), 'utf8'); + +test('驱动确实注入了本轮邮件会话', () => { + assert.match(index, /AGENTMAIL_SESSION_ID: sessionId/, '驱动要把邮件会话传下去'); +}); + +test('read_inbox 会拼上会话收窄,且在调用时读 env', () => { + assert.match(tools, /const mailSessionID = process\.env\.AGENTMAIL_SESSION_ID \|\| ''/, '调用时读(不是 import 时读死)'); + assert.match(tools, /mail\/inbox\?status=\$\{encodeURIComponent\(status\)\}&limit=\$\{limit\}\$\{scope\}/); + assert.match(tools, /session_id=\$\{encodeURIComponent\(mailSessionID\)\}/); +}); + +test('★ 判据自检:旧写法(不带 env、不带 scope)必须判红', () => { + const old = '`/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}`'; + assert.ok(!/\$\{scope\}/.test(old)); + assert.ok(!/AGENTMAIL_SESSION_ID/.test(old)); +});