Files
nodeagent/tests/unit/cli-options.test.mjs
T
jkooe ae02252a1d fix(agent): 修复 18 条实机复现缺陷(2×P0 / 5×P1 / 10×P2 / 1×P3)
一轮「静态审查 + 真机实测」的完整普查。基线 v2.0.2 / ad90634 上 unit 291 + e2e 30
全绿 —— 也就是说 18 条**全部落在既有覆盖之外**。报告见 bug-report-2026-10-13.html。

P0 两条:
- W-01:window.focus 把 JSON.stringify(title) 插进 PowerShell **双引号**串(PS 里 `\` 不是
  转义符、双引号串还会展开 $(...))→ 命令注入。真机实测最险的是它**静默**:接口返回的是
  「正常业务错误」E_EXECUTION_FAILED: 未找到匹配的窗口,而 payload 已经把文件创建出来了。
  新增 apps/agent/src/util/quote.ts 作 psLit/shLit 单一真源,14 处注入点收敛(window.focus /
  uiaFind / ocrFind / system.ts 计划任务参数 / ps-helper.ts 的 -File 裸拼 …)。
- A-01:ACL 省略 clients 时 aclPolicy.clients.find 抛错,而这段落在 if (hasAcl) 块**之外**
  无条件执行 → 配置少写一个字段,每条 invoke 都崩掉被控端。
  ⚠️ 静态审查当时判它是「fail-open 放行」,实测是崩溃(更严重)。

崩溃链共同根因:ws.on('message') 用 void handleMessage() 且无 .catch,全仓又没有任何
process.on('unhandledRejection') —— Node ≥22 遇未处理 rejection 直接终止进程。两条畸形报文
(hello {protocol:123} / auth {hmac:1})就能在**认证之前**打死被控端(verifyNonce 有 try/catch,
psk 默认路径的 verifyHmac 没有)。index.ts 新增进程级兜底:日志每次打、审计按 10s 限流
(否则畸形报文可高频刷爆 audit.log),且明确不退出进程(可用性优先,取舍写在注释里)。

其余 15 条:
- clients: [] → fail-open 全放行(判据改为「对象是否存在」而非「数组长度」)
- macOS screen.find 的 text 拼 bash → $(...) 实测执行(走 shLit)
- 控制台审计筛选项与协议 enum 漂移,除 invoke 外全 E_PARAM_INVALID
  → 新增 packages/protocol/src/audit-types.ts 作唯一真源,manifest enum 由它派生 + 编译期双向守卫
- monitor.start 的 target 注入、monitor id 路径穿越(读/删)→ psLit + guardPath
- keys{} + client_id:"constructor" 崩 → hasOwnProperty(原型链取值)
- 事件订阅跨控制端可见/可删、event.watch 绕开 fs_roots → 有状态资源落 owner + 统一白名单
- screen.capture 的 region 无上界 → assertRegionSane() 且**提前到枚举显示器之前**(fail fast)
- ipMatchesCidr('8.8.8.8','10.0.0.0/') → true(空掩码被 Number('') === 0 当 /0)
- CLI --timeout 静默失效(strict:false 下未声明选项被当 boolean,值掉进 positionals)
- 拒绝类事件不写审计 → 补 request.rejected / agent.error

回归:unit 321/321(+21)、e2e 33/33(+3 组硬化用例)、rust 11/11。
e2e 新增用例的姿势:每场景**独占端口 + 独立临时 NODEAGENT_HOME**(首版复用端口,
连上了上一块的残留实例,把对照组误判成「实现有缺陷」)。
新增守卫均按铁律注入故障实测:确认会变红、还原后复核一致。
2026-10-13 17:16:36 +08:00

115 lines
5.3 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.
/**
* L-01 守卫:CLI 的 `--timeout` 曾经**静默失效**。
*
* ⚠️ 背景(真机实测 2026-10-13,P2):`--timeout` 没在 `parseArgs` 的 `options` 里声明,
* 而解析器开了 `strict: false` → 未声明选项被当成 **boolean**(`values.timeout = true`),
* 它的值 `30000` 掉进 **positionals**。双重后果:
* ① 超时静默不生效(命令按默认超时跑);
* ② **位置参数被污染** —— 可能把子命令 / 主机名解析错。
* 而帮助文本与 `await` 命令用的正是 `--timeout`,所以这是「文档让你用、实际不管用」。
*
* 本文件钉两件事:① 两个写法都生效且不污染 positionals;
* ② **帮助文本里出现的每个 `--flag` 都必须在 options 里声明**(这一类病的总闸)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as fsMod from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseOptions } from '../../apps/cli/dist/options.js';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
const OPTIONS_TS = join(repoRoot, 'apps', 'cli', 'src', 'options.ts');
const CLI_SRC = join(repoRoot, 'apps', 'cli', 'src');
test('L-01:`--timeout 30000` 生效,且不污染位置参数', () => {
const { opts, positionals } = parseOptions(['--timeout', '30000']);
assert.equal(opts.timeoutMs, '30000', '--timeout 的值必须落到 timeoutMs(原来是静默丢弃)');
assert.deepEqual(positionals, [], `--timeout 的值不得掉进 positionals:${JSON.stringify(positionals)}`);
});
test('L-01:与子命令混用时位置参数仍正确', () => {
const { opts, positionals } = parseOptions(['await', '--condition', 'window', '--timeout', '30000']);
assert.equal(opts.timeoutMs, '30000');
assert.deepEqual(positionals, ['await'], '只有子命令应留在 positionals');
assert.equal(opts.condition, 'window');
});
test('L-01:`--timeout-ms`(bg 命令的别名)仍然生效', () => {
const { opts, positionals } = parseOptions(['bg', 'echo hi', '--timeout-ms', '5000']);
assert.equal(opts.timeoutMs, '5000');
assert.deepEqual(positionals, ['bg', 'echo hi']);
});
test('L-01:两个写法同时给出时以 `--timeout` 为准(文档写法优先)', () => {
const { opts } = parseOptions(['--timeout-ms', '1111', '--timeout', '2222']);
assert.equal(opts.timeoutMs, '2222');
});
/** 从 options.ts 的 `options: {…}` 块里取出声明过的选项名。 */
function declaredOptionNames() {
// ⚠️ 必须**先去注释**:本仓库的注释里常引用被删掉的旧声明(例如
// `// timeout: { type: 'string' },` 这种「曾经漏了、现已补上」的说明)。
// 不去注释就会把「注释里的声明」当成真的声明 → 守卫假阴性。
// (注入故障实测:不去注释时,把声明注释掉守卫仍然全绿。)
const src = readFileSync(OPTIONS_TS, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
const start = src.indexOf('options: {');
assert.ok(start >= 0, 'options.ts 里找不到 options: {');
const end = src.indexOf('allowPositionals', start);
assert.ok(end > start, '找不到 options 块的结束位置');
const block = src.slice(start, end);
const names = new Set();
for (const m of block.matchAll(/(?:'([\w-]+)'|"([\w-]+)"|([A-Za-z_][\w-]*))\s*:\s*\{\s*type\s*:/g)) {
names.add(m[1] ?? m[2] ?? m[3]);
}
assert.ok(names.size > 30, `只解析出 ${names.size} 个选项,正则可能失配(需同步更新)`);
return names;
}
/**
* 取出 CLI 里**承诺过的**所有 `--flag`。
*
* ⚠️ 必须扫**全部** src,不能只扫 `help.ts`:L-01 的那条 `--timeout` 根本不在 `help.ts` 里,
* 它只出现在 `cmd/gui.ts` 的 `fail('用法: … --timeout <ms> …')` 提示里。
* 只扫 help.ts 的守卫**放过**了真缺陷(注入故障实测:把 timeout 声明注释掉,守卫仍全绿)。
* 凡是「会出现在用户眼前的用法提示」都算承诺,故扫全目录。
*/
function promisedFlags() {
const flags = new Set();
for (const file of walkTs(CLI_SRC)) {
// 去注释:注释里会讲「曾经漏了某某选项」,那不是承诺
const text = readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
for (const m of text.matchAll(/--([A-Za-z][\w-]*)/g)) flags.add(m[1]);
}
return flags;
}
function walkTs(dir) {
const { readdirSync, statSync } = fsMod;
const out = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) out.push(...walkTs(p));
else if (p.endsWith('.ts')) out.push(p);
}
return out;
}
test('L-01 总闸:用户可见的用法提示里每个 `--flag` 都必须在 parseArgs 里声明', () => {
const declared = declaredOptionNames();
const promised = promisedFlags();
assert.ok(promised.size > 30, `只从 CLI 源码里取到 ${promised.size} 个选项名,正则可能失配`);
const missing = [...promised].filter((f) => !declared.has(f)).sort();
assert.deepEqual(
missing,
[],
`用法提示承诺了但 parseArgs 未声明的选项(会静默失效 + 污染 positionals):\n ${missing.join('\n ')}`,
);
});