From 2a5e3d7d15e0bb319b68c323ffcefae5e5d4b8d1 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 23:18:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E5=9B=9B=E5=AE=B6=E6=A1=A5?= =?UTF-8?q?=E7=9A=84=E8=AF=BB=E7=AB=AF=E7=82=B9=E4=B9=9F=E5=B8=A6=E4=B8=8A?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=94=B6=E7=AA=84=20+=20=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E6=9D=A1=E5=91=BD=EF=BC=88=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E9=9A=94=E7=A6=BB=E7=AC=AC=202=20=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 1 步(1b8cd43)把工作区判据放在服务端、pi 桥接上了线。这一步补齐另外四家, 并把**转发**纳入:转发是"把原文引出去",能转发就等于能读到那条线索的全部内容, 与 read_mail 同一条命(服务端 ForwardMail 也加了同一道校验)。 四家各自的会话来源,与各自的 read_inbox 同一处(不引入第二个来源): - dsh:`mailSessionOf(exec)`(工具第二个参数)—— 五个读工具原本没接 exec,这次补上 - opencode:`reverseMap.get(context.sessionID)` - zcode:`process.env.AGENTMAIL_SESSION_ID`(一轮一个进程) - homeagent:`p.currentSessionID`(新增 `scopeQuery(sep)`,与 inboxURL 同构) 判据(每条两侧都钉:包住了 / 没包住的不存在): - dsh:静态对照,且额外钉 **dist** —— 那是真被 dsh 加载的那份(main: dist/index.js), src 改了忘了 build 就是"源码对、线上旧代码" - opencode / zcode:同上(opencode 还钉"会话来自 context 而不是模块级变量") - homeagent:起 httptest 当网关,**五个读工具 + 转发真调一遍**,断言请求 URL 带 session_id;对照侧:不在回合里(currentSessionID 为空)时不许带 - pi:把 post 的 URL 也纳入记录,forward 进用例表 ★ zcode 那条判据我第一版**对照组写错**了:对照组只写裸 URL,而它本来就是 `withScope(\`裸URL\`)` 的子串 ⇒ `!includes(bare)` 恒假。夹具形状不对时判据会以 "恒红/恒绿"的方式骗人(这次是恒红,一眼可见;恒绿就麻烦了)。 变异:homeagent 去掉 read_mail 的收窄 ⇒ 恰好那条断言红。 (工作区共享,只 add 了上面这 12 个文件;dsh 的 dist 是 gitignore 的,由 redeploy-plugin.sh 在 staging 里构建。) --- plugins/dsh-mail-bridge/src/index.ts | 44 ++++++-- .../test/read-scope-wiring.test.mjs | 69 ++++++++++++ plugins/homeagent-mail-bridge/plugin.go | 17 +++ .../plugin_read_scope_test.go | 103 ++++++++++++++++++ plugins/homeagent-mail-bridge/tools.go | 12 +- plugins/opencode-mail-bridge/index.js | 48 ++++++-- .../test/read-scope-wiring.test.mjs | 59 ++++++++++ plugins/pi-mail-bridge/src/tools.mjs | 4 +- .../test/read-scope-wiring.test.mjs | 5 +- plugins/zcode-mail-bridge/lib/tools.mjs | 32 +++++- .../test/read-scope-wiring.test.mjs | 61 +++++++++++ server/internal/handler/forward.go | 17 +++ 12 files changed, 435 insertions(+), 36 deletions(-) create mode 100644 plugins/dsh-mail-bridge/test/read-scope-wiring.test.mjs create mode 100644 plugins/homeagent-mail-bridge/plugin_read_scope_test.go create mode 100644 plugins/opencode-mail-bridge/test/read-scope-wiring.test.mjs create mode 100644 plugins/zcode-mail-bridge/test/read-scope-wiring.test.mjs diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 7425a20..99199c0 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -856,6 +856,25 @@ export function apply(ctx: any, config: PluginConfig): void { return reverseMap.get(dshSessionId) ?? ''; } + /** + * 给读类端点拼上**自己那条邮件会话**的收窄参数。 + * + * read_inbox 早就有这一维(理由:不收窄会把别会话的未读标掉 ⇒ 静默丢信)。 + * 服务端现在拿它多干一件事:**由这条会话反查工作区**,只有同工作区的会话才放行。 + * 为什么必须有一维:一个 Agent 同时服务所有工作区(注册时 workspaces 为空), + * 不收窄时在 TrueAgent 里干活的 worker 能读到 agentmail 的整条线索 + * (2026-09-14 用户报的那类越界)。 + * + * 取不到会话 id 就原样返回:宁可退回旧行为(服务端会记一条警告日志), + * 也不猜一个 —— 猜错会拼出投不到或投到别处的地址。 + */ + function withScope(path: string, exec: any): string { + const sid = mailSessionOf(exec); + if (!sid) return path; + const sep = path.includes('?') ? '&' : '?'; + return `${path}${sep}session_id=${encodeURIComponent(sid)}`; + } + function findLiveDshSession(mailSessionID: string): { id: string; agent: any } | undefined { const bound = sessionMap.peek(mailSessionID); if (bound) { @@ -1423,12 +1442,16 @@ 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 { + async execute(args: any, exec?: any): Promise { const name = String(args.name || '').trim(); const path = String(args.path || '').trim(); const qs = new URLSearchParams(); if (name) qs.set('name', name); if (path) qs.set('path', path); + // 会话候选按调用方的工作区收窄(name/path 两段不收窄:跨工作区**发信** + // 是设计允许的,被挡的只是"浏览别的会话的标题/别名")。 + const sid = mailSessionOf(exec); + if (sid) qs.set('session_id', sid); const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`); // 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端 // 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。 @@ -1452,8 +1475,8 @@ 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 { - const data = await client.get('/agent/contacts'); + async execute(args: any, exec?: any): Promise { + const data = await client.get(withScope('/agent/contacts', exec)); return renderContacts(data, args.limit || 20); }, })); @@ -1470,8 +1493,8 @@ 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 { - const data = await client.get(`/agent/sessions/${args.session_id}/participants`); + async execute(args: any, exec?: any): Promise { + const data = await client.get(withScope(`/agent/sessions/${args.session_id}/participants`, exec)); return renderParticipants(data); }, })); @@ -1489,9 +1512,9 @@ 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 { + async execute(args: any, exec?: any): Promise { const qs = args.offset ? `?offset=${args.offset}` : ''; - const data = await client.get(`/agent/mail/${args.mail_id}/thread${qs}`); + const data = await client.get(withScope(`/agent/mail/${args.mail_id}/thread${qs}`, exec)); return renderThread(data, AGENT_NAME); }, })); @@ -1508,8 +1531,8 @@ 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 { - const data = await client.get(`/agent/mail/${args.mail_id}`); + async execute(args: any, exec?: any): Promise { + const data = await client.get(withScope(`/agent/mail/${args.mail_id}`, exec)); const m = data?.mail || {}; const lines = [ `发件人: ${m.from_name || '?'}`, @@ -1562,7 +1585,8 @@ export function apply(ctx: any, config: PluginConfig): void { render: (_args: any, value: string) => [{ type: 'text', text: value }], }, async execute(args: any, toolCtx: any): Promise { - const result = await client.post(`/mail/${args.mail_id}/forward`, { + // 转发要读原文 ⇒ 与 read_mail 同一条命(见 withScope 的注释)。 + const result = await client.post(withScope(`/mail/${args.mail_id}/forward`, toolCtx), { to: args.to, comment: args.comment || '', cc: args.cc || '', diff --git a/plugins/dsh-mail-bridge/test/read-scope-wiring.test.mjs b/plugins/dsh-mail-bridge/test/read-scope-wiring.test.mjs new file mode 100644 index 0000000..5d73ad3 --- /dev/null +++ b/plugins/dsh-mail-bridge/test/read-scope-wiring.test.mjs @@ -0,0 +1,69 @@ +/** + * 五个读类端点的请求都要带上**自己那条邮件会话**(dsh)。 + * + * read_inbox 早就有这一维(缺陷:列表按 Agent 列且按契约标已读 ⇒ A 会话标掉 + * B 会话的未读 ⇒ 静默丢信)。服务端现在拿它多干一件事:**由这条会话反查工作区**, + * 只有同工作区的会话才放行 —— 一个 Agent 同时服务所有工作区,不收窄时在 TrueAgent + * 里干活的 worker 能读到 agentmail 的整条线索(用户 2026-09-14 报的越界)。 + * + * 服务端语义由 server/internal/repo/workspace_scope_test.go 负责;这里只验接线。 + * 两侧都钉:包住了 / 没包住的不存在 —— 只验前者的话,把 withScope 写成恒等函数也能过。 + * + * 注意 dsh 的工具签名原本只有 `execute(args)`,exec 是**第二个参数**(read_inbox 一直 + * 在用)—— 这次给五个读工具都补上了,否则拿不到平台会话就没有收窄可言。 + */ +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'); +const dist = readFileSync(join(HERE, '..', 'dist', 'index.js'), 'utf8'); + +const ENDPOINTS = [ + ['read_mail', + 'withScope(`/agent/mail/${args.mail_id}`, exec)', + 'client.get(`/agent/mail/${args.mail_id}`)'], + ['read_thread', + 'withScope(`/agent/mail/${args.mail_id}/thread${qs}`, exec)', + 'client.get(`/agent/mail/${args.mail_id}/thread${qs}`)'], + ['list_contacts', + "withScope('/agent/contacts', exec)", + "client.get('/agent/contacts')"], + ['session_participants', + 'withScope(`/agent/sessions/${args.session_id}/participants`, exec)', + 'client.get(`/agent/sessions/${args.session_id}/participants`)'], +]; + +for (const [name, scoped, bare] of ENDPOINTS) { + test(`★ ${name} 的请求走 withScope(...)`, () => { + assert.ok(src.includes(scoped), `${name} 的 URL 没有包在 withScope 里:${scoped}`); + assert.ok(!src.includes(bare), `${name} 还有一处没包住的写法:${bare}`); + // dist 是**真正被 dsh 加载**的那份(main: dist/index.js)。src 改了忘了 build + // 就是"源码对、线上旧代码"——这一条正是为此存在的。 + assert.ok(dist.includes(scoped), `dist 里的 ${name} 没有 withScope —— 忘了 npm run build?`); + }); +} + +test('★ forward_mail 也带上收窄(它读的是原文)', () => { + const scoped = 'withScope(`/mail/${args.mail_id}/forward`, toolCtx)'; + const bare = 'client.post(`/mail/${args.mail_id}/forward`, {'; + assert.ok(src.includes(scoped), '转发的 URL 没有包在 withScope 里'); + assert.ok(!src.includes(bare), '转发还有一处没包住的写法'); + assert.ok(dist.includes(scoped), 'dist 里没有 —— 忘了 npm run build?'); +}); + +test('★ suggest_address 的会话候选也带上收窄(它列的是别的会话的别名与标题)', () => { + assert.ok(src.includes("qs.set('session_id', sid)"), 'suggest 没把 session_id 放进查询串'); + assert.ok(dist.includes("qs.set('session_id', sid)"), 'dist 里没有 —— 忘了 npm run build?'); +}); + +test('withScope 的会话来自平台上下文(并发安全),不是模块级变量', () => { + assert.ok(src.includes('const sid = mailSessionOf(exec);'), '要走 mailSessionOf(exec)'); + assert.match(src, /function mailSessionOf\(exec: any\)/, 'mailSessionOf 得在'); + assert.ok(src.includes("const sep = path.includes('?') ? '&' : '?';"), '自己判断分隔符'); + assert.ok(src.includes('if (!sid) return path;'), '拿不到会话就原样返回'); + assert.ok(!/let\s+currentMailSessionID/.test(src), '不得用模块级"当前会话"变量'); +}); diff --git a/plugins/homeagent-mail-bridge/plugin.go b/plugins/homeagent-mail-bridge/plugin.go index 615806a..be995bd 100644 --- a/plugins/homeagent-mail-bridge/plugin.go +++ b/plugins/homeagent-mail-bridge/plugin.go @@ -1481,6 +1481,23 @@ func (p *Plugin) markRead(ids []string) { p.client.Do(req) } +// scopeQuery 拼读类端点的**会话收窄参数**(`?session_id=…` / `&session_id=…`, +// 由调用方给分隔符)。 +// +// 与 inboxURL 同一个理由(不收窄会把别会话的未读标掉 ⇒ 静默丢信),但服务端现在 +// 拿它多干一件事:**由这条会话反查工作区**,只有同工作区的会话才放行。为什么必须 +// 有一维:一个 Agent 同时服务所有工作区(注册时 workspaces 为空),不收窄时在 +// TrueAgent 里干活的 worker 能读到 agentmail 的整条线索(用户 2026-09-14 报的越界)。 +// +// 不在回合里时 currentSessionID 为空,返回空串 —— 退回旧行为(服务端会记警告), +// 而不是猜一个会话。 +func (p *Plugin) scopeQuery(sep string) string { + if p.currentSessionID == "" { + return "" + } + return sep + "session_id=" + url.QueryEscape(p.currentSessionID) +} + // inboxURL 拼收件箱地址。单独抽出来是为了能被单测直接断言 —— // 会话收窄这种"少了个参数就静默丢信"的改动,必须有判据盯着 URL 本身。 // diff --git a/plugins/homeagent-mail-bridge/plugin_read_scope_test.go b/plugins/homeagent-mail-bridge/plugin_read_scope_test.go new file mode 100644 index 0000000..1385a26 --- /dev/null +++ b/plugins/homeagent-mail-bridge/plugin_read_scope_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +/* +读类端点必须带上**自己那条邮件会话** —— 这次多出来的作用不是"少看几封",而是 +**工作区隔离**。 + +# 缺陷(用户报的) + +「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了,还需要我亲自去解释。」 + +根因:隔离单位是 Agent,而一个 Agent 同时服务所有工作区(注册时 workspaces 为空、 +cwd 由每封邮件的 to_workspace 决定)。服务端原先的判据只是「这个 Agent 参与过这条 +会话」—— 于是同一个 agent `pi` 在 TrueAgent 里干活的 worker 眼里,对 agentmail 的 +会话也成立。服务端现在补上了第二道闸门(两条会话的 workspace 相同),而闸门的输入 +就是这里要拼的 `session_id`。 + +# 判据是行为,不是正则 + +起一个 httptest 服务当网关,把**每个读工具真调一遍**,断言它请求的 URL 里带了 +`session_id`。对照侧:不在回合里(currentSessionID 为空)时**不许**带 —— +否则"永远拼一个空 session_id"也能骗过上半段。 +*/ +func TestReadToolsCarrySessionScope(t *testing.T) { + var seen []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.URL.RequestURI()) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + const sid = "593988da-0000-0000-0000-0000000000aa" + + cases := []struct { + tool string + args map[string]interface{} + call func(*Plugin, map[string]interface{}) (interface{}, error) + }{ + {"read_mail", map[string]interface{}{"mail_id": "m-1"}, (*Plugin).handleReadMail}, + {"read_thread", map[string]interface{}{"mail_id": "m-1"}, (*Plugin).handleReadThread}, + {"suggest_address", map[string]interface{}{"name": "dsh", "path": "/home/program/x"}, (*Plugin).handleSuggestAddress}, + {"list_contacts", map[string]interface{}{}, (*Plugin).handleListContacts}, + {"session_participants", map[string]interface{}{"session_id": "s-1"}, (*Plugin).handleSessionParticipants}, + // 转发也读原文(把原文引出到别的会话)⇒ 与 read_mail 同一条命。 + {"forward", map[string]interface{}{"mail_id": "m-1", "to": "x@/home/program/y"}, (*Plugin).handleForwardMail}, + } + + for _, c := range cases { + // ── 回合中:必须带上当前会话 ── + p := &Plugin{gwURL: srv.URL, client: srv.Client(), currentSessionID: sid} + seen = nil + if _, err := c.call(p, c.args); err != nil { + t.Fatalf("%s:调用失败 %v", c.tool, err) + } + if len(seen) != 1 { + t.Fatalf("%s:应当只请求一次(实际 %d 次:%v)", c.tool, len(seen), seen) + } + if !strings.Contains(seen[0], "session_id="+url.QueryEscape(sid)) { + t.Fatalf("★ %s 的请求没带会话收窄:%s", c.tool, seen[0]) + } + + // ── 不在回合里(桥启动自检那种):不许拼出空的 session_id ── + p2 := &Plugin{gwURL: srv.URL, client: srv.Client()} + seen = nil + if _, err := c.call(p2, c.args); err != nil { + t.Fatalf("%s(无会话):调用失败 %v", c.tool, err) + } + if len(seen) == 1 && strings.Contains(seen[0], "session_id=") { + t.Fatalf("%s:拿不到会话时不该拼 session_id:%s", c.tool, seen[0]) + } + } +} + +// scopeQuery 的转义与分隔符:会话 id 里出现 `&` 时不能把后面的参数吃掉。 +func TestScopeQueryEscapesAndSeparates(t *testing.T) { + p := &Plugin{} + if got := p.scopeQuery("?"); got != "" { + t.Fatalf("不在回合里应当返回空串(实际 %q)", got) + } + + p.currentSessionID = "a b&c" + q := p.scopeQuery("?") + if !strings.HasPrefix(q, "?session_id=") { + t.Fatalf("分隔符要是调用方给的那个:%q", q) + } + if strings.Contains(q[1:], "&") { + t.Fatalf("session_id 未转义(会把后面的参数吃掉):%q", q) + } + if _, err := url.Parse("http://x/y" + q); err != nil { + t.Fatalf("拼出的 URL 不合法:%v", err) + } + if got := p.scopeQuery("&"); !strings.HasPrefix(got, "&session_id=") { + t.Fatalf("已有查询串时要用 & 分隔:%q", got) + } +} diff --git a/plugins/homeagent-mail-bridge/tools.go b/plugins/homeagent-mail-bridge/tools.go index c6259e0..d96d369 100644 --- a/plugins/homeagent-mail-bridge/tools.go +++ b/plugins/homeagent-mail-bridge/tools.go @@ -45,7 +45,7 @@ func (p *Plugin) handleReadMail(args map[string]interface{}) (interface{}, error IsSelf bool `json:"is_self"` } `json:"participants"` } - if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid, &data); err != nil { + if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid+p.scopeQuery("?"), &data); err != nil { return nil, err } @@ -112,7 +112,7 @@ func (p *Plugin) handleForwardMail(args map[string]interface{}) (interface{}, er "session_alias": sa, } var result map[string]interface{} - if err := p.post("/mail/"+mid+"/forward", payload, &result); err != nil { + if err := p.post("/mail/"+mid+"/forward"+p.scopeQuery("?"), payload, &result); err != nil { return nil, err } @@ -150,7 +150,7 @@ func (p *Plugin) handleSuggestAddress(args map[string]interface{}) (interface{}, Source string `json:"source"` } `json:"candidates"` } - if err := p.get(p.gwURL+"/api/v1/agent/contacts/suggest?"+qs, &data); err != nil { + if err := p.get(p.gwURL+"/api/v1/agent/contacts/suggest?"+qs+p.scopeQuery("&"), &data); err != nil { return nil, err } @@ -218,7 +218,7 @@ func (p *Plugin) handleListContacts(args map[string]interface{}) (interface{}, e Alias string `json:"session_alias"` } `json:"contacts"` } - if err := p.get(p.gwURL+"/api/v1/agent/contacts", &data); err != nil { + if err := p.get(p.gwURL+"/api/v1/agent/contacts"+p.scopeQuery("?"), &data); err != nil { return nil, err } @@ -287,7 +287,7 @@ func (p *Plugin) handleSessionParticipants(args map[string]interface{}) (interfa Address string `json:"address"` } `json:"participants"` } - if err := p.get(p.gwURL+"/api/v1/agent/sessions/"+sid+"/participants", &data); err != nil { + if err := p.get(p.gwURL+"/api/v1/agent/sessions/"+sid+"/participants"+p.scopeQuery("?"), &data); err != nil { return nil, err } @@ -349,7 +349,7 @@ func (p *Plugin) handleReadThread(args map[string]interface{}) (interface{}, err ParentHid bool `json:"parent_hidden"` } `json:"nodes"` } - if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid+"/thread"+offset, &data); err != nil { + if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid+"/thread"+offset+p.scopeQuery("&"), &data); err != nil { return nil, err } diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index af54aa8..b50715a 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -243,8 +243,10 @@ const forwardMailTool = { subject: z.string().optional().describe("自定义主题;留空则自动加 Fwd: 前缀"), session_alias: z.string().optional().describe("仅在目标地址以 .new 结尾时生效:给新会话命名"), }, - async execute(args) { - const result = await apiPost(`/mail/${args.mail_id}/forward`, { + async execute(args, context) { + // 转发要读原文 ⇒ 与 read_mail 同一条命:带上自己那条会话, + // 服务端据此要求原文与调用方同工作区。 + const result = await apiPost(withScope(`/mail/${args.mail_id}/forward`, context), { to: args.to, comment: args.comment || "", cc: args.cc || "", @@ -364,6 +366,24 @@ const downloadAttachmentTool = { // // 渲染逻辑在 lib/discovery.js(与平台 SDK 无关,三平台共用)。 +/** + * 读类端点的会话收窄参数 —— 与 read_inbox 同一个理由,作用更强。 + * + * 服务端拿这条会话**反查工作区**,只有同工作区的会话才放行。为什么必须有一维: + * 一个 Agent 同时服务所有工作区(注册时 workspaces 为空),不收窄时在 TrueAgent + * 里干活的 worker 能读到 agentmail 的整条线索(2026-09-14 用户报的那类越界)。 + * + * 从 context.sessionID 经 reverseMap 换成邮件会话(并发安全,不依赖模块级 + * "当前会话")—— 与 read_inbox 一致。拿不到就原样返回:宁可退回旧行为 + * (服务端会记警告),也不猜一个。 + */ +function withScope(path, context) { + const mailSessionID = reverseMap.get(String(context?.sessionID ?? "")) || ""; + if (!mailSessionID) return path; + const sep = path.includes("?") ? "&" : "?"; + return `${path}${sep}session_id=${encodeURIComponent(mailSessionID)}`; +} + const suggestAddressTool = { description: "查询可用的收件人地址,用于精准发信。分三段逐步查:不带参数给候选收件人名;" + @@ -373,12 +393,16 @@ const suggestAddressTool = { name: z.string().optional().describe("收件人名;留空则列出所有候选收件人"), path: z.string().optional().describe("工作目录;与 name 同时给出才列会话"), }, - async execute(args) { + async execute(args, context) { const name = (args.name || "").trim(); const path = (args.path || "").trim(); const qs = new URLSearchParams(); if (name) qs.set("name", name); if (path) qs.set("path", path); + // 会话候选按调用方的工作区收窄(name/path 两段不收窄:跨工作区**发信** + // 是设计允许的,被挡的只是"浏览别的会话的标题/别名")。 + const mailSessionID = reverseMap.get(String(context?.sessionID ?? "")) || ""; + if (mailSessionID) qs.set("session_id", mailSessionID); const data = await apiGet(`/agent/contacts/suggest?${qs.toString()}`); // 按服务端回的 kind 分派,而不是按本地参数判断:省略 path 与传空串在 @@ -396,13 +420,13 @@ const suggestAddressTool = { const listContactsTool = { description: - "列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。" + + "列出自己参与过的会话(**只含本工作区**)及各自的可投递地址、未读数、剩余往返预算。" + "用于回答「我还有什么没处理」以及「上次跟某人聊的那条线索地址是什么」。", args: { limit: z.number().optional().describe("最多列出多少条,默认 20"), }, - async execute(args) { - const data = await apiGet("/agent/contacts"); + async execute(args, context) { + const data = await apiGet(withScope("/agent/contacts", context)); return renderContacts(data, args.limit || 20); }, }; @@ -414,8 +438,8 @@ const sessionParticipantsTool = { args: { session_id: z.string().describe("会话 ID(read_inbox 未直接给出时可从 read_thread 或新邮件通知取得)"), }, - async execute(args) { - const data = await apiGet(`/agent/sessions/${args.session_id}/participants`); + async execute(args, context) { + const data = await apiGet(withScope(`/agent/sessions/${args.session_id}/participants`, context)); return renderParticipants(data); }, }; @@ -428,9 +452,9 @@ const readThreadTool = { mail_id: z.string().describe("线索中任一封邮件的 ID"), offset: z.number().optional().describe("分页偏移,续取时传上次返回的 next_offset"), }, - async execute(args) { + async execute(args, context) { const qs = args.offset ? `?offset=${args.offset}` : ""; - const data = await apiGet(`/agent/mail/${args.mail_id}/thread${qs}`); + const data = await apiGet(withScope(`/agent/mail/${args.mail_id}/thread${qs}`, context)); return renderThread(data, AGENT_NAME); }, }; @@ -442,8 +466,8 @@ const readMailTool = { args: { mail_id: z.string().describe("邮件 ID"), }, - async execute(args) { - const data = await apiGet(`/agent/mail/${args.mail_id}`); + async execute(args, context) { + const data = await apiGet(withScope(`/agent/mail/${args.mail_id}`, context)); const m = data?.mail || {}; const lines = [ `发件人: ${m.from_name || "?"}`, diff --git a/plugins/opencode-mail-bridge/test/read-scope-wiring.test.mjs b/plugins/opencode-mail-bridge/test/read-scope-wiring.test.mjs new file mode 100644 index 0000000..1740ec1 --- /dev/null +++ b/plugins/opencode-mail-bridge/test/read-scope-wiring.test.mjs @@ -0,0 +1,59 @@ +/** + * 五个读类端点的请求都要带上**自己那条邮件会话**(opencode)。 + * + * read_inbox 早就有这一维(缺陷:列表按 Agent 列且按契约标已读 ⇒ A 会话标掉 + * B 会话的未读 ⇒ 静默丢信)。服务端现在拿它多干一件事:**由这条会话反查工作区**, + * 只有同工作区的会话才放行 —— 一个 Agent 同时服务所有工作区,不收窄时在 TrueAgent + * 里干活的 worker 能读到 agentmail 的整条线索(用户 2026-09-14 报的越界)。 + * + * 服务端语义由 server/internal/repo/workspace_scope_test.go 负责;这里只验接线。 + * 两侧都钉:包住了 / 没包住的不存在 —— 只验前者的话,把 withScope 写成恒等函数也能过。 + */ +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, '..', 'index.js'), 'utf8'); + +const ENDPOINTS = [ + ['read_mail', + 'withScope(`/agent/mail/${args.mail_id}`, context)', + 'apiGet(`/agent/mail/${args.mail_id}`)'], + ['read_thread', + 'withScope(`/agent/mail/${args.mail_id}/thread${qs}`, context)', + 'apiGet(`/agent/mail/${args.mail_id}/thread${qs}`)'], + ['list_contacts', + 'withScope("/agent/contacts", context)', + 'apiGet("/agent/contacts")'], + ['session_participants', + 'withScope(`/agent/sessions/${args.session_id}/participants`, context)', + 'apiGet(`/agent/sessions/${args.session_id}/participants`)'], +]; + +for (const [name, scoped, bare] of ENDPOINTS) { + test(`★ ${name} 的请求走 withScope(...)`, () => { + assert.ok(src.includes(scoped), `${name} 的 URL 没有包在 withScope 里:${scoped}`); + assert.ok(!src.includes(bare), `${name} 还有一处没包住的写法:${bare}`); + }); +} + +test('★ forward_mail 也带上收窄(它读的是原文)', () => { + const scoped = 'withScope(`/mail/${args.mail_id}/forward`, context)'; + const bare = 'apiPost(`/mail/${args.mail_id}/forward`, {'; + assert.ok(src.includes(scoped), '转发的 URL 没有包在 withScope 里'); + assert.ok(!src.includes(bare), '转发还有一处没包住的写法'); +}); + +test('★ suggest_address 的会话候选也带上收窄(它列的是别的会话的别名与标题)', () => { + assert.ok(src.includes('qs.set("session_id", mailSessionID)'), 'suggest 没把 session_id 放进查询串'); +}); + +test('withScope 的会话来自平台上下文(并发安全),不是模块级变量', () => { + assert.match(src, /function withScope\(path, context\)/, 'withScope 得接住 context'); + assert.match(src, /reverseMap\.get\(String\(context\?\.sessionID/, '经 reverseMap 换邮件会话'); + assert.ok(src.includes('if (!mailSessionID) return path;'), '拿不到会话就原样返回'); + assert.ok(!/let\s+currentMailSessionID/.test(src), '不得用模块级"当前会话"变量'); +}); diff --git a/plugins/pi-mail-bridge/src/tools.mjs b/plugins/pi-mail-bridge/src/tools.mjs index 8cdf42c..e2e1860 100644 --- a/plugins/pi-mail-bridge/src/tools.mjs +++ b/plugins/pi-mail-bridge/src/tools.mjs @@ -212,7 +212,9 @@ export function createMailTools({ client, log, agentName = '', onReconnect, getM }, async execute(_id, params, _signal, _onUpdate, ctx) { // 路径带 mail_id(POST /mail/{id}/forward),不是请求体里的字段 - const result = await client.post(`/mail/${params.mail_id}/forward`, { + // 转发要读原文 ⇒ 与 read_mail 同一条命:带上自己那条会话, + // 服务端据此要求原文与调用方同工作区。 + const result = await client.post(withScope(`/mail/${params.mail_id}/forward`), { to: params.to, comment: params.comment || '', cc: params.cc || '', diff --git a/plugins/pi-mail-bridge/test/read-scope-wiring.test.mjs b/plugins/pi-mail-bridge/test/read-scope-wiring.test.mjs index f59a2db..4c3015f 100644 --- a/plugins/pi-mail-bridge/test/read-scope-wiring.test.mjs +++ b/plugins/pi-mail-bridge/test/read-scope-wiring.test.mjs @@ -48,7 +48,8 @@ function fakeResponse(url) { function toolsWith(urls, sessionId) { const client = { get: async (u) => { urls.push(u); return fakeResponse(u); }, - post: async () => ({}), + // post 也要记:转发是"把原文引出去",读的同样是邮件原文。 + post: async (u) => { urls.push(u); return { mail_id: 'new-1', session_id: 's-1' }; }, downloadFile: async () => Buffer.from(''), }; const list = createMailTools({ @@ -70,6 +71,8 @@ const READ_CALLS = [ { tool: 'read_thread', args: { mail_id: 'm-1' } }, { tool: 'session_participants', args: { session_id: 's-1' } }, { tool: 'suggest_address', args: { name: 'dsh', path: '/home/program/x' } }, + // 转发也读原文(把原文引出到别的会话),所以同样要带收窄。 + { tool: 'forward_mail', args: { mail_id: 'm-1', to: 'x@/home/program/y' } }, ]; for (const c of READ_CALLS) { diff --git a/plugins/zcode-mail-bridge/lib/tools.mjs b/plugins/zcode-mail-bridge/lib/tools.mjs index 9bdcca2..fe4e893 100644 --- a/plugins/zcode-mail-bridge/lib/tools.mjs +++ b/plugins/zcode-mail-bridge/lib/tools.mjs @@ -93,6 +93,26 @@ export function buildTools({ client, agentName }) { } }; + /** + * 读类端点的会话收窄参数。 + * + * 与 read_inbox 同一个理由(不收窄会把别会话的未读标掉 ⇒ 静默丢信), + * 但服务端现在拿它多干一件事:**由这条会话反查工作区**,只有同工作区的会话才放行。 + * 为什么必须有一维:一个 Agent 同时服务所有工作区(注册时 workspaces 为空), + * 不收窄时在 TrueAgent 里干活的 worker 能读到 agentmail 的整条线索。 + * + * 驱动每轮把本轮邮件会话注入 AGENTMAIL_SESSION_ID(授权钩子本来就用它), + * MCP 子进程继承同一个 env ⇒ 直接读即可,且天然并发安全(一轮一个进程)。 + * 在调用时读而不是 import 时读死,避免复用进程时拿到旧值。 + * 拿不到就原样返回:宁可退回旧行为(服务端会记警告),也不猜一个。 + */ + const withScope = (path) => { + const sid = process.env.AGENTMAIL_SESSION_ID || ''; + if (!sid) return path; + const sep = path.includes('?') ? '&' : '?'; + return `${path}${sep}session_id=${encodeURIComponent(sid)}`; + }; + const tools = []; // ─── 读 ──────────────────────────────────────────────────────── @@ -151,7 +171,7 @@ export function buildTools({ client, agentName }) { guard(); const id = str(obj(args).mail_id); if (!id) throw new Error('缺少 mail_id'); - const data = await client.get(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`); + const data = await client.get(withScope(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`)); const mail = data?.mail || data; const lines = [renderMail(mail, 0, agentName)]; if (Array.isArray(data?.participants) && data.participants.length) { @@ -182,7 +202,7 @@ export function buildTools({ client, agentName }) { const id = str(a.mail_id); if (!id) throw new Error('缺少 mail_id'); const qs = Number.isFinite(a.offset) ? `?offset=${a.offset}` : ''; - const data = await client.get(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`); + const data = await client.get(withScope(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`)); return renderThread(data, agentName); } }); @@ -275,7 +295,7 @@ export function buildTools({ client, agentName }) { const a = obj(args); if (!str(a.mail_id) || !str(a.to)) throw new Error('缺少 mail_id 或 to'); const result = await client.post( - `/mail/${encodeURIComponent(str(a.mail_id))}/forward`, + withScope(`/mail/${encodeURIComponent(str(a.mail_id))}/forward`), { to: str(a.to), comment: str(a.comment) } ); return `已转发(新邮件 ID: ${result?.mail_id ?? '?'},会话: ${result?.session_id ?? '?'})。`; @@ -351,7 +371,7 @@ export function buildTools({ client, agentName }) { const qs = new URLSearchParams(); if (name) qs.set('name', name); if (path) qs.set('path', path); - const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`); + const data = await client.get(withScope(`/agent/contacts/suggest?${qs.toString()}`)); if (!name) return renderNameSuggestions(data?.names || data?.suggestions || []); if (!path) return renderPathSuggestions(data?.paths || [], name); return renderSessionSuggestions(data, name, path); @@ -369,7 +389,7 @@ export function buildTools({ client, agentName }) { async run(args) { guard(); const limit = Number.isFinite(obj(args).limit) ? obj(args).limit : 20; - const data = await client.get(`/agent/contacts?limit=${limit}`); + const data = await client.get(withScope(`/agent/contacts?limit=${limit}`)); return renderContacts(data, limit); } }); @@ -389,7 +409,7 @@ export function buildTools({ client, agentName }) { guard(); const sid = str(obj(args).session_id); if (!sid) throw new Error('缺少 session_id'); - const data = await client.get(`/agent/sessions/${encodeURIComponent(sid)}/participants`); + const data = await client.get(withScope(`/agent/sessions/${encodeURIComponent(sid)}/participants`)); return renderParticipants(data); } }); diff --git a/plugins/zcode-mail-bridge/test/read-scope-wiring.test.mjs b/plugins/zcode-mail-bridge/test/read-scope-wiring.test.mjs new file mode 100644 index 0000000..ea2ae39 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/read-scope-wiring.test.mjs @@ -0,0 +1,61 @@ +/** + * 五个读类端点的请求都要带上**自己那条邮件会话**(zcode)。 + * + * read_inbox 早就有这一维(缺陷:列表按 Agent 列且按契约标已读 ⇒ A 会话标掉 + * B 会话的未读 ⇒ 静默丢信)。服务端现在拿它多干一件事:**由这条会话反查工作区**, + * 只有同工作区的会话才放行 —— 一个 Agent 同时服务所有工作区,不收窄时在 TrueAgent + * 里干活的 worker 能读到 agentmail 的整条线索(用户 2026-09-14 报的越界)。 + * + * 服务端语义由 server/internal/repo/workspace_scope_test.go 负责;这里只验接线。 + * + * 判据两侧都钉:**包住了**(withScope(...) 的整句存在)与**没包住**(去掉包装的 + * 那句不存在)。只验前者的话,把 withScope 写成恒等函数也能过。 + */ +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 ENDPOINTS = [ + ['read_mail', + 'withScope(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`)', + 'client.get(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`)'], + ['read_thread', + 'withScope(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`)', + 'client.get(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`)'], + ['suggest_address', + 'withScope(`/agent/contacts/suggest?${qs.toString()}`)', + 'client.get(`/agent/contacts/suggest?${qs.toString()}`)'], + ['list_contacts', + 'withScope(`/agent/contacts?limit=${limit}`)', + 'client.get(`/agent/contacts?limit=${limit}`)'], + ['session_participants', + 'withScope(`/agent/sessions/${encodeURIComponent(sid)}/participants`)', + 'client.get(`/agent/sessions/${encodeURIComponent(sid)}/participants`)'], +]; + +for (const [name, scoped, bare] of ENDPOINTS) { + test(`★ ${name} 的请求走 withScope(...)`, () => { + assert.ok(bare.includes('client.get('), '夹具形状不对:对照组必须是没包住的那句'); + assert.ok(tools.includes(scoped), `${name} 的 URL 没有包在 withScope 里:${scoped}`); + assert.ok(!tools.includes(bare), `${name} 还有一处没包住的写法:${bare}`); + }); +} + +test('★ forward_mail 也带上收窄(它读的是原文)', () => { + const scoped = 'withScope(`/mail/${encodeURIComponent(str(a.mail_id))}/forward`)'; + const bare = 'client.post(\n `/mail/${encodeURIComponent(str(a.mail_id))}/forward`,'; + assert.ok(tools.includes(scoped), '转发的 URL 没有包在 withScope 里'); + assert.ok(!tools.includes(bare), '转发还有一处没包住的写法'); +}); + +test('withScope 在调用时读 env,且自己判断分隔符', () => { + assert.match(tools, /const sid = process\.env\.AGENTMAIL_SESSION_ID \|\| ''/, '调用时读,不是 import 时读死'); + assert.ok(tools.includes("path.includes('?') ? '&' : '?'"), '已有查询串要用 & 分隔'); + assert.match(tools, /if \(!sid\) return path/, '拿不到会话就原样返回,不拼半截 URL'); +}); diff --git a/server/internal/handler/forward.go b/server/internal/handler/forward.go index 1789ab2..7086ba9 100644 --- a/server/internal/handler/forward.go +++ b/server/internal/handler/forward.go @@ -203,6 +203,11 @@ func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor, } // POST /api/v1/mail/{id}/forward —— Agent 侧转发 +// +// 声明了 `session_id`(调用方当前所在那条会话)时,**待转发的邮件必须在同一个工作区**: +// 转发是"把原文引出去",能读到原文就等于能读到那条线索的全部内容 —— 与 read_mail +// 同一条命(见 repo.AgentMayReadSession)。未声明时保留旧语义(doForward 的 +// "只能转发自己参与过的邮件")。 func ForwardMail(w http.ResponseWriter, r *http.Request) { agentName := middleware.GetAgentName(r) if agentName == "" { @@ -213,6 +218,18 @@ func ForwardMail(w http.ResponseWriter, r *http.Request) { if !ok { return } + scope, ok := agentScope(w, r, agentName) + if !ok { + return + } + if scope != nil { + // 只为拿 session_id:拿不到(邮件不存在)就让 doForward 去报它自己那个 404。 + if src, err := repo.GetMailByID(r.Context(), mailID); err == nil { + if !canReadSession(w, r, agentName, scope, src.SessionID) { + return + } + } + } doForward(w, r, mailID, agentName, agentName, true) }