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>
This commit is contained in:
@ -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-<uuid>`),
|
||||
* 每次邮件都不同。拿它登记会把收件箱收窄到一个**永远读不到信**的
|
||||
* 目录 —— 比 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 || '',
|
||||
|
||||
164
plugins/dsh-mail-bridge/test/workspace-of-fallback.test.mjs
Normal file
164
plugins/dsh-mail-bridge/test/workspace-of-fallback.test.mjs
Normal file
@ -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-<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 处');
|
||||
});
|
||||
Reference in New Issue
Block a user