apps/console —— 跨端(macOS/Windows)桌面控制台,复用 ops 侧的 Node agent 作本地被控端,
不做第二套 agent。技术栈:Tauri 2(Rust 壳 + 系统 WebView,包体小)+
Vue 3 + TS + Vite + Pinia + Naive UI(暗色)+ Vue Router(hash)。
- command 层(src-tauri/src/lib.rs):connect / disconnect / invoke_capability /
get_capabilities / get_state;client 事件经 Emitter 桥接为 na-log / na-state / na-event 推前端。
握手后**先 await 成功再落 state**(不持锁 await)。
- 7 页面:设备管理 / 状态总览 / 终端 / 进程与服务 / 软件 / 文件 / 审计。
- 实测:macOS .app 6.75 MiB(方案指标 <30MB;Electron 同级约 100MB+),
直接执行二进制进程存活、无崩溃 —— `codesign --verify --deep --strict` 那条
"code has no resources but signature indicates they must be present" 是 adhoc
链接签名的固有噪声,实测无害(勿被误导去「修签名」)。
⚠️ 本轮用 examples/probe.rs 对真实被控端核对字段契约,抓出 9 处**静默空列**
(不报错、类型系统也抓不到,界面只显示 "-"),全部按契约修掉:
- 进程表读 r.cpu / r.memory → 真源 cpu_pct / memory_bytes(CPU、内存两列恒为 "-")
- 服务表读 r.status → 真源 state
- 磁盘读 d.mount||d.name / d.used → 真源只有 drive + total/free,
**没有 used**(须自算 total-free)
- 网卡读 {name,rx_bytes,tx_bytes} → 真源 {adapter,ip,up}
- 软件表读 a.id → app.list 结果**没有 id**,应为 publisher
- 审计 broken_at 当数字渲染 → 真源是 {file,line,reason} 对象
另清掉幻影字段 SystemInfo.node(契约里只有 node_path)。
配套两点设计:
- **平台门控**:连接后探测 system.info.os → isWindows/platformReady;app.* 与
system.service.list 仅 Windows 被控端可用,故软件页与服务 Tab **事前禁用并说明原因**,
而不是发出注定失败的请求再解释错误。
- **结构化错误**:协议真源 InvokeResult.error 只有 {name,message,data?}(**没有 code**),
故分支一律按 name;前端 CapabilityFailure 暴露 errorName,并提供
isUnsupportedPlatform / isAclDenied / isRateLimited。未改协议(改动会扩散 4 端)。
tests/unit/console-contract.test.mjs(20 例):把「前端声明 ⊆ 契约」与「契约 ⊆ 前端声明」
双向钉死,另校验平台门控清单与 agent 侧 requireWindows 集合一致、AuditType 覆盖
apps/agent/src/audit.ts 全部取值。**已注入漂移实测会红**(把 cpu_pct 改回 cpu →
正/反向各挂 1 项),非空转守卫。
227 lines
9.7 KiB
JavaScript
227 lines
9.7 KiB
JavaScript
/**
|
||
* 控制台契约守卫:`apps/console/src/types/index.ts` 里声明的字段名,
|
||
* **必须**能在协议 manifest 的 `returns_schema` 里找到。
|
||
*
|
||
* ⚠️ 背景(2026-10-07 实测才发现):M3 七个页面写完后,用 `examples/probe.rs`
|
||
* 对真实被控端跑了一遍字段契约探针,抓到 4 处**界面恒空**的漂移:
|
||
* - 进程表读 `r.cpu` / `r.memory`,真源是 `cpu_pct` / `memory_bytes` → CPU、内存两列恒为 "-"
|
||
* - 磁盘读 `d.mount || d.name` / `d.used`,真源是 `drive`,且**没有 used**(只有 total/free)→ 盘名与已用量恒空
|
||
* - 服务表读 `r.status`,真源是 `state` → 状态列恒空
|
||
* - 软件表读 `a.id`,真源是 `publisher`(`app.list` 结果**没有 id**)→ 整列恒空
|
||
* 这些都不是运行时异常,是**静默空列**——类型系统抓不到(TS 只是声明,没人跟契约比对),
|
||
* 页面上也只是显示 "-"。所以必须由本用例把「声明 ⊆ 契约」这条铁律钉死。
|
||
*
|
||
* 反向也查(契约 ⊆ 声明):只对 `returns_schema` **完整枚举**了属性的能力生效,
|
||
* 避免"契约加了字段、前端没跟上"这种漏检。
|
||
*
|
||
* 数据源一律取 `packages/protocol/dist`(已构建),与 capabilities.test.mjs 保持一致。
|
||
*/
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
import { findCapability } from '../../packages/protocol/dist/index.js';
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url));
|
||
const repoRoot = join(here, '..', '..');
|
||
const CONSOLE_TYPES = join(repoRoot, 'apps', 'console', 'src', 'types', 'index.ts');
|
||
const CONN_STORE = join(repoRoot, 'apps', 'console', 'src', 'stores', 'conn.ts');
|
||
|
||
const source = readFileSync(CONSOLE_TYPES, 'utf8');
|
||
|
||
/**
|
||
* 极简 TS interface 解析:只取**顶层**字段名(大括号深度 = 1),
|
||
* 忽略注释、字符串与嵌套对象字面量。够用于本项目这些扁平接口。
|
||
*/
|
||
function fieldsOf(interfaceName) {
|
||
const start = source.indexOf(`export interface ${interfaceName} {`);
|
||
if (start < 0) throw new Error(`未找到 interface ${interfaceName}`);
|
||
const bodyStart = source.indexOf('{', start);
|
||
let depth = 0;
|
||
let body = '';
|
||
for (let i = bodyStart; i < source.length; i += 1) {
|
||
const ch = source[i];
|
||
if (ch === '{') {
|
||
depth += 1;
|
||
if (depth === 1) continue;
|
||
}
|
||
if (ch === '}') {
|
||
depth -= 1;
|
||
if (depth === 0) break;
|
||
}
|
||
body += ch;
|
||
}
|
||
|
||
const names = new Set();
|
||
let nest = 0;
|
||
for (const raw of body.split('\n')) {
|
||
const line = raw.replace(/\/\/.*$/, '').trim();
|
||
if (!line) continue;
|
||
const opens = (line.match(/\{/g) ?? []).length;
|
||
const closes = (line.match(/\}/g) ?? []).length;
|
||
// 只在最外层(nest === 0)收集 `key?: type` / `key: type`
|
||
if (nest === 0) {
|
||
const m = /^([A-Za-z_$][\w$]*)\s*\??\s*:/.exec(line);
|
||
if (m) names.add(m[1]);
|
||
// 索引签名 `[k: string]: unknown` 跳过
|
||
}
|
||
nest += opens - closes;
|
||
}
|
||
return names;
|
||
}
|
||
|
||
/** 取某能力 `returns_schema.properties` 的键集合。 */
|
||
function contractTop(capName) {
|
||
const cap = findCapability(capName);
|
||
assert.ok(cap, `能力 ${capName} 不存在于 manifest`);
|
||
return new Set(Object.keys(cap.returns_schema?.properties ?? {}));
|
||
}
|
||
|
||
/** 取某能力某个数组字段 `items.properties` 的键集合。 */
|
||
function contractItems(capName, arrayField) {
|
||
const cap = findCapability(capName);
|
||
const arr = cap?.returns_schema?.properties?.[arrayField];
|
||
assert.ok(arr, `${capName}.returns_schema.${arrayField} 不存在`);
|
||
return new Set(Object.keys(arr.items?.properties ?? {}));
|
||
}
|
||
|
||
/** 取某能力某对象字段(如 system.status.disks)的 items/自属性键集合。 */
|
||
function contractNested(capName, objField) {
|
||
const cap = findCapability(capName);
|
||
const node = cap?.returns_schema?.properties?.[objField];
|
||
assert.ok(node, `${capName}.returns_schema.${objField} 不存在`);
|
||
return new Set(Object.keys(node.items?.properties ?? node.properties ?? {}));
|
||
}
|
||
|
||
// ---------- 声明 ⊆ 契约(抓「声明了不存在的字段」) ----------
|
||
|
||
/** [ 控制台 interface, 能力名, 契约字段来源 ] */
|
||
const FORWARD_CASES = [
|
||
['SystemInfo', 'system.info', () => contractTop('system.info')],
|
||
['SystemStatus', 'system.status', () => contractTop('system.status')],
|
||
['DiskInfo', 'system.status', () => contractNested('system.status', 'disks')],
|
||
['NetInfo', 'system.status', () => contractNested('system.status', 'net')],
|
||
['ProcessEntry', 'system.process.list', () => contractItems('system.process.list', 'processes')],
|
||
['ServiceEntry', 'system.service.list', () => contractItems('system.service.list', 'services')],
|
||
['FsEntry', 'fs.list', () => contractItems('fs.list', 'entries')],
|
||
['FsListResult', 'fs.list', () => contractTop('fs.list')],
|
||
['FsReadResult', 'fs.read', () => contractTop('fs.read')],
|
||
['AppEntry', 'app.list', () => contractItems('app.list', 'apps')],
|
||
['AuditVerifyResult', 'system.audit.verify', () => contractTop('system.audit.verify')],
|
||
];
|
||
|
||
for (const [iface, capName, contract] of FORWARD_CASES) {
|
||
test(`契约:${iface} 的字段都存在于 ${capName} 的 returns_schema`, () => {
|
||
const declared = fieldsOf(iface);
|
||
const allowed = contract();
|
||
const missing = [...declared].filter((f) => !allowed.has(f));
|
||
assert.deepEqual(
|
||
missing,
|
||
[],
|
||
`${iface} 声明了 ${capName} 契约中不存在的字段:${missing.join(', ')}\n` +
|
||
`(契约字段:${[...allowed].join(', ')})\n` +
|
||
`👉 这类漂移不会报错,只会让界面出现**静默空列**。`,
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------- 契约 ⊆ 声明(抓「契约加了字段、前端没跟上」) ----------
|
||
|
||
/**
|
||
* 只对 returns_schema **完整枚举**属性的能力做反向校验,
|
||
* 否则 `type: 'object'` 的空壳(如 system.audit.list 的 entries item)会误报。
|
||
*/
|
||
const REVERSE_CASES = [
|
||
['SystemInfo', 'system.info', () => contractTop('system.info')],
|
||
['SystemStatus', 'system.status', () => contractTop('system.status')],
|
||
['ProcessEntry', 'system.process.list', () => contractItems('system.process.list', 'processes')],
|
||
['ServiceEntry', 'system.service.list', () => contractItems('system.service.list', 'services')],
|
||
['FsEntry', 'fs.list', () => contractItems('fs.list', 'entries')],
|
||
['FsReadResult', 'fs.read', () => contractTop('fs.read')],
|
||
['AppEntry', 'app.list', () => contractItems('app.list', 'apps')],
|
||
];
|
||
|
||
for (const [iface, capName, contract] of REVERSE_CASES) {
|
||
test(`契约:${capName} 的字段都被 ${iface} 覆盖`, () => {
|
||
const declared = fieldsOf(iface);
|
||
const required = contract();
|
||
const missed = [...required].filter((f) => !declared.has(f));
|
||
assert.deepEqual(
|
||
missed,
|
||
[],
|
||
`${capName} 契约里有 ${missed.join(', ')},但 ${iface} 未声明(前端拿不到这些字段)。`,
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------- 平台门控清单与 agent 实现一致 ----------
|
||
|
||
test('平台门控:控制台的 WINDOWS_ONLY_CAPS 与被控端抛 UNSUPPORTED_PLATFORM 的能力一致', () => {
|
||
const storeSrc = readFileSync(CONN_STORE, 'utf8');
|
||
const m = /WINDOWS_ONLY_CAPS\s*=\s*\[([^\]]*)\]/.exec(storeSrc);
|
||
assert.ok(m, '未在 conn.ts 找到 WINDOWS_ONLY_CAPS');
|
||
const declared = new Set(
|
||
m[1]
|
||
.split(',')
|
||
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
||
.filter(Boolean),
|
||
);
|
||
|
||
// 从被控端实现里找平台门控点:`UNSUPPORTED_PLATFORM` 出现处附近的能力名。
|
||
const gated = new Set();
|
||
for (const rel of [
|
||
['apps', 'agent', 'src', 'capabilities', 'app.ts'],
|
||
['apps', 'agent', 'src', 'capabilities', 'system.ts'],
|
||
]) {
|
||
const src = readFileSync(join(repoRoot, ...rel), 'utf8');
|
||
// requireWindows('app.xxx') 与 `${capability} 仅在被控端为 Windows 时可用` 两种写法
|
||
for (const mm of src.matchAll(/requireWindows\(\s*['"]([\w.]+)['"]/g)) gated.add(mm[1]);
|
||
for (const mm of src.matchAll(/['"]([\w.]+) 仅在被控端为 Windows 时可用['"]/g)) {
|
||
// 该文件里 system.service.list 是以字面量写的;app.ts 是模板串(已由上面捕获)
|
||
if (mm[1].includes('.')) gated.add(mm[1]);
|
||
}
|
||
}
|
||
|
||
const declaredArr = [...declared].sort();
|
||
const gatedArr = [...gated].sort();
|
||
assert.deepEqual(
|
||
declaredArr,
|
||
gatedArr,
|
||
'控制台的平台门控清单与被控端实际门控的能力不一致:\n' +
|
||
` 控制台声明:${declaredArr.join(', ')}\n` +
|
||
` 被控端实际:${gatedArr.join(', ')}`,
|
||
);
|
||
});
|
||
|
||
// ---------- 审计事件类别与真源一致 ----------
|
||
|
||
test('审计:控制台的 AuditType 覆盖 audit.ts 的全部 AuditType 取值', () => {
|
||
const auditSrc = readFileSync(
|
||
join(repoRoot, 'apps', 'agent', 'src', 'audit.ts'),
|
||
'utf8',
|
||
);
|
||
const union = /export type AuditType\s*=([\s\S]*?);/.exec(auditSrc);
|
||
assert.ok(union, '未在 audit.ts 找到 AuditType 联合类型');
|
||
const real = new Set(
|
||
[...union[1].matchAll(/'([\w.]+)'/g)].map((mm) => mm[1]),
|
||
);
|
||
assert.ok(real.size >= 8, `AuditType 解析异常,仅取到 ${real.size} 个`);
|
||
|
||
const declared = fieldsOf('AuditEntry');
|
||
assert.ok(declared.size > 0, 'AuditEntry 解析失败');
|
||
// union 类型不是 interface —— 直接正则取类型字面量
|
||
const m = /export type AuditType\s*=([\s\S]*?);/.exec(
|
||
readFileSync(CONSOLE_TYPES, 'utf8'),
|
||
);
|
||
assert.ok(m, '未在控制台 types/index.ts 找到 AuditType');
|
||
const consoleTypes = new Set([...m[1].matchAll(/"([\w.]+)"/g)].map((mm) => mm[1]));
|
||
|
||
const missed = [...real].filter((t) => !consoleTypes.has(t));
|
||
assert.deepEqual(
|
||
missed,
|
||
[],
|
||
`控制台 AuditType 缺少:${missed.join(', ')}(真源:${[...real].join(', ')})`,
|
||
);
|
||
});
|