Files
MailUI4Agents/plugins/zcode-mail-bridge/lib/catchup.js
JianFeeeee 153985e8b1 补投路径漏传档位(full 档被误拦)—— 修因 + 兜底,并顺出同族另外四个字段
pi 报告:离线补投的邮件把 full 档会话当 workspace 档申请审批 → 服务端 409 →
桥按「永久失败」当场 block → 这一轮 bash/write/edit 全被拦(SSE 实时送达不受影响)。
jianf 让 pi 把这件转给我,我这边定位后**先跑变异再改**。

## 1 根因:`mailToEvent` 少搬字段(不是服务端不给)

`lib/catchup.js` 的 `mailToEvent()` 只搬了 8 个字段,没有 `permission_mode` /
`permission_enforcement`,于是 worker 的 `msg.data?.permission_mode || 'workspace'`
落到默认档。**pi 以为收件箱行不含档位、于是建议"要么动服务端载荷要么另取一次"——
实测不成立**:服务端一直就给了(`repo.ListInboxScoped` 的 SQL 里有
`JOIN sessions s` + `COALESCE(NULLIF(s.permission_mode,''),'workspace')`,
`models.Mail.PermissionMode` 的注释写明"补拉路径必须有它们")。所以修因只在插件侧:
补上这两个字段,键名与 SSE 逐字一致;缺字段时给空串(**不猜档**,猜宽了就是提权)。
`lib/catchup.js` 在四个桥里**逐字节相同**,一次改动四边同步(改后 md5 仍为一份)。

## 2 兜底:409 带档位时按档位处置

服务端在"档位不该问人"时也回 409,并在回包里带 `permission_mode`。两种 409 的正确反应
**相反**:无人可问 → 拦;**full 档 → 放行**(本档无需审批,拦了就是把能干的活干死)。
`src/worker.mjs` 的 409 分支先认 `permission_mode === MODE_FULL` 放行,
plan 档与"链上没有人类"照旧 fail closed —— 只有服务端明说 full 才放行。

## 3 顺出的同族字段(用"配对"扫出来的,不是猜的)

把四个桥**读投递事件的字段**与 `mailToEvent` 的产出对了一遍,邮件类字段还缺三个:

- `from_human`:dsh 的提示词靠它决定说不说"回信不用你自己发"。缺了它,
  **人发来的信在补投路径上被当成 Agent 来信、失去自动回信**(服务端注释早写明)。
- `in_reply_to`:SSE 那边等于 `ParentMailID`。缺了它,"这封是对我的回复"被当成新派的活,
  两边互相客套到撞 hop 上限(生产实测 6 轮)。行里叫 `parent_mail_id`,**只改名不推算**。
- `session_alias`:缺了它插件只有 session_id,而 `send_mail` 不接受 session_id。

`reply_address` 是**唯一**行里真的没有的字段(SSE 在 notify 里按收件人现算)。
服务端注释明确说"插件不必自己拼(拼错了就是静默开新会话)",所以由服务端补:
`models.Mail.ReplyAddress` + `ListInboxScoped` 填 `FormatAddress(from_name,"",alias)`,
插件只搬运。

## 4 判据(这次事故**单独看任何一个桥的测试都发现不了** —— 缺口在接口上)

- `test/catchup.test.mjs`:补投必须带档位(缺字段给空串而非猜档);
  ★ **四桥配对**:把 dsh/pi/zcode 读的邮件字段与补投产出配对,缺了就红
  (非邮件事件字段走显式 ALLOW 并各写理由,白名单不许膨胀)。这条正是本次缺口的形状。
- `test/permission-mode-409.test.mjs`:409 + full 必须放行且放行分支在 block 之前,
  非 full 仍拦;带**判据自检**(拿掉放行分支后必须判红)。
