Files
nodeagent/apps/agent/src/capabilities/system.ts
T
jkooe e23788d717 refactor(versioning): 统一版本号 —— semver 为唯一版本号,开发批次代号并入其归属版本
## 问题(皇上指出)
项目里两套编号并行,无法判断哪个是"当前版本":
- **发布版本**(semver):v1.1.0 ~ v1.6.0、v2.0.0
- **开发批次代号**:v1 ~ v26(README 路线表 / 代码注释 / 文档)
时间线证据:v1.1.0 首发(09-29)时批次已推进到 v12.4 —— **二者从来不是一对一**(26 批次只发过 10 版)。

## 规则(新增 docs/VERSIONING.md §0 版本号唯一性规范)
- **唯一版本号 = semver `vX.Y.Z`**,两个来源必须永远一致:git tag 与 package.json(构建期注入 agent)
- **批次代号不是版本号**,不再单独出现;需要区分同版内多批改动时用**功能名**
- 附「批次 → 归属发布版本」映射表(按 tag 时间线与功能内容推定,并注明以 CHANGELOG 为准)

## 统一动作
1. **批次 v23 ~ v26 → v2.0.0**(22 文件 / 53 处)—— 它们确实属于 v2.0.0
2. **臣的批次号 v1.7 / v1.8 → v2.0.0**(27 文件)—— 未单独发版
3. **简写补全三段式**:v1.5 → v1.5.0、v1.6 → v1.6.0
4. **README 路线表**:`**v1**` → `**批次 1**`;表头加「批次 ≠ 版本号」说明并链到 §0;
   补齐批次 16~29 与 **v2.0.0 发布行**;删掉过时的「待办」行(提到的分支早已删除)
5. `docs/VERSIONING.md` 里**故意保留** 3 处批次号 —— 那是规范说明本身

## 保留项(真实发布版本,不动)
v1.1.0 / v1.2.1 / v1.3.0 / v1.4.0~v1.4.3 / v1.5.0 / v1.6.0 / v2.0.0(10 个 tag)

## 验证
- 版本号一致性:package.json = tag = 8 个 workspace = tauri.conf.json = **2.0.0** ✓
- v1.7/v1.8/v23~v26 全仓残留 **3 处**(均在 VERSIONING.md 规范说明中,属预期)
- 7 包构建 · 单测 273 · e2e 30 · ps1-encoding 24 全绿
2026-10-10 14:27:12 +08:00

