Files
MailUI4Agents/plugins/dsh-mail-bridge/test/workspace-of-fallback.test.mjs
JianFeeeee 9985c41b13 fix(dsh-bridge): 新开会话登记工作区,workspaceOf 加淘汰兜底
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-<uuid>`,每次邮件
   都不同。拿它登记会把收件箱收窄到一个**永远读不到信**的目录 ——
   比 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) <noreply@anthropic.com>
2026-09-28 09:26:15 +08:00

165 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* `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-<uuid>`,每次邮件都不同
* (`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 处');
});