- `server/internal/repo/session_scope_test.go`:收件箱行带 `permission_mode`(含"没设过
  回落 workspace"的反向对照)与 `reply_address`(与 `FormatAddress` 同形、path 位为空)。

变异验证:mailToEvent 去掉档位 → 2 条红;worker 新读一个补投没产的字段 → 配对判据红**并点名该字段**;
409 分支拿掉 full 放行 → 自检红;SQL 把档位写死成 workspace → Go 判据红。

## 验证

`go test ./...` 全绿(新增 2 条);四个桥套件全绿(pi 439 / dsh 381 / opencode 331 / zcode 385)。
**未部署**:`/opt/agentmail` 与 `sudo ./deploy/install.sh` 都在我的工作区之外(本会话文件策略
workspace-write,放宽需审批而这条链上没有人类),所以修复已进仓但**线上仍是有缺陷的版本** ——
需要有人跑一次 `sudo ./deploy/install.sh`(脚本自己会跑齐各套件)。
2026-09-14 15:49:45 +08:00

126 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 启动补拉:把插件离线期间到的邮件变成与 SSE 事件同形的投递任务。
*
* 为什么需要它:**SSE 只推连上之后的事件**。插件重启前发来的邮件不会再推一次,
* 心跳响应的 `pending_mails` 是唯一线索。不补拉的后果是那封邮件永远躺在
* 收件箱里,而发件人以为 Agent 收到了 —— 这比明确的失败更难排查。
*
* 两个平台共用必须逐字节相同deploy/check-shared-libs.sh 校验)。
*/
/**
* 一次补拉最多处理几封。
*
* 上限存在的理由:每封都要起一轮模型。攒了 80 封的时候一次性全放出去,
* 等于对上游打 80 个并发请求,且最后那几封要等前面全部跑完。
* 超出的部分留在收件箱里,下次重启或人工触发时再处理。
*/
export const MAX_CATCHUP = 5;
/**
* 把收件箱里的一封邮件转成 SSE `new_mail` 那个形状。
*
* 补拉与 SSE 走同一条投递路径deliverMail因此形状必须一致 ——
* 两条路径各写一遍投递逻辑的话,某一条上的修复会漏掉另一条。
*
* @param {any} mail `/mail/inbox` 返回的一行
* @returns {{mail_id: string, session_id: string, from_name: string,
* subject: string, mail_type: string, role: string,
* to_workspace: string, catchup: true}}
*/
export function mailToEvent(mail) {
return {
mail_id: mail?.mail_id || '',
session_id: mail?.session_id || '',
from_name: mail?.from_name || '',
subject: mail?.subject || '',
mail_type: mail?.mail_type || 'normal',
role: 'to',
to_workspace: mail?.to_workspace || '',
/*
* 权限档位**必须在补投路径上带过来**2026-09-14 修)。
*
* 病根SSE 的 `new_mail` 带 `permission_mode` / `permission_enforcement`
* 而这里只搬了 8 个字段 —— 于是 worker 的
* `mailContext.permissionMode = msg.data?.permission_mode || 'workspace'`
* 落到默认 workspace。**只有补投的邮件中招**SSE 实时送达的不受影响),
* 表现为:一条 full 档会话里只要有一封补投邮件worker 就去申请审批 →
* 服务端按档位回 409 → 桥按「永久失败」当场 block → 这一轮 bash/write/edit 全被拦。
*
* 字段名与 SSE 逐字一致(就是这个文件头声称的目标);
* `GET /mail/inbox` 的行**已经**带这两个字段(服务端 `ListInboxScoped` 里有
* `JOIN sessions s` + `COALESCE(NULLIF(s.permission_mode,''),'workspace')`
* `models.Mail.PermissionMode` 的注释写明了补拉路径必须有它),
* 是这里把它们丢掉的 —— 不需要动服务端载荷,也不需要插件另发一次请求。
*
* 缺失时给空串而不是猜一个档(调用方 `|| 'workspace'` 自会兜底):
* 这里猜等于替服务端做安全决策,猜宽了就是提权。
*/
permission_mode: mail?.permission_mode || '',
permission_enforcement: mail?.permission_enforcement || '',
/*
* 同一族的另外三个字段2026-09-14 用"配对"扫出来的,不是猜的):
* 把四个桥**读投递事件的字段**与 `mailToEvent` 产出的字段对了一遍,
* 缺的邮件类字段就是这三个 —— 它们和权限档位是同一个根因(这个函数搬的字段太少)。
*
* - `from_human`消费方dsh 的 adoptPrompt靠它决定"回信不用你自己发"这句
* 说不说。缺了它,**人发来的信在补投路径上被当成 Agent 来信,失去自动回信**
* —— 服务端 `models.Mail.FromHuman` 的注释早就写明"补拉路径必须有它"。
* - `in_reply_to`SSE 那边等于 `ParentMailID`。缺了它,"这封是对我的回复"
* 被当成"新派的活",两边会互相客套到撞上 hop 上限(生产实测 6 轮)。
* 行里的字段叫 `parent_mail_id`,这里**只做改名,不做推算**。
* - `session_alias`:会话的寻址名。缺了它,插件只知道 session_id
* 而 `send_mail` 不接受 session_id。
*
* **没做的那个**`reply_address`。它不在收件箱行里(服务端在 notify 的载荷里
* 现算:`FormatAddress(replyTo, "", alias)`,且是**按收件人**算的)。
* 服务端注释明确说"插件不必自己拼(拼错了就是静默开新会话)"
* 所以这里不替它拼 —— 那是服务端补一个字段的事,留给上游决定(见回信说明)。
*/
from_human: mail?.from_human === true,
in_reply_to: mail?.in_reply_to || mail?.parent_mail_id || '',
session_alias: mail?.session_alias || '',
/*
* `reply_address` 曾经是**唯一**一个收件箱行里没有的字段 —— 服务端在 notify
* 的载荷里现算(`FormatAddress(replyTo, "", alias)`)。既然补投路径也需要它
* dsh 桥的提示词读 `data.reply_address`),服务端就把它一并放进收件箱行
* `models.Mail.ReplyAddress`,由 `ListInboxScoped` 填)—— 插件只搬运,不自己拼:
* 服务端注释明确说"拼错了就是静默开新会话"。
*/
reply_address: mail?.reply_address || '',
// 标记来源,投递侧可据此决定是否在提示词里说明「这是积压的邮件」
catchup: true,
};
}
/**
* 从收件箱挑出该补投的邮件。
*
* @param {any[]} mails `/mail/inbox?status=unread` 的结果
* @param {Set<string>} seen 已经通过 SSE 投过的 mail_id避免重复投递
* @param {number} [max] 上限,默认 MAX_CATCHUP
* @returns {any[]} 与 SSE 事件同形的投递任务,按时间正序(老的先处理)
*/
export function selectCatchup(mails, seen, max = MAX_CATCHUP) {
if (!Array.isArray(mails) || mails.length === 0) return [];
const picked = [];
for (const m of mails) {
const id = m?.mail_id;
if (!id) continue;
// 心跳与 SSE 建连之间有个窗口:那期间到的邮件既在 pending_mails 里、
// 也会被 SSE 推一次。不去重就会投两遍,模型回两封信。
if (seen && seen.has(id)) continue;
// permission 类邮件不补投它是给人看的询问Agent 侧没有可恢复的上下文
// (原来的工具调用早随进程一起没了),投过去只会让模型困惑。
if (m?.mail_type && m.mail_type !== 'normal') continue;
picked.push(m);
}
// 收件箱按时间倒序返回,补投要按正序 —— 先来的先处理,
// 否则同一会话里的多封邮件会被倒着塞进去,上下文顺序是乱的。
picked.reverse();
return picked.slice(0, Math.max(0, max)).map(mailToEvent);
}