第 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 里构建。)
110 lines
4.9 KiB
JavaScript
110 lines
4.9 KiB
JavaScript
/**
|
||
* 读类工具必须把自己的**邮件会话 id** 递给服务端 —— 这是工作区隔离的前提。
|
||
*
|
||
* # 两条缺陷,同一个根因
|
||
*
|
||
* 1. 2026-09-14 上午:`read_inbox` 按 **Agent** 列未读并标已读 ⇒ A 会话的 worker
|
||
* 标掉 B 会话的未读(见 `inbox-session-scope.test.mjs`)。
|
||
* 2. 同一天用户报的越界:「agentmail 工作区的邮件会话被 trueagent 工作区的 agent
|
||
* 看到了」。根因更深一层:**隔离单位选的是 Agent**。一个 Agent 同时服务所有
|
||
* 工作区(注册时 workspaces 为空,cwd 由每封邮件的 to_workspace 决定),
|
||
* 所以 `AgentCanAccessSession`("这个 Agent 参与过这条会话")在 TrueAgent 的
|
||
* worker 眼里,对 agentmail 的会话也成立。
|
||
*
|
||
* 服务端现在的判据是「参与过 **且** 两条会话的 workspace 相同」,而 workspace
|
||
* **不接受调用方直接声明** —— 只认一条会话 id,由服务端反查。所以插件的义务只有
|
||
* 一条:把 worker 当前那条邮件会话的 id 带上。
|
||
*
|
||
* # 判据是行为,不是正则
|
||
*
|
||
* 直接调工具、用假 client 收集请求 URL —— 断言"这次调用到底请求了什么"。
|
||
* 静态正则只能证明"文件里有那句话",而这里要钉的是每个工具各自的 URL。
|
||
* 反向对照:`getMailSessionId` 返回空串时 URL 里**不许**出现 session_id
|
||
* (证明上面那条断言不是恒真)。
|
||
*/
|
||
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';
|
||
|
||
import { createMailTools } from '../src/tools.mjs';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const worker = readFileSync(join(HERE, '..', 'src', 'worker.mjs'), 'utf8');
|
||
|
||
const SID = '11111111-2222-3333-4444-555555555555';
|
||
|
||
/** 按路径给一份"形状对"的空回包:断言的是 URL,不是渲染结果。 */
|
||
function fakeResponse(url) {
|
||
if (url.startsWith('/agent/contacts/suggest')) return { kind: 'name', suggestions: [] };
|
||
if (url.startsWith('/agent/contacts')) return { contacts: [] };
|
||
if (url.includes('/participants')) return { participants: [] };
|
||
if (url.includes('/thread')) return { mails: [], session_alias: '' };
|
||
if (url.startsWith('/agent/mail/')) return { mail: {}, participants: [] };
|
||
return {};
|
||
}
|
||
|
||
function toolsWith(urls, sessionId) {
|
||
const client = {
|
||
get: async (u) => { urls.push(u); return fakeResponse(u); },
|
||
// post 也要记:转发是"把原文引出去",读的同样是邮件原文。
|
||
post: async (u) => { urls.push(u); return { mail_id: 'new-1', session_id: 's-1' }; },
|
||
downloadFile: async () => Buffer.from(''),
|
||
};
|
||
const list = createMailTools({
|
||
client, log: () => {}, agentName: 'pi',
|
||
getMailSessionId: () => sessionId,
|
||
});
|
||
return (name) => {
|
||
const t = list.find(x => x.name === name);
|
||
assert.ok(t, `找不到工具 ${name}`);
|
||
return t;
|
||
};
|
||
}
|
||
|
||
// 每个"会读服务端"的工具 + 一组能走到发请求那一步的参数。
|
||
const READ_CALLS = [
|
||
{ tool: 'read_inbox', args: {} },
|
||
{ tool: 'list_contacts', args: {} },
|
||
{ tool: 'read_mail', args: { mail_id: 'm-1' } },
|
||
{ 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) {
|
||
test(`★ ${c.tool} 的请求带上自己的邮件会话 id`, async () => {
|
||
const urls = [];
|
||
await toolsWith(urls, SID)(c.tool).execute('call-1', c.args);
|
||
assert.equal(urls.length, 1, `${c.tool} 应当只发一次请求(实际 ${urls.length} 次)`);
|
||
assert.ok(
|
||
urls[0].includes(`session_id=${SID}`),
|
||
`${c.tool} 的请求没带会话收窄:${urls[0]}`
|
||
);
|
||
});
|
||
|
||
test(`${c.tool}:没有会话 id 时不硬拼空参数(旧语义那条路)`, async () => {
|
||
const urls = [];
|
||
await toolsWith(urls, '')(c.tool).execute('call-1', c.args);
|
||
assert.equal(urls.length, 1);
|
||
assert.ok(
|
||
!urls[0].includes('session_id='),
|
||
`${c.tool} 在拿不到会话 id 时不该拼出空的 session_id:${urls[0]}`
|
||
);
|
||
});
|
||
}
|
||
|
||
test('worker 把邮件会话 id 递给工具(闭包,不是快照)', () => {
|
||
assert.match(worker, /getMailSessionId: \(\) => mailContext\.sessionID/, '要传闭包而不是快照');
|
||
assert.match(worker, /sessionID: msg\.data\?\.session_id/, '会话 id 来自 SSE 事件');
|
||
});
|
||
|
||
test('★ 判据自检:不带收窄的旧写法必须判红', () => {
|
||
const oldCall = "await client.get('/agent/contacts')";
|
||
assert.equal(/withScope\('\/agent\/contacts'\)/.test(oldCall), false,
|
||
'旧写法既没 withScope 也没 session_id —— 正则若匹配上,说明判据恒真');
|
||
});
|