fix(agents): 四家桥的 read_inbox 一律按会话收窄(dsh/opencode/zcode/homeagent)

用户:「你还是没修好不同 session agent 收件箱隔离的问题」。上一轮我只修了 **pi**,
另外四家还漏着 —— 它们是**每一家各自实现** read_inbox,不修就还是漏。

## 缺陷

列表按 Agent 列(整个收件箱),而 read_inbox 按契约把**列出来的都标成已读**
⇒ A 会话的回合会把 B 会话的未读标掉 ⇒ B 之后按 `?status=unread` 补投时
再也看不到那封信(静默丢信,不是"少看一封")。用户是在别的 Agent 上看到它的。

## 四家的修法(各自平台能力不同,但都要"并发安全")

| 桥 | 会话来源 | 为什么这样做 |
|---|---|---|
| dsh | 工具第二参数 `exec.agent.id` → `reverseMap` | 平台就在上下文里给了会话;**不能用模块级"当前会话"变量**(同进程可能同时跑多条会话的回合,会互相覆盖) |
| opencode | 工具第二参数 `context.sessionID` → `reverseMap` | 同上 |
| zcode | `AGENTMAIL_SESSION_ID`(在**调用时**读) | 一轮一个进程,驱动本来就注入它给授权钩子用;调用时读,避免将来复用进程拿到旧值 |
| homeagent | `p.currentSessionID`(回合开始设、结束清) | Go 插件,本来就有这个状态 |

取不到会话一律**退回整体收件箱**(历史行为),不猜 —— 猜错就是静默丢信。

## 判据

- 服务端语义:`server/internal/repo/session_scope_test.go`(读 A 不动 B、列表收窄、
  计数与列表口径一致)。
- 桥侧接线:dsh 4 条、opencode 3 条、zcode 3 条、homeagent Go 1 条
  (`TestInboxURLScopedBySession`,直接断言拼出来的 URL)。
  每家都带**判据自检**:拿旧写法喂进来必须判红;dsh/opencode 还专门断言
  "不得用模块级当前会话变量"。
- **部署件**(不是仓库):四家的部署快照里都能 grep 到 `session_id=`。
- **线上实测**:用 opencode 自己的 Agent 身份请求收窄列表 —— 会话 A 3 封、
  会话 B 0 封、两者无交集、且都是全量的子集。

套件:opencode **331**、dsh **381**、zcode **385**、homeagent ok,全绿。
四家桥已重新部署(dsh/opencode/zcode 快照切换 + homeagent 新 plugin.bin 并重启),
四个服务均 active。
This commit is contained in:
2026-09-14 12:07:45 +08:00
parent fc4671e55c
commit be0693821b
8 changed files with 219 additions and 6 deletions

View File

@ -261,10 +261,18 @@ const readInboxTool = {
filter: z.enum(["unread", "all"]).optional().describe("过滤条件,默认 unread"),
limit: z.number().optional().describe("返回数量,默认 5"),
},
async execute(args) {
// ★ 第二个参数是平台给的上下文:`context.sessionID` 是这次调用所属的 opencode 会话,
// 经 reverseMap 换成邮件会话(并发安全,不依赖模块级"当前会话")。
//
// 缺陷(用户报的):「不同 session 的 agent 都可以看到全部邮件」:列表按 Agent 列,
// 且 read_inbox 会把列出的都标已读 ⇒ A 会话标掉 B 会话的未读 ⇒ B 之后按
// ?status=unread 补投时再也看不到那封信(静默丢信)。
async execute(args, context) {
const filter = args.filter || DEFAULT_INBOX_STATUS;
const limit = args.limit || DEFAULT_INBOX_LIMIT;
const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}`);
const mailSessionID = reverseMap.get(String(context?.sessionID ?? "")) || "";
const scope = mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : "";
const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}${scope}`);
// 渲染与已读策略放 lib/inbox-format.js它们与平台 SDK 无关,
// 各平台插件必须一致(见该文件里每条规则对应的错误行为)。

View File

@ -0,0 +1,31 @@
/**
* read_inbox 必须收窄到**自己那条会话**(用户:「你还是没修好不同 session agent
* 收件箱隔离的问题」)。
*
* 缺陷:列表按 Agent 列,且 read_inbox 按契约把列出的都标已读 ⇒ A 会话的回合把
* B 会话的未读标掉 ⇒ B 之后按 `?status=unread` 补投时再也看不到那封信(静默丢信)。
* 服务端语义由 server/internal/repo/session_scope_test.go 负责,这里只验接线。
*/
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 src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'index.js'), 'utf8');
test('read_inbox 的 URL 会拼上会话收窄', () => {
assert.match(src, /mail\/inbox\?status=\$\{filter\}&limit=\$\{limit\}\$\{scope\}/);
assert.match(src, /session_id=\$\{encodeURIComponent\(mailSessionID\)\}/);
});
test('★ 会话来源是平台上下文(并发安全),不是模块级变量', () => {
assert.match(src, /async execute\(args, context\)/, '工具要接住第二个参数');
assert.match(src, /reverseMap\.get\(String\(context\?\.sessionID/, '经 reverseMap 换邮件会话');
assert.ok(!/let\s+currentMailSessionID/.test(src), '不得用模块级"当前会话"变量');
});
test('★ 判据自检:不带收窄的旧写法必须判红', () => {
const old = '`/mail/inbox?status=${filter}&limit=${limit}`';
assert.ok(!/\$\{scope\}/.test(old));
});