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