用户报的:「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了, 还需要我亲自去解释。」 ## 根因不是漏了一个 WHERE,是隔离单位选错了 Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace` 决定), 所以**一个 Agent 同时服务所有工作区**。而可见性判据一直是 `AgentCanAccessSession(agentName, sid)` = "这个 Agent 名出现在这条会话的 from/to/cc 里" —— 于是同一个 agent `pi`,在 TrueAgent 里干活的 worker 眼里,对 agentmail 的会话 也成立。 现场证据:`mail_reads` 里 08:11–09:19 有 8 次「同一瞬间读了多个不同工作区的会话」 (08:23:59 一次跨 agentmail / TrueAgent / webui4frpc 三条会话),最后一次是 09:19:11 —— 正好停在 `read_inbox` 按会话收窄那个提交(552fbc7,09:19:25)之前。 更要紧的是 `mail_reads` 只记 `reader_name`、**没有「读的人当时在哪个工作区」这一列**, 所以这类越界读在数据上与正常读**无法区分** —— 这也是为什么只能由用户自己去解释。 ## 改法:补一维,而不是逐个端点打补丁 - 新增 `repo.AgentMayReadSession(agentName, scope, target)`:① 参与过(原有判据) ② 两条会话的 `workspace` 相同(新增)。`scope` = 调用方当前所在的那条会话。 - 服务端只认一条**会话 id**(`?session_id=`),由它反查 workspace —— **不接受调用方直接声明工作区**,否则等于让它自己给自己发通行证。 - 应用到四个读端点:`read_mail` / `read_thread` / `session_participants` / `list_contacts`,以及 `contacts/suggest` 的**会话候选**(name/path 两段不收窄: 跨工作区**发信**是设计允许的,被挡的只是"浏览同行的线索")。 - 未声明 `session_id` 时保留旧语义(放行)并**记警告日志**:迁移要能分步走, 但"还有谁没接线"必须可观测(另四家桥仍走这条路)。 - pi 桥:五个读工具全部带上自己那条邮件会话 id(由 worker 闭包注入,模型改不了)。 ## 顺手修掉一个真 bug 联系人查询的未读计数子查询里一直有 `r.reader_name = $1`,而原写法是 "forUser 为空就不传参" ⇒ $1 悬空:Postgres 直接报 `no parameter $1`, SQLite 把 `= $1` 当 `= NULL` 比、次次不成立(未读计数静默退化成"全部未归档")。 管理员 `?all=true` 走的正是这条路。现在 $1 恒传。 ## 判据(两侧都验 + 变异) - repo:同工作区放行 / 跨工作区拒且 reason 分得清 / 没参与过拒 / 未声明 scope 的旧语义;列表类有反向对照(不带收窄两条都在); 建议补全同工作区照常给候选、跨工作区查路径不给、不带收窄会给(对照组)。 - ★ 这条判据我第一版**写错了对照组**:拿 path=wsA 去比 —— 而 path 本来就收窄, 于是"不带收窄"也只剩一条,判据等于空的。改成拿 path=wsB 比才有区分力。 - 变异 3 处(拿掉工作区判据 / ListContactsInWorkspace 不收窄 / SuggestSessionCandidatesInWorkspace 不收窄)⇒ 各自恰好红在对应那条断言。 - pi 桥 14 条:6 个读工具 × 带上/不带 scope 两侧 + worker 闭包 + 自检; 变异 read_mail 去掉收窄 ⇒ 恰好那一条红。 (工作区是多会话共用的,本次只 add 了 server/ 与 plugins/pi-mail-bridge/ 的 7 个文件。)
107 lines
4.6 KiB
JavaScript
107 lines
4.6 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: async () => ({}),
|
||
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' } },
|
||
];
|
||
|
||
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 —— 正则若匹配上,说明判据恒真');
|
||
});
|