Files
nodeagent/tests/unit/console-contract.test.mjs
T
jkooe 5a2c5f9c55 feat: 三端暴露 L2 系统级审计(MCP na_audit_l2 + 控制台 L2 分区 + agent.l2 类型)
决策「L2 同时暴露 MCP+控制台」。此前 system.audit.l2 只存在于
被控端实现里:MCP 无工具(模型看不到)、控制台无入口无类型
(用户点不到)、审计事件写成 agent.start(与进程生命周期混类筛不出)。

MCP(41 → 42 个工具):
- defs.ts 在 na_audit 后新增 na_audit_l2(action: status/enable/
  collect/verify + since/limit,additionalProperties: false)
- index.ts TOOL_TO_CAPABILITY 映射 CapabilityNames.AuditL2,
  分发走 default 直通(参数名与被控端一致,无需改写)
- mcp-tools 守卫同步:41→42、BASELINE_SEQ_SHA 重算、
  新增「na_audit_l2 必须紧随 na_audit」顺序断言

控制台:
- conn.ts WINDOWS_ONLY_CAPS += system.audit.l2(非 Windows 被控端
  上按钮禁用并明示原因,不搞「点了才报错」)
- types/index.ts 新增 AuditL2Result(四 action 属性并集,对齐
  manifest returns_schema)+ AuditType += agent.l2;
  AuditEntry 同步补 args_bytes/args_truncated(A-P0-4 新字段)
- AuditView.vue 新增 L2 卡片:查状态/拉取采集/校验 L2 链/启用策略
  (NPopconfirm 二次确认 + 管理员权限提示);信任边界如实说明
  (采集器与 agent 同权限,防篡改依赖转发出机,不夸大本地副本)

agent.l2 审计类型四端同源:
- protocol audit-types.ts AUDIT_TYPES(真源)+= agent.l2
- agent audit.ts AuditType 联合同步(AssertExact 编译期守卫兜底)
- audit-l2.ts 两处写入点 agent.start → agent.l2(L2 策略操作与
  进程生命周期无关,混类会污染两边统计与筛选)

守卫加强(console-contract 平台门控):
- audit-l2.ts 平台门控由裸 throw 改用统一 assertWindows
  (错误信息格式统一 + 可被守卫扫到)
- 守卫扫描范围 +audit-l2.ts、模式 +assertWindows —— 覆盖面扩大;
  否则把 system.audit.l2 加进 WINDOWS_ONLY_CAPS 反而报红
- mcp-tools 正则盲区修复:原 na_[a-z_]+ 匹配不了带数字的工具名,
  na_audit_l2 会整个漏扫(新增了工具两个守卫却无觉)→ 放至 [a-z0-9_]+

新增 tests/unit/console-l2.test.mjs 5 例:前端字段 ⊆ returns_schema /
契约关键字段 ⊇ 前端渲染 / MCP 工具存在且映射 / agent.l2 四端同源 /
界面纪律(信任边界文案 + enable 二次确认 + 平台门控)
故障注入实测:审计类型退回 agent.start、WINDOWS_ONLY_CAPS 删项 → 3 处红;恢复 → 绿

单测 451/451、e2e 33/33、typecheck 全绿
2026-10-26 00:28:44 +08:00

236 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 控制台契约守卫:`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),
);
// 从被控端实现里找平台门控点。
// ⚠️ 2026-10-25(v2.1.0):扫描范围原只有 app.ts + system.ts 两种写法
// (requireWindows(...) 与「仅在被控端为 Windows 时可用」字样)。
// audit-l2.ts 的门控是裸 throw UNSUPPORTED_PLATFORM,两种模式都扫不到 →
// 把 system.audit.l2 加进 WINDOWS_ONLY_CAPS 反而会让本守卫报红(少扫一个文件
// 就「清单不一致」)。现 audit-l2.ts 已改用统一 assertWindows,且本守卫同步
// 纳入该文件与 assertWindows 模式 —— 覆盖面扩大,不是放行。
const gated = new Set();
for (const rel of [
['apps', 'agent', 'src', 'capabilities', 'app.ts'],
['apps', 'agent', 'src', 'capabilities', 'system.ts'],
['apps', 'agent', 'src', 'capabilities', 'audit-l2.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]);
// util/platform.ts 的统一门控(assertWindows → assertPlatform(cap, 'win32'))
for (const mm of src.matchAll(/assertWindows\(\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(', ')})`,
);
});