/** * `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 处'); });