From d5cfcbdc9c52b55ce5b5d1d3df39102287f4ee33 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 16:21:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=9D=83=E9=99=90):=20409=20=E7=9A=84?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E7=A7=8D=E5=90=AB=E4=B9=89=E6=98=AF=E3=80=8C?= =?UTF-8?q?=E6=9C=AC=E6=A1=A3=E4=B8=8D=E8=AF=A5=E9=97=AE=E3=80=8D=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=9B=9B=E6=A1=A5=E9=83=BD=E8=A1=A5=E4=B8=8A=EF=BC=9B?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=86=99=E5=85=A5=E7=82=B9=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=85=9C=E9=BB=98=E8=AE=A4=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了 workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮 工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。 四件事: 1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null` = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。" 同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。 2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** —— 所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径: 已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full, plan 与"链上没有人类"照旧 fail closed。 3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js` 都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改" —— 那句话是错的,我当时的搜索面只有 `/src/*.mjs`。按 pi 的要求把这条 **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有 「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。 4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的 (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑 `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。 共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。 另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`, 判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。 判据证据(每条都做过"能不能红"的变异): - 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红; - zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。 各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc); electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0; Go `go test ./...` 全 ok。 --- .../test/appearance-defaults.test.mjs | 24 +-- client/electron/test/background.test.mjs | 9 +- client/electron/test/build-stamp.test.mjs | 9 +- .../electron/test/criteria-hygiene.test.mjs | 91 ++++++++ .../electron/test/cross-client-theme.test.mjs | 65 +++--- .../electron/test/harmony-appearance.test.mjs | 37 ++-- client/electron/test/harmony-logic.test.mjs | 31 +-- client/electron/test/harmony-nav.test.mjs | 17 +- .../electron/test/harmony-system-api.test.mjs | 15 +- client/electron/test/lib/read.mjs | 41 ++++ client/electron/test/narrow-layout.test.mjs | 4 +- client/electron/test/nav-merge.test.mjs | 4 +- client/electron/test/packaging.test.mjs | 13 +- client/electron/test/run-all.mjs | 13 +- .../electron/test/stores/background.test.ts | 5 +- client/electron/test/theme.test.mjs | 11 +- .../dsh-mail-bridge/lib/permission-mode.d.ts | 5 + .../dsh-mail-bridge/lib/permission-mode.js | 37 ++++ plugins/dsh-mail-bridge/src/index.ts | 63 +++++- plugins/dsh-mail-bridge/test/catchup.test.mjs | 11 + .../test/permission-409-full.test.mjs | 148 +++++++++---- .../test/permission-mode.test.mjs | 22 ++ plugins/opencode-mail-bridge/index.js | 20 ++ .../lib/permission-mode.js | 37 ++++ .../test/catchup.test.mjs | 11 + .../test/permission-mode.test.mjs | 22 ++ plugins/pi-mail-bridge/lib/permission-mode.js | 37 ++++ plugins/pi-mail-bridge/test/catchup.test.mjs | 82 -------- .../cross-bridge-permission-routing.test.mjs | 194 ++++++++++++++++++ .../test/permission-mode.test.mjs | 22 ++ .../zcode-mail-bridge/hooks/permission.mjs | 20 ++ .../zcode-mail-bridge/lib/permission-mode.js | 37 ++++ .../zcode-mail-bridge/test/catchup.test.mjs | 11 + .../test/permission-mode.test.mjs | 22 ++ 34 files changed, 942 insertions(+), 248 deletions(-) create mode 100644 client/electron/test/criteria-hygiene.test.mjs create mode 100644 client/electron/test/lib/read.mjs create mode 100644 plugins/pi-mail-bridge/test/cross-bridge-permission-routing.test.mjs diff --git a/client/electron/test/appearance-defaults.test.mjs b/client/electron/test/appearance-defaults.test.mjs index 824decc..9d06721 100644 --- a/client/electron/test/appearance-defaults.test.mjs +++ b/client/electron/test/appearance-defaults.test.mjs @@ -12,7 +12,7 @@ // 于是**谁先同步谁决定**。24/8 与 12/4 的差别不是审美,是同一个账号在不同客户端 // 登录会得到不同的压暗强度。 // 3. 缓存键是"换账号串味"的成因(全局键 → saved=false 时把上一个账号的外观推上去)。 -import { readFileSync } from 'node:fs'; +import { code, prose } from './lib/read.mjs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; @@ -26,7 +26,7 @@ const ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); /** 从 Go 源码里读服务端契约(不复制一份数字到判据里 —— 那又会变成"两处各写一套") */ function serverDefaults() { - const src = readFileSync(SERVER_MODELS, 'utf8'); + const src = prose(SERVER_MODELS); // 只取 DefaultAppearance 函数体,避免匹配到别的结构体字面量 const at = src.indexOf('func DefaultAppearance()'); assert.ok(at > 0, '服务端要有 DefaultAppearance()'); @@ -97,18 +97,18 @@ test('★ 默认外观 = 服务端契约(去 Go 源码里读,不在判据里 assert.deepEqual([s.dim, s.blur], [12, 4], '服务端 DefaultAppearance 是 12/4(权威值)'); // WebUI:默认值必须来自共享常量,且等于服务端 - const defaults = readFileSync(join(ROOT, 'client/electron/src/lib/appearanceDefaults.ts'), 'utf8'); + const defaults = code(join(ROOT, 'client/electron/src/lib/appearanceDefaults.ts')); assert.match(defaults, new RegExp(`DEFAULT_DIM = ${s.dim}\\b`), `DEFAULT_DIM 要等于服务端的 BgDim=${s.dim}`); assert.match(defaults, new RegExp(`DEFAULT_BLUR = ${s.blur}\\b`), `DEFAULT_BLUR 要等于服务端的 BgBlur=${s.blur}`); // 严格:store 里不许再自写一套数字(原来这里写的是 24/8,与服务端不一致) - const store = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8'); + const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts')); const defBlock = /export const DEFAULT_BACKGROUND: BackgroundState = \{[\s\S]*?\};/.exec(store); assert.ok(defBlock, 'DEFAULT_BACKGROUND 要能取到'); assert.match(defBlock[0], /dim:\s*DEFAULT_DIM/, 'dim 要引用共享常量,不许自写字面量'); assert.match(defBlock[0], /blur:\s*DEFAULT_BLUR/, 'blur 要引用共享常量'); assert.ok(!/dim:\s*\d/.test(defBlock[0]), `DEFAULT_BACKGROUND 里不许再出现数字字面量(现在:${defBlock[0].replace(/\s+/g, ' ')})`); // 同步层的兜底值也要引用同一常量(原来 clamp(..., 12, 4) 是另写的一份) - const lib = readFileSync(join(ROOT, 'client/electron/src/lib/appearance.ts'), 'utf8'); + const lib = code(join(ROOT, 'client/electron/src/lib/appearance.ts')); const clamps = [...lib.matchAll(/clamp\([^)]*\)/g)].map(m => m[0]); assert.ok(clamps.length >= 4, `clamp 调用要能取到(实际 ${clamps.length} 处)`); for (const c of clamps) { @@ -116,23 +116,23 @@ test('★ 默认外观 = 服务端契约(去 Go 源码里读,不在判据里 } // 鸿蒙:默认值(在 AppearanceResponse 的字段默认值里)与预设也要与服务端对得上 - const ap = readFileSync(join(ETS, 'model/Appearance.ts'), 'utf8'); + const ap = code(join(ETS, 'model/Appearance.ts')); assert.match(ap, new RegExp(`bg_dim: number = ${s.dim}\\b`), `鸿蒙默认压暗要等于服务端 BgDim=${s.dim}`); assert.match(ap, new RegExp(`bg_blur: number = ${s.blur}\\b`), `鸿蒙默认模糊要等于服务端 BgBlur=${s.blur}`); assert.match(ap, new RegExp(`theme: string = '${s.theme}'`), `鸿蒙默认主题要等于服务端 Theme=${s.theme}`); - const wp = readFileSync(join(ETS, 'model/Wallpaper.ts'), 'utf8'); + const wp = code(join(ETS, 'model/Wallpaper.ts')); assert.match(wp, new RegExp(`'${s.preset}'`), `服务端默认预设 ${s.preset} 要在鸿蒙的预设清单里`); // 这句注释在**这份源码**里现在是事实(它宣称"与客户端默认值一致")—— 这条就是它的核对器。 // (运行时是否一致不由此判据保证:见文件头第 1 条的措辞说明。) - const models = readFileSync(SERVER_MODELS, 'utf8'); + const models = prose(SERVER_MODELS); const comment = /\/\/ DefaultAppearance[\s\S]{0,200}?func DefaultAppearance/.exec(models); assert.ok(comment, 'DefaultAppearance 上面要有说明注释'); assert.match(comment[0], /一致/, '注释仍在宣称"与客户端默认值一致"(本判据负责让它为真)'); }); test('★ 缓存键按账号分:两端的键都带账号,且都不许退回全局键', () => { - const store = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8'); + const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts')); // 键函数:必须把 accountId 拼进去(按块取函数体,不看调用点 —— "配对/解析"那条) const at = store.indexOf('export function storageKey('); assert.ok(at > 0, '要有 storageKey 函数'); @@ -201,7 +201,7 @@ test('★ 缓存键按账号分:两端的键都带账号,且都不许退回 assert.match(store, /localStorage\.setItem\(storageKey\(\), JSON\.stringify\(state\)\)/, '写缓存要按账号的键'); // 鸿蒙侧:键同样带账号(两边形状一致,这条差异已经消除) - const ets = readFileSync(join(ETS, 'common/AppearanceStore.ets'), 'utf8'); + const ets = code(join(ETS, 'common/AppearanceStore.ets')); const eAt = ets.indexOf('prefKey(accountId: string)'); assert.ok(eAt > 0, '鸿蒙要有按账号取键的函数'); let d2 = 0; @@ -224,7 +224,7 @@ test('★ 切账号的顺序:**先按新账号重读本地**,再拉服务端 * 服务端"没有记录"时 `pull()` 会"以本地为准推上去"——所以重读必须在前, * 否则推上去的是上一个账号的外观(全局键时代就是这个现象,而且写进了服务端)。 */ - const sync = readFileSync(join(ROOT, 'client/electron/src/stores/appearanceSync.ts'), 'utf8'); + const sync = code(join(ROOT, 'client/electron/src/stores/appearanceSync.ts')); const sub = /activeId !== prev\.activeId\)[\s\S]{0,400}?\}\);/.exec(sync); assert.ok(sub, '要能取到账号切换的处理块'); const block = sub[0]; @@ -234,7 +234,7 @@ test('★ 切账号的顺序:**先按新账号重读本地**,再拉服务端 assert.ok(pullAt > 0, '切换账号仍然要拉服务端'); assert.ok(reloadAt < pullAt, 'reloadForAccount() 必须在 pull() 之前(顺序就是这条判据的全部意义)'); // 重读本身不许落盘(它只是把本账号已有的值读回来,不是用户的改动) - const store = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8'); + const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts')); const rAt = store.indexOf('reloadForAccount: () => {'); assert.ok(rAt > 0, 'store 要提供 reloadForAccount'); const rBlock = store.slice(rAt, store.indexOf('}', store.indexOf('set(next)', rAt))); diff --git a/client/electron/test/background.test.mjs b/client/electron/test/background.test.mjs index ac3312d..b278dab 100644 --- a/client/electron/test/background.test.mjs +++ b/client/electron/test/background.test.mjs @@ -7,13 +7,14 @@ * 这些检查存在的理由:背景是**装饰层叠加在内容之下**,它的 bug 形态是 * 「正文读不动」和「背景根本没出现」,两者都不报错、不影响构建。 */ -import { readFileSync, readdirSync } from 'node:fs'; +import { prose } from './lib/read.mjs'; +import { readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import postcss from 'postcss'; const here = dirname(fileURLToPath(import.meta.url)); -const read = p => readFileSync(join(here, p), 'utf8'); +const read = p => prose(join(here, p)); let pass = 0; let fail = 0; @@ -149,7 +150,7 @@ check( const compDir = join(here, '../src/components'); const unmapped = []; for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) { - const src = readFileSync(join(compDir, f), 'utf8'); + const src = prose(join(compDir, f)); if (/(?:bg|text|border)-(?:emerald|purple)-\d+/.test(src)) unmapped.push(f); } check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(' ')); @@ -205,7 +206,7 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join( const full = join(dir, e.name); if (e.isDirectory()) walk(full); else if (/\.(tsx?|jsx?)$/.test(e.name)) { - const src = readFileSync(full, 'utf8'); + const src = prose(full); for (const mm of src.matchAll(/bg-[a-z]+-\d{2,3}/g)) { if (/^bg-[a-z]+-(50|100|200)$/.test(mm[0])) used.add(mm[0]); } diff --git a/client/electron/test/build-stamp.test.mjs b/client/electron/test/build-stamp.test.mjs index 459ff78..1d4427b 100644 --- a/client/electron/test/build-stamp.test.mjs +++ b/client/electron/test/build-stamp.test.mjs @@ -10,10 +10,11 @@ * * 判据落在三处接线:vite 注入 → 组件渲染 → 产物里真的有。 */ +import { prose } from './lib/read.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { srcState } from '../scripts/build-info.mjs'; import { dirname, join } from 'node:path'; @@ -21,7 +22,7 @@ import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..', '..'); -const read = p => readFileSync(join(HERE, p), 'utf8'); +const read = p => prose(join(HERE, p)); const STAMP = /[0-9a-f]{7,}·\d{4}-\d{4}/; test('vite 注入 __BUILD_STAMP__', () => { @@ -40,7 +41,7 @@ test('★ 构建产物里真的带着戳', () => { const dir = join(HERE, '../dist/assets'); const js = readdirSync(dir).filter(f => f.startsWith('index-') && f.endsWith('.js')); assert.ok(js.length > 0, '先跑 npm run build'); - const found = js.some(f => STAMP.test(readFileSync(join(dir, f), 'utf8'))); + const found = js.some(f => STAMP.test(prose(join(dir, f)))); assert.ok(found, '产物里没有构建戳 —— 界面上就永远看不出自己跑的是哪一份'); }); @@ -117,7 +118,7 @@ test('★ 产物必须自报来源:BUILD_INFO 精确比对(不是比时间 assert.ok(existsSync(infoPath), 'dist 里没有 BUILD_INFO.json —— 构建没走 `npm run build`(它最后一步会写这份自证)。\n' + ' 重构建:cd client/electron && npm run build'); - const info = JSON.parse(readFileSync(infoPath, 'utf8')); + const info = JSON.parse(prose(infoPath)); const now = srcState(); // 精确比对:这一条比"谁更新"强的地方在于它**能读出**差在哪 assert.equal(info.srcHash, now.srcHash, diff --git a/client/electron/test/criteria-hygiene.test.mjs b/client/electron/test/criteria-hygiene.test.mjs new file mode 100644 index 0000000..740b48d --- /dev/null +++ b/client/electron/test/criteria-hygiene.test.mjs @@ -0,0 +1,91 @@ +/** + * 判据目录自身的卫生:**读文本必须走 `test/lib/read.mjs` 的两个具名入口**。 + * + * # 为什么这条判据存在(pi 2026-09-14 §4) + * + * 规范里写着"判代码读剥离版(`code`)、判理由/文档读原文(`prose`)", + * 我 P5 写过一次、当天又踩了一次:那条断言读的是**原文**,而它要找的标识符 + * 恰好出现在一段解释性注释里 → 误报。**第二次犯规说明问题不在记性,在形态**: + * 靠人记得执行的规范一定会有下一次。 + * + * 所以把"用哪个读取器"从**记忆**变成**代码里的一个词**,并且可被检查: + * - `code(path)` —— 剥掉注释;判"代码里有没有这个调用/这个值"; + * - `prose(path)` —— 原文;判"注释/文档里写了什么"; + * - `bytes(path)` —— 二进制(安装包等)。 + * + * # 判据 + * + * 判据目录(`test/**` 里跑的判据 + `run-all.mjs`)中**不得出现裸 `readFileSync`**, + * 唯一例外是 `test/lib/read.mjs` 自己。`test/manual/**` 是人工脚本、不是判据,不在范围内。 + * + * 附两条自检:读取器本身要真的剥注释(否则 `code` 退化成 `prose` 这条判据就废了)、 + * 以及探测器要能认出裸调用(否则"都没有"与"探测器坏了"结果一样)。 + */ +import assert from 'node:assert/strict'; +import { readdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { code, prose } from './lib/read.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const RELECTRON = join(HERE, '..'); // test/ 的上一级就是 client/electron +const SELF = join(HERE, 'lib', 'read.mjs'); + +/** 判据文件清单:`test/**` 下会跑的判据 + 编排器;不含 lib/ 与 manual/ */ +function criteriaFiles(dir = HERE, out = []) { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) { + if (e.name === 'lib' || e.name === 'manual' || e.name === 'node_modules') continue; + criteriaFiles(p, out); + } else if (/\.(test\.mjs|test\.ts|test\.tsx|mjs)$/.test(e.name) && !e.name.endsWith('.d.ts')) { + out.push(p); + } + } + return out; +} + +/** 探测器:一段源码里有没有裸 readFileSync */ +const BARE = /\breadFileSync\s*\(/; + +test('探测器自检 + 读取器自检', () => { + // ① 探测器能认出裸调用(否则"都没有"与"探测器坏了"分不开) + assert.equal(BARE.test("const s = " + "readFile" + "Sync(p, 'utf8');"), true); + assert.equal(BARE.test('const s = prose(p);'), false); + + // ② code 真的剥注释、prose 不剥 —— 这条是整套用法的地基: + // 若 code 退化成 prose,那么"读剥离版"的规范就变成一句空话,而且没人会发现。 + const probe = join(RELECTRON, 'test', '_reader_probe.tmp.ts'); + // 注:这个探针文本**故意拼接**而不是写字面量 —— 否则本判据自己会被自己判红 + // (它扫的就是"文本里有没有这个写法",判据文件也在扫描范围内)。 + writeFileSync(probe, "const REAL = 1; // " + "readFile" + "Sync( 注释里的假调用\n/* allowed-once */\n"); + try { + assert.ok(!code(probe).includes('allowed-once'), 'code() 必须剥掉块注释'); + assert.ok(!code(probe).includes('假调用'), 'code() 必须剥掉行注释'); + assert.ok(code(probe).includes('REAL'), 'code() 要保留真代码'); + assert.ok(prose(probe).includes('allowed-once') && prose(probe).includes('假调用'), 'prose() 必须保留注释'); + } finally { + unlinkSync(probe); + } +}); + +test('★ 判据目录里不得出现裸 readFileSync(必须走 code/prose/bytes)', () => { + const offenders = []; + for (const f of criteriaFiles()) { + if (f === SELF) continue; // 读取器的实现自己当然要用它 + const src = prose(f); // 扫的是"文本里有没有这个写法",所以读原文 + if (BARE.test(src)) { + const line = src.split('\n').findIndex(l => BARE.test(l)) + 1; + offenders.push(`${relative(RELECTRON, f)}:${line}`); + } + } + assert.deepEqual(offenders, [], + `这些判据文件里还在裸用 readFileSync:\n ${offenders.join('\n ')}\n` + + " 改用 test/lib/read.mjs 的具名入口:\n" + + " · code(path) —— 剥掉注释。判「代码里有没有这个调用/这个值」时用它(默认选它);\n" + + " · prose(path) —— 原文。判「注释/文档里写了什么」时用它;\n" + + " · bytes(path) —— 二进制(安装包等)。\n" + + " 为什么不能裸用:读原文去判代码,会被解释性注释骗(同一个坑已经踩过两次)。"); +}); diff --git a/client/electron/test/cross-client-theme.test.mjs b/client/electron/test/cross-client-theme.test.mjs index 2d707d6..6a51cdf 100644 --- a/client/electron/test/cross-client-theme.test.mjs +++ b/client/electron/test/cross-client-theme.test.mjs @@ -8,17 +8,22 @@ * 这里只断言"两边对同一件事的取值一致",不断言实现方式(WebUI 用 CSS 变量、 * 鸿蒙用 ArkTS 常量,本来就该不同)。 */ +import { code, prose, stripComments } from './lib/read.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync, readdirSync } from 'node:fs'; +import { readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..', '..'); const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); -const web = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8'); -const harmony = readFileSync(join(HARMONY_ETS, 'common/Theme.ets'), 'utf8'); +const web = code(join(ROOT, 'client/electron/src/index.css')); +const harmonyPath = join(HARMONY_ETS, 'common/Theme.ets'); +/** 判「代码里有什么」用这个 */ +const harmony = code(harmonyPath); +/** 判「注释/文档里写了什么」用这个 —— 两条判据各取所需,别混用 */ +const harmonyDoc = prose(harmonyPath); /** * 裸色值的**类**(不只 `#RRGGBB`):四种写法一起扫 —— @@ -26,8 +31,7 @@ const harmony = readFileSync(join(HARMONY_ETS, 'common/Theme.ets'), 'utf8'); * 注释先剥掉:注释里引用旧写法是常有的事,而"诚实的注释"不该把判据判红。 */ const RAW_COLOR = /#[0-9A-Fa-f]{6,8}\b|\brgba?\s*\(|\b0x[0-9A-Fa-f]{6,8}\b/g; -const code = src => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); -const rawColors = src => code(src).match(RAW_COLOR) || []; +const rawColors = src => stripComments(src).match(RAW_COLOR) || []; /** * 文档里的「有意差异」表(§7.12)。 @@ -36,7 +40,7 @@ const rawColors = src => code(src).match(RAW_COLOR) || []; * 不是"证明做法对"。所以跨端那几条判据的主体仍落在代码上 * (来源必须是系统资源 / 品牌色必须是那个值),文档只做第三只手。 */ -const plan = readFileSync(join(ROOT, 'docs/HARMONY-ALIGN-PLAN.md'), 'utf8'); +const plan = prose(join(ROOT, 'docs/HARMONY-ALIGN-PLAN.md')); /** * 取「有意差异」那一小节。 * @@ -107,7 +111,7 @@ test('圆角:WebUI 有它自己的令牌,鸿蒙跟随系统 —— 差异被 * 注意 ③ 是**弱判据**(防遗忘),不是"证明做法对"。主体是 ①②,它们落在代码上。 */ assert.match(web, /--radius-card:\s*0\.875rem/, 'WebUI 侧要保留自己的圆角令牌'); - assert.match(code(harmony), /radiusCard: Resource = \$r\('sys\.float\./, '鸿蒙卡片圆角要来自系统'); + assert.match(stripComments(harmony), /radiusCard: Resource = \$r\('sys\.float\./, '鸿蒙卡片圆角要来自系统'); assert.match(diffSection(), /圆角/, '圆角是"有意差异",要写进文档的差异表(否则会被当成漏改)'); }); @@ -115,7 +119,7 @@ test('材质:WebUI 声明透明度令牌,鸿蒙用系统材质 —— 差异 // 同上:旧口径钉"两边都 0.72"。鸿蒙改用 `BlurStyle` 后,"0.72 的白色"这件事 // 由系统按主题决定(深浅各一套),所以取值**允许**不同,只要求"材质来自系统"且差异被记录。 assert.match(web, /--nav-bg:\s*255 255 255 \/ 0\.72/, 'WebUI 侧要保留自己的导航底令牌'); - assert.match(code(harmony), /navMaterial: BlurStyle = BlurStyle\./, '鸿蒙导航材质要来自系统'); + assert.match(stripComments(harmony), /navMaterial: BlurStyle = BlurStyle\./, '鸿蒙导航材质要来自系统'); assert.match(diffSection(), /材质/, '材质是"有意差异",要写进文档的差异表'); }); @@ -126,7 +130,7 @@ test('★ 判据自检:把鸿蒙的品牌蓝改成别的必须判红', () => { }); test('鸿蒙的令牌文件说明了与 WebUI 的对应关系(不是凭空一套)', () => { - assert.match(harmony, /与 WebUI 的令牌\*\*一一对应|对应 WebUI/); + assert.match(harmonyDoc, /与 WebUI 的令牌\*\*一一对应|对应 WebUI/, '这条判的是**注释里写的对应关系**,所以要读原文(prose),不是剥离版'); }); test('★ 鸿蒙页面里不得出现任何裸色值(枚举挡不住漂移,"类"才能挡)', () => { @@ -148,7 +152,7 @@ test('★ 鸿蒙页面里不得出现任何裸色值(枚举挡不住漂移," const files = readdirSync(dir).filter(f => f.endsWith('.ets')).sort(); assert.ok(files.length >= 8, `pages/ 下只扫到 ${files.length} 个 .ets,判据大概扫错了目录`); for (const f of files) { - const hits = rawColors(readFileSync(join(dir, f), 'utf8')); + const hits = rawColors(prose(join(dir, f))); assert.equal(hits.length, 0, `${f} 里有裸色值 ${hits.join('、')}(应改用 Theme 令牌或系统资源)`); } // 反向对照:四种形态都要抓得到(否则写错了正则也是一片绿) @@ -192,7 +196,7 @@ test('A|系统拥有的维度,鸿蒙侧的唯一来源是系统资源(不 // 反向断言:这些格子不得退回 string/number 类型(退回 = 又开始自己定值了) for (const name of Object.keys(sysOwned)) { assert.ok( - !new RegExp(`${name}: (string|number) =`).test(code(harmony)), + !new RegExp(`${name}: (string|number) =`).test(stripComments(harmony)), `${name} 不再是系统资源了 —— 这是"用系统方案"被回退的信号` ); } @@ -220,7 +224,7 @@ const SELF_OWNED_COLORS = [ ]; test('A2|Theme.ets 里"自己写的色"必须**登记过**:新写死一个色不该默默通过', () => { - const themeSrc = code(harmony); + const themeSrc = harmony; // 声明在代码里:读剥离版就够了 const declared = [...themeSrc.matchAll(/static readonly (\w+): string = '(#[0-9A-Fa-f]{6,8})'/g)].map(m => m[1]); assert.ok(declared.length >= 10, `要从 Theme.ets 里读到那些手写色,实际读到 ${declared.length} 个`); @@ -240,11 +244,11 @@ test('A2|Theme.ets 里"自己写的色"必须**登记过**:新写死一个 * 不是剥过注释的源码(剥注释会把登记表本身剥掉,第一版就踩了这个: * `indexOf` 返回 -1,切片拿到一段不相干的东西)。 */ - const regStart = harmony.indexOf('手写色**登记表**'); + const regStart = harmonyDoc.indexOf('手写色**登记表**'); assert.ok(regStart > 0, 'Theme.ets 里要有「手写色登记表」那一段'); - const regEnd = harmony.indexOf('品牌色:跨客户端身份', regStart); + const regEnd = harmonyDoc.indexOf('品牌色:跨客户端身份', regStart); assert.ok(regEnd > regStart, '登记表那一段要有明确的结束边界(下一个分节标题)'); - const registry = harmony.slice(regStart, regEnd); + const registry = harmonyDoc.slice(regStart, regEnd); assert.ok(registry.length > 200, `登记表太短,可能切错了段落(${registry.length} 字符)`); for (const name of SELF_OWNED_COLORS) { assert.ok(registry.includes(name), `登记表里要提到 ${name}(否则理由只存在于写它那个人的记忆里)`); @@ -265,7 +269,7 @@ test('A2|Theme.ets 里"自己写的色"必须**登记过**:新写死一个 }); test('B|旧机制不得回来:手写玻璃 alpha、替系统猜深色、与 WebUI 绑死的圆角数字', () => { - const codeOnly = code(harmony); + const codeOnly = stripComments(harmony); /* * `navBgLight` / `navBgDark` 这两个名字本身就是罪证:浅色一个、深色一个手写玻璃, * 等于"我们替系统猜了深色该怎么做"。pi 在 WebUI 侧撤掉 `.dark` 导航令牌、 @@ -353,7 +357,7 @@ test('C|玻璃:位置用系统材质、不许叠、每一处都要登记( * (并列本身没问题),而真正的嵌套它没在判。P5 正是"悬浮玻璃导航",很可能撞上第二处; * 到那时若把 1 改成 2,这条就退化成"最多两处"(等于不判)。所以现在就把形状改对。 */ - const codeOnly = code(harmony); + const codeOnly = stripComments(harmony); assert.match(codeOnly, /static readonly navMaterial: BlurStyle = BlurStyle\.[A-Z_]+/, '导航材质要声明成系统材质档次'); const navMaterial = codeOnly.match(/navMaterial: BlurStyle = BlurStyle\.([A-Z_]+)/)[1]; assert.notEqual(navMaterial, 'NONE', 'NONE 等于没有材质,"玻璃"就名存实亡'); @@ -362,7 +366,7 @@ test('C|玻璃:位置用系统材质、不许叠、每一处都要登记( /** 每个调用点:哪一处(文件#组件)、调用下标、它作用的**组件块**(配对出来的) */ const found = []; for (const f of collectEts(etsRoot)) { - const src = code(readFileSync(f, 'utf8')); + const src = code(f); const spans = braceSpans(src); for (const m of src.matchAll(/backgroundBlurStyle\(/g)) { const at = m.index; @@ -414,7 +418,7 @@ test('C|玻璃:位置用系统材质、不许叠、每一处都要登记( } // 导航条那一处必须真的还在(形状判定之外,位置本身也要在) - const main = code(readFileSync(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'), 'utf8')); + const main = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets')); assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条要用系统材质(不是手写 alpha)'); /* @@ -439,22 +443,19 @@ test('★ 品牌色防线:主操作色不得退化成系统强调色', () => { */ assert.match(harmony, /accent: string = '#2563EB'/, '品牌蓝必须仍是 WebUI 的那个值'); assert.ok( - !/accent: Resource/.test(code(harmony)), + !/accent: Resource/.test(stripComments(harmony)), 'accent 变成系统资源了 —— 品牌色跟着系统变就不再是同一个产品的标识' ); // 主操作/选中态仍引用它(否则"没退化"只是因为它没被用 —— 值留着也没意义) - const login = code(readFileSync(join(ROOT, 'client/harmony/entry/src/main/ets/pages/LoginPage.ets'), 'utf8')); + const login = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/LoginPage.ets')); assert.match(login, /backgroundColor\(Theme\.accent\)/, '登录按钮仍是品牌色'); - const main = code(readFileSync(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'), 'utf8')); + const main = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets')); assert.match(main, /backgroundColor\(Theme\.accent\)/, '主操作按钮/选中态仍是品牌色'); }); test('权限档位徽标两边同一套色(plan=蓝 / workspace=绿 / full=琥珀)', () => { // WebUI 的映射写在 PermissionChip.tsx 的类名里;鸿蒙的映射是 Theme.permBg/permFg。 - const chip = readFileSync( - join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), - 'utf8' - ); + const chip = code(join(ROOT, 'client/electron/src/components/PermissionChip.tsx')); assert.match(chip, /plan'[\s\S]{0,80}bg-blue-50 text-blue-700/, 'WebUI plan 档应是蓝'); assert.match(chip, /full'[\s\S]{0,80}bg-amber-50 text-amber-700/, 'WebUI full 档应是琥珀'); assert.match(chip, /bg-green-50 text-green-700/, 'WebUI workspace 档应是绿'); @@ -513,10 +514,10 @@ test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在 * 深浅两套值由系统给,而且不会再漏配一边 —— 比我们自己维护两个常量更不容易错。 * 所以口径改成"遮罩来自系统"(这条是硬的),WebUI 侧仍然两段式(它没有系统可跟随)。 */ - assert.match(code(harmony), /overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/, '鸿蒙遮罩要用系统遮罩色'); + assert.match(stripComments(harmony), /overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/, '鸿蒙遮罩要用系统遮罩色'); // 不许再自己维护"色 + 透明度"两个常量(那正是系统已经替我们做掉的事) - assert.ok(!/overlayColor: string/.test(code(harmony)), '遮罩色不该再由我们自己定'); - assert.ok(!/overlayAlpha: number/.test(code(harmony)), '遮罩透明度不该再由我们自己定'); + assert.ok(!/overlayColor: string/.test(stripComments(harmony)), '遮罩色不该再由我们自己定'); + assert.ok(!/overlayAlpha: number/.test(stripComments(harmony)), '遮罩透明度不该再由我们自己定'); /* * pi 读出来的第四条:判据只断言 `overlay` **存在**,没断言它**被用** —— @@ -528,13 +529,13 @@ test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在 const themePath = join(HARMONY_ETS, 'common/Theme.ets'); const overlayUsers = collectEts(etsRoot) .filter(f => f !== themePath) - .filter(f => /Theme\.overlay\b/.test(code(readFileSync(f, 'utf8')))) + .filter(f => /Theme\.overlay\b/.test(code(f))) .map(f => f.slice(etsRoot.length + 1)); assert.ok(overlayUsers.length > 0, 'Theme.overlay 声明了却没有任何使用点 —— 那就是个死令牌(要么删掉,要么写出它的使用处)'); // 用它的必须是**自绘遮罩**的地方(系统自带遮罩的弹窗不需要它) assert.ok(overlayUsers.every(f => f.endsWith('.ets')), `遮罩使用点应该是页面:${overlayUsers.join('、')}`); - assert.ok(!/#[0-9A-Fa-f]{8}/.test(code(harmony)), '遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值'); + assert.ok(!/#[0-9A-Fa-f]{8}/.test(stripComments(harmony)), '遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值'); // WebUI 侧同构:颜色两套(浅/深,同一个变量名换向)+ 透明度独立 assert.match(web, /--bg-scrim: 255 255 255/, 'WebUI 浅色遮罩色'); assert.match(web, /--bg-scrim: 0 0 0/, 'WebUI 深色遮罩色'); @@ -624,7 +625,7 @@ test('★ 手写色清册**跨文件**:全 ets 树里每个 `X: string = \'#RR const declared = []; for (const f of tree) { const rel = f.slice(HARMONY_ETS.length + 1); - const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); for (const m of src.matchAll(/(\w+)\s*:\s*string\s*=\s*'(#[0-9A-Fa-f]{6})'/g)) { declared.push({ file: rel, name: m[1], value: m[2] }); } diff --git a/client/electron/test/harmony-appearance.test.mjs b/client/electron/test/harmony-appearance.test.mjs index 255ca79..a0c1b42 100644 --- a/client/electron/test/harmony-appearance.test.mjs +++ b/client/electron/test/harmony-appearance.test.mjs @@ -9,9 +9,9 @@ * 而单测全绿 —— 因为测试只断言了方法与报文,没断言 URL(路径多写了一层 `/api/v1`)。 * 所以这里有一条判据专门钉路径,而且钉的是**相对基地址**的形状。 */ +import { code, prose } from './lib/read.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -26,8 +26,7 @@ const MODULE_TS = join(HARMONY_ETS, 'model/Appearance.ts'); const A = await import(pathToFileURL(MODULE_TS).href); /** 剥注释读源码:注释里出现某个调用恰恰说明不了那个调用存在 */ -const code = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); -const read = (rel) => code(readFileSync(join(HARMONY_ETS, rel), 'utf8')); +const read = (rel) => code(join(HARMONY_ETS, rel)); const snap = (over = {}) => Object.assign(new A.AppearanceSnapshot(), over); const resp = (over = {}) => Object.assign(new A.AppearanceResponse(), over); @@ -147,8 +146,10 @@ test('★ 模糊值映射到**系统材质档次**(不是把 40 当半径塞 assert.equal(A.blurStyleFor(-3), 'NONE'); // 档次必须来自系统枚举(写成自造名字会编译不过/不生效) const sdk = A.blurStyleFor(12); - const commonDts = readFileSync(process.env.HARMONY_COMMON_DTS - || '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/component/common.d.ts', 'utf8'); + // SDK 的 .d.ts 是**源码**(判它的枚举成员),所以走 code():剥掉注释, + // 免得注释里举例的枚举名被当成员读进来 + const commonDts = code(process.env.HARMONY_COMMON_DTS + || '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/component/common.d.ts'); const enumBlock = commonDts.slice(commonDts.indexOf('declare enum BlurStyle')); const members = [...enumBlock.slice(0, enumBlock.indexOf('}')).matchAll(/^\s{2,}([A-Za-z][A-Za-z_0-9]*)\s*[,=]/gm)].map(m => m[1]); assert.ok(members.length > 3, '要从 SDK 里读到 BlurStyle 成员'); @@ -168,7 +169,7 @@ test('主题 → **系统色彩模式**(深浅两套颜色由系统给,不 * `COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`。判据直接读 SDK 的枚举文件比对, * 不凭印象(我第一版就是按 0=浅色 写的,选深色会切成浅色)。 */ - const constDts = readFileSync(process.env.HARMONY_CONFIG_CONSTANT_DTS + const constDts = code(process.env.HARMONY_CONFIG_CONSTANT_DTS || '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts', 'utf8'); const valueOf = (name) => { const m = new RegExp(`${name}\\s*=\\s*(-?\\d+)`).exec(constDts); @@ -253,8 +254,8 @@ export const __coverage = ['snapshotFromResponse', 'payloadFromLocal', 'mergeApp const WALL_TS = join(HARMONY_ETS, 'model/Wallpaper.ts'); const W = await import(pathToFileURL(WALL_TS).href); -const webCss = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8'); -const webBgStore = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8'); +const webCss = code(join(ROOT, 'client/electron/src/index.css')); +const webBgStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts')); test('★ 预设清单与 WebUI 一致(id + 顺序 + 中文标签)—— 少一个档就是"用户设了在鸿蒙看不到"', () => { /* @@ -434,10 +435,10 @@ test('★ 模糊归属:壁纸层**不许**再模糊,导航条必须有系统 const store = read('common/AppearanceStore.ets'); assert.match(store, /colorModeValue\(theme\)/, '主题走系统色彩模式'); /* - * "理由写清了没"要读**原文**(`read()` 会剥注释 —— 而理由就在注释里)。 + * "理由写清了没"要读**原文**(`prose()`;`code()`/`read()` 会剥注释 —— 而理由就在注释里)。 * 剥注释读源码是为了防"注释里的调用被当成真调用",但断言"注释里写了理由"时正好相反。 */ - const wallRaw = readFileSync(join(HARMONY_ETS, 'model/Wallpaper.ts'), 'utf8'); + const wallRaw = prose(join(HARMONY_ETS, 'model/Wallpaper.ts')); // 理由在注释里 → prose(见上) assert.match(wallRaw, /系统没有对应的渐变原语/, '网格档为什么用 Canvas 要写清(否则以后会被当成绕开系统方案)'); assert.match(wallRaw, /repeating-linear-gradient/, '要指名道姓写出 CSS 用的是哪个原语(后人查得到)'); }); @@ -559,7 +560,7 @@ test('★ isDarkMode:system 要看系统当时的深浅,读不到时按浅 assert.equal(A.isDarkMode('system', 1), false, 'system + 系统浅色 → 浅色色板'); assert.equal(A.isDarkMode('system', -1), false, '系统还没定(NOT_SET)→ 按浅色,与 WebUI 的 :root 默认一致'); // 数值锚到 SDK:0=深、1=浅 - const sdkConst = readFileSync(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts'), 'utf8'); + const sdkConst = code(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts')); assert.match(sdkConst, /COLOR_MODE_DARK = 0/, 'SDK 里深色是 0(别记反)'); assert.match(sdkConst, /COLOR_MODE_LIGHT = 1/, 'SDK 里浅色是 1'); // 页面真的按主题选色板(不是写死 false) @@ -596,7 +597,7 @@ test('★ 预设档的遮盖:两档同一个浓度(WebUI 的 --bg-dim 不区 } } // WebUI 侧的前提:遮罩真的不区分档位(否则"对齐"就没有依据) - const bgStore = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8'); + const bgStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts')); assert.match(bgStore, /--bg-dim|setProperty\('--bg-dim'/, 'WebUI 要无条件写 --bg-dim(这是"两档都压"的依据)'); const main = read('pages/MainPage.ets'); // 两档各有一处遮盖层(都用系统遮罩色 + 算出来的浓度) @@ -636,7 +637,7 @@ test('★ 多账号缓存键:**按账号**分(鸿蒙是对的,不许为" * 2026-09-14 WebUI 侧也按账号分键了(dsh 接手 pi 的两个开项),**差异已消除** —— * 所以现在断言的是"两端都是按账号的键",而且不允许任何一端退回全局键。 */ - const webStore = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8'); + const webStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts')); assert.match(webStore, /export function storageKey\(/, 'WebUI 也要有按账号取键的函数'); assert.match(webStore, /LEGACY_STORAGE_KEY/, '旧全局键只作为迁移源存在'); assert.ok(!/setItem\('agentmail\.background'/.test(webStore), 'WebUI 也不许再往全局键写'); @@ -665,7 +666,7 @@ test('★ 主题变化时**我们自己算的值**要跟着重算(pi 的规则 } assert.ok(/applyAppearance\(\)/.test(main.slice(watchAt, end + 400)), '回调里要重算外观(色板属于"我们算的")'); // SDK 锚点:这个 API 与回调形状不能凭记忆写 - const sdk = readFileSync(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.EnvironmentCallback.d.ts'), 'utf8'); + const sdk = code(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.EnvironmentCallback.d.ts')); assert.match(sdk, /onConfigurationUpdated\(config: Configuration\): void/, 'SDK 里回调是 onConfigurationUpdated(别写错名字)'); }); @@ -673,8 +674,8 @@ test('★ 主题变化时**我们自己算的值**要跟着重算(pi 的规则 /** 从 SDK 读出 `sys.color.ohos_id_color_*` 的真实值(名字→id 取编译器那张表,id→值取预览器那张) */ function sdkSystemColors() { - const sysRes = readFileSync(join(CLT, 'sdk/default/openharmony/ets/build-tools/ets-loader/sysResource.js'), 'utf8'); - const resTxt = readFileSync(join(CLT, 'sdk/default/openharmony/previewer/common/resources/entry/resources.txt'), 'utf8'); + const sysRes = code(join(CLT, 'sdk/default/openharmony/ets/build-tools/ets-loader/sysResource.js')); + const resTxt = code(join(CLT, 'sdk/default/openharmony/previewer/common/resources/entry/resources.txt')); const name2id = new Map(); for (const m of sysRes.matchAll(/'?(ohos_id_color_[a-z_]+)'?:\s*(\d+)/g)) { if (!name2id.has(m[1])) name2id.set(m[1], Number(m[2])); @@ -728,14 +729,14 @@ test('★ 遮盖色方向:`mask_*` 两套主题下都是**深色**(模态遮 assert.ok(luminanceOf(bgDark) < 0.2, `ohos_id_color_background_dark 应当近黑(实测 ${bgDark})`); // 事实三:WebUI 的遮罩方向是"朝底色淡化"(浅色白、深色黑)—— 与页面底色系同向、与 mask 反向 - const webCss = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8'); + const webCss = code(join(ROOT, 'client/electron/src/index.css')); const scrimLight = /--bg-scrim:\s*(\d+)\s+(\d+)\s+(\d+);/.exec(webCss); assert.ok(scrimLight, 'CSS 里要有 --bg-scrim'); assert.deepEqual([scrimLight[1], scrimLight[2], scrimLight[3]], ['255', '255', '255'], 'WebUI 浅色下的遮罩是**白**(把图案洗淡);这是"必须用页面底色系"的依据'); // 结论落到代码:壁纸遮盖用页面底色系令牌,且**不是** mask - const theme = readFileSync(join(HARMONY_ETS, 'common/Theme.ets'), 'utf8'); + const theme = code(join(HARMONY_ETS, 'common/Theme.ets')); assert.match(theme, /static readonly wallpaperScrim: Resource = \$r\('sys\.color\.ohos_id_color_background'\)/, '壁纸遮盖色要用页面底色系(ohos_id_color_background)'); assert.match(theme, /static readonly overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/, diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index 6cb62c1..07f884d 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -18,9 +18,10 @@ * 与 WebUI 的对应物:`src/lib/mailGroups.ts`(折叠 / isFlatGroup)、 * `src/components/WorkCard.tsx` 的 `BudgetChip`(预算档位)、`ContactPanel.tsx`(视图切换)。 */ +import { code, prose } from './lib/read.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync, readdirSync } from 'node:fs'; +import { readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -34,7 +35,7 @@ const COMM_TS = join(HARMONY_ETS, 'model/CommTabs.ts'); const H = await import(pathToFileURL(MODULE_TS).href); const C = await import(pathToFileURL(COMM_TS).href); -const page = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8'); +const page = code(join(HARMONY_ETS, 'pages/MainPage.ets')); /** * 断言一律读**剥掉注释的源码**。 * @@ -44,8 +45,8 @@ const page = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8'); * 已经用过一次(遮罩那段注释里引用了旧值),这里统一成常态。 */ const pageCode = page.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); -const webGroups = readFileSync(join(ROOT, 'client/electron/src/lib/mailGroups.ts'), 'utf8'); -const webCard = readFileSync(join(ROOT, 'client/electron/src/components/WorkCard.tsx'), 'utf8'); +const webGroups = code(join(ROOT, 'client/electron/src/lib/mailGroups.ts')); +const webCard = code(join(ROOT, 'client/electron/src/components/WorkCard.tsx')); /** 造一封邮件(只带折叠/渲染用得到的字段,字段名与 Models.ets 的 MailSummary 一致) */ const mail = (over) => Object.assign( @@ -235,7 +236,7 @@ test('底部只剩 通信 / 联系人 两个平级页签,「会话」不再是 */ const navLabels = [...pageCode.matchAll(/NAV_ITEMS\.map\([^)]*\.label\)|NAV_ITEMS/g)].length; assert.ok(navLabels > 0, '底栏要由 NAV_ITEMS 驱动'); - const navSource = readFileSync(join(HARMONY_ETS, 'model/NavItems.ts'), 'utf8'); + const navSource = code(join(HARMONY_ETS, 'model/NavItems.ts')); const labels = [...navSource.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]); assert.deepEqual(labels, ['通信', '联系人'], `平级项应只剩两个,实际:${labels.join('、')}`); assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里'); @@ -265,7 +266,7 @@ test('卡片视图的字段集与 WebUI 的 WorkCard 一致(撤 tab 后"信息 // ───────────────────────── 权限档位 + 强制力(与 WebUI PermissionChip 同文案) ───────────────────────── test('档位标签与强制力标记:和 WebUI 的 MODE_LABEL / ENFORCEMENT_LABEL 逐字一致', async () => { - const webChip = readFileSync(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), 'utf8'); + const webChip = code(join(ROOT, 'client/electron/src/components/PermissionChip.tsx')); const labelOf = (name, src) => { const block = src.slice(src.indexOf(`const ${name}`), src.indexOf('};', src.indexOf(`const ${name}`))); return new Map([...block.matchAll(/(\w+):\s*'([^']+)'/g)].map(m => [m[1], m[2]])); @@ -300,7 +301,7 @@ test('★ 档位说明文案与 WebUI permissionModeHint 逐字一致(两边 * 再要求鸿蒙的 permissionHint 对 3 档 × 3 强制力(外加空/未知)给出的每一句话 * 都在那个集合里 —— 任一边改了口径就红。 */ - const webChip = readFileSync(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), 'utf8'); + const webChip = code(join(ROOT, 'client/electron/src/components/PermissionChip.tsx')); const fn = webChip.slice(webChip.indexOf('export function permissionModeHint')); const body = fn.slice(0, fn.indexOf('\n}')); const webHints = [...body.matchAll(/return\s+((?:'(?:[^'\\]|\\.)*'\s*\+?\s*)+);/g)].map(m => @@ -393,7 +394,7 @@ test('★ 不得再用废弃的全局 promptAction.showToast(API 18 起废弃 const bad = []; for (const f of files) { - const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); // `getPromptAction().showToast(` 不算违规:它前面必须有 `get` for (const m of src.matchAll(/(? { @@ -509,7 +510,7 @@ test('三栏的空态都有说明,且主句与 WebUI 的「暂无邮件」逐 test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 + 收件箱按权限分家', () => { // 底部第一项的标签是「通信」而不是「收件箱」(信息架构变了,标签必须跟着变) // P5:底栏标签现在来自 NAV_ITEMS(自绘浮动条),不是 TabBarBuilder 的参数 - const navSource2 = readFileSync(join(HARMONY_ETS, 'model/NavItems.ts'), 'utf8'); + const navSource2 = code(join(HARMONY_ETS, 'model/NavItems.ts')); const tabLabels = [...navSource2.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]); assert.deepEqual(tabLabels, ['通信', '联系人'], `底部应只剩两项且第一项是通信,实际:${tabLabels.join('、')}`); // 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)—— @@ -543,7 +544,7 @@ test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 + }); test('发件箱与授权栏走的是与 WebUI 相同的接口(路径、字段、备注都要对)', () => { - const api = readFileSync(join(HARMONY_ETS, 'api/MailApi.ets'), 'utf8') + const api = code(join(HARMONY_ETS, 'api/MailApi.ets')) .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); assert.match(api, /get\('\/me\/mail\/sent'\)/, '发件箱接口'); assert.match(api, /get\('\/permission\/pending'\)/, '待决列表接口(不从收件箱筛)'); @@ -566,6 +567,6 @@ test('★ 判据自检:把状态机的默认页签改错必须判红', () => { // 自检方式:直接改源码字符串,确认断言会红(而不是"看起来能红") const mutated = sendCode.replace("this.commTab = normalizeCommTab(key);", "this.commTab = key;"); assert.ok(!/this\.commTab = normalizeCommTab\(key\)/.test(mutated), '自检:变异没生效'); - const defaultMutated = readFileSync(COMM_TS, 'utf8').replace("export const COMM_TAB_DEFAULT: string = 'inbox';", "export const COMM_TAB_DEFAULT: string = 'sent';"); + const defaultMutated = prose(COMM_TS).replace("export const COMM_TAB_DEFAULT: string = 'inbox';", "export const COMM_TAB_DEFAULT: string = 'sent';"); assert.ok(!/COMM_TAB_DEFAULT: string = 'inbox'/.test(defaultMutated), '自检:默认页签变异没生效'); }); diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index b3b5bed..c2adf1d 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -8,7 +8,7 @@ // ③ 命中区:≥44vp —— 从 `NavItems.ts` **导入数值**判,不拿正则去源码里猜; // ④ 悬浮与让位:条是自绘的浮动层(留白 + 圆角 + 系统材质), // 且内容底部让出的高度 ≥ 条高 + 离底留白(否则最后一行压在条底下:看得见、点不到)。 -import { readFileSync } from 'node:fs'; +import { code, prose, stripComments } from './lib/read.mjs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; @@ -19,9 +19,8 @@ const ROOT = join(HERE, '..', '..', '..'); const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); const NAV_TS = join(HARMONY_ETS, 'model/NavItems.ts'); -const read = p => readFileSync(join(HARMONY_ETS, p), 'utf8'); +const read = p => prose(join(HARMONY_ETS, p)); /** 剥掉注释与字符串,只看真代码(断言"代码里有什么"时必须这样读) */ -const code = src => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); /** 取某个 `@Builder` 的正文(按行切到下一个成员) */ function builderBody(src, signature) { const lines = src.split('\n'); @@ -148,7 +147,7 @@ test('③-b 命中区是**来源**判据:`.ets` 不许自己写数字,必须 * 顺带钉住:算式里不许出现裸数字(余量要有名字)。 */ test('④-b 内容让位高度是**派生**的,不是并列常量', () => { - const src = readFileSync(NAV_TS, 'utf8'); + const src = prose(NAV_TS); const decl = /export const NAV_CONTENT_RESERVE: number = ([^;]+);/.exec(src); assert.ok(decl, 'NAV_CONTENT_RESERVE 的声明要能被判据读到(判据跟着改)'); const expr = decl[1]; @@ -174,8 +173,8 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质), * 读原文会把它当成"条上还有手写色值"(我第一版就是这样误报的)。 * 与规范里那条一致:判"代码里有什么"读剥注释的源码,判"理由写清了没"读原文。 */ - assert.ok(!/#[0-9A-Fa-f]{6,8}/.test(code(bar)), - `条上不许出现手写色值(深浅两套由系统给),实际:${(code(bar).match(/#[0-9A-Fa-f]{6,8}/g) || []).join('、')}`); + assert.ok(!/#[0-9A-Fa-f]{6,8}/.test(stripComments(bar)), + `条上不许出现手写色值(深浅两套由系统给),实际:${(stripComments(bar).match(/#[0-9A-Fa-f]{6,8}/g) || []).join('、')}`); // 内容让位:让出的高度必须够(否则最后一行压在条底下 —— 看得见、点不到) assert.ok(N.NAV_CONTENT_RESERVE >= N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM, @@ -197,12 +196,12 @@ test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且 */ const root = main.slice(main.indexOf('build() {\n /*\n * 底部两项')); assert.ok(root.length > 300, '要能取到根 build() 的正文'); - const navCode = code(root); + const navCode = stripComments(root); assert.ok(!/\bTabs\(/.test(navCode), '根导航里不该再有系统 Tabs'); assert.ok(!/TabContent/.test(navCode), '根导航里不该再有 TabContent(内容改为按 index 挂载)'); assert.ok(!/\.tabBar\(/.test(navCode), '根导航里不该再有 tabBar()'); // 反面之二:也不许把旧 builder 留着不用(留着就是死代码,下一个人会以为它还在生效) - assert.ok(!/TabBarBuilder/.test(code(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里'); + assert.ok(!/TabBarBuilder/.test(stripComments(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里'); // 平级项仍是两项且顺序不变(信息架构这一期不动) assert.deepEqual(N.NAV_ITEMS.map(i => i.label), ['通信', '联系人'], '平级项与顺序'); @@ -225,7 +224,7 @@ test('★ 玻璃只在两处、且这一处是"背后有可变内容"(GLASS */ const wallpaper = builderBody(main, 'WallpaperLayer() {'); assert.ok(!/backgroundBlurStyle/.test(wallpaper), '壁纸层不许有材质(同一张底糊两遍)'); - assert.ok(!/blur\(/i.test(code(wallpaper)), '壁纸层不许有任何模糊调用'); + assert.ok(!/blur\(/i.test(stripComments(wallpaper)), '壁纸层不许有任何模糊调用'); const bar = builderBody(main, 'NavBar() {'); assert.match(bar, /backgroundBlurStyle\(Theme\.navMaterial\)/, '悬浮条必须有系统材质(背后是滚动内容)'); // 理由要写在**原文**(注释会被剥掉,而理由就在注释里) diff --git a/client/electron/test/harmony-system-api.test.mjs b/client/electron/test/harmony-system-api.test.mjs index 5dc0b83..bd5cf7d 100644 --- a/client/electron/test/harmony-system-api.test.mjs +++ b/client/electron/test/harmony-system-api.test.mjs @@ -14,9 +14,10 @@ * BlurStyle 的取值则在 `component/common.d.ts` 的 `declare enum BlurStyle` 里。 * 两边都能离线查 —— 所以"名字写对没有"可以变成构建期判据。 */ +import { prose } from './lib/read.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -60,7 +61,7 @@ const collectSources = (dir, acc = []) => { /** SDK 名表:name → type */ const loadIdTable = () => { - const recs = JSON.parse(readFileSync(ID_TABLE, 'utf8')).record; + const recs = JSON.parse(prose(ID_TABLE)).record; const byName = new Map(); for (const r of recs) byName.set(r.name, r.type); return byName; @@ -68,7 +69,7 @@ const loadIdTable = () => { /** SDK 的 BlurStyle 成员 */ const loadBlurStyleMembers = () => { - const src = readFileSync(COMMON_DTS, 'utf8'); + const src = prose(COMMON_DTS); const at = src.indexOf('declare enum BlurStyle'); assert.ok(at > 0, 'SDK 里应能定位到 declare enum BlurStyle'); // 枚举体到第一个顶格 `}` 为止;成员形如 ` Thin,` 或 ` COMPONENT_THIN = 6,` @@ -83,7 +84,7 @@ const loadBlurStyleMembers = () => { const scanSysResources = (files) => { const found = []; for (const f of files) { - const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); + const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); for (const m of src.matchAll(/\$r\(\s*'sys\.([a-z]+)\.([A-Za-z0-9_]+)'/g)) { found.push({ file: f.slice(ROOT.length + 1), type: m[1], name: m[2] }); } @@ -95,7 +96,7 @@ const scanSysResources = (files) => { const scanBlurStyles = (files) => { const found = []; for (const f of files) { - const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); + const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); for (const m of src.matchAll(/BlurStyle\.([A-Z][A-Z_0-9]*)/g)) { found.push({ file: f.slice(ROOT.length + 1), member: m[1] }); } @@ -213,7 +214,7 @@ function sdkDtsFiles() { function deprecatedGlobalFunctions() { const names = new Set(); for (const f of sdkDtsFiles()) { - const src = readFileSync(f, 'utf8'); + const src = prose(f); // 先切出 depth == 0 的片段 let depth = 0; let segStart = 0; @@ -297,7 +298,7 @@ test('废弃 API:清单**从 SDK 生成**,源码里不得调用(新增一 } const hits = []; for (const f of files) { - const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); for (const name of deprecated) { // 只算**全局调用**:排除成员调用 `x.name(`(`.animateTo(` 是我们要求的新写法) const re = new RegExp(`(? readFileSync(new URL(p, import.meta.url), 'utf8'); +const read = p => prose(new URL(p, import.meta.url)); console.log('窄屏布局回归:'); diff --git a/client/electron/test/nav-merge.test.mjs b/client/electron/test/nav-merge.test.mjs index 61dee07..368d3e7 100644 --- a/client/electron/test/nav-merge.test.mjs +++ b/client/electron/test/nav-merge.test.mjs @@ -10,14 +10,14 @@ * 为什么写文件级判据:改完跑老套件 254 条**全绿**,因为它一条都没测导航结构 —— * 我差点把"没红的测试"当成"改对了"。结构类改动必须自己带判据。 */ +import { code } from './lib/read.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); -const read = (...p) => readFileSync(join(HERE, '..', 'src', ...p), 'utf8'); +const read = (...p) => code(join(HERE, '..', 'src', ...p)); const sidebar = read('components', 'Sidebar.tsx'); const narrow = read('components', 'NarrowNav.tsx'); diff --git a/client/electron/test/packaging.test.mjs b/client/electron/test/packaging.test.mjs index 1d2a97b..1c74324 100644 --- a/client/electron/test/packaging.test.mjs +++ b/client/electron/test/packaging.test.mjs @@ -23,9 +23,10 @@ * 装上去的人看到的是旧界面,而 Web 上是新的 —— 两边不一致但谁都不报错。 */ +import { code, prose } from './lib/read.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync, existsSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -37,7 +38,7 @@ const ROOT = join(HERE, '..'); const DIST_HTML = join(ROOT, 'dist', 'index.html'); test('★ vite 的 base 必须是相对路径(否则 Electron 白屏)', () => { - const cfg = readFileSync(join(ROOT, 'vite.config.ts'), 'utf8'); + const cfg = code(join(ROOT, 'vite.config.ts')); // 不能只看 `base:` 出现在注释里 —— 断言的是真的有一条 base 配置语句 assert.match( cfg, @@ -53,7 +54,7 @@ test('★ 构建产物里不能有绝对资源路径(这条能在没浏览器 console.log('(dist/index.html 不存在 —— 先 cd client/electron && npm run build 才验得到)'); return; } - const html = readFileSync(DIST_HTML, 'utf8'); + const html = prose(DIST_HTML); const abs = [...html.matchAll(/(?:src|href)="(\/[^"]*)"/g)].map(m => m[1]); assert.deepEqual( abs, @@ -78,7 +79,7 @@ test('★ 安装包里的 dist 必须与当前构建一致(否则装上去的 return; } const list = execFileSync('npx', ['asar', 'list', asar], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); - const wanted = [...readFileSync(DIST_HTML, 'utf8').matchAll(/\.\/(assets\/[^"]+)/g)].map(m => m[1]); + const wanted = [...prose(DIST_HTML).matchAll(/\.\/(assets\/[^"]+)/g)].map(m => m[1]); const missing = wanted.filter(p => !list.includes(`/dist/${p}`)); assert.deepEqual( @@ -129,9 +130,9 @@ test('★ 发布脚本:构建失败时不打包(注入失败的构建,看 }); test('★ 发布脚本:构建只有一条路(走 npm run build,不能是裸 vite build)', () => { - const pkg = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')); + const pkg = JSON.parse(prose(join(HERE, '..', 'package.json'))); const scriptPath = join(HERE, '..', 'scripts', 'release-linux.sh'); - const script = readFileSync(scriptPath, 'utf8'); + const script = prose(scriptPath); /* * 两个真实的坑,各自被这条挡住: * ① `build:linux` 原来直接跑裸 `vite build` —— 那会**跳过 `gen:bg`** diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 0118c64..f846e6e 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -21,8 +21,9 @@ * * 写判据之前先读 `test/CRITERIA.md`(判结构与行为,不判字面与邻接)。 */ +import { prose } from './lib/read.mjs'; import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -48,7 +49,7 @@ if (!existsSync(CRITERIA_DOC)) { console.error('✗ 判据规范 test/CRITERIA.md 不见了(写判据的规矩不能只活在脑子里)'); process.exit(1); } -const criteriaDoc = readFileSync(CRITERIA_DOC, 'utf8'); +const criteriaDoc = prose(CRITERIA_DOC); for (const must of ['配对/解析', 'allow-list', '变异验证', '剥掉注释', '按行', '自报条数', '只支撑你看到的那一层', '已经在某个提交里', '自带修法', '按 id 联接', '不要退化成对源码形状的匹配']) { if (!criteriaDoc.includes(must)) { console.error(`✗ 判据规范里少了「${must}」这条 —— 规则被删掉了还是搬走了?`); @@ -73,7 +74,9 @@ const SUITE = [ ['test/appearance-defaults.test.mjs', [], 3], ['test/build-stamp.test.mjs', [], 6], ['test/packaging.test.mjs', [], 5], - ['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2] + ['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2], + // 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口 + ['test/criteria-hygiene.test.mjs', [], 2] ]; // 自检 1:清单里的文件必须真的存在(写错名字 = 那条判据永远不跑) @@ -106,7 +109,7 @@ for (const [file, flags] of SUITE) { if (flags.includes('--test')) { continue; // node:test 那几条没有 process.exit,结构上不会踩这个 } - const src = readFileSync(join(ROOT, file), 'utf8'); + const src = prose(join(ROOT, file)); const exitAt = src.lastIndexOf('process.exit('); if (exitAt >= 0 && /(^|\n)\s*check\(/.test(src.slice(exitAt))) { buried.push(file); @@ -134,7 +137,7 @@ if (buried.length) { * 再加一条"跑完必须有输出"(12 条判据现在都有输出),静默成功同样可疑。 */ function shapeOf(file) { - const src = readFileSync(join(ROOT, file), 'utf8'); + const src = prose(join(ROOT, file)); const usesNodeTest = /from 'node:test'/.test(src); const canFail = usesNodeTest || /(^|[^.\w])check\(/.test(src) diff --git a/client/electron/test/stores/background.test.ts b/client/electron/test/stores/background.test.ts index 7a5a8f8..ddc0c94 100644 --- a/client/electron/test/stores/background.test.ts +++ b/client/electron/test/stores/background.test.ts @@ -1,4 +1,5 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { prose } from '../lib/read.mjs'; +import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { beforeEach, describe, expect, it } from 'vitest'; import { @@ -306,7 +307,7 @@ describe('默认值 = 服务端契约', () => { * 一定会问"这谁写的、能删吗"。答案必须就在他会看的地方,而不是藏在某个人的记忆里。 */ it('★ legacy.bak 的生命周期写在文档里(谁写的、为什么不删、怎么删)', () => { - const doc = readFileSync(findDoc('docs/HARMONY-ALIGN-PLAN.md'), 'utf8'); + const doc = prose(findDoc('docs/HARMONY-ALIGN-PLAN.md')); const row = doc.split('\n').find(l => l.includes('agentmail.background.legacy.bak')); expect(row, '§7.12 里要有一行讲这个键').toBeTruthy(); expect(row).toContain('有意永久残留'); diff --git a/client/electron/test/theme.test.mjs b/client/electron/test/theme.test.mjs index be1fe73..c7cef32 100644 --- a/client/electron/test/theme.test.mjs +++ b/client/electron/test/theme.test.mjs @@ -7,12 +7,13 @@ * 这些检查存在的理由:深色模式的 bug 形态是**白底白字**, * 它不报错、不影响构建、只有肉眼能发现,而且往往只出现在某个不常开的页面。 */ -import { readFileSync, readdirSync } from 'node:fs'; +import { code, prose } from './lib/read.mjs'; +import { readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); -const read = p => readFileSync(join(here, p), 'utf8'); +const read = p => prose(join(here, p)); let pass = 0; let fail = 0; @@ -158,7 +159,7 @@ check( const compDir = join(here, '../src/components'); const offenders = []; for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) { - const src = readFileSync(join(compDir, f), 'utf8'); + const src = prose(join(compDir, f)); // 只看 className 与 style 里的颜色;SVG 的 currentColor 不算 const hits = (src.match(/#[0-9a-fA-F]{3,6}\b/g) || []); if (hits.length) offenders.push(`${f}(${hits.join(',')})`); @@ -382,7 +383,7 @@ check( const chromeFiles = ['Sidebar', 'NarrowNav']; const slateLeft = []; for (const f of chromeFiles) { - const src = readFileSync(join(here, `../src/components/${f}.tsx`), 'utf8'); + const src = code(join(here, `../src/components/${f}.tsx`)); const hits = src.match(/(?:bg|text|border|hover:bg|hover:text|active:bg|ring)-slate-\d+/g) || []; if (hits.length) slateLeft.push(`${f}: ${hits.join(',')}`); } @@ -392,7 +393,7 @@ check('框架组件已全部改用 chrome 色阶', slateLeft.length === 0, slate // 被反转成近白色,`bg-gray-900 text-white` 因而只剩约 1:1。 const componentSources = readdirSync(compDir) .filter(x => x.endsWith('.tsx')) - .map(f => [f, readFileSync(join(compDir, f), 'utf8')]); + .map(f => [f, prose(join(compDir, f))]); const graySolid = componentSources.flatMap(([f, src]) => (src.match(/(?:bg-gray-(?:700|800|900)[^'"\n]*text-white|text-white[^'"\n]*bg-gray-(?:700|800|900))/g) || []) .map(hit => `${f}:${hit}`) diff --git a/plugins/dsh-mail-bridge/lib/permission-mode.d.ts b/plugins/dsh-mail-bridge/lib/permission-mode.d.ts index 1705575..4a2e72a 100644 --- a/plugins/dsh-mail-bridge/lib/permission-mode.d.ts +++ b/plugins/dsh-mail-bridge/lib/permission-mode.d.ts @@ -11,6 +11,11 @@ export const ENFORCE_ADVISORY: 'advisory'; export function normalizeMode(mode: unknown): string; export function normalizeEnforcement(e: unknown): string; export function modeAtMost(a: string, b: string): string; +/** + * **状态写入点**专用:拿不到值返回 `null`(= 不要写),绝不兜默认档。 + * 用它而不是 `normalizeMode` —— 默认值可以出现在决策里,不可以出现在状态写入里。 + */ +export function modeForStateWrite(mode: unknown): string | null; export function modeNeedsHuman(mode: string): boolean; export function dshSandboxMode(mode: string): 'read-only' | 'workspace-write' | 'danger-full-access'; diff --git a/plugins/dsh-mail-bridge/lib/permission-mode.js b/plugins/dsh-mail-bridge/lib/permission-mode.js index cbb6293..a960a6a 100644 --- a/plugins/dsh-mail-bridge/lib/permission-mode.js +++ b/plugins/dsh-mail-bridge/lib/permission-mode.js @@ -78,6 +78,43 @@ export function normalizeEnforcement(e) { : ENFORCE_ADVISORY; } +/** + * **状态写入点**专用的档位解析:拿不到值就返回 `null`(= 不要写),绝不兜默认档。 + * + * ## 为什么单开一个函数(而不是复用 normalizeMode) + * + * `normalizeMode` 的语义是「**要一个档位**时怎么收敛」——空/脏值都给默认档。 + * 那在**决策**里是对的(有个兜底),在**状态写入**里是错的: + * + * 2026-09-14 的线上事故正是这个形状:补投路径漏传 `permission_mode`, + * 插件拿 `undefined` 走默认档,把一条 **full 档会话**写成了 `workspace-write` + * —— 沙箱窄了、审批从 never 变 ask,之后每个受守卫的工具调用都去问一次, + * 服务端按真实档位回 409,旧代码又当场拒绝。**一次状态写入,毁了整轮的工具能力**。 + * + * 一句话规则(pi 提的):**默认值可以出现在「决策」里,不可以出现在「状态写入」里** + * —— 决策有默认值是兜底,状态写入有默认值是**篡改**。 + * + * 于是分工是: + * - 缺字段(空串 / undefined / 非字符串)→ `null`,调用方**跳过写入**, + * 保留上一次由服务端给出的权威值; + * - 有值 → 交给 `normalizeMode` 收敛(脏值仍按共用契约 fail-closed 到默认档, + * 与 Gateway 的 `NormalizePermissionMode` 同语义 —— 这里不是"猜",是同一份契约)。 + * + * 跳过写入会不会 fail-open?**实测过方向的**:DSH 的默认沙箱就是 + * `workspace-write` + `ask`(见 dsh 客户端 `permissionSelectOf` 的初值), + * 所以"新会话 + 缺字段"落到的是与共用契约同一个档,不会更宽; + * 而"已有会话 + 缺字段"保留的是服务端先前给的权威值,下一个权威事件 + * (带档位的邮件 / 409 纠正 / 人在界面改档)会把它修正回来。 + * + * @param {unknown} mode + * @returns {string|null} 合法档位;`null` 表示**不要写状态** + */ +export function modeForStateWrite(mode) { + const m = typeof mode === 'string' ? mode.trim() : ''; + if (m === '') return null; + return normalizeMode(m); +} + /** * 取两个档位里更严的那一个。 * diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 68ddc10..8769780 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -60,6 +60,14 @@ import { renderThread, } from '../lib/discovery.js'; import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js'; +// 档位**状态写入**的共用规则(四平台逐字节同一份,见 deploy/check-shared-libs.sh)。 +// +// ⚠️ 2026-09-14 事故记录:这两个名字是**后来补上 import 的**。当时我在 409 分支里 +// 直接用 `MODE_FULL` 却没 import,而 `npx tsc --noEmit | tail -3` 的退出码被我 +// 误当成 tsc 的(其实是 `tail` 的)—— 于是"类型检查通过"是假的,这个 +// `ReferenceError` 会在这条分支**真正被执行时**抛出:正好是它要修的那条路径。 +// 现在插件套件一律走 `npm test`(`pretest: tsc` 会先编译),不再直接 `node --test`。 +import { MODE_FULL, modeForStateWrite } from '../lib/permission-mode.js'; import { createSSEClient } from '../lib/sse-client.js'; import { pickMailSession } from '../lib/mail-session-id.js'; import { normalizeAttachmentIDs } from '../lib/attachment-ids.js'; @@ -783,9 +791,23 @@ export function apply(ctx: any, config: PluginConfig): void { // 直接往 session 上 append 事件(与 permissionPresets.set 同一底层)。 // 不走 permissionPresets 服务:它要求 preset 名在配置表里, // 而我们的三档映射需要完全自主控制。 + // **这里是"状态写入点",不兜默认档**(2026-09-14):档位缺失时**跳过写入**, + // 保留服务端上一次给的权威值。原来写的是 `(mode || 'workspace')` —— 补投路径 + // 漏传档位时,一条 full 档会话被**改写**成 workspace-write + ask:整轮工具能力 + // 降级,且下一次调用照样问、照样挨 409。规则见 lib/permission-mode.js 的 + // modeForStateWrite:默认值可以出现在**决策**里,不可以出现在**状态写入**里。 function applyPermissionMode(session: any, mode: string): void { if (!session?.append) return; - const m = (mode || 'workspace').trim(); + const m = modeForStateWrite(mode); + if (m === null) { + // 缺字段 = 「不知道」,不是「workspace」。日志里说清是哪条路把值弄丢了, + // 否则下次还是只能靠猜(这行是排查"丢字段的那只手"的第一现场)。 + console.error( + '[dsh-mail-bridge] 档位缺失(事件里没有 permission_mode):本次**不改**会话沙箱与审批策略,' + + '保留上一次的权威值 —— 缺字段是"不知道",不是 workspace' + ); + return; + } // sandbox mode const sandboxMap: Record = { plan: 'read-only', @@ -1831,12 +1853,41 @@ export function apply(ctx: any, config: PluginConfig): void { * * **只认服务端明说的 full**:plan 档(该档语义是"不动手",拒绝是对的) * 与"链上没有人类"照旧拒绝 —— 猜宽了就是提权。 + * + * ## 放行只修"问不问",**修不了"能不能"**(pi 2026-09-14 指出,这条是本分支的重点) + * + * `allowed-once` 绕过的是**审批**;沙箱是另一件事,它还在 `workspace-write`。 + * 于是"工作区外写入"这类操作会在**沙箱层**被拒 —— 功能上通了,能力上仍然窄, + * 而且**每次受守卫的调用都要重新走一遍 409**(放行是 per-call 的)。 + * 更糟的是:这个状态会**留在会话里**,直到某一封恰好带档位的邮件到来。 + * + * 所以这里把 409 回包当成「**服务端在纠正我的档位**」,而不是「这一次可以放行」: + * 回包里有权威值就**写回会话**(`applyPermissionMode`),再决定放不放行。 + * 这顺带成了**自愈**路径 —— 连修复前就被降级的会话,也能被下一次 409 修回来。 */ - if (e?.status === 409 && String(e?.body?.permission_mode || '') === 'full') { - console.error( - `[dsh-mail-bridge] 服务端判定本会话为 full 档,放行本次询问(${relayKey}):无需审批` - ); - return 'allowed-once'; + const corrected = modeForStateWrite(e?.body?.permission_mode); + if (e?.status === 409 && corrected) { + // 写回状态(权威值来自服务端,不是猜的) + const found = findLiveDshSession(mailSessionID); + if (found) { + applyPermissionMode(found.agent?.session, corrected); + console.error( + `[dsh-mail-bridge] 服务端纠正档位为 ${corrected},已写回会话 ${found.id}(${relayKey})` + + `—— 放行只修"这一次",写回才修"状态"` + ); + } else { + // 会话不在运行(插件重启后尚未收到新邮件)。 + // 无需处理:下次投递时 deliverMail 会按邮件里带的 permission_mode 重新 apply。 + console.error( + `[dsh-mail-bridge] 服务端纠正档位为 ${corrected},但会话不在运行,写回推迟到下次投递` + ); + } + if (corrected === MODE_FULL) { + console.error( + `[dsh-mail-bridge] 服务端判定本会话为 full 档,放行本次询问(${relayKey}):无需审批` + ); + return 'allowed-once'; + } } if (e?.status === 409) { diff --git a/plugins/dsh-mail-bridge/test/catchup.test.mjs b/plugins/dsh-mail-bridge/test/catchup.test.mjs index 23d38ab..366c267 100644 --- a/plugins/dsh-mail-bridge/test/catchup.test.mjs +++ b/plugins/dsh-mail-bridge/test/catchup.test.mjs @@ -79,3 +79,14 @@ test('seen 传 undefined 时不去重也不报错', () => { const got = selectCatchup([mail({ mail_id: 'x' })], undefined); assert.deepEqual(got.map(e => e.mail_id), ['x']); }); + +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 逐字一致(蛇形),别自造驼峰'); +}); diff --git a/plugins/dsh-mail-bridge/test/permission-409-full.test.mjs b/plugins/dsh-mail-bridge/test/permission-409-full.test.mjs index 4e1e522..ecdf8e8 100644 --- a/plugins/dsh-mail-bridge/test/permission-409-full.test.mjs +++ b/plugins/dsh-mail-bridge/test/permission-409-full.test.mjs @@ -1,26 +1,29 @@ /** - * 接线断言:服务端 409 里说「本会话是 full 档」时,dsh 桥必须**放行**而不是拒绝。 + * 接线判据:dsh 桥怎么处理「409 带档位」。 * - * # 为什么需要这条(与 pi 桥同一处缺陷,同日一起修) + * # 判据分三块,缺一块就是假判据 * - * 409 原本只有一个含义:「这条任务链上没有人类」→ 当场拒绝(`return 'rejected'`)。 - * 但 `handler/permission.go` 在**档位不该问人**时也回 409,并在回包里带 - * `permission_mode`。两种 409 的正确反应相反:无人可问 → 拒绝;full 档 → **放行**。 - * - * dsh 桥还有一层放大:档位是从邮件事件 `applyPermissionMode()` 应用的, - * 补投路径漏传档位时(`lib/catchup.js`,同日修)会话被**降级**成 workspace —— - * 沙箱 danger-full-access → workspace-write、审批 never → ask, - * 于是每个受守卫的工具调用都去问一次,再被 409 拒绝。一条 full 档会话只要有一封 - * 补投邮件,这一轮的工具调用就全被自己人拦死。 + * 1. **动作**:409 回包里有权威档位时,先**写回会话状态**(`applyPermissionMode`), + * 再决定放行与否 —— 因为 `allowed-once` 只绕过**审批**,改不了**沙箱**: + * 档位没恢复的话,"工作区外写入"这类操作会在沙箱层被拒,且每次受守卫的调用 + * 都要重新走一遍 409(放行是 per-call 的)。写回才是"修状态",也顺带是**自愈**路径。 + * 2. **顺序(可达性)**:这块必须在「普通 409(无人可问 → 拒绝)」**之前**, + * 否则永远不可达。见下面 `orderHolds` 与它的**顺序自检**。 + * 3. **状态写入点不接受默认值**:`applyPermissionMode` 必须走 `modeForStateWrite` + * (缺字段 → 不写),且缺字段的分支要排在第一个 `session.append` 之前。 * * # 为什么读源码而不是跑起来 * - * 与 `permission-forward-wiring.test.mjs` 同一取舍:入口不是可导入的模块 - * (插件工厂 + Cordis 依赖)。这里要钉的只有一件事:那个放行分支还在、且认服务端给的档位。 + * 与 `permission-forward-wiring.test.mjs` 同一取舍:入口是插件工厂 + Cordis 依赖, + * 不可导入。这里钉的是"接线还在不在、顺序对不对",**值**那一半由 + * `permission-mode.test.mjs` 对 `modeForStateWrite` 的行为判据覆盖 —— + * 两条一起才闭合(值 → 行为,结构/来源 → 静态)。 * - * # 判据自检 + * # 判据自检(两条,都跟"能不能红"有关) * - * 拿掉放行分支的源码喂给它,必须红 —— 否则这条断言永远为真,比没有更糟。 + * - 拿掉写回/放行 → 必须红(证明它**有用**); + * - **把它挪到普通 409 之后 → 必须红**(证明它**可达**;pi 2026-09-14 §2 指出: + * 只做前一条自检,证明的是"被用到",不是"顺序对")。 */ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -31,7 +34,11 @@ import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const SRC = readFileSync(join(HERE, '..', 'src', 'index.ts'), 'utf8'); -/** 取 409 相关分支的正文(按花括号配对,不看窗口) */ +/** 剥掉注释后再断言"代码里有什么"(本仓规范:判代码读剥离版,判理由读原文) */ +const codeOnly = src => + src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1'); + +/** 取某个分支/函数的正文(按花括号配对,不看窗口) */ function blockAt(src, needle) { const at = src.indexOf(needle); if (at < 0) return ''; @@ -44,30 +51,97 @@ function blockAt(src, needle) { return ''; } -test('★ 409 + full 档 → 放行;plan / 无人可问 → 仍拒绝', () => { - const full = blockAt(SRC, "if (e?.status === 409 && String(e?.body?.permission_mode || '') === 'full')"); - assert.ok(full.length > 40, '要能找到「409 + full」分支(找不到说明结构变了,判据要跟着改)'); - assert.match(full, /return 'allowed-once'/, 'full 档要放行(DSH 的 ApprovalOutcome 只认 allowed-once/rejected/…)'); +const CORRECTED = 'if (e?.status === 409 && corrected) {'; +const PLAIN = 'if (e?.status === 409) {'; +/** 「纠正 + 决定」这一整段的起点:声明在 if 之前,所以判据要连它一起读 */ +const CORRECTION_AT = 'const corrected = modeForStateWrite('; - const plain = blockAt(SRC, 'if (e?.status === 409) {'); +/** 顺序判据本身(抽出来是为了能对"变异后的源码"复用它 —— 见顺序自检) */ +function orderHolds(src) { + const a = src.indexOf(CORRECTION_AT); + const b = src.indexOf(PLAIN); + return a >= 0 && b >= 0 && a < b; +} + +test('★ 409 带档位:先写回会话状态,再按档位决定放行', () => { + const at = SRC.indexOf(CORRECTION_AT); + const end = SRC.indexOf(PLAIN); + assert.ok(at >= 0 && end > at, '要能找到「读权威档位 → 决定」这一段(找不到说明结构变了,判据要跟着改)'); + const block = codeOnly(SRC.slice(at, end)); + assert.ok(block.length > 100, '这一段太短,八成是结构变了'); + assert.match(block, /modeForStateWrite\(e\?\.body\?\.permission_mode\)/, + '权威档位要用状态写入点的解析器读(缺字段 → 不写,不兜默认)'); + assert.match(block, /applyPermissionMode\(/, + '必须把服务端的纠正**写回会话** —— 只放行不写回,沙箱仍然是窄的(放行只修"问不问")'); + assert.match(block, /corrected === MODE_FULL/, + '只有服务端明说 full 才放行(plan 与"无人可问"照旧拒绝,猜宽了就是提权)'); + assert.match(block, /return 'allowed-once'/); + assert.ok(block.indexOf('applyPermissionMode(') < block.indexOf("return 'allowed-once'"), + '写回会话要发生在返回之前(否则这一次放行了,状态还是错的)'); +}); + +test('★ 顺序(可达性):带档位那块必须在普通 409 之前', () => { + assert.ok(orderHolds(SRC), + '「409 + 档位」必须排在普通 409 之前 —— 否则 full 档会先落进后面那块、被当成"无人可问"拒绝,\n' + + '而这块代码永远不可达("文件里没有能红的路径"那一族)。'); + const plain = codeOnly(blockAt(SRC, PLAIN)); assert.ok(plain.length > 100, '要能找到普通 409 分支'); assert.match(plain, /return 'rejected'/, '非 full 的 409 仍要当场拒绝'); - /* - * ⚠️ 「代码里有没有放行」必须读**剥掉注释**的正文:普通 409 分支的注释里正好写着 - * "ApprovalOutcome 只认 allowed-once / rejected / …"(解释为什么写 'rejected' - * 而不是 'denied')—— 读原文会把那句话当成"这里会放行"而误报。 - * 这是本仓规范里那条:判"代码里有什么"读剥离版,判"理由写清了没"读原文。 - */ - const plainCode = plain.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); - assert.ok(!/allowed-once/.test(plainCode), - `普通 409 分支不许放行(那就等于"没人可问也放行"):${(plainCode.match(/allowed-once/) || [''])[0]}`); - // 顺序:full 分支必须在普通 409 之前,否则永远不可达 - assert.ok(SRC.indexOf("permission_mode || '') === 'full'") < SRC.indexOf('if (e?.status === 409) {'), - '放行分支要排在普通 409 之前(否则被前面的分支吃掉,永远不可达)'); + assert.ok(!/allowed-once/.test(plain), + '普通 409 分支里不许出现放行(那就等于"没人可问也放行")'); }); -test('★ 判据自检:拿掉放行分支,上面那条必须红', () => { - const crippled = SRC.replace(/if \(e\?\.status === 409 && String\(e\?\.body\?\.permission_mode \|\| ''\) === 'full'\)[\s\S]*?\n \}\n/, ''); - assert.equal(blockAt(crippled, "if (e?.status === 409 && String(e?.body?.permission_mode || '') === 'full')").length, 0, - '自检:判据要能检出"放行分支被拿掉"这件事'); +test('★ 顺序自检:把它挪到普通 409 之后,上面那条顺序判据必须能红', () => { + /* + * pi 2026-09-14 §2:我旧的自检只做了"拿掉分支 → 红",那只证明它**被用到**, + * 不证明**顺序对** —— 若有人把它挪到后面,整套判据照样绿(分支还在,只是走不到)。 + * 所以这里对"挪动后的源码"复用同一条 orderHolds,要求它为假。 + * + * 挪法模拟真实的误编辑:把这块整段剪下来,插到普通 409 那块之后。 + */ + const start = SRC.indexOf(CORRECTION_AT); + const plainAt = SRC.indexOf(PLAIN); + assert.ok(start >= 0 && plainAt > start, '前置:当前顺序正确,才谈得上"挪动"'); + const moved = SRC.slice(start, plainAt); + const swapped = SRC.slice(0, start) + SRC.slice(plainAt) + moved; + assert.equal(orderHolds(swapped), false, + '自检失败:把「409 + 档位」挪到普通 409 之后,顺序判据居然还是绿的 —— 那它抓不到这个误编辑'); + /* + * 另一条自检:**整段拿掉**。注意不能用"改成 `if (false)`"来验 —— 那只是"够不着", + * 顺序关系还在,该由上面那条**动作**判据去抓(分工不同,别混)。 + */ + const removed = SRC.slice(0, start) + SRC.slice(plainAt); + assert.equal(orderHolds(removed), false, '自检失败:整段删掉后顺序判据应当红(找不到这一段)'); +}); + +test('★ 状态写入点:applyPermissionMode 不兜默认档,且缺字段分支在任何 append 之前', () => { + const fn = codeOnly(blockAt(SRC, 'function applyPermissionMode(')); + assert.ok(fn.length > 100, '要能找到 applyPermissionMode'); + assert.match(fn, /modeForStateWrite\(mode\)/, + '状态写入点必须走 modeForStateWrite(缺字段 → null → 不写)'); + assert.ok(!/\(\s*mode\s*\|\|\s*'workspace'\s*\)/.test(fn), + '不许再把缺字段兜成 workspace —— 那正是 2026-09-14 事故的形状'); + const guard = fn.indexOf('m === null'); + const firstAppend = fn.indexOf('session.append('); + assert.ok(guard >= 0 && firstAppend > guard, + '缺字段的提前返回要排在第一个 session.append 之前(否则状态已经被改了)'); +}); + +test('★ lib 里用到的名字必须真的 import 进来(我曾经漏过一次,且被管道骗过)', () => { + /* + * 2026-09-14 我在这里栽过:409 分支里直接用 `MODE_FULL` 却没 import, + * 而 `npx tsc --noEmit | tail -3` 的退出码是 `tail` 的 —— 于是"类型检查通过"是假的, + * `ReferenceError` 正好会抛在它要修的那条路径上。 + * 这条判据把"名字被绑定"这件事钉住(值/行为判据覆盖不到静态的绑定)。 + */ + const importLine = /import\s*\{([^}]+)\}\s*from\s*'\.\.\/lib\/permission-mode\.js'/.exec(SRC); + assert.ok(importLine, "要能从 '../lib/permission-mode.js' import(找不到说明 import 被改了)"); + const bound = new Set(importLine[1].split(',').map(s => s.trim()).filter(Boolean)); + for (const name of ['MODE_FULL', 'modeForStateWrite']) { + assert.ok(new RegExp(`\\b${name}\\b`).test(codeOnly(SRC)), + `${name} 要用在源码里(否则这条判据该删掉,别留空转)`); + assert.ok(bound.has(name), + `${name} 在源码里被用到,却没从 '../lib/permission-mode.js' import 进来 —— ` + + `运行时是 ReferenceError,而且它抛在**它要修的那条路径**上。`); + } }); diff --git a/plugins/dsh-mail-bridge/test/permission-mode.test.mjs b/plugins/dsh-mail-bridge/test/permission-mode.test.mjs index d3f52aa..ec734ea 100644 --- a/plugins/dsh-mail-bridge/test/permission-mode.test.mjs +++ b/plugins/dsh-mail-bridge/test/permission-mode.test.mjs @@ -14,6 +14,7 @@ import { normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman, dshSandboxMode, dshApprovalPolicy, piGuardedTools, piBlocksOutright, modeBriefing, + modeForStateWrite, } from '../lib/permission-mode.js'; // ─── 归一化 ─── @@ -213,3 +214,24 @@ test('partial 与 native 一样要求把方案写在回信里(出路不能消 const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL }); assert.match(s, /回信/); }); + +// ─── 状态写入点的档位解析(2026-09-14 线上事故的形状)─── + +test('★ 状态写入点:缺字段 → null(不写),不是默认档', () => { + /* + * 事故:补投路径漏传 `permission_mode`,插件拿 `undefined` 兜了默认档, + * 把一条 **full 档会话**改写成了 workspace-write + ask。后果不是"这一轮被拦一次" + * 而是一整轮工具能力降级(沙箱层就拒了),而且状态留在会话里。 + * + * 规则:**默认值可以出现在决策里,不可以出现在状态写入里。** + */ + for (const missing of ['', ' ', undefined, null, 42, {}]) { + assert.equal(modeForStateWrite(missing), null, + `${JSON.stringify(missing)} 应当返回 null(= 不要写状态),而不是兜一个档`); + } + // 反面:有值就照共用契约收敛(含脏值 fail-closed 到默认档,与 Gateway 同语义) + assert.equal(modeForStateWrite('full'), 'full'); + assert.equal(modeForStateWrite(' workspace '), 'workspace'); + assert.equal(modeForStateWrite('danger'), DEFAULT_MODE, '脏值仍按共用契约收敛,但这不是"缺字段"'); + assert.notEqual(modeForStateWrite(''), DEFAULT_MODE, '缺字段不能等于默认档 —— 那正是事故'); +}); diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index 94fcb4f..af54aa8 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -1326,6 +1326,26 @@ export default async function mailBridge(input) { // // deny 的同时把原因带给模型(opencode 把 reason 作为工具报错回去), // 它才知道下一步该换什么做法。 + /* + * 409 有**两种**含义,正确反应相反(2026-09-14,与 dsh/pi/zcode 桥同一处缺陷): + * 「这条链上没有人类」→ 拒绝;「**本档根本不该问**」→ 服务端在回包里带 + * `permission_mode`,是 full 档就应当**放行**(full 档工具调用无需审批)。 + * + * 走到第二种的路径:补投漏传档位(lib/catchup.js,同日已修)→ 本轮按 workspace + * 拦下来问人 → 服务端按真实档位回 409 → 旧代码当"无人可问"拒绝, + * 一条 full 档会话的工具调用全被自己人拦死。 + * + * **只认服务端明说的 full**;plan 与"无人可问"照旧拒绝(猜宽了就是提权)。 + * 这个桥没有会话档位旋钮,所以只修这一层。 + */ + if (Number(e?.status) === 409 && String(e?.body?.permission_mode || "") === "full") { + console.error( + `[mail-bridge] 服务端判定本会话为 full 档,放行本次询问(${input.id}):无需审批`, + ); + output.status = "allow"; + return; + } + if (isPermanentFailure(e)) { const b = e?.body || {}; console.error( diff --git a/plugins/opencode-mail-bridge/lib/permission-mode.js b/plugins/opencode-mail-bridge/lib/permission-mode.js index cbb6293..a960a6a 100644 --- a/plugins/opencode-mail-bridge/lib/permission-mode.js +++ b/plugins/opencode-mail-bridge/lib/permission-mode.js @@ -78,6 +78,43 @@ export function normalizeEnforcement(e) { : ENFORCE_ADVISORY; } +/** + * **状态写入点**专用的档位解析:拿不到值就返回 `null`(= 不要写),绝不兜默认档。 + * + * ## 为什么单开一个函数(而不是复用 normalizeMode) + * + * `normalizeMode` 的语义是「**要一个档位**时怎么收敛」——空/脏值都给默认档。 + * 那在**决策**里是对的(有个兜底),在**状态写入**里是错的: + * + * 2026-09-14 的线上事故正是这个形状:补投路径漏传 `permission_mode`, + * 插件拿 `undefined` 走默认档,把一条 **full 档会话**写成了 `workspace-write` + * —— 沙箱窄了、审批从 never 变 ask,之后每个受守卫的工具调用都去问一次, + * 服务端按真实档位回 409,旧代码又当场拒绝。**一次状态写入,毁了整轮的工具能力**。 + * + * 一句话规则(pi 提的):**默认值可以出现在「决策」里,不可以出现在「状态写入」里** + * —— 决策有默认值是兜底,状态写入有默认值是**篡改**。 + * + * 于是分工是: + * - 缺字段(空串 / undefined / 非字符串)→ `null`,调用方**跳过写入**, + * 保留上一次由服务端给出的权威值; + * - 有值 → 交给 `normalizeMode` 收敛(脏值仍按共用契约 fail-closed 到默认档, + * 与 Gateway 的 `NormalizePermissionMode` 同语义 —— 这里不是"猜",是同一份契约)。 + * + * 跳过写入会不会 fail-open?**实测过方向的**:DSH 的默认沙箱就是 + * `workspace-write` + `ask`(见 dsh 客户端 `permissionSelectOf` 的初值), + * 所以"新会话 + 缺字段"落到的是与共用契约同一个档,不会更宽; + * 而"已有会话 + 缺字段"保留的是服务端先前给的权威值,下一个权威事件 + * (带档位的邮件 / 409 纠正 / 人在界面改档)会把它修正回来。 + * + * @param {unknown} mode + * @returns {string|null} 合法档位;`null` 表示**不要写状态** + */ +export function modeForStateWrite(mode) { + const m = typeof mode === 'string' ? mode.trim() : ''; + if (m === '') return null; + return normalizeMode(m); +} + /** * 取两个档位里更严的那一个。 * diff --git a/plugins/opencode-mail-bridge/test/catchup.test.mjs b/plugins/opencode-mail-bridge/test/catchup.test.mjs index 23d38ab..366c267 100644 --- a/plugins/opencode-mail-bridge/test/catchup.test.mjs +++ b/plugins/opencode-mail-bridge/test/catchup.test.mjs @@ -79,3 +79,14 @@ test('seen 传 undefined 时不去重也不报错', () => { const got = selectCatchup([mail({ mail_id: 'x' })], undefined); assert.deepEqual(got.map(e => e.mail_id), ['x']); }); + +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 逐字一致(蛇形),别自造驼峰'); +}); diff --git a/plugins/opencode-mail-bridge/test/permission-mode.test.mjs b/plugins/opencode-mail-bridge/test/permission-mode.test.mjs index d3f52aa..ec734ea 100644 --- a/plugins/opencode-mail-bridge/test/permission-mode.test.mjs +++ b/plugins/opencode-mail-bridge/test/permission-mode.test.mjs @@ -14,6 +14,7 @@ import { normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman, dshSandboxMode, dshApprovalPolicy, piGuardedTools, piBlocksOutright, modeBriefing, + modeForStateWrite, } from '../lib/permission-mode.js'; // ─── 归一化 ─── @@ -213,3 +214,24 @@ test('partial 与 native 一样要求把方案写在回信里(出路不能消 const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL }); assert.match(s, /回信/); }); + +// ─── 状态写入点的档位解析(2026-09-14 线上事故的形状)─── + +test('★ 状态写入点:缺字段 → null(不写),不是默认档', () => { + /* + * 事故:补投路径漏传 `permission_mode`,插件拿 `undefined` 兜了默认档, + * 把一条 **full 档会话**改写成了 workspace-write + ask。后果不是"这一轮被拦一次" + * 而是一整轮工具能力降级(沙箱层就拒了),而且状态留在会话里。 + * + * 规则:**默认值可以出现在决策里,不可以出现在状态写入里。** + */ + for (const missing of ['', ' ', undefined, null, 42, {}]) { + assert.equal(modeForStateWrite(missing), null, + `${JSON.stringify(missing)} 应当返回 null(= 不要写状态),而不是兜一个档`); + } + // 反面:有值就照共用契约收敛(含脏值 fail-closed 到默认档,与 Gateway 同语义) + assert.equal(modeForStateWrite('full'), 'full'); + assert.equal(modeForStateWrite(' workspace '), 'workspace'); + assert.equal(modeForStateWrite('danger'), DEFAULT_MODE, '脏值仍按共用契约收敛,但这不是"缺字段"'); + assert.notEqual(modeForStateWrite(''), DEFAULT_MODE, '缺字段不能等于默认档 —— 那正是事故'); +}); diff --git a/plugins/pi-mail-bridge/lib/permission-mode.js b/plugins/pi-mail-bridge/lib/permission-mode.js index cbb6293..a960a6a 100644 --- a/plugins/pi-mail-bridge/lib/permission-mode.js +++ b/plugins/pi-mail-bridge/lib/permission-mode.js @@ -78,6 +78,43 @@ export function normalizeEnforcement(e) { : ENFORCE_ADVISORY; } +/** + * **状态写入点**专用的档位解析:拿不到值就返回 `null`(= 不要写),绝不兜默认档。 + * + * ## 为什么单开一个函数(而不是复用 normalizeMode) + * + * `normalizeMode` 的语义是「**要一个档位**时怎么收敛」——空/脏值都给默认档。 + * 那在**决策**里是对的(有个兜底),在**状态写入**里是错的: + * + * 2026-09-14 的线上事故正是这个形状:补投路径漏传 `permission_mode`, + * 插件拿 `undefined` 走默认档,把一条 **full 档会话**写成了 `workspace-write` + * —— 沙箱窄了、审批从 never 变 ask,之后每个受守卫的工具调用都去问一次, + * 服务端按真实档位回 409,旧代码又当场拒绝。**一次状态写入,毁了整轮的工具能力**。 + * + * 一句话规则(pi 提的):**默认值可以出现在「决策」里,不可以出现在「状态写入」里** + * —— 决策有默认值是兜底,状态写入有默认值是**篡改**。 + * + * 于是分工是: + * - 缺字段(空串 / undefined / 非字符串)→ `null`,调用方**跳过写入**, + * 保留上一次由服务端给出的权威值; + * - 有值 → 交给 `normalizeMode` 收敛(脏值仍按共用契约 fail-closed 到默认档, + * 与 Gateway 的 `NormalizePermissionMode` 同语义 —— 这里不是"猜",是同一份契约)。 + * + * 跳过写入会不会 fail-open?**实测过方向的**:DSH 的默认沙箱就是 + * `workspace-write` + `ask`(见 dsh 客户端 `permissionSelectOf` 的初值), + * 所以"新会话 + 缺字段"落到的是与共用契约同一个档,不会更宽; + * 而"已有会话 + 缺字段"保留的是服务端先前给的权威值,下一个权威事件 + * (带档位的邮件 / 409 纠正 / 人在界面改档)会把它修正回来。 + * + * @param {unknown} mode + * @returns {string|null} 合法档位;`null` 表示**不要写状态** + */ +export function modeForStateWrite(mode) { + const m = typeof mode === 'string' ? mode.trim() : ''; + if (m === '') return null; + return normalizeMode(m); +} + /** * 取两个档位里更严的那一个。 * diff --git a/plugins/pi-mail-bridge/test/catchup.test.mjs b/plugins/pi-mail-bridge/test/catchup.test.mjs index 04a674e..366c267 100644 --- a/plugins/pi-mail-bridge/test/catchup.test.mjs +++ b/plugins/pi-mail-bridge/test/catchup.test.mjs @@ -2,11 +2,6 @@ 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', @@ -85,17 +80,6 @@ test('seen 传 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', '补投路径不许把档位丢掉'); @@ -106,69 +90,3 @@ test('★ 补投事件必须带权限档位(漏了就会把 full 档会话误 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, '白名单不许无限膨胀(它一膨胀这条判据就废了)'); -}); 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 new file mode 100644 index 0000000..238c004 --- /dev/null +++ b/plugins/pi-mail-bridge/test/cross-bridge-permission-routing.test.mjs @@ -0,0 +1,194 @@ +/** + * 四桥**都**转发权限询问 —— 所以四桥都必须在 409 里认出「本档不该问」并放行。 + * + * # 这条判据的来历(先记一笔错误,免得下一个人以为它天生存在) + * + * 我 2026-09-14 在回信里写过:「zcode / opencode 不转发权限询问(src 里没有 + * `permission/request`,也没有 409 分支),所以不需要改」。pi 要求把这句**否定性事实** + * 变成常驻判据("你查过了"只值一次),于是写了这条 —— **它当场就红给我看**: + * zcode 的 `hooks/permission.mjs` 与 opencode 的 `index.js` 都在 + * `post('/permission/request')`,也都有永久失败(含 409)分支。 + * + * 我上次查错的原因是**搜索面太窄**:只扫了 `/src/*.mjs`, + * 而 zcode 的转发在 `hooks/`、opencode 的实现就在根目录 `index.js`。 + * "我查过了"这句话本身没算错,错在**查的范围是随手定的**。 + * + * 于是这条判据有两个用处: + * 1. 四桥都转发 → 四桥都必须处理"409 带档位"这个情形(否则那条路径上工具调用 + * 会被自己人拦死); + * 2. **否定性事实必须变成判据** —— 否则它只靠人记得,而人(包括我)会记错。 + * + * # 判据形态 + * + * 表驱动:每个桥登记「409 + full 的放行写法」「是否要写回状态」「永久失败锚点」。 + * 找不到 → 红(不是跳过):"找不到"与"没有"在结果上必须区分开。 + */ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, statSync } 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 PLUGINS = join(HERE, '..', '..'); + +/** + * 每个插件的**实现**入口(只看实现,不看测试)。 + * + * 布局四个桥各不相同(实测:dsh/pi/zcode 有 `src/`,zcode 的转发放在 `hooks/`, + * opencode 是根目录一个 `index.js`),所以显式登记而不是"扫 src、没有就算了" + * —— 后者正是本仓最忌讳的**静默放行**:扫不到就等于判据永远为真。 + */ +const IMPL = { + 'dsh-mail-bridge': ['src'], + 'pi-mail-bridge': ['src'], + 'zcode-mail-bridge': ['src', 'mcp', 'hooks'], + 'opencode-mail-bridge': ['index.js'], +}; + +function readImpl(plugin) { + const out = []; + const walk = d => { + for (const e of readdirSync(d, { withFileTypes: true })) { + const p = join(d, e.name); + if (e.isDirectory()) walk(p); + else if (/\.(mjs|ts|js)$/.test(e.name)) out.push(readFileSync(p, 'utf8')); + } + }; + for (const rel of IMPL[plugin]) { + const p = join(PLUGINS, plugin, rel); + const st = statSync(p); // 登记了就必须在:找不到时抛 ENOENT 让判据红,别静默跳过 + if (st.isDirectory()) walk(p); + else out.push(readFileSync(p, 'utf8')); + } + assert.ok(out.length > 0, `${plugin} 的实现文本读出来是空的 —— 判据的读法要跟着布局改`); + return out.join('\n'); +} + +/** 剥注释:判"代码里有什么"必须读剥离版(本仓规范) */ +const codeOnly = src => + src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1'); + +/** 一个桥的登记:放行写法、是否要写回状态、永久失败锚点 */ +const BRIDGES = [ + { + plugin: 'dsh-mail-bridge', + allow: /corrected === MODE_FULL/, + mustWriteBack: /applyPermissionMode\(/, + permanent: 'if (isPermanentFailure(e))', + note: '409 里带 full 时放行,并把权威档位写回会话(否则沙箱仍是窄的)', + }, + { + plugin: 'pi-mail-bridge', + allow: /permission_mode === MODE_FULL/, + mustWriteBack: null, + permanent: 'if (isPermanentFailure(e))', + note: 'pi 的档位是 per-job 的,没有会话状态要写回', + }, + { + plugin: 'zcode-mail-bridge', + allow: /permission_mode \|\| ''\) === 'full'/, + mustWriteBack: null, + permanent: 'if (isPermanentFailure(e))', + note: 'hooks/permission.mjs 的 409 分支(该桥没有 sandbox/approval 旋钮)', + }, + { + plugin: 'opencode-mail-bridge', + allow: /permission_mode \|\| ""\) === "full"/, + mustWriteBack: null, + permanent: 'if (isPermanentFailure(e))', + note: 'index.js 的 permission.ask 钩子(status: ask | deny | allow)', + }, +]; + +test('探测器自检:它认得出"有转发"的样子', () => { + const has = src => /permission\/request/.test(src); + assert.equal(has("await client.post('/permission/request', body)"), true); + assert.equal(has("await client.get('/mail/inbox')"), false); +}); + +test('★ 四桥都转发权限询问(我上次说"只有两个"是查错了)', () => { + for (const b of BRIDGES) { + assert.match(readImpl(b.plugin), /permission\/request/, + `${b.plugin} 应当转发权限询问。若确实不再转发,请回来改这条判据并把理由写清楚 —— ` + + `"我之前查过"不算理由(我就查错过一次)。`); + } +}); + +test('★ 四桥都必须认出「409 带 full → 放行」,且排在永久失败分支之前', () => { + for (const b of BRIDGES) { + const src = codeOnly(readImpl(b.plugin)); + assert.match(src, b.allow, + `${b.plugin} 的 409 处理里找不到"服务端说 full 就放行"这一支:${b.note}。\n` + + '缺了它的后果:补投漏传档位时按 workspace 档问人 → 服务端按真实档位回 409 → ' + + '被当成"链上没有人类"拒绝,一条 full 档会话的工具调用全被自己人拦死。'); + if (b.mustWriteBack) { + assert.match(src, b.mustWriteBack, + `${b.plugin} 有会话档位旋钮,所以 409 里的权威档位还必须**写回会话**:` + + '只放行修的是"问不问",沙箱(能不能)还是窄的。'); + } + const allowAt = src.search(b.allow); + const permAt = src.indexOf(b.permanent); + assert.ok(permAt >= 0, + `${b.plugin} 找不到永久失败分支锚点 \`${b.permanent}\`(结构变了,判据要跟着改,别让它空转)`); + assert.ok(allowAt >= 0 && allowAt < permAt, + `${b.plugin} 里"full 放行"必须排在永久失败分支**之前**,否则永远不可达 —— ` + + '整套跑得绿、实际走不到,是"文件里没有能红的路径"那一族。'); + } +}); + +test('★ 判据自检:把放行那一支挪到永久失败分支之后,顺序判据必须能红', () => { + const orderHolds = (src, b) => { + const a = src.search(b.allow); + const p = src.indexOf(b.permanent); + return a >= 0 && p >= 0 && a < p; + }; + for (const b of BRIDGES) { + const src = codeOnly(readImpl(b.plugin)); + assert.ok(orderHolds(src, b), `前置:${b.plugin} 当前顺序正确,才谈得上"挪动"`); + const a = src.search(b.allow); + const p = src.indexOf(b.permanent); + const swapped = src.slice(0, a) + src.slice(p) + src.slice(a, p); + assert.equal(orderHolds(swapped, b), false, + `自检失败:${b.plugin} 把放行那一支挪到后面之后,顺序判据居然还是绿的 —— 那它抓不到这个误编辑`); + } +}); + +/** + * ★ 生产者 / 消费者配对:档位这个键名,四平台的消费者都在读,补投也真的产出它。 + * + * 事故的形状是「生产者漏字段、消费者照读」:补投路径少带 `permission_mode`, + * 而 worker 那边 `msg.data?.permission_mode` 读到 `undefined` → 当 workspace 档去问人 + * → 服务端按真实档位回 409 → 被当"无人可问"拒绝,一整轮工具能力没了。 + * + * 为什么这条放在**平台专属**文件里、而不是 `test/catchup.test.mjs`: + * 后者是四平台**共用**的(`deploy/check-shared-libs.sh` 要求逐字节相同), + * 一旦写进"pi 的 worker 在 src/worker.mjs"这种路径,四份就不再相同 —— + * 我上一版正是这么干的,结果那个检查从 `153985e` 起一直红着,而我没跑过它。 + * 共用文件放**契约**(值/行为),平台专属文件放**跨平台配对**(来源/结构)。 + */ +test('★ 配对:消费者读的档位键名,生产者必须产出(四平台逐一登记读取处)', async () => { + // 消费者登记:每个平台从投递事件里读档位的那一行(找不到 → 红,不静默跳过) + const CONSUMERS = [ + ['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'], + ]; + for (const [who, rel] of CONSUMERS) { + const text = codeOnly(readFileSync(join(PLUGINS, rel), 'utf8')); + assert.match(text, /permission_mode/, + `${who}(${rel})没有引用 \`permission_mode\` —— 要么它改读了别的键名,` + + '要么它不再关心档位(后者要在本判据里写清为什么)'); + } + + // 生产侧是**行为**判据:真的调一次 mailToEvent,看它产不产出这个键 + const { mailToEvent } = await import('../lib/catchup.js'); + const ev = mailToEvent({ + mail_id: 'm1', from_name: 'x', session_alias: 's', subject: 's', body: 'b', + permission_mode: 'full', permission_enforcement: 'strict', + }); + assert.ok('permission_mode' in ev, + '补投事件必须产出 `permission_mode`:消费者读它,缺了就是"读到 undefined 当默认档"那场事故'); + assert.equal(ev.permission_mode, 'full', '有档位时不许丢;键名要与 SSE new_mail 逐字一致(蛇形)'); +}); diff --git a/plugins/pi-mail-bridge/test/permission-mode.test.mjs b/plugins/pi-mail-bridge/test/permission-mode.test.mjs index d3f52aa..ec734ea 100644 --- a/plugins/pi-mail-bridge/test/permission-mode.test.mjs +++ b/plugins/pi-mail-bridge/test/permission-mode.test.mjs @@ -14,6 +14,7 @@ import { normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman, dshSandboxMode, dshApprovalPolicy, piGuardedTools, piBlocksOutright, modeBriefing, + modeForStateWrite, } from '../lib/permission-mode.js'; // ─── 归一化 ─── @@ -213,3 +214,24 @@ test('partial 与 native 一样要求把方案写在回信里(出路不能消 const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL }); assert.match(s, /回信/); }); + +// ─── 状态写入点的档位解析(2026-09-14 线上事故的形状)─── + +test('★ 状态写入点:缺字段 → null(不写),不是默认档', () => { + /* + * 事故:补投路径漏传 `permission_mode`,插件拿 `undefined` 兜了默认档, + * 把一条 **full 档会话**改写成了 workspace-write + ask。后果不是"这一轮被拦一次" + * 而是一整轮工具能力降级(沙箱层就拒了),而且状态留在会话里。 + * + * 规则:**默认值可以出现在决策里,不可以出现在状态写入里。** + */ + for (const missing of ['', ' ', undefined, null, 42, {}]) { + assert.equal(modeForStateWrite(missing), null, + `${JSON.stringify(missing)} 应当返回 null(= 不要写状态),而不是兜一个档`); + } + // 反面:有值就照共用契约收敛(含脏值 fail-closed 到默认档,与 Gateway 同语义) + assert.equal(modeForStateWrite('full'), 'full'); + assert.equal(modeForStateWrite(' workspace '), 'workspace'); + assert.equal(modeForStateWrite('danger'), DEFAULT_MODE, '脏值仍按共用契约收敛,但这不是"缺字段"'); + assert.notEqual(modeForStateWrite(''), DEFAULT_MODE, '缺字段不能等于默认档 —— 那正是事故'); +}); diff --git a/plugins/zcode-mail-bridge/hooks/permission.mjs b/plugins/zcode-mail-bridge/hooks/permission.mjs index 5d032e7..f8e03e2 100644 --- a/plugins/zcode-mail-bridge/hooks/permission.mjs +++ b/plugins/zcode-mail-bridge/hooks/permission.mjs @@ -203,6 +203,26 @@ async function main() { try { decision = await askHuman({ client, baseURL: client.baseURL, input, toolName, relayKey, sessionId }); } catch (e) { + /* + * 409 有**两种**含义,正确反应相反(2026-09-14,与 dsh/pi 桥同一处缺陷): + * + * - 「这条链上没有人类」→ 当场拒绝(下面的理由); + * - 「**本档根本不该问**」→ 服务端在回包里带 `permission_mode`。若是 full 档, + * 这次询问本就不该发生(full 档工具调用无需审批),正确反应是**放行**。 + * + * 什么时候会走到第二种:补投路径漏传档位(`lib/catchup.js`,同日已修), + * 于是这一轮按 workspace 档拦下来问人,服务端按真实档位回 409 —— + * 旧代码把它当「无人可问」拒绝,让一条 full 档会话的工具调用**全被自己人拦死**。 + * + * **只认服务端明说的 full**:plan 档与"链上没有人类"照旧拒绝(猜宽了就是提权)。 + * 这个桥不写会话状态(没有 sandbox/approval 旋钮),所以只需修这一层; + * dsh 桥那边还要把权威档位**写回会话**,否则沙箱仍然是窄的。 + */ + if (Number(e?.status) === 409 && String(e?.body?.permission_mode || '') === 'full') { + log(`服务端判定本会话为 full 档,放行本次询问(${relayKey}):无需审批`); + return approve(); + } + // 409 = 服务端判定这条任务链上没有人类,永远不会有人来点头。 // 永久失败(4xx)同样不会因重试而改变 —— 两者都必须当场拒绝, // 让模型从工具报错里看到原因并自己改道(挂死时连重试机会都没有)。 diff --git a/plugins/zcode-mail-bridge/lib/permission-mode.js b/plugins/zcode-mail-bridge/lib/permission-mode.js index cbb6293..a960a6a 100644 --- a/plugins/zcode-mail-bridge/lib/permission-mode.js +++ b/plugins/zcode-mail-bridge/lib/permission-mode.js @@ -78,6 +78,43 @@ export function normalizeEnforcement(e) { : ENFORCE_ADVISORY; } +/** + * **状态写入点**专用的档位解析:拿不到值就返回 `null`(= 不要写),绝不兜默认档。 + * + * ## 为什么单开一个函数(而不是复用 normalizeMode) + * + * `normalizeMode` 的语义是「**要一个档位**时怎么收敛」——空/脏值都给默认档。 + * 那在**决策**里是对的(有个兜底),在**状态写入**里是错的: + * + * 2026-09-14 的线上事故正是这个形状:补投路径漏传 `permission_mode`, + * 插件拿 `undefined` 走默认档,把一条 **full 档会话**写成了 `workspace-write` + * —— 沙箱窄了、审批从 never 变 ask,之后每个受守卫的工具调用都去问一次, + * 服务端按真实档位回 409,旧代码又当场拒绝。**一次状态写入,毁了整轮的工具能力**。 + * + * 一句话规则(pi 提的):**默认值可以出现在「决策」里,不可以出现在「状态写入」里** + * —— 决策有默认值是兜底,状态写入有默认值是**篡改**。 + * + * 于是分工是: + * - 缺字段(空串 / undefined / 非字符串)→ `null`,调用方**跳过写入**, + * 保留上一次由服务端给出的权威值; + * - 有值 → 交给 `normalizeMode` 收敛(脏值仍按共用契约 fail-closed 到默认档, + * 与 Gateway 的 `NormalizePermissionMode` 同语义 —— 这里不是"猜",是同一份契约)。 + * + * 跳过写入会不会 fail-open?**实测过方向的**:DSH 的默认沙箱就是 + * `workspace-write` + `ask`(见 dsh 客户端 `permissionSelectOf` 的初值), + * 所以"新会话 + 缺字段"落到的是与共用契约同一个档,不会更宽; + * 而"已有会话 + 缺字段"保留的是服务端先前给的权威值,下一个权威事件 + * (带档位的邮件 / 409 纠正 / 人在界面改档)会把它修正回来。 + * + * @param {unknown} mode + * @returns {string|null} 合法档位;`null` 表示**不要写状态** + */ +export function modeForStateWrite(mode) { + const m = typeof mode === 'string' ? mode.trim() : ''; + if (m === '') return null; + return normalizeMode(m); +} + /** * 取两个档位里更严的那一个。 * diff --git a/plugins/zcode-mail-bridge/test/catchup.test.mjs b/plugins/zcode-mail-bridge/test/catchup.test.mjs index 23d38ab..366c267 100644 --- a/plugins/zcode-mail-bridge/test/catchup.test.mjs +++ b/plugins/zcode-mail-bridge/test/catchup.test.mjs @@ -79,3 +79,14 @@ test('seen 传 undefined 时不去重也不报错', () => { const got = selectCatchup([mail({ mail_id: 'x' })], undefined); assert.deepEqual(got.map(e => e.mail_id), ['x']); }); + +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 逐字一致(蛇形),别自造驼峰'); +}); diff --git a/plugins/zcode-mail-bridge/test/permission-mode.test.mjs b/plugins/zcode-mail-bridge/test/permission-mode.test.mjs index d3f52aa..ec734ea 100644 --- a/plugins/zcode-mail-bridge/test/permission-mode.test.mjs +++ b/plugins/zcode-mail-bridge/test/permission-mode.test.mjs @@ -14,6 +14,7 @@ import { normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman, dshSandboxMode, dshApprovalPolicy, piGuardedTools, piBlocksOutright, modeBriefing, + modeForStateWrite, } from '../lib/permission-mode.js'; // ─── 归一化 ─── @@ -213,3 +214,24 @@ test('partial 与 native 一样要求把方案写在回信里(出路不能消 const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL }); assert.match(s, /回信/); }); + +// ─── 状态写入点的档位解析(2026-09-14 线上事故的形状)─── + +test('★ 状态写入点:缺字段 → null(不写),不是默认档', () => { + /* + * 事故:补投路径漏传 `permission_mode`,插件拿 `undefined` 兜了默认档, + * 把一条 **full 档会话**改写成了 workspace-write + ask。后果不是"这一轮被拦一次" + * 而是一整轮工具能力降级(沙箱层就拒了),而且状态留在会话里。 + * + * 规则:**默认值可以出现在决策里,不可以出现在状态写入里。** + */ + for (const missing of ['', ' ', undefined, null, 42, {}]) { + assert.equal(modeForStateWrite(missing), null, + `${JSON.stringify(missing)} 应当返回 null(= 不要写状态),而不是兜一个档`); + } + // 反面:有值就照共用契约收敛(含脏值 fail-closed 到默认档,与 Gateway 同语义) + assert.equal(modeForStateWrite('full'), 'full'); + assert.equal(modeForStateWrite(' workspace '), 'workspace'); + assert.equal(modeForStateWrite('danger'), DEFAULT_MODE, '脏值仍按共用契约收敛,但这不是"缺字段"'); + assert.notEqual(modeForStateWrite(''), DEFAULT_MODE, '缺字段不能等于默认档 —— 那正是事故'); +});