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

@ -243,8 +243,10 @@ const forwardMailTool = {
subject: z.string().optional().describe("自定义主题;留空则自动加 Fwd: 前缀"),
session_alias: z.string().optional().describe("仅在目标地址以 .new 结尾时生效:给新会话命名"),
},
async execute(args) {
const result = await apiPost(`/mail/${args.mail_id}/forward`, {
async execute(args, context) {
// 转发要读原文 ⇒ 与 read_mail 同一条命:带上自己那条会话,
// 服务端据此要求原文与调用方同工作区。
const result = await apiPost(withScope(`/mail/${args.mail_id}/forward`, context), {
to: args.to,
comment: args.comment || "",
cc: args.cc || "",
@ -364,6 +366,24 @@ const downloadAttachmentTool = {
//
// 渲染逻辑在 lib/discovery.js(与平台 SDK 无关,三平台共用)。
/**
* 读类端点的会话收窄参数 —— 与 read_inbox 同一个理由,作用更强。
*
* 服务端拿这条会话**反查工作区**,只有同工作区的会话才放行。为什么必须有一维:
* 一个 Agent 同时服务所有工作区(注册时 workspaces 为空),不收窄时在 TrueAgent
* 里干活的 worker 能读到 agentmail 的整条线索(2026-09-14 用户报的那类越界)。
*
* 从 context.sessionID 经 reverseMap 换成邮件会话(并发安全,不依赖模块级
* "当前会话")—— 与 read_inbox 一致。拿不到就原样返回:宁可退回旧行为
* (服务端会记警告),也不猜一个。
*/
function withScope(path, context) {
const mailSessionID = reverseMap.get(String(context?.sessionID ?? "")) || "";
if (!mailSessionID) return path;
const sep = path.includes("?") ? "&" : "?";
return `${path}${sep}session_id=${encodeURIComponent(mailSessionID)}`;
}
const suggestAddressTool = {
description:
"查询可用的收件人地址,用于精准发信。分三段逐步查:不带参数给候选收件人名;" +
@ -373,12 +393,16 @@ const suggestAddressTool = {
name: z.string().optional().describe("收件人名;留空则列出所有候选收件人"),
path: z.string().optional().describe("工作目录;与 name 同时给出才列会话"),
},
async execute(args) {
async execute(args, context) {
const name = (args.name || "").trim();
const path = (args.path || "").trim();
const qs = new URLSearchParams();
if (name) qs.set("name", name);
if (path) qs.set("path", path);
// 会话候选按调用方的工作区收窄(name/path 两段不收窄:跨工作区**发信**
// 是设计允许的,被挡的只是"浏览别的会话的标题/别名")。
const mailSessionID = reverseMap.get(String(context?.sessionID ?? "")) || "";
if (mailSessionID) qs.set("session_id", mailSessionID);
const data = await apiGet(`/agent/contacts/suggest?${qs.toString()}`);
// 按服务端回的 kind 分派,而不是按本地参数判断:省略 path 与传空串在
@ -396,13 +420,13 @@ const suggestAddressTool = {
const listContactsTool = {
description:
"列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。" +
"列出自己参与过的会话(**只含本工作区**)及各自的可投递地址、未读数、剩余往返预算。" +
"用于回答「我还有什么没处理」以及「上次跟某人聊的那条线索地址是什么」。",
args: {
limit: z.number().optional().describe("最多列出多少条,默认 20"),
},
async execute(args) {
const data = await apiGet("/agent/contacts");
async execute(args, context) {
const data = await apiGet(withScope("/agent/contacts", context));
return renderContacts(data, args.limit || 20);
},
};
@ -414,8 +438,8 @@ const sessionParticipantsTool = {
args: {
session_id: z.string().describe("会话 ID(read_inbox 未直接给出时可从 read_thread 或新邮件通知取得)"),
},
async execute(args) {
const data = await apiGet(`/agent/sessions/${args.session_id}/participants`);
async execute(args, context) {
const data = await apiGet(withScope(`/agent/sessions/${args.session_id}/participants`, context));
return renderParticipants(data);
},
};
@ -428,9 +452,9 @@ const readThreadTool = {
mail_id: z.string().describe("线索中任一封邮件的 ID"),
offset: z.number().optional().describe("分页偏移,续取时传上次返回的 next_offset"),
},
async execute(args) {
async execute(args, context) {
const qs = args.offset ? `?offset=${args.offset}` : "";
const data = await apiGet(`/agent/mail/${args.mail_id}/thread${qs}`);
const data = await apiGet(withScope(`/agent/mail/${args.mail_id}/thread${qs}`, context));
return renderThread(data, AGENT_NAME);
},
};
@ -442,8 +466,8 @@ const readMailTool = {
args: {
mail_id: z.string().describe("邮件 ID"),
},
async execute(args) {
const data = await apiGet(`/agent/mail/${args.mail_id}`);
async execute(args, context) {
const data = await apiGet(withScope(`/agent/mail/${args.mail_id}`, context));
const m = data?.mail || {};
const lines = [
`发件人: ${m.from_name || "?"}`,

View File

@ -0,0 +1,59 @@
/**
* 五个读类端点的请求都要带上**自己那条邮件会话**(opencode)。
*
* read_inbox 早就有这一维(缺陷:列表按 Agent 列且按契约标已读 ⇒ A 会话标掉
* B 会话的未读 ⇒ 静默丢信)。服务端现在拿它多干一件事:**由这条会话反查工作区**,
* 只有同工作区的会话才放行 —— 一个 Agent 同时服务所有工作区,不收窄时在 TrueAgent
* 里干活的 worker 能读到 agentmail 的整条线索(用户 2026-09-14 报的越界)。
*
* 服务端语义由 server/internal/repo/workspace_scope_test.go 负责;这里只验接线。
* 两侧都钉:包住了 / 没包住的不存在 —— 只验前者的话,把 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 src = readFileSync(join(HERE, '..', 'index.js'), 'utf8');
const ENDPOINTS = [
['read_mail',
'withScope(`/agent/mail/${args.mail_id}`, context)',
'apiGet(`/agent/mail/${args.mail_id}`)'],
['read_thread',
'withScope(`/agent/mail/${args.mail_id}/thread${qs}`, context)',
'apiGet(`/agent/mail/${args.mail_id}/thread${qs}`)'],
['list_contacts',
'withScope("/agent/contacts", context)',
'apiGet("/agent/contacts")'],
['session_participants',
'withScope(`/agent/sessions/${args.session_id}/participants`, context)',
'apiGet(`/agent/sessions/${args.session_id}/participants`)'],
];
for (const [name, scoped, bare] of ENDPOINTS) {
test(`★ ${name} 的请求走 withScope(...)`, () => {
assert.ok(src.includes(scoped), `${name} 的 URL 没有包在 withScope 里:${scoped}`);
assert.ok(!src.includes(bare), `${name} 还有一处没包住的写法:${bare}`);
});
}
test('★ forward_mail 也带上收窄(它读的是原文)', () => {
const scoped = 'withScope(`/mail/${args.mail_id}/forward`, context)';
const bare = 'apiPost(`/mail/${args.mail_id}/forward`, {';
assert.ok(src.includes(scoped), '转发的 URL 没有包在 withScope 里');
assert.ok(!src.includes(bare), '转发还有一处没包住的写法');
});
test('★ suggest_address 的会话候选也带上收窄(它列的是别的会话的别名与标题)', () => {
assert.ok(src.includes('qs.set("session_id", mailSessionID)'), 'suggest 没把 session_id 放进查询串');
});
test('withScope 的会话来自平台上下文(并发安全),不是模块级变量', () => {
assert.match(src, /function withScope\(path, context\)/, 'withScope 得接住 context');
assert.match(src, /reverseMap\.get\(String\(context\?\.sessionID/, '经 reverseMap 换邮件会话');
assert.ok(src.includes('if (!mailSessionID) return path;'), '拿不到会话就原样返回');
assert.ok(!/let\s+currentMailSessionID/.test(src), '不得用模块级"当前会话"变量');
});