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:
2026-09-14 16:21:27 +08:00
parent ec5ee5eb9b
commit d5cfcbdc9c
34 changed files with 942 additions and 248 deletions

View File

@ -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), '自检:默认页签变异没生效');
});