fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档
线上事故(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 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改" —— 那句话是错的,我当时的搜索面只有 `<plugin>/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。
This commit is contained in:
@ -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)));
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
91
client/electron/test/criteria-hygiene.test.mjs
Normal file
91
client/electron/test/criteria-hygiene.test.mjs
Normal file
@ -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" +
|
||||
" 为什么不能裸用:读原文去判代码,会被解释性注释骗(同一个坑已经踩过两次)。");
|
||||
});
|
||||
@ -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] });
|
||||
}
|
||||
|
||||
@ -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'\)/,
|
||||
|
||||
@ -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(/(?<!get)promptAction\.showToast\(/g)) bad.push(f.slice(ROOT.length + 1));
|
||||
}
|
||||
@ -405,10 +406,10 @@ test('★ 不得再用废弃的全局 promptAction.showToast(API 18 起废弃
|
||||
|
||||
// ───────────────────── 通信页:内部页签 / 徽标 / 三栏分家(P2b) ─────────────────────
|
||||
|
||||
const webUiStore = readFileSync(join(ROOT, 'client/electron/src/stores/uiStore.ts'), 'utf8');
|
||||
const webCommTabs = readFileSync(join(ROOT, 'client/electron/src/components/CommTabs.tsx'), 'utf8');
|
||||
const webMailList = readFileSync(join(ROOT, 'client/electron/src/components/MailList.tsx'), 'utf8');
|
||||
const sendCode = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8')
|
||||
const webUiStore = code(join(ROOT, 'client/electron/src/stores/uiStore.ts'));
|
||||
const webCommTabs = code(join(ROOT, 'client/electron/src/components/CommTabs.tsx'));
|
||||
const webMailList = code(join(ROOT, 'client/electron/src/components/MailList.tsx'));
|
||||
const sendCode = code(join(HARMONY_ETS, 'pages/MainPage.ets'))
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
|
||||
test('通信页的页签键与顺序,与 WebUI 的 CommTab / TABS 完全一致', () => {
|
||||
@ -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<SentResponse>\('\/me\/mail\/sent'\)/, '发件箱接口');
|
||||
assert.match(api, /get<PendingResponse>\('\/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), '自检:默认页签变异没生效');
|
||||
});
|
||||
|
||||
@ -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\)/, '悬浮条必须有系统材质(背后是滚动内容)');
|
||||
// 理由要写在**原文**(注释会被剥掉,而理由就在注释里)
|
||||
|
||||
@ -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(`(?<![.\\w])${name}\\s*\\(`, 'g');
|
||||
|
||||
41
client/electron/test/lib/read.mjs
Normal file
41
client/electron/test/lib/read.mjs
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 判据目录里**唯一**允许读文本文件的两个入口 —— 名字自己解释该选哪个。
|
||||
*
|
||||
* # 为什么要有这个模块(pi 2026-09-14 §4:同一处坑我踩了两次)
|
||||
*
|
||||
* 规范里写着"判代码读剥离版、判理由读原文",我 P5 写过一次、当天又踩了一次
|
||||
* (`'rejected'` 那段**注释**里正好写着 `allowed-once`,被当成"这里会放行"误报)。
|
||||
* **第二次犯规说明问题不在记性,在形态**:靠人记得执行的规范一定会有下一次。
|
||||
* 所以把"用哪个读取器"从**记忆**变成**代码里的一个词**,并且可以被判据检查。
|
||||
*
|
||||
* - `code(path)`:**剥掉注释**。判"代码里有没有这个调用/这个值"时必须用它 ——
|
||||
* 否则解释性注释("这里写 'rejected' 而不是 'denied',因为只认 allowed-once")
|
||||
* 会被当代码读,产生假红/假绿。
|
||||
* - `prose(path)`:**原文**。判"理由写清了没/文档里有没有这句话"时用它。
|
||||
*
|
||||
* 选错的典型症状:断言里的标识符恰好在同文件的注释里出现过(这类误报几乎都集中在
|
||||
* "解释性注释与它解释的标识符同名"的地方)。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/** 剥掉注释与字符串字面量里的注释样式文本之外的东西:只用于"代码里有什么" */
|
||||
export function stripComments(src) {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // 块注释
|
||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // 行注释(避开 https:// 这类)
|
||||
}
|
||||
|
||||
/** 读文件并**剥掉注释** —— 判"代码里有什么"用这个 */
|
||||
export function code(path) {
|
||||
return stripComments(readFileSync(path, 'utf8'));
|
||||
}
|
||||
|
||||
/** 读文件**原文** —— 判"注释/文档里写了什么"用这个 */
|
||||
export function prose(path) {
|
||||
return readFileSync(path, 'utf8');
|
||||
}
|
||||
|
||||
/** 读**二进制**(安装包、图片等)—— 需要 Buffer 时用它,别在判据里裸用 readFileSync */
|
||||
export function bytes(path) {
|
||||
return readFileSync(path);
|
||||
}
|
||||
@ -2,10 +2,10 @@
|
||||
//
|
||||
// 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。
|
||||
// 这里守住几条真正会坏掉的不变量。
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { prose } from './lib/read.mjs';
|
||||
import { check, finish } from './lib/checks.mjs';
|
||||
|
||||
const read = p => readFileSync(new URL(p, import.meta.url), 'utf8');
|
||||
const read = p => prose(new URL(p, import.meta.url));
|
||||
|
||||
console.log('窄屏布局回归:');
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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`**
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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('有意永久残留');
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取两个档位里更严的那一个。
|
||||
*
|
||||
|
||||
@ -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<string, string> = {
|
||||
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) {
|
||||
|
||||
@ -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 逐字一致(蛇形),别自造驼峰');
|
||||
});
|
||||
|
||||
@ -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,而且它抛在**它要修的那条路径**上。`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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, '缺字段不能等于默认档 —— 那正是事故');
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取两个档位里更严的那一个。
|
||||
*
|
||||
|
||||
@ -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 逐字一致(蛇形),别自造驼峰');
|
||||
});
|
||||
|
||||
@ -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, '缺字段不能等于默认档 —— 那正是事故');
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取两个档位里更严的那一个。
|
||||
*
|
||||
|
||||
@ -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, '白名单不许无限膨胀(它一膨胀这条判据就废了)');
|
||||
});
|
||||
|
||||
@ -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)分支。
|
||||
*
|
||||
* 我上次查错的原因是**搜索面太窄**:只扫了 `<plugin>/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 逐字一致(蛇形)');
|
||||
});
|
||||
@ -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, '缺字段不能等于默认档 —— 那正是事故');
|
||||
});
|
||||
|
||||
@ -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)同样不会因重试而改变 —— 两者都必须当场拒绝,
|
||||
// 让模型从工具报错里看到原因并自己改道(挂死时连重试机会都没有)。
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取两个档位里更严的那一个。
|
||||
*
|
||||
|
||||
@ -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 逐字一致(蛇形),别自造驼峰');
|
||||
});
|
||||
|
||||
@ -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, '缺字段不能等于默认档 —— 那正是事故');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user