From 9985c41b133a64584b87e1861bcda1dbef80c065 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 09:26:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(dsh-bridge):=20=E6=96=B0=E5=BC=80=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E7=99=BB=E8=AE=B0=E5=B7=A5=E4=BD=9C=E5=8C=BA=EF=BC=8C?= =?UTF-8?q?workspaceOf=20=E5=8A=A0=E6=B7=98=E6=B1=B0=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_inbox 在**新开会话**里恒 400("缺少 workspace"),而 read_mail / read_thread / list_contacts / session_participants 都不受影响。 ## 根因 `workspaceOf()` 取不到工作区就返空,URL 不带 `&workspace=`,服务端 400 (`server/internal/handler/mail.go:626`,用户裁定:不带 workspace 是错误 发件格式)。而 `sessionWorkspace` 只有两个写入点 —— adopt(bindAdopted) 与确定性 id 恢复 —— **新开会话分支漏了**。 漏了之后只有两种情况能发现:那条会话后来恰好走过另外两条绑定路径, 或者重启后 `sessionWorkspace`(500 条上限)把它淘汰掉。 ## 改法 1. 新开会话分支补 `sessionWorkspace.set(attemptSessionId, sessionCwd)`。 ★ 取值必须是 `header.cwd`,**不是那个 `cwd`**:后者来自 `resolveWorkspaceCwd`,`to_workspace` 不可用时回退到 `mailSessionFallback` → `~/.dsh/mail-sessions/mail-`,每次邮件 都不同。拿它登记会把收件箱收窄到一个**永远读不到信**的目录 —— 比 400 更坏,因为 400 至少是可见的错误。同一文件下方工作区注册那段 (`actualCwd`)也是从 header 取,两处保持同源。 2. `workspaceOf()` 读侧兜底:反查 `sessionMap` 的 `directory`。 补在读侧而不是写侧,因为淘汰是**事后**发生的:补写站点只能覆盖 「我这次走过」,被淘汰的键下次谁来读都读不到。反查的 `directory` 在两条绑定路径上都是真实 cwd(adopt 走 `persistedCwd` 读磁盘 header),不是兜底目录。 ## 影响面 `read_inbox` 的两维收窄(session_id + workspace)是**防越界读**的机制 (2026-09-14 用户报的越界)。这个改动让更多会话能拿到 workspace,因此 **可见范围确实变宽**了 —— 这是有意的(原本是读不到,不是读得少), 但复核时应当盯住这一条。 ## 测试 新增 test/workspace-of-fallback.test.mjs(7 项)。其中 5 项把 `workspaceOf` 的真函数体抠出来在真 BoundedMap 上跑(不复制一份实现, 否则源文件改了测试还在绿)。已做变异验证:删掉读侧兜底 → 3 项红; 删掉写入点 → 2 项红。 dsh 桥 414 项全绿(改前 407,无回归)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/dsh-mail-bridge/src/index.ts | 45 ++++- .../test/workspace-of-fallback.test.mjs | 164 ++++++++++++++++++ 2 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 plugins/dsh-mail-bridge/test/workspace-of-fallback.test.mjs diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 2e845a4..619e404 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -937,12 +937,32 @@ export function apply(ctx: any, config: PluginConfig): void { * 取不到返回空串(= 退回整个 Agent 的收件箱)。宁可退回旧行为,也不猜 —— * 猜错会把别人会话的未读标掉,那是静默丢信。 */ - /** 这次调用所属会话的工作区(与 mailSessionOf 同一把键)。拿不到返回空串 —— 那时 - * 收件箱请求会 400,是刻意的:错误可见,好过静默跨工作区拿到别处的信。 */ + /** + * 这次调用所属会话的工作区(与 mailSessionOf 同一把键)。拿不到返回空串 —— 那时 + * 收件箱请求会 400,是刻意的:错误可见,好过静默跨工作区拿到别处的信。 + * + * ★ 兜底:反查 `sessionMap` 里的 `directory`。 + * + * `sessionWorkspace` 与 `sessionMap` 是**两张独立有界表**(各 500 条, + * `MAX_TRACKED_SESSIONS`),淘汰的进度也就各走各的。写站点少一处的后果 + * 是**可复现**的:`sessionMap` 命中 ⇒ 走复用分支 ⇒ `sessionWorkspace` 永远 + * 不会被这条路径补上,于是收件箱恒 400。 + * + * 补在**读侧**而不是写侧,是因为淘汰是**事后**发生的:补写站点只能覆盖 + * 「我这次走过」,被淘汰的键下次谁来读都读不到。反查的 `directory` 在两条 + * 绑定路径上都是真实 cwd(adopt 走 `persistedCwd` 读磁盘 header,新开走 + * 会话 header),不是 `resolveWorkspaceCwd` 的兜底目录 —— 那是下面 + * 那个 `mail-sessions/` 陷阱,见新开会话分支的注释。 + */ function workspaceOf(exec: any): string { - const dshSessionId = exec?.agent?.id ?? exec?.agent ?? ''; + const dshSessionId = String(exec?.agent?.id ?? exec?.agent ?? ''); if (!dshSessionId) return ''; - return sessionWorkspace.get(String(dshSessionId)) ?? ''; + const direct = sessionWorkspace.get(dshSessionId); + if (direct) return direct; + for (const bound of sessionMap.values()) { + if (bound.dshSessionId === dshSessionId && bound.directory) return bound.directory; + } + return ''; } function mailSessionOf(exec: any): string { @@ -1235,6 +1255,23 @@ export function apply(ctx: any, config: PluginConfig): void { sessionMap.set(mailSessionID, { dshSessionId: attemptSessionId, directory: cwd }); reverseMap.set(attemptSessionId, mailSessionID); mailDrivenSessions.add(attemptSessionId); + + /* 会话自己的 cwd 就是它的工作区 —— read_inbox 的收窄要用它。 + * + * 这一处原先**漏了**,后果是新开会话里 `read_inbox` 恒 400 + * (`workspaceOf` 返空 ⇒ URL 不带 workspace ⇒ 服务端 400)。 + * 漏了之后只有两条路能发现:重启后 `sessionWorkspace` 被淘汰, + * 或者那条会话恰好走过 adopt / 确定性 id 恢复分支。 + * + * ★ 取值必须是 `header.cwd`,**不能用上面那个 `cwd`**:后者是 + * `resolveWorkspaceCwd` 的结果,`to_workspace` 不可用时是兜底目录 + * (`mailSessionFallback` → `~/.dsh/mail-sessions/mail-`), + * 每次邮件都不同。拿它登记会把收件箱收窄到一个**永远读不到信**的 + * 目录 —— 比 400 更坏,因为 400 至少是可见的错误。 + * 与下面工作区注册那段同源,两处必须一致。 */ + const sessionCwd = String(handle.agent?.session?.header?.cwd ?? ''); + if (sessionCwd) sessionWorkspace.set(attemptSessionId, sessionCwd); + mailContexts.set(mailSessionID, { replyTo: data.from_name || '', subject: data.subject || '', diff --git a/plugins/dsh-mail-bridge/test/workspace-of-fallback.test.mjs b/plugins/dsh-mail-bridge/test/workspace-of-fallback.test.mjs new file mode 100644 index 0000000..ad92a05 --- /dev/null +++ b/plugins/dsh-mail-bridge/test/workspace-of-fallback.test.mjs @@ -0,0 +1,164 @@ +/** + * `workspaceOf` 必须在任何情况下给出**真实 cwd** —— 取不到就 400(dsh)。 + * + * # 背景 + * + * 投递时 `markDelivered` 就把信标成已读,所以模型若按提示词调 `read_inbox` + * (默认 `status=unread`)看不到刚投递的那封信 —— 那是文案问题。 + * 但同一句提示词在**新开会话**里连 400 都发不出来: + * + * 缺少 workspace:收件箱按工作区收窄(三维地址 name@path.session 的 path 位) + * + * 服务端要求 `workspace` 是**刻意**的(`server/internal/handler/mail.go:626` + * 用户裁定:不带 workspace 是错误发件格式)。根因是 `sessionWorkspace` + * 只有 adopt(`:1043`)与确定性 id 恢复(`:997`)两个写入点, + * **新开会话分支漏写** ⇒ `workspaceOf` 返空 ⇒ URL 不带 workspace。 + * + * # 为什么取值必须是 `header.cwd` 而不是 `cwd` + * + * `cwd` 来自 `resolveWorkspaceCwd`:`to_workspace` 不可用时**回退到兜底目录** + * `mailSessionFallback` → `~/.dsh/mail-sessions/mail-`,每次邮件都不同 + * (`lib/workspace.js:38-40`)。拿它登记会把收件箱收窄到一个永远读不到信的 + * 目录 —— 比 400 更坏,因为 400 是可见的错误。 + * + * 所以这里不只钉「要 set」,还钉**必须从 header 取**。 + */ +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'); + +/** + * 把 workspaceOf 的函数体抠出来,在真 BoundedMap 上跑。 + * + * 抠出来而不是复制一份:复制的那份会在源文件改了之后继续绿, + * 而这里要的是「源文件里的那个函数」的行为。 + */ +function extractWorkspaceOf(source, sessionWorkspace, sessionMap) { + const start = source.indexOf('function workspaceOf('); + assert.notEqual(start, -1, 'workspaceOf 不见了'); + let depth = 0; + let end = start; + for (let i = source.indexOf('{', start); i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}') { depth--; if (depth === 0) { end = i; break; } } + } + const body = source.slice(start, end + 1) + // 抠出来的函数引用模块级的两张表,替成形参以便注入测试自己的表。 + .replace(/function workspaceOf\(exec: any\): string \{/, + 'function workspaceOf(exec, sessionWorkspace, sessionMap) {'); + assert.ok( + body.startsWith('function workspaceOf(exec, sessionWorkspace, sessionMap) {'), + `抠出来的签名没替换上,实际是:${body.slice(0, 80)}`, + ); + const raw = new Function( + 'sessionWorkspace', 'sessionMap', + `return (${body});`, + )(sessionWorkspace, sessionMap); + return (exec) => raw(exec, sessionWorkspace, sessionMap); +} + +class BoundedMap { + constructor(limit = 500) { this.limit = limit; this.map = new Map(); } + get size() { return this.map.size; } + get(k) { + if (!this.map.has(k)) return undefined; + const v = this.map.get(k); + this.map.delete(k); this.map.set(k, v); + return v; + } + set(k, v) { + if (this.map.has(k)) this.map.delete(k); + this.map.set(k, v); + while (this.map.size > this.limit) { this.map.delete(this.map.keys().next().value); } + return this; + } + values() { return this.map.values(); } +} + +test('★ workspaceOf 命中 sessionWorkspace 时原样返回', () => { + const ws = new BoundedMap(); + const sm = new BoundedMap(); + ws.set('s1', '/home/program/agentmail'); + sm.set('m1', { dshSessionId: 's1', directory: '/home/program/agentmail' }); + const f = extractWorkspaceOf(src, ws, sm); + assert.equal(f({ agent: { id: 's1' } }), '/home/program/agentmail'); +}); + +test('★ sessionWorkspace 没有这条会话时,反查 sessionMap 的 directory(淘汰后的兜底)', () => { + const ws = new BoundedMap(); // 空的:模拟这条键已被淘汰 + const sm = new BoundedMap(); + sm.set('m1', { dshSessionId: 's1', directory: '/home/program/agentmail' }); + const f = extractWorkspaceOf(src, ws, sm); + assert.equal( + f({ agent: { id: 's1' } }), + '/home/program/agentmail', + 'sessionWorkspace 被淘汰时仍须给出工作区,否则收件箱恒 400', + ); +}); + +test('★ 反查必须挑 dshSessionId 匹配的那条,不能返回别的会话的目录', () => { + const ws = new BoundedMap(); + const sm = new BoundedMap(); + sm.set('m1', { dshSessionId: 'other', directory: '/home/program/other' }); + sm.set('m2', { dshSessionId: 's1', directory: '/home/program/agentmail' }); + const f = extractWorkspaceOf(src, ws, sm); + assert.equal(f({ agent: { id: 's1' } }), '/home/program/agentmail'); +}); + +test('两表都没有 ⇒ 返空(交由服务端 400:错误可见,好过静默越界读)', () => { + const ws = new BoundedMap(); + const sm = new BoundedMap(); + sm.set('m1', { dshSessionId: 'other', directory: '/home/program/other' }); + const f = extractWorkspaceOf(src, ws, sm); + assert.equal(f({ agent: { id: 's1' } }), ''); + assert.equal(f({ agent: {} }), '', '拿不到平台会话 id 时返空'); +}); + +test('★ 兜底不得返回 mail-sessions/ 兜底目录(比 400 更坏:静默读不到信)', () => { + const ws = new BoundedMap(); + const sm = new BoundedMap(); + const f = extractWorkspaceOf(src, ws, sm); + // sessionMap.directory 在两条绑定路径上都是真实 cwd;这条断言是反向对照: + // 万一有人把 resolveWorkspaceCwd 的兜底值写进来,下面的形状必须被抓住。 + const src2 = src.replace(/directory: cwd/g, 'directory: `/home/program/agentmail`'); + assert.notEqual(src2, src, '样本没替换上,断言无效'); + sm.set('m1', { dshSessionId: 's1', directory: '/root/.dsh/mail-sessions/mail-abc' }); + assert.equal( + f({ agent: { id: 's1' } }), + '/root/.dsh/mail-sessions/mail-abc', + 'workspaceOf 不该自己判断目录是否合法——判断在写入侧;这里只锁定它忠实转述', + ); + // 真正的防线在下面的源码断言上:写入侧不得用 cwd。 +}); + +test('★ 新开会话分支必须登记 sessionWorkspace,且取值来自 header.cwd', () => { + // 缺这一处 = 新开会话里 read_inbox 恒 400(用户报的「缺少 workspace」)。 + const re = /if \(mailSessionID\) \{[\s\S]*?mailDrivenSessions\.add\(attemptSessionId\);[\s\S]*?sessionWorkspace\.set\(attemptSessionId, sessionCwd\);/; + assert.match(src, re, '新开会话分支没有登记 sessionWorkspace —— 这就是 400 的根因'); + assert.ok( + /const sessionCwd = String\(handle\.agent\?\.session\?\.header\?\.cwd \?\? ''\);/.test(src), + '登记值必须取自会话 header 的 cwd', + ); + assert.ok( + !/sessionWorkspace\.set\(attemptSessionId, cwd\)/.test(src), + '不能用 cwd 登记:那是 resolveWorkspaceCwd 的结果,可能是 mail-sessions/ 兜底目录', + ); + assert.ok( + dist.includes('sessionWorkspace.set(attemptSessionId, sessionCwd)'), + 'dist 里没有 —— 忘了 npm run build?', + ); +}); + +test('★ 三条绑定路径都要登记工作区', () => { + // adopt(bindAdopted)、确定性 id 恢复、新开会话。少一处的后果是 + // 「只有走过那条路径的会话能读收件箱」——与用户看到的一致。 + const sites = src.match(/sessionWorkspace\.set\(/g) || []; + assert.equal(sites.length, 3, `登记点应有 3 处,实际 ${sites.length} 处`); + assert.ok(dist.match(/sessionWorkspace\.set\(/g)?.length === 3, 'dist 里应是 3 处'); +});