fix(inbox): 收件箱按**工作区**收窄(三维地址的 path 位此前从未被使用)

用户 12 天前就提过(`552fbc7` 只修了 session_id 那一维),这轮才真修。
用户原话:「难道让一个不在项目工作区的 agentsession 去修工程吗?」

# 缺陷(生产实测,2026-09-26)

在 `mc` 工作区干活的 pi 读收件箱拿到 **200 封,其中 191 封属于
`/home/program/agentmail`** —— 它照着那些信里的断言去改 agentmail 的代码,
把手上的 mc 活丢在一边。用户当场问它「你怎么干着干着修 agentmail 去了?」
(这条对话就在 mc 会话的 jsonl 里)

根因:`ListInboxScoped` 的 WHERE 只有 `m.to_name = $1`(+ 可选 session_id),
**没有任何 workspace 条件**。三维地址 `name@path.session` 的 path 位
在收件箱侧从未生效 —— 那不是"另一种语义",是没兑现契约。

# 三条守卫全部只覆盖自动转发,防不住这个

| 守卫 | 只覆盖 | 为何无效 |
| --- | --- | --- |
| 会话预算 | `relay != ""` 才扣 | 这批信 relay=0(模型主动发)⇒ 不扣 |
| maxRelayHops=5 | 同上,只数 relay | 同上 ⇒ 不进那个分支 |
| 插件自动转发守卫 | 插件代劳时 | 日志明说"本轮不自动转发" ⇒ 模型自己发的不受管 |

# 服务端

· `ListInboxScoped` / `CountUnreadScoped` / `MarkAllInboxReadForSession`
  三处统一加 `s.workspace = $N`(用会话的 workspace,不用 mails.to_workspace:
  后者是信封字段、可能是抄送或历史遗留;"线索属于哪个工作区"是会话属性)。
  ★ 三处必须是**同一个谓词** —— 列表看不到的信却被"全部标掉"标掉就是静默丢信
  (session_scope_test.go 记过这个形状)。
· **workspace 在 Agent 侧必需,缺了 400**(用户裁定:「不带 workspace 是错误
  发件格式,直接退回!」)。旧语义(不带=全部)正是缺陷本身,不留兼容回退。
· 人类侧**不过滤**(一个人跨工作区,WebUI 按 session_workspace 分组显示)——
  所以"必需"这条约束放在 Handler 而不是 repo 层:它是接口契约,不是数据层不变量。
· 新增 `UnreadWorkspaces`:心跳是**进程级**(一个桥服务所有工作区),没有
  "我的工作区"可言;但只有总数桥不知道去哪个工作区补投 ⇒ 心跳回
  `pending_workspaces` 清单,桥逐个消费。
· 决策载荷补 `workspace`(服务端知道 session→workspace,插件重启后推不出来)。
· `TouchAgentLastSeen` 从 HeartbeatAgent 拆出:middleware 在每个认证请求上都调它,
  而那时工作区还没解析(请求体没读),原来在白算一次 CountUnread。

# 三个插件(pi / opencode / dsh)

· 读类工具带 `workspace`;补投从"读一次全局收件箱"改为**逐工作区**读。
· pi:worker 信封的 `to_workspace` 经闭包递进工具(不是会话文件 header 的 cwd ——
  后者是"会话上次落在哪",前者是"这封信寄到哪个工作区")。
· opencode/dsh:插件常驻、信封在 deliverMail 那刻就消费掉了 ⇒ 新增
  `sessionWorkspace` 映射(键与既有 reverseMap 同一把)。
· 修一处真 bug:`UnreadWorkspaces` 原先会返回相对路径工作区(历史库里有
  `workspace='root'`),桥侧实测撞 400(`补投工作区 root 失败`)⇒ 只报可寻址的。

# 实测凭据

· 改前:`pi` 的收件箱 200 封混 3 个工作区(agentmail 191 / TrueAgent 7 / huawei 2)
· 改后:agentmail=100(total 228)、mc=16、TrueAgent=7 —— 各工作区独立
· 不带 workspace ⇒ **HTTP 400**,话术给出可执行步骤
· 桥日志:`rw=/home/newqqagent/plugindev/editdoc-upgrade` —— 终于是别的工作区了
  (改前 78 次 worker 启动**全部**是 `/home/program/agentmail`)

# 判据

· `server/internal/repo/workspace_scope_test.go`(3 条):
  两向收窄 + **反向对照**(不带时两条都看得到 ⇒ 证明是收窄不是清空)+
  未读数同口径 + 相对路径必须报错
· `plugins/pi-mail-bridge/test/inbox-workspace-scope.test.mjs`(4 条):接线 +
  取信封而非 cwd + 补投逐工作区 + 判据自检
· dsh 那条 `取不到会话时退回整体收件箱` **改了**:它钉的"退回整体"正是缺陷,
  现在钉"两维各自缺席时各自不带、服务端 400 让错误可见"
· 变异验证:服务端 2 处 + 插件 3 处,全部判红后恢复回绿

全量:server `go test ./...` 绿;三插件 513+340+403 全绿。
This commit is contained in:
2026-09-26 07:44:33 +08:00
parent b85f2b26f0
commit 7634be8966
22 changed files with 957 additions and 179 deletions

167
docs/DEBTS-REVIEW.md Normal file
View File

@ -0,0 +1,167 @@
# `docs/DEBTS.json` 审读(2026-09-26)
审读对象:`docs/DEBTS.json`(25 条、余额 27)。
方法:读文件 + 读**它的三个消费方**(Go 判据 / electron RESULT 行 / `debt-visibility`),
再用仓库现状**反查每条的 `due`/`where` 是否仍与事实一致**。凡是我能实测的都实测了。
结论先说:**结构是好的,两条被机器钉住的条目也是准的**;
但**「已结算」这件事没有任何判据在管** —— 于是「结算了却看起来没结算」就成了一种静默状态,
而且我实测**已经发生过一次**,且它把一个**真·未验的欠账**藏进了不可见的桶里。
---
## 一、总体健康度(这些是好的,先说清楚)
| 项 | 实测 | 判断 |
|---|---|---|
| JSON 合法、`debts` 25 条 | ✓ | 好 |
| 每条 `id/count/due/where` 齐全 | ✓ 0 条缺失 | 好 |
| `static-criteria` 登记 vs `STATIC_ONLY.length` | 5 vs **5** | 好(`commit-hygiene` 钉住) |
| `mails-status-derived` 登记 vs 实测 | 1 vs 1 | 好(`TestDebtLedgerMatchesMeasurement` 钉住) |
| 头部"两端都读同一个文件" | 属实 | 好 |
| 余额打印来自权威源(无手写副本) | 属实 | 好(`TestDebtSummaryReadsAuthoritativeLedger` 钉住) |
★ 特别值得一提:`debt_registry_test.go` 里那段"**该断言的是形状,不是点名**"(删掉硬编码三笔清单)
和 `run-all.mjs` 里"子集关系必须打进字符串本身" —— 这两条**是这份文件里最有价值的资产**:
它们挡的正是"余额看起来对、其实没人判"这一族。下面的问题**不是**它们失职,
而是**它们覆盖的相位之外还有一片空地**。
---
## 二、★ 主要问题:**「已结算」无判据 ⇒ 结算后的条目会"看起来没结算"**
### 2.1 机制
```
余额打印只收 count>0(debtSummary / run-all.mjs 的 reduce,两处一致)
⇒ 一旦把某条 count 置 0,它就从**默认可见的余额**里消失
⇒ 而 due / where 是**自由文本**,没有任何判据校验它们与 note 里"已结算"是否一致
(实测: 读 `note` 的判据 **0 处** —— `run-all.mjs` 里那个 `.note` 属于变异体 `DIAG` 表,
与 DEBTS 条目无关;due/where 则只被断言"非空")
⇒ 结果: 「已结算」只写在 note 深处,而 due/where 可以永远停在结算前 —— 全绿
```
### 2.2 我实测到的**真实案例**(不是假想)
`harmony-maildetail-missing-three`:
```
count = 0 ← 不计入余额 ⇒ 默认路径上看不见
due = 「鸿蒙详情页对齐 WebUI 时(三块功能,服务端都已支持)」 ← 读起来是**未完成**
where = 「MailDetailPage.ets(对照 MailView.tsx)」 ← 读起来是**缺口**
note 末段 = 「2026-09-19 结算:三块**都做完了**」 ← 事实是**已完成**
```
我反查了代码,`note` 是对的、`due`/`where` 是**过期**的:
```
client/harmony/.../MailDetailPage.ets 里三块都在: Forward ×19 / rename ×22 / Budget ×17(max_rounds ×5)
```
⇒ ★ 这条**已还清**,但它**三条字段全都停在结算前**,且因 `count=0` 而不可见。
下一个人来审计,看到的是"鸿蒙详情页缺三块功能"这个**已经不存在的欠账**。
### 2.3 ★★ 而它顺带藏起了一个**真的**未验项
同一条的 note 末尾有一句**实质未验**:
> ★ 未验:预算条的**点击**在设备上没走通 —— 模拟器顶部 155px 是系统手势区,
> 而折叠头部恰在其中(坐标式点击会被系统抢走)。数据链路已实测,观感没验。
我实测:这句话**只存在于这个 `count=0` 的 note 里**,
`MailDetailPage.ets` 里搜不到(grep `155px`/`系统手势区` = 0)。
而本仓对"设备未验"的**既有惯例是单独立条、`count=1`**:
```
harmony-morph-unverified-middleframes count=1
harmony-account-errors-banner-unverified count=1
harmony-permission-history-render-unverified count=1
```
⇒ ★ 于是这里出现了**双重的不可见**:
① 它被写在一个 `count=0` 的条目的 note 里(不进余额);
② 它**没有**按同一族的惯例**单独立条**。
⇒ 按本文件自己的判据("**欠账不显形,就等于没有**"),这条**等于不存在**。
### 2.4 为什么现有判据抓不到(这不是它们写错了)
```
· debt_registry_test.go : 只断言 due/where **非空** + 两笔存在 + 与**实测**一致
⇒ "结算后字段该更新"不在这三条里
· commit-hygiene : 只钉 static-criteria(那笔有权威实测源)
· debt-visibility : 形状是"**判据目录里**声明未覆盖/未验 ⇒ 必须登记"
★ 它**确实**读了 DEBTS.json,但只取 `d.where` 做"这一笔是否被引用"的比对,
**从不读 `d.note`**(实测: 该文件里 `\.note` 命中 0)
⇒ 扫描面 = 判据目录的文件 + 条目的 `where`
⇒ 而这里那句"未验"写在 **`note` 里**,两处都不在 ⇒ **不在它的扫描面**
⇒ 三者的盲区**恰好交汇**在这条上 ⇒ 全绿
```
### 2.5 建议(判据形状,不是改数字)
```
① 结算语义判据(Go 侧,最便宜):
若 note 里出现"结算/已还清/都做完了"等词 ⇒ 断言 due 也带已清标记(或断言该条被移出 debts[])
反向: 若 due 带已清标记 ⇒ 断言 note 里确有结算条目(防止"标了却没说清是什么")
② note 里的"未验/未覆盖"必须**另有登记**:
把 debt-visibility 的 MARKERS 扫描面从"判据目录"**扩到 docs/DEBTS.json 的 note**
(否则 note 就成了新的"注释里写边界"——正是该判据 §16 要挡的那件事)
③ 惯例二选一并写进头部:
"结算即移除"(头部现在这么写)与"置 count=0 留档"(实际做法,5 条)**两套并存**
⇒ 我倾向**保留留档**(有考古价值),但**头部那句话要改**,否则它本身在误导
```
---
## 三、次要问题
### 3.1 `kind` 无判据,且 `run-all.mjs` 拿它当**分类**用
```
16 条 'scope' / 6 条 'env' / 3 条是**整句话**(被当成分类值):
deploy-space-prefix-fs kind = 「判据铺得不满(不是新列)」
pi-bridge-adopt-cwd-mismatch kind = 「沙箱 rw 与 worker 实际 cwd 的第三个来源未对齐(同类已修两处,剩接管路径)」
deploy-interrupt-trap-other-scripts kind = 「只在 redeploy-gateway.sh 做了,另两个部署脚本没做」
```
消费侧 `run-all.mjs:1960` 做的是 `filter(d => d.kind === 'env')` ⇒ 那 3 条**静默落进 other 桶**。
Go 侧 struct **根本不读 `kind`**,所以没有编译期或运行期反馈。
⇒ 不是错误(余额没算丢),但**"分类"这个字段目前是装饰性的**:
读的人会以为 `kind` 是枚举,实际是自由文本。
建议:要么把 `kind` 收成枚举并登记那 3 条,要么承认它是"备注"并**改名**(`kind_note`),
否则下一个写条目的人会照着这 3 条继续写句子。
### 3.2 无"路径腐烂"检查
`where` 里的仓库路径没有任何判据验证仍存在。
我扫了全部 `where`+`due`,**实测只有 1 处疑似**(`platform-mirror-…` 的 `server/main.go`),
复核后**是散文里的裸文件名、不是路径声明**(真路径 `server/cmd/server/main.go`)—— 所以当前没坏。
但这是**运气**:`where` 指的文件被改名/移动时,没有任何东西会红。
建议:`where` 里形如 `client/…|server/…|deploy/…|plugins/…|docs/…` 的 token 断言 `os.Stat` 通过。
### 3.3 `count=0` 条目的"到期前提"措辞不一致
5 条已清条目的 `due` 都带「已…」,但 `harmony-maildetail-missing-three` 不带(见 §2.2)。
这正是 §2.5① 那条判据要抓的形状 —— 单列出来是为了说明**它不是孤例的猜测,是可复现的类别**。
---
## 四、我**没有**发现问题的部分(避免下一个人重复审)
- 两处"同一个事实两份实现"的坑(`var debts` 手写 map、`debtSummary` 不读权威源)**已经修好且判据在位** ——
且 `debt_registry_test.go` 里那个"锚点会自匹配"的教训(`var " + "debts"`)写得很清楚,值得保留。
- 余额数值本身:25 条 / 余额 27 / env 桶 6 / other 21 —— 我逐条加过,**算术无误**。
- `note` 可选:9 条没有 `note` 字段(都在头部或 `due` 里交代了),**不是缺失**。
- `harmony-permission-history`(0) 与 `...-render-unverified`(1) 看似重复,实为两条不同对象
(前者=数据源只读 pending,后者=渲染段未验)—— **不是重复登记**。
---
## 五、一句话
**这份文件最擅长的、也已经做到的是"销账"(条目还清 → count 归零);它最弱的是"销账之后"** ——
`count=0` 让条目离开默认可见的余额,而 `due`/`where` 与 `note` 的一致性、
以及 `note` 里新写下的边界,都**没有判据**。
`harmony-maildetail-missing-three` 同时踩中两处:它**已还清**(却读起来像没还),
并且把一个**真的未验项**(预算条点击)藏进了不可见的 note —— 按本文件自己的话,
**那一条等于没有**。

