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

@ -856,6 +856,25 @@ export function apply(ctx: any, config: PluginConfig): void {
return reverseMap.get(dshSessionId) ?? '';
}
/**
* 给读类端点拼上**自己那条邮件会话**的收窄参数。
*
* read_inbox 早就有这一维(理由:不收窄会把别会话的未读标掉 ⇒ 静默丢信)。
* 服务端现在拿它多干一件事:**由这条会话反查工作区**,只有同工作区的会话才放行。
* 为什么必须有一维:一个 Agent 同时服务所有工作区(注册时 workspaces 为空),
* 不收窄时在 TrueAgent 里干活的 worker 能读到 agentmail 的整条线索
* (2026-09-14 用户报的那类越界)。
*
* 取不到会话 id 就原样返回:宁可退回旧行为(服务端会记一条警告日志),
* 也不猜一个 —— 猜错会拼出投不到或投到别处的地址。
*/
function withScope(path: string, exec: any): string {
const sid = mailSessionOf(exec);
if (!sid) return path;
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}session_id=${encodeURIComponent(sid)}`;
}
function findLiveDshSession(mailSessionID: string): { id: string; agent: any } | undefined {
const bound = sessionMap.peek(mailSessionID);
if (bound) {
@ -1423,12 +1442,16 @@ export function apply(ctx: any, config: PluginConfig): void {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
async execute(args: any, exec?: any): Promise<string> {
const name = String(args.name || '').trim();
const path = String(args.path || '').trim();
const qs = new URLSearchParams();
if (name) qs.set('name', name);
if (path) qs.set('path', path);
// 会话候选按调用方的工作区收窄(name/path 两段不收窄:跨工作区**发信**
// 是设计允许的,被挡的只是"浏览别的会话的标题/别名")。
const sid = mailSessionOf(exec);
if (sid) qs.set('session_id', sid);
const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`);
// 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端
// 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
@ -1452,8 +1475,8 @@ export function apply(ctx: any, config: PluginConfig): void {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const data = await client.get('/agent/contacts');
async execute(args: any, exec?: any): Promise<string> {
const data = await client.get(withScope('/agent/contacts', exec));
return renderContacts(data, args.limit || 20);
},
}));
@ -1470,8 +1493,8 @@ export function apply(ctx: any, config: PluginConfig): void {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const data = await client.get(`/agent/sessions/${args.session_id}/participants`);
async execute(args: any, exec?: any): Promise<string> {
const data = await client.get(withScope(`/agent/sessions/${args.session_id}/participants`, exec));
return renderParticipants(data);
},
}));
@ -1489,9 +1512,9 @@ export function apply(ctx: any, config: PluginConfig): void {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
async execute(args: any, exec?: any): Promise<string> {
const qs = args.offset ? `?offset=${args.offset}` : '';
const data = await client.get(`/agent/mail/${args.mail_id}/thread${qs}`);
const data = await client.get(withScope(`/agent/mail/${args.mail_id}/thread${qs}`, exec));
return renderThread(data, AGENT_NAME);
},
}));
@ -1508,8 +1531,8 @@ export function apply(ctx: any, config: PluginConfig): void {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const data = await client.get(`/agent/mail/${args.mail_id}`);
async execute(args: any, exec?: any): Promise<string> {
const data = await client.get(withScope(`/agent/mail/${args.mail_id}`, exec));
const m = data?.mail || {};
const lines = [
`发件人: ${m.from_name || '?'}`,
@ -1562,7 +1585,8 @@ export function apply(ctx: any, config: PluginConfig): void {
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any, toolCtx: any): Promise<string> {
const result = await client.post(`/mail/${args.mail_id}/forward`, {
// 转发要读原文 ⇒ 与 read_mail 同一条命(见 withScope 的注释)。
const result = await client.post(withScope(`/mail/${args.mail_id}/forward`, toolCtx), {
to: args.to,
comment: args.comment || '',
cc: args.cc || '',