649 lines
25 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 { createHash } from 'node:crypto';
import { guardPath } from './fs.js';
import { readFileSync, statSync } from 'node:fs';
import { spawn } from 'node:child_process';
import os from 'node:os';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { currentCertFingerprint } from '../certs.js';
import { loadAgentConfig } from '../config.js';
import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, compareWithAnchors } from '../audit.js';
import { computeMetrics } from '../metrics.js';
import { allPsShellStats } from '../util/ps-helper.js';
import { startTask } from './task.js';
type Args = Record<string, unknown>;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
/** 归一化 PowerShell 的 ConvertTo-Json 输出(单对象 → 数组)。 */
function toArray<T>(value: T | T[] | null | undefined): T[] {
if (value === null || value === undefined) return [];
return Array.isArray(value) ? value : [value];
}
function platformName(): string {
switch (process.platform) {
case 'win32':
return 'Windows';
case 'darwin':
return 'macOS';
case 'linux':
return 'Linux';
default:
return process.platform;
}
}
async function isAdmin(): Promise<boolean> {
if (IS_WINDOWS) {
try {
const r = await execCommand({ command: 'whoami /groups', timeoutMs: 5000 });
return /S-1-16-12288/.test(r.stdout);
} catch {
return false;
}
}
return typeof process.getuid === 'function' && process.getuid() === 0;
}
// ---------------- system.info ----------------
// ---------- 构建指纹(v18 / v20)----------
// 构建时由 esbuild --define 注入(见 scripts/pack.mjs);开发态(直接跑 dist)下未定义,
// 故一律用 typeof 探测,绝不能直接引用(否则 dev 启动就 ReferenceError)。
declare const __AGENT_VERSION__: string | undefined;
declare const __BUILD_COMMIT__: string | undefined;
/** v21:读取当前生效的来源白名单(用于 system.info 上报)。 */
function configAllowFrom(): string[] | null {
try {
const cfg = loadAgentConfig().config;
return cfg.allow_from && cfg.allow_from.length > 0 ? cfg.allow_from : null;
} catch {
return null;
}
}
function injected(name: 'version' | 'commit'): string {
if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__;
return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
}
const STARTED_AT = Date.now();
let buildCache: { hash: string; bytes: number; mtime_ms: number } | null = null;
/**
* 计算「正在运行的这一份 agent 脚本」的指纹(sha256 前 12 位)。
*
* 用途:部署后**校验远端真的换成了新代码**。仅靠「PID 变了 / 能力数对了」证明不了这点
* (旧版可能恰好也是同样的能力数);内容哈希是唯一可靠的判据。
* 懒计算 + 缓存:文件约 1MB,只在首次 system.info 时读一次。
*/
export function buildInfo(): {
hash: string;
bytes: number;
mtime_ms: number;
node: string;
started_at: number;
uptime_ms: number;
version: string;
commit: string;
built_at: string;
cert_sha256: string | null;
} {
if (!buildCache) {
let hash = 'unknown';
let bytes = 0;
let mtime = 0;
try {
const scriptPath = process.argv[1] ?? '';
const buf = readFileSync(scriptPath);
bytes = buf.length;
mtime = statSync(scriptPath).mtimeMs;
hash = createHash('sha256').update(buf).digest('hex').slice(0, 12);
} catch {
/* 读不到脚本(如被内联启动)时保持 unknown,不影响其他功能 */
}
buildCache = { hash, bytes, mtime_ms: Math.round(mtime) };
}
return {
...buildCache,
node: process.version,
started_at: STARTED_AT,
uptime_ms: Date.now() - STARTED_AT,
// v20:语义化版本 + 提交 + 构建时间(来自构建期注入)
version: injected('version'),
commit: injected('commit'),
// 构建时间取入口文件的 mtime(而非构建期注入)—— 注入会让每次打包字节不同,
// 破坏「指纹 = 构建身份」的确定性(v22 真机踩过)
built_at: buildCache.mtime_ms ? new Date(buildCache.mtime_ms).toISOString() : 'unknown',
// v21:TLS 证书指纹(控制端据此钉住被控端身份)
cert_sha256: currentCertFingerprint(),
};
}
export async function systemInfo(args: Args): Promise<unknown> {
const fields = args['fields'] as string[] | undefined;
const info: Record<string, unknown> = {
hostname: os.hostname(),
os: platformName(),
os_version: os.release(),
arch: os.arch(),
cpu_model: os.cpus()[0]?.model?.trim() ?? 'unknown',
cpu_cores: os.cpus().length,
memory_total: os.totalmem(),
uptime_sec: Math.round(os.uptime()),
is_admin: await isAdmin(),
// v11:自描述信息,供控制端「一键部署/升级」定位目标文件与数据目录
pid: process.pid,
agent_script: process.argv[1] ?? '',
agent_home: process.env['NODEAGENT_HOME'] ?? '',
node_path: process.execPath,
// v15:PowerShell 常驻助手状态(排查 GUI 能力性能/降级时用)
ps_helper: allPsShellStats(),
// v18/v21:构建指纹(部署校验用)+ 证书指纹(身份钉定用)
build: buildInfo(),
// v21:来源网段访问控制(null = 未配置 = 放行全部,属于应尽快收敛的风险)
network: {
allow_from: configAllowFrom(),
},
};
if (fields && fields.length > 0) {
const picked: Record<string, unknown> = {};
for (const f of fields) if (f in info) picked[f] = info[f];
return picked;
}
return info;
}
// ---------------- system.status ----------------
function cpuTimes(): { idle: number; total: number } {
let idle = 0;
let total = 0;
for (const c of os.cpus()) {
idle += c.times.idle;
total += c.times.user + c.times.nice + c.times.sys + c.times.idle + c.times.irq;
}
return { idle, total };
}
async function cpuPercent(intervalMs = 200): Promise<number> {
const a = cpuTimes();
await sleep(intervalMs);
const b = cpuTimes();
const idleDiff = b.idle - a.idle;
const totalDiff = b.total - a.total;
if (totalDiff <= 0) return 0;
return Math.round((1 - idleDiff / totalDiff) * 10000) / 100;
}
interface Disk {
drive: string;
total: number;
free: number;
used_pct: number;
}
async function listDisks(): Promise<Disk[]> {
if (IS_WINDOWS) {
const r = await execCommand({
command:
'Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | Select-Object DeviceID,Size,FreeSpace | ConvertTo-Json -Compress',
timeoutMs: 15_000,
});
const rows = toArray<{ DeviceID?: string; Size?: number; FreeSpace?: number }>(
r.stdout ? JSON.parse(r.stdout) : [],
);
return rows.map((d) => {
const total = Number(d.Size ?? 0);
const free = Number(d.FreeSpace ?? 0);
return {
drive: d.DeviceID ?? '?',
total,
free,
used_pct: total > 0 ? Math.round(((total - free) / total) * 10000) / 100 : 0,
};
});
}
const r = await execCommand({ command: 'df -kP', timeoutMs: 15_000 });
const skipFs = new Set(['devfs', 'map', 'none', 'tmpfs', 'overlay', 'autofs']);
const disks: Disk[] = [];
for (const line of r.stdout.split('\n').slice(1)) {
const parts = line.trim().split(/\s+/);
if (parts.length < 6) continue;
const fs = parts[0]!;
const mount = parts[5]!;
// 跳过虚拟文件系统与系统卷(macOS 特有噪音)
if (skipFs.has(fs) || mount.startsWith('/System/Volumes/')) continue;
const totalKb = Number(parts[1]);
const availKb = Number(parts[3]);
if (!Number.isFinite(totalKb) || !Number.isFinite(availKb)) continue;
const total = totalKb * 1024;
const free = availKb * 1024;
if (total === 0) continue;
disks.push({
drive: mount,
total,
free,
used_pct: Math.round(((total - free) / total) * 10000) / 100,
});
}
return disks;
}
function listNet(): Array<{ adapter: string; ip: string; up: boolean }> {
const out: Array<{ adapter: string; ip: string; up: boolean }> = [];
for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
for (const a of addrs ?? []) {
if (a.family === 'IPv4' && !a.internal) {
out.push({ adapter: name, ip: a.address, up: !a.internal });
}
}
}
return out;
}
export async function systemStatus(_args: Args): Promise<unknown> {
const total = os.totalmem();
const free = os.freemem();
const used = total - free;
const [cpu, disks] = await Promise.all([cpuPercent(), listDisks()]);
return {
cpu_pct: cpu,
memory_used: used,
memory_total: total,
memory_pct: Math.round((used / total) * 10000) / 100,
disks,
net: listNet(),
};
}
// ---------------- system.process.list ----------------
interface Proc {
pid: number;
name: string;
cpu_pct: number;
memory_bytes: number;
started_at: number;
}
function parseEtime(etime: string): number {
// 支持 dd-hh:mm:ss / hh:mm:ss / mm:ss
const days = /^(\d+)-/.exec(etime);
const rest = days ? etime.slice(days[0].length) : etime;
const parts = rest.split(':').map(Number);
let sec = 0;
if (parts.length === 3) sec = parts[0]! * 3600 + parts[1]! * 60 + parts[2]!;
else if (parts.length === 2) sec = parts[0]! * 60 + parts[1]!;
else sec = parts[0] ?? 0;
if (days) sec += Number(days[1]) * 86_400;
return Date.now() - sec * 1000;
}
/** 解析 POSIX `ps -axo pid=,%cpu=,rss=,comm=,etime=` 输出。 */
function parsePosixPs(stdout: string): Proc[] {
const out: Proc[] = [];
for (const raw of stdout.split('\n')) {
const line = raw.trim();
if (!line) continue;
const m = /^(\d+)\s+([\d.]+)\s+(\d+)\s+(.*)$/.exec(line);
if (!m) continue;
const tail = m[4]!.trim().split(/\s+/);
const etime = tail.length > 1 ? tail.pop()! : '0:00';
const name = tail.join(' ').split('/').pop() ?? m[4]!;
out.push({
pid: Number(m[1]),
cpu_pct: Number(m[2]),
memory_bytes: Number(m[3]) * 1024, // rss 单位为 KB
name,
started_at: parseEtime(etime),
});
}
return out;
}
/** 降级:ps 不可用时用 pgrep 枚举(仅有 PID 与名称)。 */
async function pgrepFallback(): Promise<Proc[]> {
const g = await execCommand({ command: 'pgrep -l .', timeoutMs: 15_000 });
return g.stdout
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const m = /^(\d+)\s+(.*)$/.exec(l);
if (!m) return null;
return { pid: Number(m[1]), name: m[2]!.trim(), cpu_pct: 0, memory_bytes: 0, started_at: 0 } satisfies Proc;
})
.filter((p): p is Proc => p !== null);
}
export async function processList(args: Args): Promise<unknown> {
const limit = (args['limit'] as number | undefined) ?? 50;
const sortBy = (args['sort_by'] as string | undefined) ?? 'cpu';
const pattern = (args['filter'] as { name_pattern?: string } | undefined)?.name_pattern;
let procs: Proc[] = [];
if (IS_WINDOWS) {
const script =
'$a=@{};foreach($p in Get-Process){$a[$p.Id]=if($p.CPU){$p.CPU}else{0}};' +
'Start-Sleep -Milliseconds 500;$r=foreach($p in Get-Process){' +
'$prev=if($a.ContainsKey($p.Id)){$a[$p.Id]}else{0};$cur=if($p.CPU){$p.CPU}else{0};' +
'$st=0;if($p.StartTime){$st=[int][double]::Parse($p.StartTime.ToUniversalTime().Subtract([datetime]"1970-01-01").TotalMilliseconds)};' +
'[pscustomobject]@{pid=$p.Id;name=$p.ProcessName;cpu_pct=[math]::Round((($cur-$prev)/0.5)*100,2);memory_bytes=$p.WorkingSet64;started_at=$st}};' +
'$r|ConvertTo-Json -Compress';
const r = await execCommand({ command: script, timeoutMs: 30_000 });
procs = toArray<Proc>(r.stdout ? JSON.parse(r.stdout) : []);
} else {
const r = await execCommand({ command: 'ps -axo pid=,%cpu=,rss=,comm=,etime=', timeoutMs: 15_000 });
procs = r.exit_code === 0 && r.stdout.trim().length > 0 ? parsePosixPs(r.stdout) : await pgrepFallback();
}
let filtered = procs;
if (pattern) {
const re = new RegExp(pattern, 'i');
filtered = filtered.filter((p) => re.test(p.name));
}
const keymap: Record<string, (p: Proc) => number | string> = {
cpu: (p) => -p.cpu_pct,
memory: (p) => -p.memory_bytes,
pid: (p) => p.pid,
name: (p) => p.name.toLowerCase(),
};
const key = keymap[sortBy] ?? keymap['cpu']!;
filtered = [...filtered].sort((a, b) => {
const ka = key(a);
const kb = key(b);
if (typeof ka === 'string' && typeof kb === 'string') return ka < kb ? -1 : ka > kb ? 1 : 0;
return (ka as number) - (kb as number);
});
return { processes: filtered.slice(0, limit) };
}
// ---------------- system.service.list ----------------
export async function serviceList(args: Args): Promise<unknown> {
if (!IS_WINDOWS) {
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'system.service.list 仅在被控端为 Windows 时可用',
{ platform: process.platform },
);
}
const limit = (args['limit'] as number | undefined) ?? 100;
const filter = args['filter'] as { name_pattern?: string; state?: string } | undefined;
const r = await execCommand({
// 注意:Get-Service 的 Status/StartType 是 .NET 枚举,直接 ConvertTo-Json 会退化为
// 数字(如 4=Running、1=Stopped),必须显式 ToString() 才能得到可读状态。
command:
'Get-Service | Select-Object Name,DisplayName,' +
"@{n='Status';e={$_.Status.ToString()}},@{n='StartType';e={$_.StartType.ToString()}} | " +
'ConvertTo-Json -Compress',
timeoutMs: 30_000,
});
let services = toArray<{ Name?: string; DisplayName?: string; Status?: string | number; StartType?: string | number }>(
r.stdout ? JSON.parse(r.stdout) : [],
).map((s) => ({
name: s.Name ?? '',
display_name: s.DisplayName ?? '',
state: String(s.Status ?? '').toLowerCase() || 'unknown',
start_type: String(s.StartType ?? '').toLowerCase() || 'unknown',
}));
if (filter?.name_pattern) {
const re = new RegExp(filter.name_pattern, 'i');
services = services.filter((s) => re.test(s.name) || re.test(s.display_name));
}
if (filter?.state) {
services = services.filter((s) => s.state === filter.state);
}
return { services: services.slice(0, limit) };
}
// ---------------- system.shell.exec ----------------
export async function shellExec(args: Args): Promise<unknown> {
const command = args['command'] as string;
if (typeof command !== 'string' || command.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'command 不能为空');
}
const timeoutMs = (args['timeout_ms'] as number | undefined) ?? 30_000;
// 异步模式:立即返回 task_id,长命令用 system.task.* 查询/续读/终止
if (args['async'] === true) {
const rec = startTask({
command,
shell: args['shell'] as never,
cwd: args['cwd'] as string | undefined,
timeoutMs: args['wait_forever'] === true ? 0 : timeoutMs,
});
return {
async: true,
task_id: rec.id,
state: rec.state,
hint: '用 system.task.get 增量读取输出,system.task.kill 终止',
};
}
const r = await execCommand({
command,
shell: args['shell'] as never,
cwd: args['cwd'] as string | undefined,
timeoutMs,
});
return r;
}
// ---------------- system.agent.restart ----------------
interface RestartParams {
nodeExe: string;
agentJs: string;
home: string;
pid: number;
delayMs: number;
reason: string;
}
/**
* 受控重启:延时后由「脱离当前进程组」的机制拉起新的 agent 进程。
*
* 背景(真实教训):此前用 `Start-Process` 起的"分离进程",会被本项目的
* killTree(超时强杀整棵进程树)连带清理 —— 导致重启失败、端口僵死、控制端彻底失联。
* 因此这里改用**计划任务**(Windows)/ detached+unref(POSIX),
* 二者均不属于当前进程树,可安全地在自身退出后继续执行。
*/
export async function agentRestart(args: Args): Promise<unknown> {
const delayMs = (args['delay_ms'] as number | undefined) ?? 2000;
const reason = (args['reason'] as string | undefined) ?? '';
const nodeExe = process.execPath;
const agentJs = process.argv[1] ?? '';
if (!agentJs) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'无法定位 agent 入口(process.argv[1] 为空),拒绝重启以免进程无法拉起',
);
}
const params: RestartParams = {
nodeExe,
agentJs,
home: process.env['NODEAGENT_HOME'] ?? '',
pid: process.pid,
delayMs,
reason,
};
return IS_WINDOWS ? scheduleWindowsRestart(params) : detachedPosixRestart(params);
}
/** 把 PowerShell 脚本编码为 UTF-16LE base64,彻底规避引号/中文/换行的转义问题。 */
function encodePowerShell(script: string): string {
return Buffer.from(script, 'utf16le').toString('base64');
}
async function scheduleWindowsRestart(p: RestartParams): Promise<unknown> {
// 方案演进(均为真机实测后的结论):
// 1) 计划任务 + `start "" node.exe` → 失败:Session 0 无桌面,start 拉不起进程
// 2) detached spawn + `start /B` → 失败:cmd 退出即带走子进程
// 3) detached spawn + 直接跑 node → 失败:detached cmd 随 agent 一起消失(日志文件都没生成)
// 4) 【本方案】计划任务,action = cmd /c「延时 → 杀旧 → 直接跑 node」
// —— Task Scheduler 会持有 cmd 进程,cmd 再持有 node 进程,
// 整条链与旧 agent 完全无关,且不依赖桌面会话。
// ⚠️ 日志文件必须唯一(带时间戳):重启任务用 `>> LOG` 启动新 agent,
// 新 agent 会**长期持有该文件的 stdout 句柄**。若下次重启仍写同一文件,
// 所有带该重定向的命令会因句柄冲突被整体跳过(包括 taskkill!)——
// 表现为「restart 返回 ok 但实际什么都没发生」(真机实证:Result=1、
// 无日志写入、旧进程幸存)。
const LOG = `C:\\Windows\\Temp\\nodeagent-restart-${Date.now()}.log`;
const steps = [
// 清理历史重启日志:仅保留最近 5 个(正被运行中 agent 持有的删除会静默失败,无妨)
'powershell -NoProfile -Command "Get-ChildItem \'C:\\Windows\\Temp\\nodeagent-restart-*.log\' -EA SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 | Remove-Item -Force -EA SilentlyContinue"',
`echo [%TIME%] begin > "${LOG}" 2>&1`,
'ping -n 3 127.0.0.1 >nul',
`echo [%TIME%] killing ${p.pid} >> "${LOG}" 2>&1`,
`taskkill /F /PID ${p.pid} >> "${LOG}" 2>&1`,
'ping -n 2 127.0.0.1 >nul',
`echo [%TIME%] launching >> "${LOG}" 2>&1`,
`"${p.nodeExe}" "${p.agentJs}" >> "${LOG}" 2>&1`,
`echo [%TIME%] exited code=%ERRORLEVEL% >> "${LOG}" 2>&1`,
];
// 注意:`set X=Y & cmd` 中 `&` 前的空格会被算进变量值(变成 "Y "),
// 导致 agent 读不到配置而回退到默认端口 → EADDRINUSE。故此处不加空格。
const cmdLine = '/c ' + (p.home ? `set NODEAGENT_HOME=${p.home}& ` : '') + steps.join(' & ');
// ⚠️ 任务名必须唯一(带时间戳):重启任务的 cmd 会一直持有新 agent 进程,
// 任务因此长期处于 Running 状态;若沿用固定名,下次 restart 的
// Unregister/Register 会静默失败(任务无法覆盖正在运行的实例),
// 导致「restart 返回成功但实际没有重启」(真机踩过)。
const TASK = `nodeagent-selfrestart-${Date.now()}`;
const ps = [
`$ErrorActionPreference='SilentlyContinue'`,
// 清理历史 selfrestart 任务。
//
// ⚠️ 为什么不能简单清所有非 Running 的:重启任务的 cmd 会**一直持有新 agent 进程**,
// 所以任务长期处于 Running;若把 Running 的全清掉,会连当前 agent 的宿主一起杀
// (= 自杀,表现为"restart 成功但 agent 消失")。原实现只清非 Running,
// 代价是真机累积了 3 个 Running 僵尸(2026-10-09 实测)。
//
// 现策略:非 Running **或** LastRunTime 早于 1 小时 → 清。
// 1 小时余量远大于一次重启的耗时,因此"当前宿主"绝不会被误判(它刚刚才运行)。
`$cut = (Get-Date).AddHours(-1)`,
`Get-ScheduledTask -TaskName 'nodeagent-selfrestart*' -ErrorAction SilentlyContinue | Where-Object { if ($_.State -ne 'Running') { return $true }; $ti = ($_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue); if ($ti -and $ti.LastRunTime -and $ti.LastRunTime -lt $cut) { return $true }; return $false } | Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue`,
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument '${cmdLine.replace(/'/g, "''")}'`,
// 不限时(默认 72h 后强制结束),保证 node 能长期运行
`$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0)`,
// ① 先试「最高权限」:agent 通常以管理员运行,普通权限的 taskkill 杀不掉它,
// 否则会出现「新进程起来了但端口被占(EADDRINUSE)」。
`$p = New-ScheduledTaskPrincipal -UserId $env:USERNAME -RunLevel Highest -LogonType Interactive`,
`Register-ScheduledTask -TaskName '${TASK}' -Action $a -Settings $s -Principal $p -Force | Out-Null`,
// ② 降级:**非管理员身份下 Highest 会被系统拒绝**(真机 2026-10-09 坐实:
// agent 以普通用户运行时,deploy/restart 一律报"注册重启任务失败 exit=1")。
// 此时不指定 -Principal —— 默认继承当前用户权限级别(Limited),
// 注册能成功,且同权限下 taskkill 自己完全够用。
`if ((Get-ScheduledTask -TaskName '${TASK}' -ErrorAction SilentlyContinue) -eq $null) { Register-ScheduledTask -TaskName '${TASK}' -Action $a -Settings $s -Force | Out-Null }`,
// 校验注册真的成功(SilentlyContinue 会吞错,必须显式确认)
`if ((Get-ScheduledTask -TaskName '${TASK}' -ErrorAction SilentlyContinue) -eq $null) { Write-Output 'register-failed'; exit 1 }`,
`Start-ScheduledTask -TaskName '${TASK}'`,
`Write-Output 'ok'`,
].join('; ');
const r = await execCommand({
command: `powershell.exe -NoProfile -EncodedCommand ${encodePowerShell(ps)}`,
timeoutMs: 25_000,
});
if (r.exit_code !== 0) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
`注册重启任务失败(exit=${r.exit_code}):${r.stderr || r.stdout}`,
);
}
return {
scheduled: true,
delay_ms: p.delayMs,
mechanism: 'scheduled-task',
message: `已排入计划任务 ${TASK}(约 ${Math.ceil(p.delayMs / 1000) + 2}s 后重启)`,
};
}
async function detachedPosixRestart(p: RestartParams): Promise<unknown> {
const cmd = [
'sleep 2',
`kill -9 ${p.pid} 2>/dev/null || true`,
'sleep 1',
p.home ? `NODEAGENT_HOME='${p.home}' nohup '${p.nodeExe}' '${p.agentJs}' >/dev/null 2>&1 &` : `nohup '${p.nodeExe}' '${p.agentJs}' >/dev/null 2>&1 &`,
].join('; ');
// detached + unref:脱离当前进程组,父进程退出后仍继续
const child = spawn('/bin/sh', ['-c', cmd], {
detached: true,
stdio: 'ignore',
env: { ...process.env },
});
child.unref();
return {
scheduled: true,
delay_ms: p.delayMs,
mechanism: 'detached-spawn',
message: `已启动分离的重启进程(约 ${Math.ceil(p.delayMs / 1000) + 2}s 后重启)`,
};
}
// ---------------- system.audit.list ----------------
export async function auditList(args: Args): Promise<unknown> {
const result = readAudit({
limit: args['limit'] as number | undefined,
since: args['since'] as number | undefined,
clientId: args['client_id'] as string | undefined,
type: args['type'] as string | undefined,
});
return result;
}
/** v11:审计链完整性校验(防篡改)。 */
export async function auditVerify(_args: Args): Promise<unknown> {
return verifyAudit();
}
/**
* v2.0.0:返回审计链的链头(只读)。
*
* 用途:控制端定期拉取并存到**链外**(Mac 本地 / 另一台机器)—— 这是"外部锚定"的一半。
* compare=true 时附带与最近一条锚点的比对结论(另一半)。
*/
export async function auditHead(args: Args): Promise<unknown> {
const head = computeAuditHead();
if (args['compare'] !== true) return head;
return { ...head, comparison: compareWithAnchors(args['anchor_path'] as string | undefined) };
}
/**
* v2.0.0:把当前链头**追加**写到锚点文件(默认 <数据目录>/audit-anchors.jsonl)。
*
* 为什么是追加而不是覆盖:历史锚点一旦写成就不可被后续覆盖,否则攻击者重写链后
* 再"刷新"锚点即可抹掉痕迹。追加式让每次锚定都留痕。
* path 若指定,仍受 fs_roots 白名单约束(不新开写入面)。
*/
export async function auditAnchor(args: Args): Promise<unknown> {
const raw = args['path'] as string | undefined;
const path = raw ? guardPath(raw) : undefined;
return anchorAudit({ ...(path ? { path } : {}), ...(args['note'] ? { note: String(args['note']) } : {}) });
}
/** v13:成功指标聚合(对齐 PRD 2.2)—— 数据源为审计日志,无需额外埋点。 */
export async function metricsReport(args: Args): Promise<unknown> {
const since = args['since'] as number | undefined;
const entries = readAuditAll(since);
return computeMetrics(entries);
}