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:
@ -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), '自检:默认页签变异没生效');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user