View File

@ -173,6 +173,20 @@ class GatewayClient {
const sessionMap = new BoundedMap<string, { dshSessionId: string; directory: string }>(MAX_TRACKED_SESSIONS); const sessionMap = new BoundedMap<string, { dshSessionId: string; directory: string }>(MAX_TRACKED_SESSIONS);
const reverseMap = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS); const reverseMap = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
/*
★ 2026-09-26:DSH 会话 id -> **工作区**。
收件箱接口现在要求 `workspace`(缺了 400),而 `read_inbox` 的 URL 是从
`exec.agent` 现场拼的 —— 那里只有 DSH 的会话 id,推不出工作区。
信封(`data.to_workspace`)在 `deliverMail` 那一刻就消费掉了,
到 `read_inbox` 时已不在作用域里 ⇒ 在这里留一份映射。
用户报的缺陷(这一维此前完全没做):在 `mc` 工作区干活的会话读收件箱拿到
agentmail 的信,照着去改 agentmail 的代码。三维地址 `name@path.session`
的 **path 位本来就该参与寻址**。
*/
const sessionWorkspace = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
const mailDrivenSessions = new BoundedSet<string>(MAX_TRACKED_SESSIONS); const mailDrivenSessions = new BoundedSet<string>(MAX_TRACKED_SESSIONS);
// 回信上下文。fromHuman / inReplyTo 是服务端给的两个信号: // 回信上下文。fromHuman / inReplyTo 是服务端给的两个信号:
// 前者决定要不要自动转发(Agent 间不转,见 lib/relay-policy.js), // 前者决定要不要自动转发(Agent 间不转,见 lib/relay-policy.js),
@ -475,27 +489,43 @@ export function apply(ctx: any, config: PluginConfig): void {
* SSE 只推连上之后的事件,插件重启前发来的邮件不会再推一次。 * SSE 只推连上之后的事件,插件重启前发来的邮件不会再推一次。
* 不补的话那封邮件永远躺在收件箱里,而发件人以为 Agent 收到了。 * 不补的话那封邮件永远躺在收件箱里,而发件人以为 Agent 收到了。
*/ */
async function catchUp(pending: unknown): Promise<void> { async function catchUp(pending: unknown, workspaces?: unknown): Promise<void> {
if (!pending) return; if (!pending) return;
try { /*
const box = await client.get('/mail/inbox?status=unread&limit=20'); ★ 逐工作区补投,不再读一次全局收件箱。
const tasks = selectCatchup(box?.mails ?? box, deliveredMails); 旧写法不带收窄 ⇒ 会把**所有工作区**的漏投一起重放(在 mc 干活却被补投
if (tasks.length === 0) return; agentmail 的信)。清单来自心跳的 `pending_workspaces`:心跳是进程级、
console.error(`[dsh-mail-bridge] 补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`); 没有"我的工作区"可言,所以由它给清单,这里逐个消费。
// 串行:每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求 */
for (const ev of tasks) { const list = Array.isArray(workspaces) ? (workspaces as string[]) : [];
// 逐封再查一次:拉收件箱和逐封投递之间 SSE 可能已经投过其中某封 if (list.length === 0) {
if (deliveredMails.has(ev.mail_id)) continue; console.error(`[dsh-mail-bridge] 补投跳过:pending_mails=${pending} 但服务端未给出 pending_workspaces(旧版服务端?)`);
deliveredMails.add(ev.mail_id); return;
try {
await deliverMail(ev, 'mail');
} catch (e: any) {
console.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`);
}
}
} catch (e: any) {
console.error(`[dsh-mail-bridge] 补投失败: ${e?.message || e}`);
} }
let delivered = 0;
for (const ws of list) {
try {
const box = await client.get(`/mail/inbox?status=unread&limit=20&workspace=${encodeURIComponent(ws)}`);
const tasks = selectCatchup(box?.mails ?? box, deliveredMails);
if (tasks.length === 0) continue;
// 串行:每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求
for (const ev of tasks) {
// 逐封再查一次:拉收件箱和逐封投递之间 SSE 可能已经投过其中某封
if (deliveredMails.has(ev.mail_id)) continue;
deliveredMails.add(ev.mail_id);
try {
await deliverMail(ev, 'mail');
delivered += 1;
} catch (e: any) {
console.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`);
}
}
} catch (e: any) {
// 单个工作区失败不影响其余(与"心跳失败不报错"同一原则)
console.error(`[dsh-mail-bridge] 补投工作区 ${ws} 失败(不影响其余): ${e?.message || e}`);
}
}
if (delivered) console.error(`[dsh-mail-bridge] 补投 ${delivered} 封离线期间的邮件(共 ${pending} 封未读,跨 ${list.length} 个工作区)`);
} }
async function beat(): Promise<void> { async function beat(): Promise<void> {
@ -523,7 +553,7 @@ export function apply(ctx: any, config: PluginConfig): void {
// 每轮心跳都补的话会把「模型正在处理中、尚未标已读」的邮件重复投递。 // 每轮心跳都补的话会把「模型正在处理中、尚未标已读」的邮件重复投递。
if (!caughtUp) { if (!caughtUp) {
caughtUp = true; caughtUp = true;
await catchUp(res?.pending_mails); await catchUp(res?.pending_mails, res?.pending_workspaces);
} }
} catch { } catch {
// 心跳失败不报错:网络抖动很常见,下一轮会补上。 // 心跳失败不报错:网络抖动很常见,下一轮会补上。
@ -871,6 +901,14 @@ export function apply(ctx: any, config: PluginConfig): void {
* 取不到返回空串(= 退回整个 Agent 的收件箱)。宁可退回旧行为,也不猜 —— * 取不到返回空串(= 退回整个 Agent 的收件箱)。宁可退回旧行为,也不猜 ——
* 猜错会把别人会话的未读标掉,那是静默丢信。 * 猜错会把别人会话的未读标掉,那是静默丢信。
*/ */
/** 这次调用所属会话的工作区(与 mailSessionOf 同一把键)。拿不到返回空串 —— 那时
* 收件箱请求会 400,是刻意的:错误可见,好过静默跨工作区拿到别处的信。 */
function workspaceOf(exec: any): string {
const dshSessionId = exec?.agent?.id ?? exec?.agent ?? '';
if (!dshSessionId) return '';
return sessionWorkspace.get(String(dshSessionId)) ?? '';
}
function mailSessionOf(exec: any): string { function mailSessionOf(exec: any): string {
const dshSessionId = String(exec?.agent?.id ?? ''); const dshSessionId = String(exec?.agent?.id ?? '');
if (!dshSessionId) return ''; if (!dshSessionId) return '';
@ -919,6 +957,8 @@ export function apply(ctx: any, config: PluginConfig): void {
const cwd = String(agent?.session?.header?.cwd ?? ''); const cwd = String(agent?.session?.header?.cwd ?? '');
sessionMap.set(mailSessionID, { dshSessionId: id, directory: cwd }); sessionMap.set(mailSessionID, { dshSessionId: id, directory: cwd });
reverseMap.set(id, mailSessionID); reverseMap.set(id, mailSessionID);
/* 会话自己的 cwd 就是它的工作区 —— read_inbox 的收窄要用它 */
if (cwd) sessionWorkspace.set(id, cwd);
mailDrivenSessions.add(id); mailDrivenSessions.add(id);
console.error(`[dsh-mail-bridge] 由确定性 id 恢复会话映射 ${mailSessionID} -> ${id}`); console.error(`[dsh-mail-bridge] 由确定性 id 恢复会话映射 ${mailSessionID} -> ${id}`);
} }
@ -963,6 +1003,8 @@ export function apply(ctx: any, config: PluginConfig): void {
if (!mailSessionID) return; if (!mailSessionID) return;
sessionMap.set(mailSessionID, { dshSessionId, directory: cwd }); sessionMap.set(mailSessionID, { dshSessionId, directory: cwd });
reverseMap.set(dshSessionId, mailSessionID); reverseMap.set(dshSessionId, mailSessionID);
/* 同上:cwd 即工作区 */
if (cwd) sessionWorkspace.set(dshSessionId, cwd);
mailDrivenSessions.add(dshSessionId); mailDrivenSessions.add(dshSessionId);
mailContexts.set(mailSessionID, { mailContexts.set(mailSessionID, {
replyTo: data.from_name || '', replyTo: data.from_name || '',
@ -1341,7 +1383,15 @@ export function apply(ctx: any, config: PluginConfig): void {
async execute(args: any, exec?: any): Promise<string> { async execute(args: any, exec?: any): Promise<string> {
const status = args.status || DEFAULT_INBOX_STATUS; const status = args.status || DEFAULT_INBOX_STATUS;
const mailSessionID = mailSessionOf(exec); const mailSessionID = mailSessionOf(exec);
const scope = mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : ''; /*
★ 两维收窄都要带:
· session_id —— 只列**这条线索**的信(防"A 会话标掉 B 会话的未读")
· workspace —— 只列**这个工作区**的信(防"在 mc 干活却读到 agentmail 的信")
服务端对缺 workspace 直接 400 —— 刻意如此(旧语义正是那个缺陷本身)。
*/
const ws = workspaceOf(exec);
const scope = (mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : '')
+ (ws ? `&workspace=${encodeURIComponent(ws)}` : '');
const { mails } = await client.get( const { mails } = await client.get(
`/mail/inbox?status=${status}&limit=${args.limit || DEFAULT_INBOX_LIMIT}${scope}` `/mail/inbox?status=${status}&limit=${args.limit || DEFAULT_INBOX_LIMIT}${scope}`
); );

View File

@ -32,9 +32,20 @@ test('★ 会话来源是平台上下文(并发安全),不是模块级变
assert.ok(!bad.test(src), '不得用模块级"当前会话"变量(同进程多会话会互相覆盖)'); assert.ok(!bad.test(src), '不得用模块级"当前会话"变量(同进程多会话会互相覆盖)');
}); });
test('取不到会话时退回整体收件箱(不猜)', () => { test('取不到会话时不猜(session 位与 workspace 位各自独立缺席)', () => {
assert.match(src, /if \(!dshSessionId\) return ''/); assert.match(src, /if \(!dshSessionId\) return ''/, '拿不到 DSH 会话 → 空串,不去反查');
assert.match(src, /const scope = mailSessionID \? `&session_id=[^`]*` : ''/); /*
★★ 2026-09-26 这条断言改了:原来钉的是"取不到会话时退回**整体**收件箱"。
那个"退回整体"正是缺陷 —— 在 mc 工作区干活的会话会因此拿到 agentmail 的信
并照着去改 agentmail 的代码(用户当场指出)。
现在的规矩:两维**各自**缺席时各自不带,服务端对缺 workspace 直接 400。
"错误可见"胜于"静默跨工作区"。(服务端那半见 workspace_scope_test.go)
*/
assert.match(src, /const ws = workspaceOf\(exec\);/,
'workspace 要经 workspaceOf(exec) 取(与 mailSessionOf 同一把键)');
assert.match(src, /ws \? `&workspace=\$\{encodeURIComponent\(ws\)\}` : ''/,
'workspace 那维要真的拼进去');
}); });
test('★ 判据自检:不带收窄的旧写法必须判红', () => { test('★ 判据自检:不带收窄的旧写法必须判红', () => {

View File

@ -273,7 +273,16 @@ const readInboxTool = {
const filter = args.filter || DEFAULT_INBOX_STATUS; const filter = args.filter || DEFAULT_INBOX_STATUS;
const limit = args.limit || DEFAULT_INBOX_LIMIT; const limit = args.limit || DEFAULT_INBOX_LIMIT;
const mailSessionID = reverseMap.get(String(context?.sessionID ?? "")) || ""; const mailSessionID = reverseMap.get(String(context?.sessionID ?? "")) || "";
const scope = mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : ""; const workspace = sessionWorkspace.get(String(context?.sessionID ?? "")) || "";
/*
★ 两维收窄都要带:
· session_id —— 只列**这条线索**的信(防"A 会话标掉 B 会话的未读")
· workspace —— 只列**这个工作区**的信(防"在 mc 干活却读到 agentmail 的信")
服务端对缺 workspace 直接 400 —— 那是刻意的(旧语义正是缺陷本身)。
workspace 拿不到时**不带**,让服务端报 400:错误可见,好过静默跨工作区。
*/
const scope = (mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : "")
+ (workspace ? `&workspace=${encodeURIComponent(workspace)}` : "");
const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}${scope}`); const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}${scope}`);
// 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关, // 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关,
@ -603,6 +612,28 @@ function startSSE(onEvent) {
// 条目,而 GC 收不掉(还被强引用着)。 // 条目,而 GC 收不掉(还被强引用着)。
const sessionMap = new BoundedMap(MAX_TRACKED_SESSIONS); // agentmail session_id -> opencode session id const sessionMap = new BoundedMap(MAX_TRACKED_SESSIONS); // agentmail session_id -> opencode session id
const reverseMap = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> agentmail session_id(供 event 钩子回写命名) const reverseMap = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> agentmail session_id(供 event 钩子回写命名)
/*
★ 2026-09-26:opencode session id -> **工作区**。
# 为什么要这一张表
收件箱接口现在要求 `workspace`(缺了 400),而 `read_inbox` 的 URL 是从
`context.sessionID` 现场拼的 —— 那里只有 opencode 的会话 id,推不出工作区。
# 与 pi 桥的差别(同一件事的两种做法)
pi 的 worker 一个进程只服务一封邮件,信封就在手上(`data.to_workspace`),
所以它直接把闭包传进工具。opencode 是**插件常驻、按会话分派**,
信封在 `resolveSessionForMail` 那一刻消费掉了,到 `read_inbox` 时已经不在作用域里。
⇒ 在这里留一份映射,键与 reverseMap 同一个(都是 opencode 的 session id)。
# 用户报的缺陷(这一维此前完全没做)
在 `mc` 工作区干活的会话读收件箱拿到 agentmail 的 191 封信,照着去改 agentmail
的代码。三维地址 `name@path.session` 的 **path 位本来就该参与寻址**。
*/
const sessionWorkspace = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 工作区绝对路径
const syncedTitles = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏) const syncedTitles = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏)
// 权限询问的双向定位。 // 权限询问的双向定位。
@ -666,6 +697,7 @@ async function resolveSessionForMail(client, directory, data, kind) {
if (mailSessionID) { if (mailSessionID) {
sessionMap.set(mailSessionID, adoptedID); sessionMap.set(mailSessionID, adoptedID);
reverseMap.set(adoptedID, mailSessionID); reverseMap.set(adoptedID, mailSessionID);
if (wantDir) sessionWorkspace.set(adoptedID, wantDir);
// 标记为邮件驱动:接管之后这条会话**开始**参与邮件往来, // 标记为邮件驱动:接管之后这条会话**开始**参与邮件往来,
// 轮次结束要把总结转回发件人。不标记的话邮件投进去了却永远没有回音。 // 轮次结束要把总结转回发件人。不标记的话邮件投进去了却永远没有回音。
mailDrivenSessions.add(adoptedID); mailDrivenSessions.add(adoptedID);
@ -706,6 +738,7 @@ async function resolveSessionForMail(client, directory, data, kind) {
if (mailSessionID) { if (mailSessionID) {
sessionMap.set(mailSessionID, sessionID); sessionMap.set(mailSessionID, sessionID);
reverseMap.set(sessionID, mailSessionID); reverseMap.set(sessionID, mailSessionID);
if (wantDir) sessionWorkspace.set(sessionID, wantDir);
mailDrivenSessions.add(sessionID); mailDrivenSessions.add(sessionID);
// slug 在创建时就有(如 nimble-lagoon),立即作为寻址别名回写; // slug 在创建时就有(如 nimble-lagoon),立即作为寻址别名回写;
@ -1186,28 +1219,46 @@ export default async function mailBridge(input) {
* SSE 只推连上之后的事件,插件重启前发来的邮件不会再推一次。 * SSE 只推连上之后的事件,插件重启前发来的邮件不会再推一次。
* 不补的话那封邮件永远躺在收件箱里,而发件人以为 Agent 收到了。 * 不补的话那封邮件永远躺在收件箱里,而发件人以为 Agent 收到了。
*/ */
async function catchUp(pending) { async function catchUp(pending, workspaces) {
if (!pending) return; if (!pending) return;
try { /*
const box = await apiGet("/mail/inbox?status=unread&limit=20"); ★ 逐工作区补投,不再读一次全局收件箱。
const tasks = selectCatchup(box?.mails ?? box, deliveredMails);
if (tasks.length === 0) return; 旧写法 `apiGet("/mail/inbox?status=unread&limit=20")` 不带任何收窄,
console.error(`[mail-bridge] 补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`); 会把**所有工作区**的漏投一起重放 —— 在 mc 干活却被补投 agentmail 的信。
// 串行:每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求 清单来自心跳的 `pending_workspaces`(心跳是进程级、没有"我的工作区",
for (const ev of tasks) { 所以由它给清单,这里逐个消费)。
// 逐封再查一次:拉收件箱和逐封投递之间 SSE 可能已经投过其中某封 */
// (selectCatchup 只在拉完那一刻去过重) const list = Array.isArray(workspaces) ? workspaces : [];
if (deliveredMails.has(ev.mail_id)) continue; if (!list.length) {
deliveredMails.add(ev.mail_id); console.error(`[mail-bridge] 补投跳过:pending_mails=${pending} 但服务端未给出 pending_workspaces(旧版服务端?)`);
try { return;
await deliverMail(client, directory, ev, "mail");
} catch (e) {
console.error(`[mail-bridge] 补投 ${ev.mail_id} 失败:`, e?.message || e);
}
}
} catch (e) {
console.error("[mail-bridge] 补投失败:", e?.message || e);
} }
let delivered = 0;
for (const ws of list) {
try {
const box = await apiGet(`/mail/inbox?status=unread&limit=20&workspace=${encodeURIComponent(ws)}`);
const tasks = selectCatchup(box?.mails ?? box, deliveredMails);
if (tasks.length === 0) continue;
// 串行:每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求
for (const ev of tasks) {
// 逐封再查一次:拉收件箱和逐封投递之间 SSE 可能已经投过其中某封
// (selectCatchup 只在拉完那一刻去过重)
if (deliveredMails.has(ev.mail_id)) continue;
deliveredMails.add(ev.mail_id);
try {
await deliverMail(client, directory, ev, "mail");
delivered += 1;
} catch (e) {
console.error(`[mail-bridge] 补投 ${ev.mail_id} 失败:`, e?.message || e);
}
}
} catch (e) {
// 单个工作区失败不影响其余(与"心跳失败不报错"同一原则)
console.error(`[mail-bridge] 补投工作区 ${ws} 失败(不影响其余):`, e?.message || e);
}
}
if (delivered) console.error(`[mail-bridge] 补投 ${delivered} 封离线期间的邮件(共 ${pending} 封未读,跨 ${list.length} 个工作区)`);
} }
let caughtUp = false; let caughtUp = false;
@ -1232,7 +1283,7 @@ export default async function mailBridge(input) {
// 每轮心跳都补的话会把「模型正在处理中、尚未标已读」的邮件重复投递。 // 每轮心跳都补的话会把「模型正在处理中、尚未标已读」的邮件重复投递。
if (!caughtUp) { if (!caughtUp) {
caughtUp = true; caughtUp = true;
await catchUp(res?.pending_mails); await catchUp(res?.pending_mails, res?.pending_workspaces);
} }
} catch { } catch {
// 心跳失败不报错:网络抖动很常见,下一轮会补上。 // 心跳失败不报错:网络抖动很常见,下一轮会补上。

View File

@ -183,7 +183,13 @@ async function routeDecision(data) {
const decision = String(data?.decision || '拒绝'); const decision = String(data?.decision || '拒绝');
const note = typeof data?.note === 'string' ? data.note : ''; const note = typeof data?.note === 'string' ? data.note : '';
const sessionID = data?.session_id || ''; const sessionID = data?.session_id || '';
const waiting = sessionID ? await collectWaitingMails(sessionID) : []; /*
★ workspace 由服务端给(决策载荷里的 `workspace`)—— 插件推不出来:
重启后待决映射丢光,此时手上只有这个事件。缺了它下面的收件箱读会 400,
而这条路的用途是"把人在等待期间补的更正一并交给模型",读不到 = 更正丢失。
*/
const workspace = data?.workspace || '';
const waiting = sessionID ? await collectWaitingMails(sessionID, workspace) : [];
if (relayKey && pool.routePermission(relayKey, decision, note, waiting)) { if (relayKey && pool.routePermission(relayKey, decision, note, waiting)) {
log(`权限 ${relayKey} 决策 ${data.decision}(决策人 ${data.decided_by || '?'})已转交 worker` log(`权限 ${relayKey} 决策 ${data.decision}(决策人 ${data.decided_by || '?'})已转交 worker`
@ -206,9 +212,18 @@ async function routeDecision(data) {
* *
* 拉取失败**不能拖垮决策投递**:备注还在,最坏情况是模型晚一步看到更正。 * 拉取失败**不能拖垮决策投递**:备注还在,最坏情况是模型晚一步看到更正。
*/ */
async function collectWaitingMails(sessionID) { async function collectWaitingMails(sessionID, workspace = '') {
// ★ 必须带 workspace(服务端缺它 400)。旧版这里读的是**全局**收件箱 ——
// 那正是"在 mc 干活却拿到 agentmail 的信"那条缺陷的另一处载体。
// workspace 为空时不读:宁可少给一封更正,也不要跨工作区误取。
if (!workspace) {
log('等人期间的邮件拉取跳过:决策载荷未带 workspace(旧版服务端?)');
return [];
}
try { try {
const box = await client.get('/mail/inbox?status=unread&limit=20'); const box = await client.get(
`/mail/inbox?status=unread&limit=20&workspace=${encodeURIComponent(workspace)}`,
);
return selectWaitingMails(box, { sessionID, seen: deliveredMails }); return selectWaitingMails(box, { sessionID, seen: deliveredMails });
} catch (e) { } catch (e) {
log(`等人期间的邮件拉取失败(不影响决策投递): ${describeError(e)}`); log(`等人期间的邮件拉取失败(不影响决策投递): ${describeError(e)}`);
@ -260,21 +275,57 @@ async function reportModels() {
* 并发放出去等于对上游打 N 个并发请求」—— 那个约束现在由 pool 的 maxWorkers * 并发放出去等于对上游打 N 个并发请求」—— 那个约束现在由 pool 的 maxWorkers
* 承担,而且它比串行更好:同一条会话仍然串行,不同会话可以并行。 * 承担,而且它比串行更好:同一条会话仍然串行,不同会话可以并行。
*/ */
async function catchUp(pending) { /**
* 补投离线期间的邮件。
*
* ★★ 2026-09-26 改:**逐工作区**补投,而不是读一次全局收件箱。
*
* ── 原来错在哪 ──
* 它调 `/mail/inbox?status=unread&limit=20` —— **不带任何收窄**。那时的语义是
* "该 Agent 的全部未读",于是桥会把**所有工作区**的漏投邮件一起重放:
* 我在 `mc` 工作区干活,却被补投一堆 `agentmail` 的信(用户报的那类现象)。
*
* ── 现在怎么走 ──
* 心跳返回 `pending_workspaces`(哪些工作区有未读)。逐个工作区去读 ——
* 每次读都带上那个工作区的 `workspace`,与服务端要求的收窄口径一致。
* 服务端在缺 workspace 时直接 400,所以这里不可能"忘了带"。
*
* ── 为什么不是"心跳带一个 workspace" ──
* 心跳是**进程级**的(一个桥进程服务所有工作区),它没有"我的工作区"可言。
* 而收件箱是 **worker 级**的(每个 worker 手上那封信有明确的 path 位)。
* 在进程级强制 workspace 是概念错配 —— 所以心跳给清单,这里逐个消费。
*
* @param {number} pending 全局未读总数(仅用于日志;不再是过滤依据)
* @param {string[]} workspaces 有未读的工作区清单
*/
async function catchUp(pending, workspaces) {
if (!pending) return; if (!pending) return;
try { const list = Array.isArray(workspaces) ? workspaces : [];
const box = await client.get('/mail/inbox?status=unread&limit=20'); if (!list.length) {
const tasks = selectCatchup(box?.mails ?? box, deliveredMails); // 有未读却给不出工作区:只可能是上报侧出了问题。明说,别静默跳过。
if (!tasks.length) return; log(`补投跳过:pending_mails=${pending} 但服务端未给出 pending_workspaces(旧版服务端?)`);
log(`补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`); return;
for (const ev of tasks) {
if (deliveredMails.has(ev.mail_id)) continue; // 逐封再查(B-7.6)
deliveredMails.add(ev.mail_id);
pool.submit('mail', ev);
}
} catch (e) {
log(`补投失败: ${describeError(e)}`);
} }
let delivered = 0;
for (const ws of list) {
try {
// ★ workspace 是必填参数 —— 缺了服务端 400(这是刻意的,见服务端 GetInbox)。
const box = await client.get(
`/mail/inbox?status=unread&limit=20&workspace=${encodeURIComponent(ws)}`,
);
const tasks = selectCatchup(box?.mails ?? box, deliveredMails);
for (const ev of tasks) {
if (deliveredMails.has(ev.mail_id)) continue; // 逐封再查(B-7.6)
deliveredMails.add(ev.mail_id);
pool.submit('mail', ev);
delivered += 1;
}
} catch (e) {
// 单个工作区失败不影响其余:与"心跳失败不报错"同一原则。
log(`补投工作区 ${ws} 失败(不影响其余): ${describeError(e)}`);
}
}
if (delivered) log(`补投 ${delivered} 封离线期间的邮件(共 ${pending} 封未读,跨 ${list.length} 个工作区)`);
} }
// ─── 启动 / 关停 ─── // ─── 启动 / 关停 ───
@ -370,7 +421,7 @@ async function main() {
if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models; // B-2.2 if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models; // B-2.2
if (!caughtUp) { // B-7.1:只在首个成功心跳后补一次 if (!caughtUp) { // B-7.1:只在首个成功心跳后补一次
caughtUp = true; caughtUp = true;
await catchUp(res?.pending_mails); await catchUp(res?.pending_mails, res?.pending_workspaces);
} }
} catch { } catch {
// B-2.1:心跳失败不重试不报错。真连不上时 Gateway 会把它判成离线, // B-2.1:心跳失败不重试不报错。真连不上时 Gateway 会把它判成离线,

View File

@ -61,30 +61,65 @@ const text = (s) => ({ content: [{ type: 'text', text: s }] });
* opencode / dsh / homeagent 三个平台都没写这一行,只有这里写了 —— 它不是 * opencode / dsh / homeagent 三个平台都没写这一行,只有这里写了 —— 它不是
* 「更严格更好」,而是与 pi 的参数传递机制直接冲突。 * 「更严格更好」,而是与 pi 的参数传递机制直接冲突。
*/ */
export function createMailTools({ client, log, agentName = '', onReconnect, getMailSessionId = () => ''}) { export function createMailTools({ client, log, agentName = '', onReconnect,
// ─── 会话收窄参数(读类工具的公共前缀)─── getMailSessionId = () => '', getWorkspace = () => '' }) {
// ─── 收窄参数(读类工具的公共前缀):两维,都必须由 worker 闭包递进来 ───
// //
// 服务端拿它干两件事: // ★★ 2026-09-26 补上 workspace 这一维,并订正下面那段注释。
// ① 收件箱只列/只标本会话的邮件(缺了它,A 会话的 worker 会把 B 会话的未读标掉 //
// ⇒ 补投再也看不到那封信 = 静默丢信。用户原话「不同 session 的 agent // ── 这段注释原来写着什么(以及为什么它是错的)──
// 都可以看到全部邮件」);
// ② **工作区隔离**:服务端由这条 session 反查 workspace,只有同工作区的会话才放行。 // ② **工作区隔离**:服务端由这条 session 反查 workspace,只有同工作区的会话才放行。
// 一个 Agent 同时服务所有工作区,不带这一维时在 TrueAgent 里干活的 worker // ……服务端不接受调用方直接声明工作区,那等于自己给自己发通行证。
// 能读到 agentmail 的整条线索(2026-09-14 用户报的那类越界)。
// //
// 取值只能是**邮件会话 id**,且必须由 worker 闭包递进来(模型改不了它)—— // 前半句描述的能力**服务端从来没有过** —— `ListInboxScoped` 的 WHERE 里
// 服务端不接受调用方直接声明工作区,那等于自己给自己发通行证。 // 只有 `m.to_name = $1`(+ 可选的 session_id),没有任何 workspace 条件。
// 我(写这段注释的人)把"设计意图"当成"已实现",于是插件侧也一直只传 session_id。
//
// 后半句的理由**不成立**:session_id → workspace 这条反查在服务端确实可行,
// 但它只覆盖"这条线索属于哪个工作区",覆盖不了"**我这个 worker 在哪个工作区**"。
// 两者的差别就是缺陷本身:见下。
//
// ── 缺陷(用户 2026-09-26 当场指出,此前已提过多次)──
// 在 `mc` 工作区干活的 pi 读收件箱拿到 200 封,其中 **191 封属于
// `/home/program/agentmail`** —— 它照着那些信里的断言去改 agentmail 的代码,
// 把手上 mc 的活丢在一边(用户当场问「你怎么干着干着修 agentmail 去了?」)。
//
// ── 为什么 workspace 由**调用方声明**是对的(而不是"自己给自己发通行证")──
// 它是**过滤条件**,不是鉴权依据:声明错了只影响"我能看到什么",
// 越权不了别人的东西(服务端仍然只列 `to_name = 我` 的信)。
// 与 session_id 同构。而且服务端没有别的办法知道它 —— 那是 worker 每回合的 cwd,
// 随回合变,服务端拿不到可靠来源。
//
// ── 取值 ──
// workspace 取**信封上的 path 位**(`data.to_workspace`),不是会话文件 header 里的
// cwd:后者是会话上次落在哪,前者是"这封信寄到哪个工作区"—— 收件箱要回答的是后者。
// (且 mailTools 在 loadSession **之前**装配,那时也拿不到 cwd。)
//
// ★ 服务端要求它**必需**:缺了直接 400(用户裁定「不带 workspace 是错误发件格式,
// 直接退回!」)。所以这里不能再像原来那样"缺了就不带、让服务端走旧语义"——
// 旧语义就是那个缺陷。
const wsParam = () => {
const ws = typeof getWorkspace === 'function' ? getWorkspace() : '';
return ws ? `workspace=${encodeURIComponent(ws)}` : '';
};
const scopeQS = () => { const scopeQS = () => {
const sid = typeof getMailSessionId === 'function' ? getMailSessionId() : ''; const sid = typeof getMailSessionId === 'function' ? getMailSessionId() : '';
return sid ? `session_id=${encodeURIComponent(sid)}` : ''; const parts = [];
const ws = wsParam();
if (ws) parts.push(ws);
if (sid) parts.push(`session_id=${encodeURIComponent(sid)}`);
return parts.join('&');
}; };
// 已经带了查询串的 URL 用这个(收件箱那条要 append 到 status/limit 后面) // 已经带了查询串的 URL 用这个(收件箱那条要 append 到 status/limit 后面)
const inboxScope = () => { const inboxScope = () => {
const q = scopeQS(); const q = scopeQS();
return q ? `&${q}` : ''; return q ? `&${q}` : '';
}; };
// 任意路径用这个:自己判断该用 ? 还是 &。缺了 scope 就原样返回, // 任意路径用这个:自己判断该用 ? 还是 &。
// 让服务端走"旧语义 + 记警告"那条路,而不是拼出一个半截 URL。 //
// ★ 不再有"缺了就原样返回让服务端走旧语义"那条路 —— 服务端现在对缺 workspace
// 的读请求直接 400。缺了就会 400,这正是我们要的:宁可失败得明确,
// 也不要静默看到别的工作区的信。
const withScope = (path) => { const withScope = (path) => {
const q = scopeQS(); const q = scopeQS();
if (!q) return path; if (!q) return path;

View File

@ -103,7 +103,7 @@ let piSessionId = '';
// sessionID = **AgentMail 的邮件会话 id**(不是 pi 的 session id)。 // sessionID = **AgentMail 的邮件会话 id**(不是 pi 的 session id)。
// read_inbox 要靠它把自己那条会话的邮件与别的会话区分开 —— 少了它,A 会话的 // read_inbox 要靠它把自己那条会话的邮件与别的会话区分开 —— 少了它,A 会话的
// worker 会把 B 会话的未读一起列出来并标掉(2026-09-14 用户报的缺陷)。 // worker 会把 B 会话的未读一起列出来并标掉(2026-09-14 用户报的缺陷)。
let mailContext = { replyTo: '', subject: '', mailID: '', sessionID: '', permissionMode: 'workspace' }; let mailContext = { replyTo: '', subject: '', mailID: '', sessionID: '', permissionMode: 'workspace', workspace: '' };
let lastSyncedName = ''; let lastSyncedName = '';
let relayedKey = ''; let relayedKey = '';
let finished = false; let finished = false;
@ -558,6 +558,9 @@ async function run() {
const mailTools = createMailTools({ const mailTools = createMailTools({
// read_inbox 用它在服务端把列表收窄到自己这条会话。 // read_inbox 用它在服务端把列表收窄到自己这条会话。
getMailSessionId: () => mailContext.sessionID, getMailSessionId: () => mailContext.sessionID,
// ★ 收件箱的第二维收窄:**这封信寄到哪个工作区**(信封的 path 位)。
// 缺了它,在 mc 干活的 pi 会看到 agentmail 的 191 封信并照着去改 agentmail。
getWorkspace: () => mailContext.workspace,
client, log, agentName: job.config.agentName, client, log, agentName: job.config.agentName,
onReconnect: () => send({ onReconnect: () => send({
type: 'reconfigure', url: client.baseURL, agentKey: client.agentKey, type: 'reconfigure', url: client.baseURL, agentKey: client.agentKey,
@ -735,6 +738,9 @@ process.on('message', (msg) => {
mailID: msg.data?.mail_id || '', mailID: msg.data?.mail_id || '',
sessionID: msg.data?.session_id || '', sessionID: msg.data?.session_id || '',
permissionMode: msg.data?.permission_mode || 'workspace', permissionMode: msg.data?.permission_mode || 'workspace',
/* ★ 信封上的 path 位 = 这封信寄到哪个工作区。读类工具用它收窄收件箱
(缺了服务端会 400 —— 见 tools.mjs 里 createMailTools 那段注释)。 */
workspace: msg.data?.to_workspace || '',
}; };
lastSyncedName = msg.lastSyncedName || ''; lastSyncedName = msg.lastSyncedName || '';
for (const t of msg.grants || []) grants.add(t); for (const t of msg.grants || []) grants.add(t);

View File

@ -0,0 +1,65 @@
/**
* read_inbox 必须把列表收窄到**自己所在的工作区**(2026-09-26 用户报的缺陷)。
*
* # 缺陷(用户当场指出,此前已提过多次)
*
* 在 `mc` 工作区干活的 pi 读收件箱拿到 200 封,其中 **191 封属于
* `/home/program/agentmail`** —— 它照着那些信里的断言去改 agentmail 的代码,
* 把手上 mc 的活丢在一边。用户当场问「你怎么干着干着修 agentmail 去了?」
*
* 三维地址是 `name@path.session`:**path 位本来就该参与寻址**,而收件箱侧此前
* 完全没用它(`ListInboxScoped` 的 WHERE 里只有 `to_name`)。
*
* # 与 inbox-session-scope 的分工(两条判据、两维收窄,别混)
*
* · `session_id` —— 只列**这条线索**的信。防的是"A 会话的 worker 标掉 B 会话的未读"。
* · `workspace` —— 只列**这个工作区**的信。防的是"在 mc 干活却读到 agentmail 的信"。
*
* 两维都要,缺一维就漏一类越界。这里只验**接线**;服务端语义
* (读 A 工作区不动 B)由 `server/internal/repo/workspace_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 HERE = dirname(fileURLToPath(import.meta.url));
const tools = readFileSync(join(HERE, '..', 'src', 'tools.mjs'), 'utf8');
const worker = readFileSync(join(HERE, '..', 'src', 'worker.mjs'), 'utf8');
const index = readFileSync(join(HERE, '..', 'src', 'index.mjs'), 'utf8');
test('read_inbox 的收窄参数带上 workspace', () => {
assert.match(tools, /workspace=\$\{encodeURIComponent\(ws\)\}/,
'scopeQS 要真的拼出 workspace=…(服务端缺它直接 400)');
assert.match(tools, /const ws = wsParam\(\);/,
'scopeQS 要消费 wsParam,而不是只留着函数不调');
});
test('worker 把"这封信寄到哪个工作区"递给工具', () => {
assert.match(worker, /getWorkspace: \(\) => mailContext\.workspace/,
'要传闭包而不是快照(worker 每回合的 mailContext 会变)');
assert.match(worker, /workspace: msg\.data\?\.to_workspace/,
'★ 取**信封的 path 位**(to_workspace),不是会话文件的 cwd —— ' +
'前者是"这封信寄到哪个工作区",后者是"会话上次落在哪"');
});
test('补投逐工作区走,不再读一次全局收件箱', () => {
// 原来:client.get('/mail/inbox?status=unread&limit=20') —— 不带收窄,
// 于是桥会把所有工作区的漏投一起重放。
assert.match(index, /pending_workspaces/,
'心跳要消费 pending_workspaces 清单');
assert.match(index, /mail\/inbox\?status=unread&limit=20&workspace=\$\{encodeURIComponent\(ws\)\}/,
'每个工作区各读一次,且带上 workspace');
assert.doesNotMatch(index, /client\.get\('\/mail\/inbox\?status=unread&limit=20'\)/,
'旧的"不带收窄读全局"那条路必须消失(它就是跨工作区重放的载体)');
});
test('★ 判据自检:拿没带工作区收窄的旧写法喂进来必须判红', () => {
const oldScope = "const sid = typeof getMailSessionId === 'function' ? getMailSessionId() : '';\n return sid ? `session_id=${encodeURIComponent(sid)}` : '';";
assert.equal(/workspace=\$\{encodeURIComponent\(ws\)\}/.test(oldScope), false);
const oldCatchup = "const box = await client.get('/mail/inbox?status=unread&limit=20');";
assert.match(oldCatchup, /client\.get\('\/mail\/inbox\?status=unread&limit=20'\)/,
'自检:旧写法确实能被那条 doesNotMatch 抓到');
});

View File

@ -25,6 +25,11 @@ type registerRequest struct {
// //
// 字段全可省:旧插件发空心跳,不能因为新增了上报就把它们报错。 // 字段全可省:旧插件发空心跳,不能因为新增了上报就把它们报错。
type heartbeatRequest struct { type heartbeatRequest struct {
// Workspace 是调用方当前所处的工作区(绝对路径)——**必需**。
//
// 心跳返回的 pending_mails 按它算:那是桥的补投判据,混了别的工作区
// 就会让桥反复重放(见 handler 里那段说明)。
Workspace string `json:"workspace"`
// PlatformSessions 是平台侧当前的会话快照(按最近活跃排序)。 // PlatformSessions 是平台侧当前的会话快照(按最近活跃排序)。
// //
// 为什么让插件上报而不是 Gateway 反向拉取:当前架构是单向的 // 为什么让插件上报而不是 Gateway 反向拉取:当前架构是单向的
@ -170,17 +175,29 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
return return
} }
/*
★ pending_mails 是**全局**未读数(跨工作区),而收件箱接口是 worker 级
(要求 workspace)。两者不矛盾,是不同层的东西 —— 见 UnreadWorkspaces 注释。
但只有总数,桥不知道去**哪个**工作区补投(它此前调的是不带收窄的
`/mail/inbox`,于是会跨工作区重放)。⇒ 一并给出有未读的工作区清单,
桥逐个去读。这也是"心跳返回什么才够用"的答案。
*/
pendingWorkspaces, _ := repo.UnreadWorkspaces(r.Context(), agentName)
// 宽容解码(心跳是全站唯一一处容忍未知字段的端点,理由见下面的说明)。
var req heartbeatRequest
var unknownFields []string
if r.ContentLength > 0 {
unknownFields, _ = DecodeLenient(r, &req)
}
// 可选的平台会话快照。解不开就当作没带:心跳的主职责是「我还活着」, // 可选的平台会话快照。解不开就当作没带:心跳的主职责是「我还活着」,
// 不该因为上报体格式不对就把 Agent 判成离线。 // 不该因为上报体格式不对就把 Agent 判成离线。
// //
// 但**未知字段必须回报**(resp["unknown_fields"]):这是全站唯一一处宽容 // 但**未知字段必须回报**(resp["unknown_fields"]):这是全站唯一一处宽容
// 解码的端点,若还静默忽略,插件把 `models` 拼成 `modles` 就永远没人知道 —— // 解码的端点,若还静默忽略,插件把 `models` 拼成 `modles` 就永远没人知道 ——
// 而那与 `attachments` vs `attachment_ids` 是同一种事故形状。 // 而那与 `attachments` vs `attachment_ids` 是同一种事故形状。
var req heartbeatRequest
var unknownFields []string
if r.ContentLength > 0 {
unknownFields, _ = DecodeLenient(r, &req)
}
syncedSessions := -1 // -1 = 本次未上报 syncedSessions := -1 // -1 = 本次未上报
if req.PlatformSessions != nil { if req.PlatformSessions != nil {
if err := repo.ReplacePlatformSessions(r.Context(), agentName, req.PlatformSessions); err != nil { if err := repo.ReplacePlatformSessions(r.Context(), agentName, req.PlatformSessions); err != nil {
@ -216,9 +233,10 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
} }
resp := map[string]interface{}{ resp := map[string]interface{}{
"status": "ok", "pending_workspaces": pendingWorkspaces,
"pending_mails": pending, "status": "ok",
"stats": stats, "pending_mails": pending,
"stats": stats,
} }
if syncedSessions >= 0 { if syncedSessions >= 0 {
resp["platform_sessions_synced"] = syncedSessions resp["platform_sessions_synced"] = syncedSessions

View File

@ -583,6 +583,35 @@ func GetInbox(w http.ResponseWriter, r *http.Request) {
} }
} }
/*
★ workspace **必需**(用户裁定:「不带 workspace 是错误发件格式,直接退回!」)。
# 为什么是 400 而不是"退回旧语义"
旧语义(不带 = 列该 Agent 全部)正是缺陷本身:`pi` 在 mc 工作区读收件箱
拿到 200 封,191 封属于 agentmail 工作区 ⇒ 它照着那些信改 agentmail 的代码。
留着旧语义当兜底,等于给这个缺陷留一条随时会被走上去的路。
三维地址 `name@path.session` 的 path 位本来就该参与寻址 —— 收件箱侧此前
完全没用它。现在要求调用方说清自己在哪个工作区,服务端据此收窄。
★ 为什么服务端**不该**自己去推调用方的工作区:
它是每个 worker 的 cwd(会随回合变),服务端拿不到可靠来源。
让调用方声明、服务端只当过滤条件用(不做鉴权依据)——
与 session_id 那条同构:声明错了只影响自己看到什么,越权不了别人。
*/
workspace := strings.TrimSpace(r.URL.Query().Get("workspace"))
if workspace == "" {
Error(w, http.StatusBadRequest,
"缺少 workspace:收件箱按工作区收窄(三维地址 name@path.session 的 path 位)。"+
"请带上你所处工作区的绝对路径,例如 &workspace=/home/program/agentmail")
return
}
if !strings.HasPrefix(workspace, "/") {
Error(w, http.StatusBadRequest, "workspace 必须是绝对路径,收到: "+workspace)
return
}
// 可选会话收窄:桥的 read_inbox 会带上自己那条会话。 // 可选会话收窄:桥的 read_inbox 会带上自己那条会话。
// //
// 不带 = 整个 Agent 的收件箱(旧语义,浏览器/脚本仍可用);带了就只列这条线索 —— // 不带 = 整个 Agent 的收件箱(旧语义,浏览器/脚本仍可用);带了就只列这条线索 ——
@ -598,7 +627,7 @@ func GetInbox(w http.ResponseWriter, r *http.Request) {
sessionID = id sessionID = id
} }
mails, err := repo.ListInboxScoped(r.Context(), agentName, status, limit, sessionID) mails, err := repo.ListInboxScoped(r.Context(), agentName, status, workspace, limit, sessionID)
if err != nil { if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list inbox") Error(w, http.StatusInternalServerError, "Failed to list inbox")
return return
@ -609,7 +638,9 @@ func GetInbox(w http.ResponseWriter, r *http.Request) {
ptrs[i] = &mails[i] ptrs[i] = &mails[i]
} }
fillAttachments(r, ptrs...) fillAttachments(r, ptrs...)
total, _ := repo.CountUnread(r.Context(), agentName) // ★ total 必须用**同一个收窄口径**,否则列表与计数自相矛盾
// (桥的补投判据就是 pending_mails = 这个数)。
total, _ := repo.CountUnreadScoped(r.Context(), agentName, workspace, sessionID)
JSON(w, http.StatusOK, map[string]interface{}{ JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails), "mails": emptySlice(mails),
@ -698,6 +729,10 @@ func parseInt(s string) (int, error) {
type markReadRequest struct { type markReadRequest struct {
// SessionID 可选:带了就只在这条会话内标(见 GetInbox 里那段说明)。 // SessionID 可选:带了就只在这条会话内标(见 GetInbox 里那段说明)。
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
// Workspace **必需**(仅"全部标掉"那条路):只标这个工作区的信。
// 带上 mail_ids 时不要求 —— 那些 id 是调用方从**已收窄的**列表里原样拿回来的,
// 服务端按 id 逐个判归属即可(MarkMailsReadFor 只标真发给它的)。
Workspace string `json:"workspace"`
// MailIDs 要标记为已读的邮件;省略/为空 = 把收件箱里全部未读标掉。 // MailIDs 要标记为已读的邮件;省略/为空 = 把收件箱里全部未读标掉。
MailIDs []string `json:"mail_ids"` MailIDs []string `json:"mail_ids"`
} }
@ -728,8 +763,26 @@ func MarkInboxRead(w http.ResponseWriter, r *http.Request) {
// 不给 id 就把收件箱里全部未读标掉。 // 不给 id 就把收件箱里全部未读标掉。
// 这是 Agent 最常见的用法:一轮处理完,剩下的都不必再看。 // 这是 Agent 最常见的用法:一轮处理完,剩下的都不必再看。
if len(req.MailIDs) == 0 { if len(req.MailIDs) == 0 {
/*
★ workspace **必需**,与 GET /mail/inbox 同一条规矩。
这条路口径更宽("全部标掉"),缺了工作区会把**别的工作区的未读也标掉** ——
而"被别人标掉"正是那封静默丢信的旧缺陷(read_inbox 按会话收窄那次修的
就是它,见 session_scope_test.go)。收窄到工作区,同一类错误就没法再发生。
*/
ws := strings.TrimSpace(req.Workspace)
if ws == "" {
Error(w, http.StatusBadRequest,
"缺少 workspace:全部标已读必须指明工作区(否则会标掉别的工作区的未读)。"+
"若是按 id 标记,请直接传 mail_ids")
return
}
if !strings.HasPrefix(ws, "/") {
Error(w, http.StatusBadRequest, "workspace 必须是绝对路径,收到: "+ws)
return
}
var sessionID uuid.UUID var sessionID uuid.UUID
scope := "all" scope := "workspace"
if raw := strings.TrimSpace(req.SessionID); raw != "" { if raw := strings.TrimSpace(req.SessionID); raw != "" {
id, perr := uuid.Parse(raw) id, perr := uuid.Parse(raw)
if perr != nil { if perr != nil {
@ -738,7 +791,7 @@ func MarkInboxRead(w http.ResponseWriter, r *http.Request) {
} }
sessionID, scope = id, "session" sessionID, scope = id, "session"
} }
n, err := repo.MarkAllInboxReadForSession(r.Context(), agentName, sessionID) n, err := repo.MarkAllInboxReadForSession(r.Context(), agentName, ws, sessionID)
if err != nil { if err != nil {
Error(w, http.StatusInternalServerError, "Failed to mark read") Error(w, http.StatusInternalServerError, "Failed to mark read")
return return

View File

@ -218,7 +218,7 @@ func MeGetInbox(w http.ResponseWriter, r *http.Request) {
} }
} }
mails, err := repo.ListInbox(r.Context(), user.Username, status, limit) mails, err := repo.ListInbox(r.Context(), user.Username, status, "", limit)
if err != nil { if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list inbox") Error(w, http.StatusInternalServerError, "Failed to list inbox")
return return
@ -229,7 +229,7 @@ func MeGetInbox(w http.ResponseWriter, r *http.Request) {
ptrs[i] = &mails[i] ptrs[i] = &mails[i]
} }
fillAttachments(r, ptrs...) fillAttachments(r, ptrs...)
total, _ := repo.CountUnread(r.Context(), user.Username) total, _ := repo.CountUnread(r.Context(), user.Username, "")
JSON(w, http.StatusOK, map[string]interface{}{ JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails), "mails": emptySlice(mails),

View File

@ -394,6 +394,17 @@ func DecidePermission(w http.ResponseWriter, r *http.Request) {
// 那条路径要靠这个字段找到原会话,否则会凭空另开一个。 // 那条路径要靠这个字段找到原会话,否则会凭空另开一个。
"session_id": perm.SessionID.String(), "session_id": perm.SessionID.String(),
} }
/*
★ workspace:插件用它去收窄"等人期间新到的邮件"(那是一次 /mail/inbox 读,
而收件箱接口要求 workspace,缺了 400)。
为什么服务端给而不是让插件推:插件重启后待决映射丢光(上面那段说的),
此时它手上只有这个事件 —— 从事件里推不出工作区。而服务端**知道**
(`perm.SessionID` → `sessions.workspace`),一处给对,三平台都能用。
*/
if ws := repo.SessionWorkspaceOf(r.Context(), perm.SessionID); ws != "" {
payload["workspace"] = ws
}
if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" { if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" {
payload["relay_key"] = key payload["relay_key"] = key
payload["relay_kind"] = kind payload["relay_kind"] = kind

View File

@ -64,7 +64,7 @@ func AgentAuth(next http.Handler) http.Handler {
http.Error(w, `{"error":"密钥尚未绑定 Agent,请先调用 /agent/register 完成注册"}`, http.StatusForbidden) http.Error(w, `{"error":"密钥尚未绑定 Agent,请先调用 /agent/register 完成注册"}`, http.StatusForbidden)
return return
} }
repo.HeartbeatAgent(r.Context(), agentName) repo.TouchAgentLastSeen(r.Context(), agentName)
ctx := context.WithValue(r.Context(), AgentNameKey, agentName) ctx := context.WithValue(r.Context(), AgentNameKey, agentName)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
return return
@ -83,7 +83,7 @@ func AgentAuth(next http.Handler) http.Handler {
return return
} }
repo.HeartbeatAgent(r.Context(), agent.Name) repo.TouchAgentLastSeen(r.Context(), agent.Name)
ctx := context.WithValue(r.Context(), AgentNameKey, agent.Name) ctx := context.WithValue(r.Context(), AgentNameKey, agent.Name)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })

View File

@ -205,7 +205,7 @@ func TestSetAgentDisabledKeepsMailAndSessions(t *testing.T) {
t.Fatalf("停用: %v", err) t.Fatalf("停用: %v", err)
} }
mails, err := ListInbox(ctx, "bot", "all", 10) mails, err := ListInbox(ctx, "bot", "all", "", 10)
if err != nil { if err != nil {
t.Fatalf("ListInbox: %v", err) t.Fatalf("ListInbox: %v", err)
} }

View File

@ -49,7 +49,7 @@ func measureMailStatusDebt(t *testing.T) (outstanding bool, detail, derived stri
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
list, err := ListInbox(ctx, "alice", "all", 50) list, err := ListInbox(ctx, "alice", "all", "", 50)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -125,7 +125,7 @@ func TestMarkAllInboxReadForSkipsArchivedAndOthers(t *testing.T) {
db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, b).Scan(&sid) db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, b).Scan(&sid)
db.DB.ExecContext(ctx, `UPDATE sessions SET status = 'archived' WHERE session_id = $1`, sid) db.DB.ExecContext(ctx, `UPDATE sessions SET status = 'archived' WHERE session_id = $1`, sid)
n, err := MarkAllInboxReadFor(ctx, "bot") n, err := MarkAllInboxReadFor(ctx, "bot", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -80,7 +80,7 @@ func TestPermissionDeadlineVisibleOnEveryReadPath(t *testing.T) {
assertPending("GetMailByID", got) assertPending("GetMailByID", got)
// 2) ListInbox —— 授权页列表的来源。 // 2) ListInbox —— 授权页列表的来源。
inbox, err := ListInbox(ctx, "alice", "all", 50) inbox, err := ListInbox(ctx, "alice", "all", "", 50)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -78,7 +78,7 @@ func TestPermissionFieldsVisibleOnEveryReadPath(t *testing.T) {
assertQuestion("GetMailByID", got) assertQuestion("GetMailByID", got)
// 2) ListInbox —— 授权页的列表来源。 // 2) ListInbox —— 授权页的列表来源。
inbox, err := ListInbox(ctx, "alice", "all", 50) inbox, err := ListInbox(ctx, "alice", "all", "", 50)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -23,7 +23,7 @@ import (
// unreadList 返回 ListInbox 在 unread 过滤下给出的邮件 id。 // unreadList 返回 ListInbox 在 unread 过滤下给出的邮件 id。
func unreadList(t *testing.T, reader string) []uuid.UUID { func unreadList(t *testing.T, reader string) []uuid.UUID {
t.Helper() t.Helper()
mails, err := ListInbox(context.Background(), reader, "unread", 50) mails, err := ListInbox(context.Background(), reader, "unread", "", 50)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -46,7 +46,7 @@ func hasID(list []uuid.UUID, id uuid.UUID) bool {
// statusSeenBy 返回 ListInbox 在 all 过滤下、某个读者看到的那封邮件的 status。 // statusSeenBy 返回 ListInbox 在 all 过滤下、某个读者看到的那封邮件的 status。
func statusSeenBy(t *testing.T, reader string, id uuid.UUID) string { func statusSeenBy(t *testing.T, reader string, id uuid.UUID) string {
t.Helper() t.Helper()
mails, err := ListInbox(context.Background(), reader, "all", 50) mails, err := ListInbox(context.Background(), reader, "all", "", 50)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -67,10 +67,10 @@ func TestReadStateIsPerRecipient(t *testing.T) {
id := seedMailTo(t, "alice", "bob") id := seedMailTo(t, "alice", "bob")
// 初始:两边都未读 // 初始:两边都未读
if n, _ := CountUnread(ctx, "alice"); n != 1 { if n, _ := CountUnread(ctx, "alice", ""); n != 1 {
t.Fatalf("alice 初始未读 = %d,期望 1", n) t.Fatalf("alice 初始未读 = %d,期望 1", n)
} }
if n, _ := CountUnread(ctx, "bob"); n != 1 { if n, _ := CountUnread(ctx, "bob", ""); n != 1 {
t.Fatalf("bob 初始未读 = %d,期望 1", n) t.Fatalf("bob 初始未读 = %d,期望 1", n)
} }
@ -80,10 +80,10 @@ func TestReadStateIsPerRecipient(t *testing.T) {
} }
// ★ 核心判据:alice 的未读清了,bob 的**必须还在** // ★ 核心判据:alice 的未读清了,bob 的**必须还在**
if n, _ := CountUnread(ctx, "alice"); n != 0 { if n, _ := CountUnread(ctx, "alice", ""); n != 0 {
t.Fatalf("alice 读过后未读 = %d,期望 0", n) t.Fatalf("alice 读过后未读 = %d,期望 0", n)
} }
if n, _ := CountUnread(ctx, "bob"); n != 1 { if n, _ := CountUnread(ctx, "bob", ""); n != 1 {
t.Fatalf("★ bob 没读,未读却变成了 %d —— 这正是要修的缺陷(任一收件人读掉,所有人变已读)", n) t.Fatalf("★ bob 没读,未读却变成了 %d —— 这正是要修的缺陷(任一收件人读掉,所有人变已读)", n)
} }
if !hasID(unreadList(t, "bob"), id) { if !hasID(unreadList(t, "bob"), id) {
@ -105,7 +105,7 @@ func TestReadStateIsPerRecipient(t *testing.T) {
if err := MarkMailRead(ctx, id, "bob"); err != nil { if err := MarkMailRead(ctx, id, "bob"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if n, _ := CountUnread(ctx, "bob"); n != 0 { if n, _ := CountUnread(ctx, "bob", ""); n != 0 {
t.Fatalf("bob 读过后未读 = %d,期望 0", n) t.Fatalf("bob 读过后未读 = %d,期望 0", n)
} }
} }
@ -141,15 +141,15 @@ func TestBulkMarkReadOnlyAffectsCaller(t *testing.T) {
if _, err := MarkMailsReadFor(ctx, "alice", []uuid.UUID{id}); err != nil { if _, err := MarkMailsReadFor(ctx, "alice", []uuid.UUID{id}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if n, _ := CountUnread(ctx, "bob"); n != 1 { if n, _ := CountUnread(ctx, "bob", ""); n != 1 {
t.Fatalf("★ 批量标已读后 bob 未读 = %d,期望 1", n) t.Fatalf("★ 批量标已读后 bob 未读 = %d,期望 1", n)
} }
// 全部标已读(不带 id)也只能标自己收件箱里的 // 全部标已读(不带 id)也只能标自己收件箱里的
if _, err := MarkAllInboxReadFor(ctx, "alice"); err != nil { if _, err := MarkAllInboxReadFor(ctx, "alice", ""); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if n, _ := CountUnread(ctx, "bob"); n != 1 { if n, _ := CountUnread(ctx, "bob", ""); n != 1 {
t.Fatalf("★ 全标已读后 bob 未读 = %d,期望 1", n) t.Fatalf("★ 全标已读后 bob 未读 = %d,期望 1", n)
} }
} }
@ -169,7 +169,7 @@ func TestArchivedStaysArchivedForEveryone(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if n, _ := CountUnread(ctx, "bob"); n != 0 { if n, _ := CountUnread(ctx, "bob", ""); n != 0 {
t.Fatalf("归档会话里的邮件不该计入未读,实际 %d", n) t.Fatalf("归档会话里的邮件不该计入未读,实际 %d", n)
} }
if hasID(unreadList(t, "bob"), id) { if hasID(unreadList(t, "bob"), id) {
@ -177,7 +177,7 @@ func TestArchivedStaysArchivedForEveryone(t *testing.T) {
} }
// ListInbox 会整体排除归档会话(`s.status <> 'archived'`),所以它在 // ListInbox 会整体排除归档会话(`s.status <> 'archived'`),所以它在
// `all` 里也不出现 —— 归档是"从收件箱拿走",不是"标成已读"。 // `all` 里也不出现 —— 归档是"从收件箱拿走",不是"标成已读"。
mails, err := ListInbox(ctx, "bob", "all", 50) mails, err := ListInbox(ctx, "bob", "all", "", 50)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -204,10 +204,10 @@ func TestPermissionDecisionMarksOnlyDecider(t *testing.T) {
if _, err := DecidePermission(ctx, id, "alice", "同意"); err != nil { if _, err := DecidePermission(ctx, id, "alice", "同意"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if n, _ := CountUnread(ctx, "alice"); n != 0 { if n, _ := CountUnread(ctx, "alice", ""); n != 0 {
t.Fatalf("决策人 alice 未读 = %d,期望 0", n) t.Fatalf("决策人 alice 未读 = %d,期望 0", n)
} }
if n, _ := CountUnread(ctx, "bob"); n != 1 { if n, _ := CountUnread(ctx, "bob", ""); n != 1 {
t.Fatalf("★ bob 未读 = %d,期望 1(决策是 alice 做的,不该替他标记已读)", n) t.Fatalf("★ bob 未读 = %d,期望 1(决策是 alice 做的,不该替他标记已读)", n)
} }
} }
@ -286,10 +286,10 @@ func TestReadSideRejectsEmptyReader(t *testing.T) {
seedMailTo(t, "alice", "") seedMailTo(t, "alice", "")
for _, empty := range []string{"", " "} { for _, empty := range []string{"", " "} {
if n, err := CountUnread(ctx, empty); err == nil { if n, err := CountUnread(ctx, empty, ""); err == nil {
t.Fatalf("CountUnread(%q) 必须报错,实际返回 %d —— 空读者会静默算出错误的未读数", empty, n) t.Fatalf("CountUnread(%q) 必须报错,实际返回 %d —— 空读者会静默算出错误的未读数", empty, n)
} }
if _, err := ListInbox(ctx, empty, "all", 50); err == nil { if _, err := ListInbox(ctx, empty, "all", "", 50); err == nil {
t.Fatalf("ListInbox(%q) 必须报错 —— 空读者是过滤条件,不能兜底", empty) t.Fatalf("ListInbox(%q) 必须报错 —— 空读者是过滤条件,不能兜底", empty)
} }
if n, err := CountUnreadInSession(ctx, empty, uuid.Nil); err == nil { if n, err := CountUnreadInSession(ctx, empty, uuid.Nil); err == nil {
@ -298,7 +298,7 @@ func TestReadSideRejectsEmptyReader(t *testing.T) {
} }
// 正例:正常读者照常工作(别把守卫写成"一律拒绝") // 正例:正常读者照常工作(别把守卫写成"一律拒绝")
if n, err := CountUnread(ctx, "alice"); err != nil || n != 1 { if n, err := CountUnread(ctx, "alice", ""); err != nil || n != 1 {
t.Fatalf("正常读者 alice 的未读数应为 1,实际 %d(err=%v)", n, err) t.Fatalf("正常读者 alice 的未读数应为 1,实际 %d(err=%v)", n, err)
} }
} }

View File

@ -55,7 +55,73 @@ func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, wor
return err return err
} }
func HeartbeatAgent(ctx context.Context, agentName string) (int, error) { /*
UnreadWorkspaces 列出"这个 Agent 在哪些工作区有未读"。
# 为什么要它,而不是让心跳收一个 workspace 参数
心跳是**进程级**的(一个桥进程同时服务所有工作区),而收件箱是**worker 级**的
(每个 worker 手上只有一封信,信封上有明确的 path 位)。在进程级强制要求
workspace 是概念错配 —— 它没有一个"我的工作区"可言。
但 `pending_mails` 是桥的补投判据:若它是一个跨工作区的总数,桥就不知道该去
**哪个工作区**补投。⇒ 心跳返回这个清单,桥逐个工作区去读(见 catchUp)。
这同时修掉一个隐蔽问题:补投原先调 `/mail/inbox?status=unread`(不带收窄),
按当时的语义会列出**所有工作区**的未读并逐封重放 —— 在 mc 干活时会去补投
agentmail 的信。
*/
func UnreadWorkspaces(ctx context.Context, agentName string) ([]string, error) {
if err := requireReader(agentName); err != nil {
return nil, err
}
rows, err := db.DB.QueryContext(ctx, `
SELECT s.workspace, COUNT(*) AS n
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
AND `+unreadFor("$1")+`
AND s.status <> 'archived'
-- ★ 只报**可寻址**的工作区(绝对路径)。
--
-- 历史库里存在 workspace 为相对路径的行(早期以 "pi@root" 寻址留下的
-- 测试会话)。收件箱接口要求绝对路径,把它们放进清单只会在桥侧撞 400
-- —— 我实测就撞到了:"补投工作区 root 失败: HTTP 400"。
--
-- 过滤放在这里而不是让桥去试错:这个清单的语义是"**能去补投**的工作区",
-- 列出不可寻址的等于给调用方递一个注定失败的任务。
AND s.workspace LIKE '/%'
GROUP BY s.workspace
ORDER BY n DESC`, agentName)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var ws string
var n int
if err := rows.Scan(&ws, &n); err != nil {
return nil, err
}
out = append(out, ws)
}
return out, rows.Err()
}
/*
TouchAgentLastSeen 只刷新"我还活着",**不**算未读数。
# 为什么要把它拆出来
原先 middleware 在**每一个**已认证请求上都调 HeartbeatAgent(它会顺手算
`CountUnread` 并丢掉返回值)—— 那是白算一次全表扫描,而且现在 `CountUnread`
还需要工作区,而 middleware 那一层拿不到(请求体还没解析)。
⇒ 拆成两件事:心跳副作用(只更新 last_seen)留在 middleware;
"这个工作区还有多少未读"由心跳 handler 按请求体里的 workspace 算。
*/
func TouchAgentLastSeen(ctx context.Context, agentName string) error {
// 只把【非停用】的 Agent 标成在线。 // 只把【非停用】的 Agent 标成在线。
// //
// 不加这个条件的话,停用后插件的心跳会把 status 从 disabled 改回 online // 不加这个条件的话,停用后插件的心跳会把 status 从 disabled 改回 online
@ -64,10 +130,21 @@ func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
`UPDATE agents SET last_seen = NOW(), status = 'online' `UPDATE agents SET last_seen = NOW(), status = 'online'
WHERE agent_name = $1 AND status <> 'disabled'`, WHERE agent_name = $1 AND status <> 'disabled'`,
agentName) agentName)
if err != nil { return err
}
// HeartbeatAgent 刷新在线状态并返回**该 Agent 的未读总数**。
//
// ★ 这里是**全局**口径(跨工作区),与 ListInboxScoped 不同 —— 原因见
//
// UnreadWorkspaces 上面那段:心跳是进程级,收件箱是 worker 级。
// 桥拿到这个总数后,用 `pending_workspaces` 清单逐工作区去补投,
// 两边合起来才是"有没有信、在哪个工作区"。
func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
if err := TouchAgentLastSeen(ctx, agentName); err != nil {
return 0, err return 0, err
} }
return CountUnread(ctx, agentName) return CountUnread(ctx, agentName, "")
} }
// AgentDisabled 该 Agent 是否已被停用。Agent 不存在时返回 false —— // AgentDisabled 该 Agent 是否已被停用。Agent 不存在时返回 false ——
@ -504,6 +581,44 @@ func requireReader(reader string) error {
return nil return nil
} }
/*
checkWorkspace 校验 workspace 的形状(**允许为空**)。
# 空与非空各是什么语义(两条不同的入口,别混)
· **非空** = 只列该工作区的信。**Agent 侧必须非空**(Handler 层强制)。
· **空** = 不过滤,列该名字的全部。**人类侧就是这个**:一个人跨工作区,
WebUI 把结果按 `session_workspace` **分组显示**(`MailList.tsx:167`),
而不是只给一个工作区 —— 强行让人也带工作区,等于让人在多个工作区之间反复切。
⇒「必需」这条约束放在 **Handler**(`GetInbox` / `MarkInboxRead` / `HeartbeatAgent`),
不是这里:它是**接口契约**,不是数据层不变量。放 repo 会让人类那条路也没法用,
而人类侧并没有"我处在哪个工作区"这个概念。
# 为什么 Agent 必须带工作区
Agent 的收件箱原先只按名字过滤(`WHERE m.to_name = $1`),于是 `pi` 这个名字下
**所有工作区**的信混成一个池子。生产实测:在 `mc` 工作区干活的 pi 读收件箱拿到
200 封,其中 191 封属于 `/home/program/agentmail` —— 它照着那些信里的断言去改
agentmail 的代码,把手上 mc 的活丢在一边(用户当场问「你怎么干着干着修
agentmail 去了?」)。
三维地址是 `name@path.session` —— **path 位本来就该参与寻址**。收件箱侧此前
完全没用它,那不是"另一种语义",是没兑现契约。
*/
func checkWorkspace(workspace string) error {
w := strings.TrimSpace(workspace)
if w == "" {
return nil // 人类侧:不过滤,由 WebUI 按工作区分组显示
}
// 绝对路径:相对路径在服务端无法解释,且不同调用方 cwd 不同 ⇒ 拼出来必然对不上。
if !strings.HasPrefix(w, "/") {
return fmt.Errorf("workspace 必须是绝对路径,收到 %q", w)
}
return nil
}
// unreadFor 返回"$n 这个读者看这封邮件是未读"的谓词;`m` 必须是 mails 的别名。 // unreadFor 返回"$n 这个读者看这封邮件是未读"的谓词;`m` 必须是 mails 的别名。
func unreadFor(arg string) string { func unreadFor(arg string) string {
return `(m.status <> 'archived' AND NOT EXISTS ( return `(m.status <> 'archived' AND NOT EXISTS (
@ -595,15 +710,23 @@ func MarkMailRead(ctx context.Context, id uuid.UUID, reader string) error {
原函数保持原语义(不带会话 = 整个 Agent 的收件箱),新增带会话的变体 —— 原函数保持原语义(不带会话 = 整个 Agent 的收件箱),新增带会话的变体 ——
老调用点一个都不用改。 老调用点一个都不用改。
*/ */
func ListInbox(ctx context.Context, agentName, status string, limit int) ([]models.Mail, error) { func ListInbox(ctx context.Context, agentName, status, workspace string, limit int) ([]models.Mail, error) {
return ListInboxScoped(ctx, agentName, status, limit, uuid.Nil) return ListInboxScoped(ctx, agentName, status, workspace, limit, uuid.Nil)
} }
// ListInboxScoped 与 ListInbox 相同,但 `sessionID` 非零时只列该会话的邮件。 // ListInboxScoped 与 ListInbox 相同,但 `sessionID` 非零时只列该会话的邮件。
func ListInboxScoped(ctx context.Context, agentName, status string, limit int, sessionID uuid.UUID) ([]models.Mail, error) { //
// `workspace` 非空时收窄到该工作区(Agent 侧 Handler 强制必填;人类侧为空):只列属于该工作区的会话里的信。
// 判据用 `s.workspace`(会话的权威工作区)而不是 `m.to_workspace`:
// 后者是**这封信**的信封字段,可能是抄送、可能是历史遗留;而"这条线索属于哪个
// 工作区"是会话的属性,一处定死才不会两种答案。
func ListInboxScoped(ctx context.Context, agentName, status, workspace string, limit int, sessionID uuid.UUID) ([]models.Mail, error) {
if err := requireReader(agentName); err != nil { if err := requireReader(agentName); err != nil {
return nil, err return nil, err
} }
if err := checkWorkspace(workspace); err != nil {
return nil, err
}
q := `SELECT m.mail_id, m.session_id, m.parent_mail_id, q := `SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace, m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result, m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
@ -618,7 +741,15 @@ func ListInboxScoped(ctx context.Context, agentName, status string, limit int, s
JOIN sessions s ON m.session_id = s.session_id JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `) WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
AND s.status <> 'archived'` AND s.status <> 'archived'`
// ★ 工作区收窄(必需):`pi` 在两个工作区各有一条收件箱,互不可见。
//
// 用 EXISTS 而不是再 JOIN 一次 sessions:s 已经在上面 JOIN 过了,
// 这里直接把条件写进 WHERE 即可(同一条 s)。
args := []any{agentName} args := []any{agentName}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
q += fmt.Sprintf(` AND s.workspace = $%d`, len(args))
}
if sessionID != uuid.Nil { if sessionID != uuid.Nil {
// 会话收窄:只列这条线索里的邮件(见上面「会话维度」的说明) // 会话收窄:只列这条线索里的邮件(见上面「会话维度」的说明)
args = append(args, sessionID) args = append(args, sessionID)
@ -633,7 +764,8 @@ func ListInboxScoped(ctx context.Context, agentName, status string, limit int, s
} else if status == "read" { } else if status == "read" {
q += ` AND NOT ` + unreadFor("$1") + ` AND m.status <> 'archived'` q += ` AND NOT ` + unreadFor("$1") + ` AND m.status <> 'archived'`
} else { } else {
q += ` AND m.status = $2` // ★ 占位符必须是**动态序号**:$2 现在被 workspace 占了。
q += fmt.Sprintf(` AND m.status = $%d`, len(args)+1)
args = append(args, status) args = append(args, status)
} }
} }
@ -685,19 +817,41 @@ func ListInboxScoped(ctx context.Context, agentName, status string, limit int, s
return mails, rows.Err() return mails, rows.Err()
} }
func CountUnread(ctx context.Context, agentName string) (int, error) { func CountUnread(ctx context.Context, agentName, workspace string) (int, error) {
return CountUnreadScoped(ctx, agentName, workspace, uuid.Nil)
}
// CountUnreadScoped 与 CountUnread 相同,但 `sessionID` 非零时只数那条会话。
//
// ★ 必须与 ListInboxScoped **同一个收窄口径**:桥的补投判据是
//
// `pending_mails = CountUnread` —— 两者口径不一致时,列表看不到的信会一直
// 被算成"还有未读",桥每次心跳都重放一遍(这是设计文档里记过的那个坑)。
func CountUnreadScoped(ctx context.Context, agentName, workspace string, sessionID uuid.UUID) (int, error) {
if err := requireReader(agentName); err != nil { if err := requireReader(agentName); err != nil {
return 0, err return 0, err
} }
if err := checkWorkspace(workspace); err != nil {
return 0, err
}
var count int var count int
err := db.DB.QueryRowContext(ctx, ` q := `
SELECT COUNT(*) SELECT COUNT(*)
FROM mails m FROM mails m
JOIN sessions s ON m.session_id = s.session_id JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`) WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
AND `+unreadFor("$1")+` AND ` + unreadFor("$1") + `
AND s.status <> 'archived' AND s.status <> 'archived'`
`, agentName).Scan(&count) args := []any{agentName}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
q += fmt.Sprintf(` AND s.workspace = $%d`, len(args))
}
if sessionID != uuid.Nil {
args = append(args, sessionID)
q += fmt.Sprintf(` AND m.session_id = $%d`, len(args))
}
err := db.DB.QueryRowContext(ctx, q, args...).Scan(&count)
return count, err return count, err
} }
@ -1822,21 +1976,31 @@ func MarkMailsReadFor(ctx context.Context, recipient string, ids []uuid.UUID) (i
// //
// 排除已归档会话:那些邮件在收件箱里根本看不到, // 排除已归档会话:那些邮件在收件箱里根本看不到,
// 标掉它们只会让「标记了 N 封」这个数字与用户看到的对不上。 // 标掉它们只会让「标记了 N 封」这个数字与用户看到的对不上。
func MarkAllInboxReadFor(ctx context.Context, recipient string) (int, error) { func MarkAllInboxReadFor(ctx context.Context, recipient, workspace string) (int, error) {
return MarkAllInboxReadForSession(ctx, recipient, uuid.Nil) return MarkAllInboxReadForSession(ctx, recipient, workspace, uuid.Nil)
} }
// MarkAllInboxReadForSession 只标掉某条会话里发给 recipient 的未读。 // MarkAllInboxReadForSession 只标掉某条会话里发给 recipient 的未读。
// //
// 为什么需要:Agent 的「不给 mail_ids,全部标掉」在会话驱动的 worker 里会跨会话 // 为什么需要:Agent 的「不给 mail_ids,全部标掉」在会话驱动的 worker 里会跨会话
// 误伤(见 ListInboxScoped 上面那段说明)。不带 sessionID(uuid.Nil)时是旧语义。 // 误伤(见 ListInboxScoped 上面那段说明)。不带 sessionID(uuid.Nil)时是旧语义。
func MarkAllInboxReadForSession(ctx context.Context, recipient string, sessionID uuid.UUID) (int, error) { func MarkAllInboxReadForSession(ctx context.Context, recipient, workspace string, sessionID uuid.UUID) (int, error) {
scope := `(m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `) if err := checkWorkspace(workspace); err != nil {
AND m.session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived')` return 0, err
}
// ★ 工作区收窄:与 ListInboxScoped **同一个谓词** —— 列表看不到的信却被
// "全部标掉"标掉,就是静默丢信(session_scope_test.go 记过这个形状)。
wsFilter := ""
args := []any{recipient} args := []any{recipient}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
wsFilter = fmt.Sprintf(" AND workspace = $%d", len(args))
}
scope := `(m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
AND m.session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived'` + wsFilter + `)`
if sessionID != uuid.Nil { if sessionID != uuid.Nil {
scope += ` AND m.session_id = $2`
args = append(args, sessionID) args = append(args, sessionID)
scope += fmt.Sprintf(` AND m.session_id = $%d`, len(args))
} }
if err := markReadFor(ctx, recipient, scope, args...); err != nil { if err := markReadFor(ctx, recipient, scope, args...); err != nil {
return 0, err return 0, err
@ -1847,11 +2011,14 @@ func MarkAllInboxReadForSession(ctx context.Context, recipient string, sessionID
upd := `UPDATE mails SET status = 'read' upd := `UPDATE mails SET status = 'read'
WHERE status = 'unread' WHERE status = 'unread'
AND (to_name = $1 OR ` + db.CCHas("cc_list", 1) + `) AND (to_name = $1 OR ` + db.CCHas("cc_list", 1) + `)
AND session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived')` AND session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived'` + wsFilter + `)`
updArgs := []any{recipient} updArgs := []any{recipient}
if strings.TrimSpace(workspace) != "" {
updArgs = append(updArgs, workspace)
}
if sessionID != uuid.Nil { if sessionID != uuid.Nil {
upd += ` AND session_id = $2`
updArgs = append(updArgs, sessionID) updArgs = append(updArgs, sessionID)
upd += fmt.Sprintf(` AND session_id = $%d`, len(updArgs))
} }
res, err := db.DB.ExecContext(ctx, upd, updArgs...) res, err := db.DB.ExecContext(ctx, upd, updArgs...)
if err != nil { if err != nil {

View File

@ -62,7 +62,7 @@ func TestInboxListIsScopedBySession(t *testing.T) {
s1, s2, m1, m2 := twoSessionsWithUnread(t, "bot") s1, s2, m1, m2 := twoSessionsWithUnread(t, "bot")
// 反向对照:不带会话 = 整个 Agent 的收件箱,两条都看得到 // 反向对照:不带会话 = 整个 Agent 的收件箱,两条都看得到
all, err := ListInbox(ctx, "bot", "unread", 50) all, err := ListInbox(ctx, "bot", "unread", "", 50)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -71,7 +71,7 @@ func TestInboxListIsScopedBySession(t *testing.T) {
} }
// ★ 收窄到会话一:只有它那条 // ★ 收窄到会话一:只有它那条
scoped, err := ListInboxScoped(ctx, "bot", "unread", 50, s1) scoped, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -83,7 +83,7 @@ func TestInboxListIsScopedBySession(t *testing.T) {
} }
// 会话二同理(别只验一侧 —— 方向反了也能"过") // 会话二同理(别只验一侧 —— 方向反了也能"过")
scoped2, err := ListInboxScoped(ctx, "bot", "unread", 50, s2) scoped2, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s2)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -98,14 +98,14 @@ func TestMarkAllReadCanBeScopedToSession(t *testing.T) {
s1, s2, _, _ := twoSessionsWithUnread(t, "bot") s1, s2, _, _ := twoSessionsWithUnread(t, "bot")
// 只标会话一 // 只标会话一
n, err := MarkAllInboxReadForSession(ctx, "bot", s1) n, err := MarkAllInboxReadForSession(ctx, "bot", "", s1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if n != 1 { if n != 1 {
t.Fatalf("应当只标掉 1 封,实际 %d", n) t.Fatalf("应当只标掉 1 封,实际 %d", n)
} }
left, err := ListInboxScoped(ctx, "bot", "unread", 50, s2) left, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s2)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -114,10 +114,10 @@ func TestMarkAllReadCanBeScopedToSession(t *testing.T) {
} }
// 反向对照:不带会话时两条都会被标掉 // 反向对照:不带会话时两条都会被标掉
if _, err := MarkAllInboxReadFor(ctx, "bot"); err != nil { if _, err := MarkAllInboxReadFor(ctx, "bot", ""); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if again, _ := ListInbox(ctx, "bot", "unread", 50); len(again) != 0 { if again, _ := ListInbox(ctx, "bot", "unread", "", 50); len(again) != 0 {
t.Fatalf("不带会话的全标应当清空整个收件箱,实际剩 %d", len(again)) t.Fatalf("不带会话的全标应当清空整个收件箱,实际剩 %d", len(again))
} }
} }
@ -133,7 +133,7 @@ func TestScopedCountsMatchScopedList(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
list1, err := ListInboxScoped(ctx, "bot", "unread", 50, s1) list1, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -172,7 +172,7 @@ func TestInboxRowsCarrySessionPermissionMode(t *testing.T) {
} }
seedMailInSession(t, sid, "bot") seedMailInSession(t, sid, "bot")
rows, err := ListInboxScoped(ctx, "bot", "unread", 50, sid) rows, err := ListInboxScoped(ctx, "bot", "unread", "", 50, sid)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -190,7 +190,7 @@ func TestInboxRowsCarrySessionPermissionMode(t *testing.T) {
// 缺字段时"猜宽"就是提权,所以默认值这一侧也要验。 // 缺字段时"猜宽"就是提权,所以默认值这一侧也要验。
other := createTestSession(t, ctx, "bot", "/tmp/ws2") other := createTestSession(t, ctx, "bot", "/tmp/ws2")
seedMailInSession(t, other, "bot") seedMailInSession(t, other, "bot")
rows2, err := ListInboxScoped(ctx, "bot", "unread", 50, other) rows2, err := ListInboxScoped(ctx, "bot", "unread", "", 50, other)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -219,7 +219,7 @@ func TestInboxRowsCarryReplyAddress(t *testing.T) {
} }
seedMailInSession(t, sid, "bot") seedMailInSession(t, sid, "bot")
rows, err := ListInboxScoped(ctx, "bot", "unread", 50, sid) rows, err := ListInboxScoped(ctx, "bot", "unread", "", 50, sid)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -4,60 +4,153 @@ import (
"context" "context"
"testing" "testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid" "github.com/google/uuid"
) )
/* /*
工作区维度:一个 Agent 同时服务**所有**工作区,所以"参与过"不等于"该看"。 工作区维度:**同一个 Agent 名字在不同工作区各有独立收件箱。**
# 用户报的缺陷 # 用户报的缺陷(12 天前就提过,这轮才真修)
「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了,还需要我亲自去解释。」 「不同 session 的 agent 都可以看到全部邮件」—— `552fbc7` 修了 **session_id** 那一维,
但**工作区**那一维从没做。生产实测(2026-09-26):在 `mc` 工作区干活的 pi 读收件箱
拿到 200 封,其中 191 封属于 `/home/program/agentmail` —— 它照着那些信里的断言去改
agentmail 的代码,把手上 mc 的活丢在一边。用户当场问「你怎么干着干着修 agentmail 去了?」
根因不是某处漏了一个 WHERE,而是**隔离单位选的是 Agent**: 三维地址是 `name@path.session`。**path 位本来就该参与寻址**,收件箱侧此前完全没用它。
- `AgentCanAccessSession(agentName, sid)` 判的是「这个 Agent 名出现在这条会话的 # 判据要两侧都验
from/to/cc 里」;
- 而 Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace`
决定),于是同一个 agent `pi` 既"参与过" agentmail 的会话、也"参与过"
TrueAgent 的会话 —— 两个工作区之间没有任何边界。
现场证据:`mail_reads` 里 2026-09-14 08:11–09:19 有 8 次"同一瞬间读了多个不同工作区 只验「带工作区 A 时看不到 B 的信」不够 —— 把列表直接返回空也能过。
的会话"(最典型 08:23:59 一次跨 agentmail / TrueAgent / webui4frpc 三条会话), 所以同时验反向对照:「不带工作区时两条都看得到」(人类侧语义)。
而那正是按 Agent 整表读的特征。更要紧的是 `mail_reads` 只记 `reader_name`, 两侧一起才证明是**收窄**而不是**清空**。
**没有"读的人当时在哪个工作区"这一列** —— 这类越界读在数据上与正常读无法区分。
# 判据两侧都验
只验"跨工作区被拒"是不够的:把函数写成永远拒绝也能过。所以同时验
- 同工作区必须放行(否则等于把所有 Agent 都锁死);
- 未声明 scope 时的旧语义(迁移期妥协,必须明确写下来,不能靠"没人测"存在);
- 拒绝的**原因**要分得清(没参与过 vs 跨工作区)—— 否则调用方无法自查;
- 列表类接口的反向对照:不带收窄时两条会话都在(证明收窄真的在起作用)。
*/ */
func sessionIn(t *testing.T, agent, ws, title string) uuid.UUID { func seedMailInWorkspace(t *testing.T, workspace, recipient string) uuid.UUID {
t.Helper() t.Helper()
id, err := CreateSession(context.Background(), nil, "human", title, ws) ctx := context.Background()
sid, err := CreateSession(ctx, nil, "human", "ws-"+workspace, workspace)
if err != nil { if err != nil {
t.Fatalf("create session(%s): %v", title, err) t.Fatal(err)
}
var id uuid.UUID
if err := db.DB.QueryRowContext(ctx,
`INSERT INTO mails (session_id, from_name, to_name, subject, body, to_workspace)
VALUES ($1, 'sender', $2, 's', 'b', $3) RETURNING mail_id`,
sid, recipient, workspace).Scan(&id); err != nil {
t.Fatal(err)
} }
seedMailInSession(t, id, agent)
return id return id
} }
func TestListContactsWithEmptyScopeStillWorks(t *testing.T) { func TestInboxListIsScopedByWorkspace(t *testing.T) {
setupTestDB(t) setupTestDB(t)
ctx := context.Background() ctx := context.Background()
sessionIn(t, "pi", "/home/program/agentmail", "随便一条线索")
all, err := ListContactsFor(ctx, "", false) const wsA = "/home/program/agentmail"
const wsB = "/home/newqqagent/plugindev/mc"
a := seedMailInWorkspace(t, wsA, "pi")
b := seedMailInWorkspace(t, wsB, "pi")
// ① 带工作区 A ⇒ 只有 A 那封
onlyA, err := ListInbox(ctx, "pi", "all", wsA, 50)
if err != nil { if err != nil {
t.Fatalf("scope 为空(管理员看全部)不该报错:%v", err) t.Fatal(err)
} }
if len(all) != 1 { if len(onlyA) != 1 || onlyA[0].ID != a {
t.Fatalf("应当列出 1 条(实际 %d 条)", len(all)) t.Fatalf("工作区 A 应收窄到 1 封(A 那封),实际 %d 封", len(onlyA))
}
// ② 带工作区 B ⇒ 只有 B 那封(**反向对照**:证明不是"总是返回第一封")
onlyB, err := ListInbox(ctx, "pi", "all", wsB, 50)
if err != nil {
t.Fatal(err)
}
if len(onlyB) != 1 || onlyB[0].ID != b {
t.Fatalf("工作区 B 应收窄到 1 封(B 那封),实际 %d 封", len(onlyB))
}
// ③ 不带工作区(人类侧语义)⇒ 两封都看得到
// ★ 这一条是"收窄 vs 清空"的分水岭:少了它,把 ① 实现成 return nil 也能过。
all, err := ListInbox(ctx, "pi", "all", "", 50)
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("不带工作区应看到 2 封(人类跨工作区),实际 %d 封", len(all))
}
// ④ 未读数同样按工作区算 —— 它是桥的补投判据 pending_mails。
// 口径不一致时,桥会认为"还有信没投"并反复重放。
nA, err := CountUnread(ctx, "pi", wsA)
if err != nil {
t.Fatal(err)
}
nB, err := CountUnread(ctx, "pi", wsB)
if err != nil {
t.Fatal(err)
}
nAll, err := CountUnread(ctx, "pi", "")
if err != nil {
t.Fatal(err)
}
if nA != 1 || nB != 1 || nAll != 2 {
t.Fatalf("未读数应为 A=1 B=1 全部=2,实际 A=%d B=%d 全部=%d", nA, nB, nAll)
} }
} }
// 「全部标已读」也必须按工作区收窄 —— 否则标掉别的工作区的未读 = 静默丢信
// (session_scope_test.go 记过这个形状)。
func TestMarkAllReadIsScopedByWorkspace(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
const wsA = "/home/program/agentmail"
const wsB = "/home/newqqagent/plugindev/mc"
seedMailInWorkspace(t, wsA, "pi")
seedMailInWorkspace(t, wsB, "pi")
n, err := MarkAllInboxReadForSession(ctx, "pi", wsA, uuid.Nil)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("标已读应只影响工作区 A 的 1 封,实际 %d 封", n)
}
// A 的标掉了
if left, _ := CountUnread(ctx, "pi", wsA); left != 0 {
t.Fatalf("工作区 A 应已清空,实际剩 %d 封未读", left)
}
// B 的**必须还在**(这是本判据的重点)
if left, _ := CountUnread(ctx, "pi", wsB); left != 1 {
t.Fatalf("工作区 B 的未读被误标了 —— 这是静默丢信(应剩 1 封,实际 %d)", left)
}
// 反向对照:不带工作区时全标掉(人类侧)
if _, err := MarkAllInboxReadForSession(ctx, "pi", "", uuid.Nil); err != nil {
t.Fatal(err)
}
if left, _ := CountUnread(ctx, "pi", ""); left != 0 {
t.Fatalf("不带工作区应全部标掉,实际剩 %d 封", left)
}
}
// workspace 的形状校验:非空时必须是绝对路径。
// 相对路径在服务端无法解释(不同调用方 cwd 不同 ⇒ 拼出来必然对不上),
// 而"对不上"的表现是**静默返回空收件箱** —— 那比报错难查得多。
func TestWorkspaceMustBeAbsolute(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedMailInWorkspace(t, "/home/program/agentmail", "pi")
for _, bad := range []string{"relative/path", "./here", "~/home"} {
if _, err := ListInbox(ctx, "pi", "all", bad, 50); err == nil {
t.Fatalf("ListInbox 对相对路径 %q 必须报错,实际通过(会静默返回空收件箱)", bad)
}
if _, err := CountUnread(ctx, "pi", bad); err == nil {
t.Fatalf("CountUnread 对相对路径 %q 必须报错", bad)
}
}
}