Files
MailUI4Agents/plugins/pi-mail-bridge/test/read-scope-wiring.test.mjs
JianFeeeee 2a5e3d7d15 fix(auth): 四家桥的读端点也带上会话收窄 + 转发同一条命(工作区隔离第 2 步)
第 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 里构建。)
2026-09-14 23:18:12 +08:00

110 lines
4.9 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.

/**
* 读类工具必须把自己的**邮件会话 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 —— 正则若匹配上,说明判据恒真');
});