fix(auth): 工作区成为读权限的边界 —— Agent 侧读端点按会话工作区收窄
用户报的:「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 个文件。)
This commit is contained in:
@ -62,14 +62,33 @@ const text = (s) => ({ content: [{ type: 'text', text: s }] });
|
||||
* 「更严格更好」,而是与 pi 的参数传递机制直接冲突。
|
||||
*/
|
||||
export function createMailTools({ client, log, agentName = '', onReconnect, getMailSessionId = () => ''}) {
|
||||
// 收件箱列表的会话收窄参数。
|
||||
// ─── 会话收窄参数(读类工具的公共前缀)───
|
||||
//
|
||||
// ★ 缺了它,这条列表会把**别的会话**的来信一起列出来并按契约标成已读 ——
|
||||
// 别的会话的 worker 之后按 ?status=unread 补投时就再也看不到那封信。
|
||||
// 用户原话:「不同 session 的 agent 都可以看到全部邮件」。
|
||||
const inboxScope = () => {
|
||||
// 服务端拿它干两件事:
|
||||
// ① 收件箱只列/只标本会话的邮件(缺了它,A 会话的 worker 会把 B 会话的未读标掉
|
||||
// ⇒ 补投再也看不到那封信 = 静默丢信。用户原话「不同 session 的 agent
|
||||
// 都可以看到全部邮件」);
|
||||
// ② **工作区隔离**:服务端由这条 session 反查 workspace,只有同工作区的会话才放行。
|
||||
// 一个 Agent 同时服务所有工作区,不带这一维时在 TrueAgent 里干活的 worker
|
||||
// 能读到 agentmail 的整条线索(2026-09-14 用户报的那类越界)。
|
||||
//
|
||||
// 取值只能是**邮件会话 id**,且必须由 worker 闭包递进来(模型改不了它)——
|
||||
// 服务端不接受调用方直接声明工作区,那等于自己给自己发通行证。
|
||||
const scopeQS = () => {
|
||||
const sid = typeof getMailSessionId === 'function' ? getMailSessionId() : '';
|
||||
return sid ? `&session_id=${encodeURIComponent(sid)}` : '';
|
||||
return sid ? `session_id=${encodeURIComponent(sid)}` : '';
|
||||
};
|
||||
// 已经带了查询串的 URL 用这个(收件箱那条要 append 到 status/limit 后面)
|
||||
const inboxScope = () => {
|
||||
const q = scopeQS();
|
||||
return q ? `&${q}` : '';
|
||||
};
|
||||
// 任意路径用这个:自己判断该用 ? 还是 &。缺了 scope 就原样返回,
|
||||
// 让服务端走"旧语义 + 记警告"那条路,而不是拼出一个半截 URL。
|
||||
const withScope = (path) => {
|
||||
const q = scopeQS();
|
||||
if (!q) return path;
|
||||
return path.includes('?') ? `${path}&${q}` : `${path}?${q}`;
|
||||
};
|
||||
|
||||
const sendMail = {
|
||||
@ -293,6 +312,10 @@ export function createMailTools({ client, log, agentName = '', onReconnect, getM
|
||||
const qs = new URLSearchParams();
|
||||
if (name) qs.set('name', name);
|
||||
if (path) qs.set('path', path);
|
||||
// 会话候选按调用方的工作区收窄(name/path 两段不收窄:跨工作区**发信**
|
||||
// 是设计允许的,被挡的只是"浏览别的会话的标题/别名")。
|
||||
const sid = typeof getMailSessionId === 'function' ? getMailSessionId() : '';
|
||||
if (sid) qs.set('session_id', sid);
|
||||
const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`);
|
||||
// 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端
|
||||
// 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
|
||||
@ -308,7 +331,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect, getM
|
||||
name: 'list_contacts',
|
||||
label: 'ListContacts',
|
||||
description:
|
||||
'列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。' +
|
||||
'列出自己参与过的会话(**只含本工作区**)及各自的可投递地址、未读数、剩余往返预算。' +
|
||||
'用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
@ -317,7 +340,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect, getM
|
||||
},
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const data = await client.get('/agent/contacts');
|
||||
const data = await client.get(withScope('/agent/contacts'));
|
||||
return text(renderContacts(data, params.limit || 20));
|
||||
},
|
||||
};
|
||||
@ -336,7 +359,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect, getM
|
||||
required: ['session_id'],
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const data = await client.get(`/agent/sessions/${params.session_id}/participants`);
|
||||
const data = await client.get(withScope(`/agent/sessions/${params.session_id}/participants`));
|
||||
return text(renderParticipants(data));
|
||||
},
|
||||
};
|
||||
@ -357,7 +380,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect, getM
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const qs = params.offset ? `?offset=${params.offset}` : '';
|
||||
const data = await client.get(`/agent/mail/${params.mail_id}/thread${qs}`);
|
||||
const data = await client.get(withScope(`/agent/mail/${params.mail_id}/thread${qs}`));
|
||||
return text(renderThread(data, agentName));
|
||||
},
|
||||
};
|
||||
@ -376,7 +399,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect, getM
|
||||
required: ['mail_id'],
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const data = await client.get(`/agent/mail/${params.mail_id}`);
|
||||
const data = await client.get(withScope(`/agent/mail/${params.mail_id}`));
|
||||
const m = data?.mail || {};
|
||||
const lines = [
|
||||
`发件人: ${m.from_name || '?'}`,
|
||||
|
||||
106
plugins/pi-mail-bridge/test/read-scope-wiring.test.mjs
Normal file
106
plugins/pi-mail-bridge/test/read-scope-wiring.test.mjs
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 读类工具必须把自己的**邮件会话 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 —— 正则若匹配上,说明判据恒真');
|
||||
});
|
||||
Reference in New Issue
Block a user