Files
MailUI4Agents/deploy/check-parent-from.mjs
JianFeeeee 1ea92098d1 fix(四桥)★★: parent_from 方向判据在 dsh/opencode 上是死代码 —— 接线补齐
## 起因:修部署漂移时撞出来的

b0c8719(2026-09-30)给 `inboundHeadline` 加了 `parentFrom`/`selfName`,
用来分辨「回的是你那封」与「多方线索里的他人续谈」——单向续信链(8 封全是
对方发来的)同样满足 `in_reply_to`,不判方向就会被逐封宣称「回的是你那封」,
模型把它当新任务处理。**但只有 pi 桥接了**:

    pi        parentFrom: data?.parent_from    ✓(turn.mjs:148)
    dsh       从不传                        ✗ 3 处
    opencode  从不传                        ✗ 1 处

判据函数是三个桥**同一份** relay-policy.js 的拷贝,看起来测过了 ——
但「函数支持」≠「调用方传了」。三个桥各自都有 relay-policy 单元测试且全绿,
因为它们只测那个**纯函数**,没有一个测「桥真的把参数传进去了」。

dsh 还多一层:它有一份**手写**的 `lib/relay-policy.d.ts`,缺这两个字段
⇒ tsc 报 TS2353 ⇒ 调用方即使想传也过不了类型检查。那行判据是被类型系统
**主动拦住**的死代码。只补 .js 不补 .d.ts 等于没补。

## 改动

dsh 3 处 + opencode 1 处调用点补 `parentFrom`/`selfName`,对齐 pi 的样板;
dsh 的 `.d.ts` 补声明(注释写明「光有声明不够,调用点也得传」)。

## 新增判据 deploy/check-parent-from.mjs(7 格)

不只是查「有没有传」,还查**三处曾经不一致的接缝**:

  ① 每个 inboundHeadline 调用点都传了 parentFrom(次数写死,新增调用点
     忘了传要能被发现,而不是被 `>0` 静默接受)
  ② 传了 parentFrom 就必须传 selfName —— 只传前者时
     `parentFrom === selfName` 永不成立,判据形同虚设
  ③ 三份 relay-policy.js 都真的有 `parentFrom === selfName`
  ④ dsh 的 .d.ts 声明与实现一致(否则 tsc 拦住,判据等于没有)

**变异验证**:撤一处传参 → 红 2;只撤 selfName → 红 1;撤 .d.ts 声明 → 红 1。

## ★ 判据自身也踩了两次坑(都已修)

1. 初版 `.d.ts` 那格用 `\bparentFrom\b` 全文件搜 ⇒ 撤掉字段声明后,
   **注释文字里还留着这个词** ⇒ 照样匹配 ⇒ 变异不红(假绿)。
   改为只认字段声明 `^\s*parentFrom\??\s*:\s*string\s*;`。
2. 初版 `.mjs` 写了 `require` ⇒ ReferenceError(ESM)。已改 import。

## 顺带修掉 6 个陈旧判据(都在 HEAD 上、此前静默红着)

`turn.test.mjs` 的「回信到达时明说不是新任务」:只造 `in_reply_to` 却断言
`/m-0/`,而 `b0c8719` 新增的「我的回复」分支文案里压根不含父邮件 ID ——
判据没跟上那次改动。已改成覆盖三种情形(parent_from 是我 / 是别人 / 缺席)。

★ 我第一版改完是**假绿**:只断言 `/续谈/` 时,把 `mine` 分支改坏也会走
「续谈」分支而照样通过。加反向断言(mine 不含「续谈」、theirs 不含
「不是新任务」)后才抓住。变异 A/B 各 35 pass / 1 fail。

`cross-bridge-permission-routing.test.mjs`:zcode 的路径与目录还指向
`d09ef39` 已删的 `hooks/`、`mcp/` ⇒ ENOENT。zcode 确实**没有** 409 分支了
(移除执行类工具所致,非缺陷)⇒ `permanent: null` 表示不适用,并加反向
守卫:若 `src/` 里出现 `isPermanentFailure` 就红(防止前提变了却继续空转)。
`isPermanentFailure` 是 `lib/relay-key.js` 的通用网关辅助,与权限放行无关 ——
踩过一次:把 `lib` 加进扫描范围后对着共享辅助函数误报。

全量:pi 533 / opencode 364 / dsh 433,零红(此前 4 红)。

## 部署与端到端

pi / opencode / dsh 三个宿主均已部署;dsh 已真实收发验证:

    [dsh-mail-bridge] 本轮不自动转发:来信方 pi 是 Agent…
    [dsh-mail-bridge] new_mail -> 新会话 mail-8e8bf319…
2026-10-03 00:25:28 +08:00

119 lines
5.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.

