Files
MailUI4Agents/plugins/pi-mail-bridge/test/permission-mode-409.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

67 lines
3.4 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.

/**
* 接线断言:服务端 409 里说「本会话是 full 档」时,桥必须**放行**而不是拦。
*
* # 为什么需要这条
*
* 409 原本只有一个含义:「这条任务链上没有人类,永远不会有人来点头」→ 当场 block。
* 但 `handler/permission.go` 在**档位不该问人**时也回 409并在回包里带上
* `permission_mode`。两种 409 的正确反应**相反**
* - 无人可问 → 拦(模型改道走不需要授权的办法);
* - full 档 → **放行**(本档工具调用无需审批;拦了就是把能干的活干死)。
* 混为一谈时,一条 full 档会话只要有一封补投邮件(档位漏传 → 默认 workspace
* 这一轮 bash/write/edit 全被拦死。
*
* # 为什么读源码而不是跑起来
*
* 与 `permission-forward-wiring.test.mjs` 同一个理由worker 是**子进程入口**、
* 不是可导入的模块(没有 export靠 `process.on('message')` 接活)。要跑起来需要
* 造一个假的 pi 运行时 + 走完整轮次,改动面远大于这里要钉的一件事:
* 「那个放行分支还在、且认服务端给的档位」。
*
* # 判据自检
*
* 一个永远为真的接线断言比没有更糟,所以下面先证明「拿掉该分支的源码喂给它,它会红」。
*/
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const WORKER = readFileSync(join(HERE, '..', 'src', 'worker.mjs'), 'utf8');
/** 从 409 分支里取出「放行」那一支的正文(按花括号配对,不看窗口) */
function releaseBranch(src) {
const at = src.indexOf("if (e?.status === 409) {");
if (at < 0) return '';
const open = src.indexOf('{', at);
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(open + 1, i); }
}
return '';
}
test('★ 409 说 full 档时放行plan / 无人可问仍 fail closed', () => {
const branch = releaseBranch(WORKER);
assert.ok(branch.length > 200, '要能取到 409 分支的正文(取不到说明结构变了,判据要跟着改)');
assert.match(branch, /permission_mode\s*===\s*MODE_FULL/,
'409 分支要认服务端回的 permission_mode回包里有真实档位');
assert.match(branch, /return;/, 'full 档要**放行**(返回 undefined = 不拦截)');
// 放行必须排在构造 block 之前:否则 reason 先被建出来,逻辑上仍是拦
assert.ok(branch.indexOf('MODE_FULL') < branch.indexOf("block: true"),
'放行分支必须在 block 之前');
// 其余 409 照旧拦,并把服务端原文当理由(模型据此改道)
assert.match(branch, /block: true/, '非 full 的 409 仍要当场拒绝');
assert.match(branch, /b\.error|b\.detail|b\.suggestion/, '拒绝理由要带服务端原文');
});
test('★ 判据自检:把放行分支拿掉,上面那条必须红', () => {
const crippled = WORKER.replace(/if \(b\.permission_mode === MODE_FULL\)[\s\S]*?\n\s*\}\n/, '');
const branch = releaseBranch(crippled);
const wouldFail = !/permission_mode\s*===\s*MODE_FULL/.test(branch);
assert.equal(wouldFail, true, '拿掉放行分支后判据必须能判红(否则这条接线断言形同虚设)');
});