Files
MailUI4Agents/plugins/pi-mail-bridge/test/catchup.test.mjs
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

175 lines
8.1 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.

import assert from 'node:assert/strict';
import test from 'node:test';
import { MAX_CATCHUP, mailToEvent, selectCatchup } from '../lib/catchup.js';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const mail = (over = {}) => ({
mail_id: 'm1',
session_id: 's1',
from_name: 'admin',
subject: '主题',
mail_type: 'normal',
to_workspace: '/tmp/ws',
...over,
});
test('mailToEvent 产出与 SSE new_mail 同形的对象', () => {
const ev = mailToEvent(mail());
// 投递侧读的就是这几个键,形状不一致会让补拉那条路径静默地少带信息
for (const k of ['mail_id', 'session_id', 'from_name', 'subject', 'mail_type', 'to_workspace']) {
assert.ok(k in ev, `缺少 ${k}`);
}
assert.equal(ev.role, 'to');
assert.equal(ev.catchup, true);
});
test('mailToEvent 对缺字段的行给出空串而非 undefined', () => {
const ev = mailToEvent({});
assert.equal(ev.mail_id, '');
assert.equal(ev.to_workspace, '');
assert.equal(ev.mail_type, 'normal');
});
test('已经通过 SSE 投过的不再补投', () => {
const mails = [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })];
const got = selectCatchup(mails, new Set(['a']));
assert.deepEqual(got.map(e => e.mail_id), ['b']);
});
test('按时间正序补投(收件箱是倒序返回的)', () => {
// 收件箱:新的在前
const mails = [mail({ mail_id: 'new' }), mail({ mail_id: 'mid' }), mail({ mail_id: 'old' })];
const got = selectCatchup(mails, new Set());
assert.deepEqual(
got.map(e => e.mail_id),
['old', 'mid', 'new'],
'先来的邮件必须先处理,否则同一会话里的上下文顺序是乱的',
);
});
test('permission 类邮件不补投', () => {
const mails = [mail({ mail_id: 'p', mail_type: 'permission' }), mail({ mail_id: 'n' })];
const got = selectCatchup(mails, new Set());
assert.deepEqual(got.map(e => e.mail_id), ['n']);
});
test('超过上限的部分留在收件箱里', () => {
const mails = Array.from({ length: MAX_CATCHUP + 4 }, (_, i) => mail({ mail_id: 'm' + i }));
const got = selectCatchup(mails, new Set());
assert.equal(got.length, MAX_CATCHUP, '一次补拉不该把几十封邮件同时放出去');
});
test('上限可显式压到 0用于禁用补拉', () => {
const got = selectCatchup([mail()], new Set(), 0);
assert.deepEqual(got, []);
});
test('空输入与非数组不炸', () => {
assert.deepEqual(selectCatchup([], new Set()), []);
assert.deepEqual(selectCatchup(undefined, new Set()), []);
assert.deepEqual(selectCatchup(null, new Set()), []);
});
test('没有 mail_id 的行跳过', () => {
const got = selectCatchup([mail({ mail_id: '' }), mail({ mail_id: 'ok' })], new Set());
assert.deepEqual(got.map(e => e.mail_id), ['ok']);
});
test('seen 传 undefined 时不去重也不报错', () => {
const got = selectCatchup([mail({ mail_id: 'x' })], undefined);
assert.deepEqual(got.map(e => e.mail_id), ['x']);
});
/**
* ★ 权限档位必须带过来2026-09-14 生产事故的回归判据)。
*
* 现象:一条 full 档会话里只要有一封**补投**邮件worker 就以为自己是 workspace 档,
* 去申请审批 → 服务端按档位回 409 → 桥按「永久失败」当场 block → 这一轮
* bash/write/edit 全被拦SSE 实时送达的邮件不受影响,只有补投的中招)。
*
* 这条判据本该早就存在:上面那条「与 SSE new_mail 同形」的断言只列了 6 个键,
* **照抄了实现里搬的那几个字段** —— 于是"形状不一致"这件事恰好落在它的盲区里。
* 盲区不是"漏了一个字段",是"字段清单由被检查方提供"。
*/
test('★ 补投事件必须带权限档位(漏了就会把 full 档会话误拦)', () => {
const ev = mailToEvent(mail({ permission_mode: 'full', permission_enforcement: 'strict' }));
assert.equal(ev.permission_mode, 'full', '补投路径不许把档位丢掉');
assert.equal(ev.permission_enforcement, 'strict');
// 缺字段时给空串:**不猜档位**(猜宽了就是提权),由调用方 `|| 'workspace'` 兜底
const bare = mailToEvent(mail());
assert.equal(bare.permission_mode, '');
assert.equal(bare.permission_enforcement, '');
assert.ok(!('permissionMode' in bare), '键名要与 SSE 逐字一致(蛇形),别自造驼峰');
});
test('★ 配对判据worker 从事件里读的每个档位字段,补投都要产出', () => {
/*
* 上面那条只钉两个已知字段,将来 worker 再读一个 `msg.data?.xxx` 时它不会响。
* 这条把**生产者与消费者配对**worker 读哪些 `msg.data?.X``mailToEvent`
* 就得产出哪些 X。字段清单不再由被检查方提供 —— 这正是上次漏掉档位的根因。
* 与 narrow-layout 同一取舍:验形态,不验行为;重构敏感是它该做的提醒。
*/
const worker = readFileSync(join(HERE, '..', 'src/worker.mjs'), 'utf8');
const read = new Set([...worker.matchAll(/msg\.data\?\.([a-z_]+)/g)].map(m => m[1]));
assert.ok(read.size >= 4, `要能取到 worker 读的字段(实际 ${read.size} 个)`);
const produced = new Set(Object.keys(mailToEvent(mail())));
const missing = [...read].filter(k => !produced.has(k));
assert.deepEqual(missing, [],
`worker 会从事件里读这些字段,补投却没产出:${missing.join('、')} —— ` +
'补投是重启后的唯一送达路径,缺字段等于那条路径静默降级(档位漏传就是这么出的)');
});
/**
* ★ 四桥配对:每个桥从投递事件里读的**邮件类**字段,补投都要产出。
*
* # 为什么这条判据存在
*
* 2026-09-14 的事故full 档会话被误拦)根因是 `mailToEvent` 搬的字段太少,
* 而它**单独看任何一个桥的测试都发现不了**pi 的测试只列了自己关心的 6 个键
* 照抄实现dsh/zcode 读别的字段,各测各的。缺的字段在**接口**上,
* 不在任何一侧的内部。
*
* 所以这条把四桥的读法与补投的产出**配对**,并且:
* - 只认邮件类事件:`decision_mail_id / kind / note / suggestions / participants`
* 属于权限**决策**事件(`permission_decision`),不走 mailToEvent —— 列进
* ALLOW 并各写一句理由,别用"看起来像"糊过去;
* - 白名单里的每一项都必须是**明确的非邮件事件字段**,加新的要写理由。
*/
const ALLOW = new Map([
['decision_mail_id', '权限决策事件permission_decision的字段不走补投'],
['kind', '决策事件:同意/拒绝'],
['note', '决策事件:备注'],
['suggestions', '决策事件:选项'],
['participants', '决策事件:参与方'],
]);
test('★ 四桥配对:各桥读的邮件字段,补投都要产出(含决策字段白名单自检)', () => {
const produced = new Set(Object.keys(mailToEvent(mail())));
const bridges = [
['dsh', '../../dsh-mail-bridge/src/index.ts'],
['pi', '../src/worker.mjs'],
['zcode', '../../zcode-mail-bridge/src/index.mjs'],
];
const problems = [];
for (const [name, rel] of bridges) {
const src = readFileSync(join(HERE, rel), 'utf8');
const reads = new Set([...src.matchAll(/\b(?:data|msg\.data\?|ev)\.([a-z_]+)/g)].map(m => m[1]));
const missing = [...reads].filter(k => !produced.has(k) && !ALLOW.has(k));
if (missing.length) problems.push(`${name} 读但补投不产出:${missing.join('、')}`);
}
assert.deepEqual(problems, [],
`${problems.join('')}\n` +
'补投是重启后的唯一送达路径,缺字段等于那条路径静默降级(档位漏传就是这么出的)。' +
'若某个字段确实属于别的(非邮件)事件,请往本文件的 ALLOW 里加并写理由。');
// 白名单自检:每个理由都非空,且确实是"决策事件"那一族(别拿"看起来像"糊过去)
for (const [k, why] of ALLOW) {
assert.ok(why.length >= 6, `ALLOW 里 ${k} 要写清为什么可以不产出`);
}
assert.ok(ALLOW.size <= 8, '白名单不许无限膨胀(它一膨胀这条判据就废了)');
});