/*
跨桥一致性判据:`parent_from` 方向判据必须在**每个**桥真正生效(2026-10-02)。
# 这个缺陷是怎么活下来的
b0c8719(2026-09-30)给 `inboundHeadline` 加了 `parentFrom`/`selfName` 两个参数,
用来分辨「回的是你那封」和「多方线索里的他人续谈」——单向续信链(8 封全是
对方发来的)同样满足 `in_reply_to`,不判方向就会被逐封宣称「回的是你那封」,
模型于是把它当新任务处理。pi 桥接了,**另外两个桥没接**:
pi parentFrom: data?.parent_from ✓
dsh (从不传) ✗ 3 处调用点
opencode (从不传) ✗ 1 处调用点
判据函数在 `lib/relay-policy.js` 里对三个桥是**同一份拷贝**,看起来测过了;
但「函数支持」≠「调用方传了」。dsh 还多一层:它有一份**手写**的
`lib/relay-policy.d.ts`,缺这两个字段 ⇒ tsc 报 TS2353 ⇒ 调用方即使想传
也传不过类型检查 —— 于是那行判据在 dsh 上是被类型系统**主动拦住**的死代码。
# 为什么这值得单独一格判据
三个桥各自都有 relay-policy 的单元测试,且全绿 —— 因为它们测的是
`inboundHeadline` 这个**纯函数**,没有一个测「桥真的把参数传进去了」。
纯函数测试与接线测试是两件事,缺后者就等于没测。
用法:在仓库根跑,见 go 之外的 `node deploy/check-parent-from.mjs`。
*/
// 这是 .mjs —— ESM。(初版写了 require,跑起来直接 ReferenceError;
// 与其它 deploy/check-*.mjs 保持一致用 import。)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
/** 每个桥:入口文件 + 期望出现 parentFrom 传参的次数。
* 次数写死而不是「>0」:新增调用点时忘了传参,要能被发现而不是被静默接受。 */
const BRIDGES = [
{ name: 'pi', files: ['plugins/pi-mail-bridge/src/turn.mjs'], expect: 1 },
{ name: 'dsh', files: ['plugins/dsh-mail-bridge/src/index.ts'], expect: 3 },
{ name: 'opencode', files: ['plugins/opencode-mail-bridge/index.js'], expect: 1 },
];
const PASS = (m) => console.log(` 通过 ${m}`);
const FAIL = (m) => { console.log(` 失败 ${m}`); failures.push(m); };
const failures = [];
console.log('判据:三个桥都必须把 parent_from 传给 inboundHeadline');
for (const b of BRIDGES) {
for (const rel of b.files) {
const full = path.join(REPO, rel);
if (!fs.existsSync(full)) { FAIL(`${b.name}: 文件不存在 ${rel}`); continue; }
const src = fs.readFileSync(full, 'utf8');
// ① 每个 inboundHeadline 调用点都必须带 parentFrom
const calls = [...src.matchAll(/inboundHeadline\(\s*\{([\s\S]*?)\n\s*\}\)/g)];
if (calls.length === 0) { FAIL(`${b.name}: 没找到 inboundHeadline 调用点(判据本身失效?)`); continue; }
const missing = calls.filter((c) => !/parentFrom\s*:/.test(c[1]));
if (missing.length) {
FAIL(`${b.name}: ${missing.length}/${calls.length} 个 inboundHeadline 调用点没传 parentFrom ` +
`⇒ 单向续信链会被误判成回信`);
}
const have = calls.filter((c) => /parentFrom\s*:/.test(c[1])).length;
if (have !== b.expect) {
FAIL(`${b.name}: 实传 ${have} 处,期望 ${b.expect} 处(新增/漏改调用点)`);
}
if (!missing.length && have === b.expect) {
PASS(`${b.name}: ${calls.length} 个调用点全部传了 parent_from(${rel})`);
}
// ② 传了 parentFrom 就必须传 selfName,否则判据永远不成立
const noSelf = calls.filter((c) => /parentFrom\s*:/.test(c[1]) && !/selfName\s*:/.test(c[1]));
if (noSelf.length) {
FAIL(`${b.name}: ${noSelf.length} 处传了 parentFrom 却没传 selfName ` +
`⇒ parentFrom === selfName 永不成立,判据形同虚设`);
}
}
}
// ③ 判据函数本身必须真的认这两个参数(三份拷贝各一份)
for (const lib of [
'plugins/pi-mail-bridge/lib/relay-policy.js',
'plugins/dsh-mail-bridge/lib/relay-policy.js',
'plugins/opencode-mail-bridge/lib/relay-policy.js',
]) {
const full = path.join(REPO, lib);
if (!fs.existsSync(full)) { FAIL(`判据函数缺失 ${lib}`); continue; }
const src = fs.readFileSync(full, 'utf8');
if (!/parentFrom\s*===\s*selfName/.test(src)) {
FAIL(`${lib}: 方向判据 parentFrom === selfName 不在(调用方传了也没用)`);
} else {
PASS(`${lib}: 方向判据在`);
}
}
// ④ dsh 的手写 .d.ts 必须声明这两个字段,否则 tsc 会拦住调用方
{
const full = path.join(REPO, 'plugins/dsh-mail-bridge/lib/relay-policy.d.ts');
if (fs.existsSync(full)) {
const src = fs.readFileSync(full, 'utf8');
// ★ 只认**字段声明**,不能只搜字段名。
// 初版用 `\bparentFrom\b` 全文件搜 —— 撤掉 `parentFrom?: string;` 后
// 注释文字里还留着这个词 ⇒ 照样匹配 ⇒ 变异 ③ 不红(假绿,已实测)。
// 判据本身就是这次事故的一部分,所以这里必须真的能判。
const missing = ['parentFrom', 'selfName'].filter((f) =>
!new RegExp(`^\\s*${f}\\??\\s*:\\s*string\\s*;`, 'm').test(src));
if (missing.length) {
FAIL(`dsh relay-policy.d.ts 缺声明 ${missing.join(', ')} ` +
`⇒ TS2353 会让调用方传不了参(这正是它一度是死代码的原因)`);
} else {
PASS('dsh relay-policy.d.ts 声明与实现一致');
}
}
}
console.log(failures.length ? `\n✗ ${failures.length} 处不通过` : '\n✓ 全部通过');
process.exit(failures.length ? 1 : 0);