fix(auth): 四家桥的读端点也带上会话收窄 + 转发同一条命(工作区隔离第 2 步)

第 1 步(1b8cd43)把工作区判据放在服务端、pi 桥接上了线。这一步补齐另外四家,
并把**转发**纳入:转发是"把原文引出去",能转发就等于能读到那条线索的全部内容,
与 read_mail 同一条命(服务端 ForwardMail 也加了同一道校验)。

四家各自的会话来源,与各自的 read_inbox 同一处(不引入第二个来源):
- dsh:`mailSessionOf(exec)`(工具第二个参数)—— 五个读工具原本没接 exec,这次补上
- opencode:`reverseMap.get(context.sessionID)`
- zcode:`process.env.AGENTMAIL_SESSION_ID`(一轮一个进程)
- homeagent:`p.currentSessionID`(新增 `scopeQuery(sep)`,与 inboxURL 同构)

判据(每条两侧都钉:包住了 / 没包住的不存在):
- dsh:静态对照,且额外钉 **dist** —— 那是真被 dsh 加载的那份(main: dist/index.js),
  src 改了忘了 build 就是"源码对、线上旧代码"
- opencode / zcode:同上(opencode 还钉"会话来自 context 而不是模块级变量")
- homeagent:起 httptest 当网关,**五个读工具 + 转发真调一遍**,断言请求 URL 带
  session_id;对照侧:不在回合里(currentSessionID 为空)时不许带
- pi:把 post 的 URL 也纳入记录,forward 进用例表

★ zcode 那条判据我第一版**对照组写错**了:对照组只写裸 URL,而它本来就是
`withScope(\`裸URL\`)` 的子串 ⇒ `!includes(bare)` 恒假。夹具形状不对时判据会以
"恒红/恒绿"的方式骗人(这次是恒红,一眼可见;恒绿就麻烦了)。

变异:homeagent 去掉 read_mail 的收窄 ⇒ 恰好那条断言红。

(工作区共享,只 add 了上面这 12 个文件;dsh 的 dist 是 gitignore 的,由
redeploy-plugin.sh 在 staging 里构建。)
This commit is contained in:
2026-09-14 23:18:12 +08:00
parent 1b8cd43935
commit 2a5e3d7d15
12 changed files with 435 additions and 36 deletions

View File

@ -93,6 +93,26 @@ export function buildTools({ client, agentName }) {
}
};
/**
* 读类端点的会话收窄参数。
*
* 与 read_inbox 同一个理由(不收窄会把别会话的未读标掉 ⇒ 静默丢信),
* 但服务端现在拿它多干一件事:**由这条会话反查工作区**,只有同工作区的会话才放行。
* 为什么必须有一维:一个 Agent 同时服务所有工作区(注册时 workspaces 为空),
* 不收窄时在 TrueAgent 里干活的 worker 能读到 agentmail 的整条线索。
*
* 驱动每轮把本轮邮件会话注入 AGENTMAIL_SESSION_ID(授权钩子本来就用它),
* MCP 子进程继承同一个 env ⇒ 直接读即可,且天然并发安全(一轮一个进程)。
* 在调用时读而不是 import 时读死,避免复用进程时拿到旧值。
* 拿不到就原样返回:宁可退回旧行为(服务端会记警告),也不猜一个。
*/
const withScope = (path) => {
const sid = process.env.AGENTMAIL_SESSION_ID || '';
if (!sid) return path;
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}session_id=${encodeURIComponent(sid)}`;
};
const tools = [];
// ─── 读 ────────────────────────────────────────────────────────
@ -151,7 +171,7 @@ export function buildTools({ client, agentName }) {
guard();
const id = str(obj(args).mail_id);
if (!id) throw new Error('缺少 mail_id');
const data = await client.get(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`);
const data = await client.get(withScope(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`));
const mail = data?.mail || data;
const lines = [renderMail(mail, 0, agentName)];
if (Array.isArray(data?.participants) && data.participants.length) {
@ -182,7 +202,7 @@ export function buildTools({ client, agentName }) {
const id = str(a.mail_id);
if (!id) throw new Error('缺少 mail_id');
const qs = Number.isFinite(a.offset) ? `?offset=${a.offset}` : '';
const data = await client.get(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`);
const data = await client.get(withScope(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`));
return renderThread(data, agentName);
}
});
@ -275,7 +295,7 @@ export function buildTools({ client, agentName }) {
const a = obj(args);
if (!str(a.mail_id) || !str(a.to)) throw new Error('缺少 mail_id 或 to');
const result = await client.post(
`/mail/${encodeURIComponent(str(a.mail_id))}/forward`,
withScope(`/mail/${encodeURIComponent(str(a.mail_id))}/forward`),
{ to: str(a.to), comment: str(a.comment) }
);
return `已转发(新邮件 ID: ${result?.mail_id ?? '?'},会话: ${result?.session_id ?? '?'})。`;
@ -351,7 +371,7 @@ export function buildTools({ client, agentName }) {
const qs = new URLSearchParams();
if (name) qs.set('name', name);
if (path) qs.set('path', path);
const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`);
const data = await client.get(withScope(`/agent/contacts/suggest?${qs.toString()}`));
if (!name) return renderNameSuggestions(data?.names || data?.suggestions || []);
if (!path) return renderPathSuggestions(data?.paths || [], name);
return renderSessionSuggestions(data, name, path);
@ -369,7 +389,7 @@ export function buildTools({ client, agentName }) {
async run(args) {
guard();
const limit = Number.isFinite(obj(args).limit) ? obj(args).limit : 20;
const data = await client.get(`/agent/contacts?limit=${limit}`);
const data = await client.get(withScope(`/agent/contacts?limit=${limit}`));
return renderContacts(data, limit);
}
});
@ -389,7 +409,7 @@ export function buildTools({ client, agentName }) {
guard();
const sid = str(obj(args).session_id);
if (!sid) throw new Error('缺少 session_id');
const data = await client.get(`/agent/sessions/${encodeURIComponent(sid)}/participants`);
const data = await client.get(withScope(`/agent/sessions/${encodeURIComponent(sid)}/participants`));
return renderParticipants(data);
}
});

