Files
MailUI4Agents/plugins/pi-mail-bridge/test/read-scope-wiring.test.mjs
JianFeeeee 1b8cd43935 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 个文件。)
2026-09-14 23:03:25 +08:00

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