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:
2026-09-14 23:03:25 +08:00
parent 4c2bf26c42
commit 1b8cd43935
7 changed files with 602 additions and 34 deletions

View File

@ -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 || '?'}`,

View 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 —— 正则若匹配上,说明判据恒真');
});