View File

@ -0,0 +1,61 @@
/**
* 五个读类端点的请求都要带上**自己那条邮件会话**(zcode)。
*
* read_inbox 早就有这一维(缺陷:列表按 Agent 列且按契约标已读 ⇒ A 会话标掉
* B 会话的未读 ⇒ 静默丢信)。服务端现在拿它多干一件事:**由这条会话反查工作区**,
* 只有同工作区的会话才放行 —— 一个 Agent 同时服务所有工作区,不收窄时在 TrueAgent
* 里干活的 worker 能读到 agentmail 的整条线索(用户 2026-09-14 报的越界)。
*
* 服务端语义由 server/internal/repo/workspace_scope_test.go 负责;这里只验接线。
*
* 判据两侧都钉:**包住了**(withScope(...) 的整句存在)与**没包住**(去掉包装的
* 那句不存在)。只验前者的话,把 withScope 写成恒等函数也能过。
*/
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';
const HERE = dirname(fileURLToPath(import.meta.url));
const tools = readFileSync(join(HERE, '..', 'lib', 'tools.mjs'), 'utf8');
// 每个端点两句话:包住的 / 没包住的。
const ENDPOINTS = [
['read_mail',
'withScope(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`)',
'client.get(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`)'],
['read_thread',
'withScope(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`)',
'client.get(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`)'],
['suggest_address',
'withScope(`/agent/contacts/suggest?${qs.toString()}`)',
'client.get(`/agent/contacts/suggest?${qs.toString()}`)'],
['list_contacts',
'withScope(`/agent/contacts?limit=${limit}`)',
'client.get(`/agent/contacts?limit=${limit}`)'],
['session_participants',
'withScope(`/agent/sessions/${encodeURIComponent(sid)}/participants`)',
'client.get(`/agent/sessions/${encodeURIComponent(sid)}/participants`)'],
];
for (const [name, scoped, bare] of ENDPOINTS) {
test(`★ ${name} 的请求走 withScope(...)`, () => {
assert.ok(bare.includes('client.get('), '夹具形状不对:对照组必须是没包住的那句');
assert.ok(tools.includes(scoped), `${name} 的 URL 没有包在 withScope 里:${scoped}`);
assert.ok(!tools.includes(bare), `${name} 还有一处没包住的写法:${bare}`);
});
}
test('★ forward_mail 也带上收窄(它读的是原文)', () => {
const scoped = 'withScope(`/mail/${encodeURIComponent(str(a.mail_id))}/forward`)';
const bare = 'client.post(\n `/mail/${encodeURIComponent(str(a.mail_id))}/forward`,';
assert.ok(tools.includes(scoped), '转发的 URL 没有包在 withScope 里');
assert.ok(!tools.includes(bare), '转发还有一处没包住的写法');
});
test('withScope 在调用时读 env,且自己判断分隔符', () => {
assert.match(tools, /const sid = process\.env\.AGENTMAIL_SESSION_ID \|\| ''/, '调用时读,不是 import 时读死');
assert.ok(tools.includes("path.includes('?') ? '&' : '?'"), '已有查询串要用 & 分隔');
assert.match(tools, /if \(!sid\) return path/, '拿不到会话就原样返回,不拼半截 URL');
});