Files
nodeagent/apps/cli/src/cmd/system.ts
T
jkooe aaf0175e94 feat(v17): 音频控制能力 system.audio.get / set(40 -> 41 项)
## 动机
「远程把那台机器静音」是接管场景里的高频小事,此前只能靠 exec 拼脚本,
容易踩本项目已记录的各种坑(C# 转义 / 编码 / 引号)。做成正式能力后一条命令即可。

## 实现
- **Windows**:Core Audio COM(IMMDeviceEnumerator → IAudioEndpointVolume)
  —— 可**读回真实状态**,所以是确定性的「设为静音」而不是「切换」
  (切换在未知状态下可能反而打开声音)。走项目既有的常驻助手基础设施(label=audio)。
- **macOS**:osascript 原生 `output muted` / `output volume`,零互操作。

## C# 互操作的两个硬约束(已写进文件头注释)
1. **槽位顺序即 vtable 顺序**:接口方法必须按 COM 定义顺序声明,不调用的也要占位。
   顺序错了不报编译错,而是调用到错误的内存地址 → 进程崩溃。
   (本次:m1..m4 → SetMasterVolumeLevelScalar(5) → m6 → GetMasterVolumeLevelScalar(7)
     → m8..m11 → SetMute(12) → GetMute(13))
2. **C# 源码必须纯 ASCII**:Add-Type 按系统 ANSI(GBK) 落盘,非 ASCII 会乱码。

## 出口
- CLI:`nodeagent audio [get]` / `nodeagent audio set [--mute on|off] [--volume 0-100]`
- MCP:na_audio_get / na_audio_set
- 参数校验:mute 必须布尔、volume 必须 0-100 整数(越界由 schema 在协议层拒绝)

## 验证
- 单测 124 → 125(新增音频 schema 用例:范围/类型/未知参数)
- e2e 26 → 27:新增音频用例,**macOS 路径已实测**(读回真实 state、写回原值走通写入路径不产生可感知变化)
- ⚠️ **Windows 侧(Core Audio COM)尚未真机验证** —— 被控端重装系统后当前离线,上线后补验
- 顺带修正 e2e 一处断言:空参数属**能力层**失败(返回 failed),越界属**协议层**(抛异常)——
  正好把本项目那条契约固化进用例
2026-10-02 19:53:46 +08:00

323 lines
14 KiB
TypeScript
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.
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
fail,
getClientConfig,
humanSize,
printJson,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/system.ts
export async function cmdList(opts: Options): Promise<void> {
const caps = await withClient(async (c) => {
return c.listCapabilities();
});
if (opts.json) return printJson(caps);
console.log(`被控端可用能力(${caps.length}):`);
for (const c of caps as CapabilityDescriptor[]) {
console.log(` ${riskIcon(c.risk)} ${c.name.padEnd(24)} ${c.description}`);
}
}
export async function cmdStatus(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.SystemStatus, {}, opts.json, (data) => {
const d = data as {
cpu_pct: number;
memory_used: number;
memory_total: number;
memory_pct: number;
disks: Array<{ drive: string; total: number; free: number; used_pct: number }>;
net: Array<{ adapter: string; ip: string }>;
};
console.log(`CPU 占用 : ${d.cpu_pct}%`);
console.log(`内存 : ${humanSize(d.memory_used)} / ${humanSize(d.memory_total)} (${d.memory_pct}%)`);
console.log('磁盘 :');
for (const disk of d.disks) {
console.log(` ${disk.drive.padEnd(12)} ${humanSize(disk.total - disk.free)} / ${humanSize(disk.total)} 已用 ${disk.used_pct}%`);
}
console.log('网络 :');
for (const n of d.net) console.log(` ${n.adapter.padEnd(16)} ${n.ip}`);
}),
);
}
export async function cmdInfo(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.SystemInfo, {}, opts.json, (data) => {
const d = data as Record<string, unknown>;
for (const [k, v] of Object.entries(d)) {
const val = k === 'memory_total' && typeof v === 'number' ? humanSize(v) : String(v);
console.log(`${k.padEnd(14)}: ${val}`);
}
}),
);
}
export async function cmdPs(opts: Options): Promise<void> {
const limit = Number(opts.limit ?? 20);
await withClient((c) =>
callAndPrint(c, CapabilityNames.ProcessList, { limit }, opts.json, (data) => {
const rows = (data as { processes: Array<{ pid: number; name: string; cpu_pct: number; memory_bytes: number }> }).processes;
console.log(`${'PID'.padStart(7)} ${'CPU%'.padStart(7)} ${'内存'.padStart(9)} 名称`);
for (const p of rows) {
console.log(`${String(p.pid).padStart(7)} ${String(p.cpu_pct).padStart(7)} ${humanSize(p.memory_bytes).padStart(9)} ${p.name}`);
}
}),
);
}
export async function cmdServices(opts: Options): Promise<void> {
const limit = Number(opts.limit ?? 20);
await withClient((c) =>
callAndPrint(c, CapabilityNames.ServiceList, { limit }, opts.json, (data) => {
const rows = (data as { services: Array<{ name: string; display_name: string; state: string }> }).services;
console.log(`${'状态'.padEnd(10)} ${'服务名'.padEnd(28)} 显示名`);
for (const s of rows) console.log(`${s.state.padEnd(10)} ${s.name.padEnd(28)} ${s.display_name}`);
}),
);
}
export async function cmdExec(command: string, opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.ShellExec, { command }, opts.json, (data) => {
const d = data as { exit_code: number; stdout: string; stderr: string; duration_ms: number; truncated: boolean };
// 命令自身的输出原样打到 stdout —— 保证 `nodeagent exec "..." | jq` 不被污染
if (d.stdout) console.log(d.stdout);
if (d.stderr) console.error(d.stderr);
// 诊断信息(退出码/耗时)走 stderr:终端下照常可见,管道里不干扰数据
console.error(
`[退出码 ${d.exit_code} · ${d.duration_ms}ms${d.truncated ? ' · 输出已截断' : ''}]`,
);
}),
);
}
export async function cmdRestart(opts: Options): Promise<void> {
const delayMs = opts.delay ? Number(opts.delay) : 2000;
await withClient((c) =>
callAndPrint(c, CapabilityNames.AgentRestart, { delay_ms: delayMs, reason: 'cli' }, opts.json, (data) => {
const d = data as { scheduled: boolean; delay_ms: number; mechanism: string; message: string };
console.log(`✓ ${d.message}`);
console.log(` 机制: ${d.mechanism}`);
console.log(' 提示: 约 3~4 秒后重连,可执行 `nodeagent info` 验证是否已恢复');
}),
);
}
export async function cmdAuditVerify(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditVerify, {}, opts.json, (data) => {
const d = data as {
ok: boolean;
checked: number;
legacy: number;
broken_at?: { file: string; line: number; reason: string };
};
if (d.ok) {
console.log(`✓ 审计链完整(校验 ${d.checked} 条${d.legacy ? `,跳过历史条目 ${d.legacy} 条` : ''})`);
return;
}
console.error(`✗ 审计链已损坏:${d.broken_at?.reason ?? '未知原因'}`);
if (d.broken_at) console.error(` 位置: ${d.broken_at.file}:${d.broken_at.line}`);
console.error(` 已校验 ${d.checked} 条`);
process.exitCode = 1;
}),
);
}
export async function cmdAudit(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.limit) args['limit'] = Number(opts.limit);
if (opts.since) args['since'] = Number(opts.since);
if (opts.type) args['type'] = opts.type;
if (opts.clientId) args['client_id'] = opts.clientId;
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditList, args, opts.json, (data) => {
const d = data as { entries: Array<Record<string, unknown>>; total: number; file: string };
console.log(`审计文件: ${d.file}`);
console.log(`读取 ${d.total} 条,展示最新 ${d.entries.length} 条:\n`);
for (const e of d.entries) {
const ts = new Date(Number(e['ts'])).toLocaleString('zh-CN');
const cols = [
ts.padEnd(20),
String(e['type'] ?? '').padEnd(17),
String(e['client_id'] ?? '-').padEnd(10),
String(e['capability'] ?? '-').padEnd(20),
String(e['status'] ?? '-').padEnd(7),
e['duration_ms'] !== undefined ? `${e['duration_ms']}ms` : '',
e['error'] ? `err=${e['error']}` : '',
e['reason'] ? String(e['reason']) : '',
];
console.log(' ' + cols.filter((x) => x !== '').join(' '));
}
}),
);
}
// ---------- v4 局域网发现 ----------
export async function cmdMetrics(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.since) args['since'] = Number(opts.since);
await withClient((c) =>
callAndPrint(c, CapabilityNames.Metrics, args, opts.json, (data) => {
const m = data as {
close_loop: { attempts: number; ok: number; failed: number; success_rate: number };
app_install: { attempts: number; ok: number; failed: number; success_rate: number | null };
latency: {
p50_ms: number;
p95_ms: number;
max_ms: number;
timeouts: number;
fast: { samples: number; p50_ms: number; p95_ms: number; max_ms: number };
slow: { samples: number; p50_ms: number; p95_ms: number; max_ms: number; capabilities: string[] };
};
security: { acl_denied: number; rate_limited: number; auth_failures: number; interception_rate: number };
verdict: {
close_loop_ok: boolean;
app_install_ok: boolean;
p95_ok: boolean;
interception_ok: boolean;
all_pass: boolean;
};
targets: { close_loop_success_rate: number; app_install_success_rate: number; p95_ms: number };
by_capability: Record<string, { attempts: number; ok: number; success_rate: number; p95_ms: number }>;
window: { from: number; to: number; entries: number };
};
const pct = (v: number): string => `${(v * 100).toFixed(1)}%`;
const mark = (ok: boolean): string => (ok ? '✅' : '❌');
const from = m.window.from ? new Date(m.window.from).toLocaleString('zh-CN') : '-';
const to = m.window.to ? new Date(m.window.to).toLocaleString('zh-CN') : '-';
console.log(`窗口: ${from} → ${to} (审计 ${m.window.entries} 条)\n`);
console.log(`闭环成功率 ${pct(m.close_loop.success_rate).padStart(7)} ${mark(m.verdict.close_loop_ok)} 目标 ≥${pct(m.targets.close_loop_success_rate)} 样本 ${m.close_loop.ok}/${m.close_loop.attempts}`);
const ir = m.app_install.success_rate;
console.log(`装软件成功率 ${ir === null ? ' 无样本' : pct(ir).padStart(7)} ${mark(m.verdict.app_install_ok)} 目标 ≥${pct(m.targets.app_install_success_rate)} 样本 ${m.app_install.ok}/${m.app_install.attempts}`);
console.log(`P95 时延 ${String(m.latency.fast.p95_ms + 'ms').padStart(7)} ${mark(m.verdict.p95_ok)} 交互层(目标 <${m.targets.p95_ms}ms,样本 ${m.latency.fast.samples}:P50 ${m.latency.fast.p50_ms}ms / 最大 ${m.latency.fast.max_ms}ms${m.latency.timeouts ? ` / 超时 ${m.latency.timeouts}` : ''})`);
console.log(`慢操作耗时 ${String(m.latency.slow.p95_ms + 'ms').padStart(7)} —— 样本 ${m.latency.slow.samples}(${m.latency.slow.capabilities.join(' / ')};本身耗时由业务决定,不计入达标判定)`);
console.log(`安全拦截 ${pct(m.security.interception_rate).padStart(7)} ${mark(m.verdict.interception_ok)} ACL 拒绝 ${m.security.acl_denied} / 超频 ${m.security.rate_limited} / 认证失败 ${m.security.auth_failures}`);
console.log(`\n总体: ${m.verdict.all_pass ? '✅ 四项全达标' : '⚠️ 存在未达标项'}`);
const caps = Object.entries(m.by_capability).sort((a, b) => b[1].attempts - a[1].attempts);
if (caps.length > 0) {
console.log('\n按能力细分(Top 10):');
console.log(` ${'能力'.padEnd(26)} ${'次数'.padStart(6)} ${'成功率'.padStart(8)} ${'P95'.padStart(9)}`);
for (const [name, st] of caps.slice(0, 10)) {
console.log(` ${name.padEnd(26)} ${String(st.attempts).padStart(6)} ${pct(st.success_rate).padStart(8)} ${String(st.p95_ms + 'ms').padStart(9)}`);
}
}
}),
);
}
/** v16:网络变更两阶段提交(备份 → 应用 → 定时回滚 → 确认提交)。 */
export async function cmdInvoke(capability: string, opts: Options): Promise<void> {
let args: Record<string, unknown> = {};
if (opts.args) {
try {
args = JSON.parse(opts.args) as Record<string, unknown>;
} catch {
fail('--args 必须是合法 JSON');
}
}
await withClient(async (c) => {
const result: InvokeResult = await c.invoke(capability, args);
printJson(result);
if (result.status === 'failed') process.exitCode = 2;
});
}
// ---------- v2 图形操作 ----------
export async function cmdApps(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AppList, {}, opts.json, (data) => {
const rows = (data as { apps: Array<{ name: string; version: string; publisher: string }> }).apps;
console.log(`已安装软件(${rows.length}):`);
for (const a of rows) console.log(` ${a.name.padEnd(40)} ${(a.version || '-').padEnd(14)} ${a.publisher}`);
}),
);
}
export async function cmdInstall(pkg: string, opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AppInstall, { package: pkg, id: opts.id }, opts.json, (data) => {
const d = data as { installed: boolean; name: string; version: string; detail: string };
console.log(d.installed ? `✓ 安装成功: ${d.name} ${d.version}` : `✗ 安装失败: ${d.name}`);
if (d.detail) console.log(`\n${d.detail}`);
}),
);
}
/** v17:音频控制(静音 / 主音量)。 */
export async function cmdAudio(sub: string | undefined, opts: Options): Promise<void> {
if (sub === 'set') {
const args: Record<string, unknown> = {};
if (opts.mute !== undefined) {
const v = String(opts.mute).toLowerCase();
if (!['on', 'off', 'true', 'false', '1', '0'].includes(v)) {
fail('--mute 只接受 on|off');
}
args['mute'] = ['on', 'true', '1'].includes(v);
}
if (opts.volume !== undefined) args['volume'] = Number(opts.volume);
if (Object.keys(args).length === 0) fail('用法: nodeagent audio set [--mute on|off] [--volume 0-100]');
await withClient((c) =>
callAndPrint(c, CapabilityNames.AudioSet, args, opts.json, (data) => {
const d = data as { muted: boolean; volume: number; backend: string };
console.log(`✓ 已应用:${d.muted ? '🔇 静音' : '🔊 未静音'} · 音量 ${d.volume}%(${d.backend})`);
}),
);
return;
}
if (sub === undefined || sub === 'get') {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AudioGet, {}, opts.json, (data) => {
const d = data as { muted: boolean; volume: number; backend: string };
console.log(`${d.muted ? '🔇 静音' : '🔊 未静音'} · 主音量 ${d.volume}%(${d.backend})`);
}),
);
return;
}
fail('用法: nodeagent audio [get] | audio set [--mute on|off] [--volume 0-100]');
}