diff --git a/deploy/check-parent-from.mjs b/deploy/check-parent-from.mjs new file mode 100644 index 0000000..b7cbaf3 --- /dev/null +++ b/deploy/check-parent-from.mjs @@ -0,0 +1,119 @@ +/* +跨桥一致性判据:`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); \ No newline at end of file diff --git a/plugins/dsh-mail-bridge/lib/relay-policy.d.ts b/plugins/dsh-mail-bridge/lib/relay-policy.d.ts index dd7dd16..cb214a9 100644 --- a/plugins/dsh-mail-bridge/lib/relay-policy.d.ts +++ b/plugins/dsh-mail-bridge/lib/relay-policy.d.ts @@ -16,6 +16,20 @@ export function replyInstruction(ctx: ReplyInstructionCtx): string[]; export interface InboundHeadlineCtx { inReplyTo?: string; + /** + * parentFrom 是**父邮件的发件人**(服务端 parent_from)。 + * + * ★ 2026-10-02 补:`.js` 早就认这两个字段(b0c8719 加的),但这份手写声明 + * 没跟上 ⇒ TypeScript 报 TS2353,调用方**不敢传**,于是那行方向判据 + * 在本桥上一直是死代码:单向续信链同样满足 in_reply_to,被逐封宣称 + * 「回的是你那封」,模型把它当新任务处理。pi 桥是唯一接了的。 + * + * 光有声明不够 —— 调用点也得传,见 src/index.ts 的三处 inboundHeadline。 + * 两者缺一,判据都等于没有。 + */ + parentFrom?: string; + /** selfName 是本方名字(收件方自己);`parentFrom === selfName` 才是「我的回复到了」。 */ + selfName?: string; fromHuman: boolean; catchup?: boolean; reused?: boolean; diff --git a/plugins/dsh-mail-bridge/package.json b/plugins/dsh-mail-bridge/package.json index 562df76..fe4f1ef 100644 --- a/plugins/dsh-mail-bridge/package.json +++ b/plugins/dsh-mail-bridge/package.json @@ -17,8 +17,8 @@ "test": "node --test 'test/*.test.mjs'" }, "peerDependencies": { - "@deepseek-ai/cordis": "^4.0.1", - "@deepseek-ai/dsh-tools": "^0.1.0" + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-tools": "0.2.0-rc.2" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 4ebcbc5..20991e0 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -1076,7 +1076,13 @@ export function apply(ctx: any, config: PluginConfig): void { } const fromHuman = data.from_human === true; return [ - inboundHeadline({ inReplyTo: data.in_reply_to, fromHuman, reused: true }), + inboundHeadline({ + inReplyTo: data.in_reply_to, + parentFrom: data.parent_from, + selfName: AGENT_NAME, + fromHuman, + reused: true, + }), ``, `发件人:${data.from_name || 'unknown'}`, `主题:${data.subject || '(无主题)'}`, @@ -1190,8 +1196,16 @@ export function apply(ctx: any, config: PluginConfig): void { const promptText = kind === 'permission' ? permissionPrompt(data) : [ + // ★ 方向判据(2026-10-02 补):b0c8719 给 inboundHeadline 加了 + // parentFrom/selfName 用来分辨「回的是你那封」与「他人续谈」, + // 但**本桥从不传这两个参数** ⇒ 那行判据在这里是死代码。 + // 实测:单向续信链(8 封全是对方发来的)同样满足 in_reply_to, + // 于是被逐封宣称「回的是你那封」,模型把它当新任务处理。 + // pi 桥是唯一接了的(turn.mjs:148),此处按它的样板对齐。 inboundHeadline({ inReplyTo: data.in_reply_to, + parentFrom: data.parent_from, + selfName: AGENT_NAME, fromHuman: data.from_human === true, reused: true, }), @@ -1241,6 +1255,8 @@ export function apply(ctx: any, config: PluginConfig): void { : [ inboundHeadline({ inReplyTo: data.in_reply_to, + parentFrom: data.parent_from, + selfName: AGENT_NAME, fromHuman: data.from_human === true, catchup: data.catchup, reused: false, diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index e31e8a7..5e28d6e 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -1142,6 +1142,12 @@ async function deliverMail(client, directory, data, kind) { : [ inboundHeadline({ inReplyTo: data.in_reply_to, + // ★ 方向判据(2026-10-02 补):b0c8719 给 inboundHeadline 加了 + // parentFrom/selfName,本桥却**从不传** ⇒ 那行判据是死代码。 + // 单向续信链同样满足 in_reply_to,会被逐封宣称「回的是你那封」。 + // pi 桥是唯一接了的(turn.mjs:148),此处按它的样板对齐。 + parentFrom: data.parent_from, + selfName: AGENT_NAME, fromHuman, catchup: data.catchup, reused, diff --git a/plugins/pi-mail-bridge/test/cross-bridge-permission-routing.test.mjs b/plugins/pi-mail-bridge/test/cross-bridge-permission-routing.test.mjs index 238c004..18a17c1 100644 --- a/plugins/pi-mail-bridge/test/cross-bridge-permission-routing.test.mjs +++ b/plugins/pi-mail-bridge/test/cross-bridge-permission-routing.test.mjs @@ -42,7 +42,10 @@ const PLUGINS = join(HERE, '..', '..'); const IMPL = { 'dsh-mail-bridge': ['src'], 'pi-mail-bridge': ['src'], - 'zcode-mail-bridge': ['src', 'mcp', 'hooks'], + // ★ 2026-10-02:d09ef39 删掉了 zcode 的 mcp/ 与 hooks/(MCP 已内置网关, + // 执行门禁随之退场)。留着这两个目录名 ⇒ stat 抛 ENOENT ⇒ 整档红, + // 而且是「文件不存在」这种红,会盖住真正要看的判据失败。 + 'zcode-mail-bridge': ['src', 'lib'], 'opencode-mail-bridge': ['index.js'], }; @@ -87,10 +90,16 @@ const BRIDGES = [ }, { plugin: 'zcode-mail-bridge', - allow: /permission_mode \|\| ''\) === 'full'/, - mustWriteBack: null, - permanent: 'if (isPermanentFailure(e))', - note: 'hooks/permission.mjs 的 409 分支(该桥没有 sandbox/approval 旋钮)', + allow: /normalizeMode\(data\?\.permission_mode\)/, + allow: /normalizeMode\(data\?\.permission_mode\)/, + // ★ zcode 整条 409 分支都不存在(d09ef39 移除执行类工具后不再向人问权限), + // 所以「放行必须排在永久失败之前」对它**不适用**:没有 permanent 分支, + // 也就没有顺序可排。置 null = 本桥不参与该子判据,不是「跳过了检查」。 + permanent: null, + // ★ zcode 已无 409 分支:d09ef39 移除执行类工具后,src/index.mjs:232 的 + // modeReachesPermissionHook 只是提醒(本平台没有执行面),不存在「按 + // workspace 档问人」这条路。此处只钉住它仍读 permission_mode 并归一档位。 + note: 'src/index.mjs:219 的 normalizeMode(data?.permission_mode)', }, { plugin: 'opencode-mail-bridge', @@ -127,6 +136,17 @@ test('★ 四桥都必须认出「409 带 full → 放行」,且排在永久 `${b.plugin} 有会话档位旋钮,所以 409 里的权威档位还必须**写回会话**:` + '只放行修的是"问不问",沙箱(能不能)还是窄的。'); } + if (b.permanent === null) { + // 本桥没有 409 分支 ⇒ 顺序问题不适用。但仍要确认它**真的**没有, + // 而不是路径/正则过时了:出现任一个就说明前提变了,该改判据。 + // 只扫它自己的 src/:isPermanentFailure 是 lib/relay-key.js 里的**通用** + // 网关辅助(所有桥都有),与「409 权限放行分支」无关。全量扫会误判 —— + // 踩过一次:把 lib 加进 IMPL 后,这格对着共享辅助函数报红。 + assert.doesNotMatch(readImpl(b.plugin).split('lib/')[0], /isPermanentFailure/, + `${b.plugin} 声明「无 409 分支」但 src/ 里出现了 isPermanentFailure —— ` + + '前提变了,别让这格继续空转'); + continue; + } const allowAt = src.search(b.allow); const permAt = src.indexOf(b.permanent); assert.ok(permAt >= 0, @@ -144,6 +164,10 @@ test('★ 判据自检:把放行那一支挪到永久失败分支之后,顺 return a >= 0 && p >= 0 && a < p; }; for (const b of BRIDGES) { + // ★ 2026-10-02:permanent === null 的桥(zcode)没有 409 分支,顺序无从谈起。 + // 原来这里直接 indexOf(null) ⇒ 把 null 当成字符串找不到 ⇒ 前置恒红 —— + // 一条判据自己制造的红。跳过前仍要过一遍上面那格,确认「无分支」是真的。 + if (b.permanent === null) continue; const src = codeOnly(readImpl(b.plugin)); assert.ok(orderHolds(src, b), `前置:${b.plugin} 当前顺序正确,才谈得上"挪动"`); const a = src.search(b.allow); @@ -173,7 +197,11 @@ test('★ 配对:消费者读的档位键名,生产者必须产出(四平 ['pi', 'pi-mail-bridge/src/worker.mjs'], ['dsh', 'dsh-mail-bridge/src/index.ts'], ['opencode', 'opencode-mail-bridge/index.js'], - ['zcode', 'zcode-mail-bridge/hooks/permission.mjs'], + // ★ 2026-10-02 修路径(不是删判据):d09ef39 删掉 zcode 的本地 MCP 与整套 + // 执行门禁(含 hooks/permission.mjs),zcode 改为在 src/index.mjs:219 + // 读档位。能力没丢,只是搬了家;原路径不存在 ⇒ 4 个 subtest 全是 + // ENOENT,白烧 4 个红且会掩盖真实失败。判据仍要求它登记。 + ['zcode', 'zcode-mail-bridge/src/index.mjs'], ]; for (const [who, rel] of CONSUMERS) { const text = codeOnly(readFileSync(join(PLUGINS, rel), 'utf8')); diff --git a/plugins/pi-mail-bridge/test/turn.test.mjs b/plugins/pi-mail-bridge/test/turn.test.mjs index 69f99f6..55be3ee 100644 --- a/plugins/pi-mail-bridge/test/turn.test.mjs +++ b/plugins/pi-mail-bridge/test/turn.test.mjs @@ -215,17 +215,50 @@ test('不变量:from_human 缺失时按 Agent 处理(不能承诺做不到 '宁可让它多调一次 send_mail,也不能让发件方白等一个不会发生的自动回信'); }); -test('不变量:回信到达时明说「不是新任务」', () => { - // 把回复当新任务处理正是互相客套的起点。 - const p = buildMailPrompt({ - agentName: 'pi', - data: { ...mailData, from_human: false, in_reply_to: 'm-0' }, - kind: 'mail', - reused: true, - }); - assert.match(p, /回复/); - assert.match(p, /不是新任务/); - assert.match(p, /m-0/, '要说出回的是哪封'); + test('不变量:回信到达时明说「不是新任务」', () => { + // 把回复当新任务处理正是互相客套的起点。 + // + // ★ 2026-10-02 修判据(不是修代码):这格原本只造 in_reply_to,于是走进 + // `parentFrom === selfName` 的 **mine** 分支,而那行文案压根不含父邮件 + // ID ⇒ 断言 /m-0/ 必然失败。它是 b0c8719 加方向判据时漏改的陈旧判据 + // (实测:HEAD 上就是红的,且 pi 当时一个字节都没动 ⇒ 不是回归)。 + // + // 三种情形都要覆盖,因为它们走的是**不同分支、文案不同**: + // · parent_from === 我自己 ⇒ 我的回复到了(只说不是新任务) + // · parent_from === 别人 ⇒ 多方线索里的续谈(要说回的是谁的) + // · 无 parent_from ⇒ 服务端未升级,退回旧行为 + const mine = buildMailPrompt({ + agentName: 'pi', + data: { ...mailData, from_human: false, in_reply_to: 'm-0', parent_from: 'pi' }, + kind: 'mail', + reused: true, + }); + assert.match(mine, /回复/); + assert.match(mine, /不是新任务/); + // ★ 反向对照:这格原来是假绿 —— 只断言 /续谈/ 时, + // 把 mine 分支改坏(mine=false)也会走「续谈」分支而照样通过。 + // 所以必须反向断言 mine **不是**续谈文案,否则这格没有区分力。 + assert.doesNotMatch(mine, /续谈/, '方向是我的就不该说成续谈'); + + const theirs = buildMailPrompt({ + agentName: 'pi', + data: { ...mailData, from_human: false, in_reply_to: 'm-0', parent_from: 'opencode' }, + kind: 'mail', + reused: true, + }); + assert.match(theirs, /续谈/); + assert.match(theirs, /opencode/, '★ 方向不对时必须说清回的是谁的信'); + // ★ 同理:他人续谈**不是**我的回复,不该说成「不是新任务」 + // (那会把别人的话当成我上一封信的回音,正是要避免的误判)。 + assert.doesNotMatch(theirs, /不是新任务/, '他人续谈不是我的回信'); + + const legacy = buildMailPrompt({ + agentName: 'pi', + data: { ...mailData, from_human: false, in_reply_to: 'm-0' }, + kind: 'mail', + reused: true, + }); + assert.match(legacy, /不是新任务/, '服务端未升级时仍要明说不是新任务'); }); test('不变量:提示词带 mail_id 与 read_inbox 指引', () => {