Files
nodeagent/apps/cli/src/options.ts
T
jkooe e95a0248da feat(v19): 拉取式自更新 system.agent.update(42 项)—— 回答「能否直接用 GitHub 做更新服务器」
## 结论:能,而且这是「控制端与被控端不可达」场景的正解
`deploy`(推送式)要求两者可达;本能力只要求**被控端能上网**,因此跨网段/NAT/异地都能更新。
GitHub Release / raw / 自建镜像 / 任意 http(s) 地址均可 —— 地址是参数,不写死。

## 实现(apps/agent/src/capabilities/selfupdate.ts)
下载 → **校验 sha256** → 备份 → **原子替换**(同目录 rename)→ 受控重启(复用 v7)→ CLI 复核指纹
- 出口:CLI `nodeagent update --url <u> --sha256 <h> [--dry-run]` · MCP `na_agent_update`
- 审计新增 `agent.update` 事件(记录 url / 前后指纹 / 字节数 / 备份路径)

## 安全底线(否则等于开放远程代码执行)
1. **sha256 必填**,且逐字节校验通过才替换;不符即丢弃并明确报错(未改动任何文件)
2. 只接受 http/https,拒绝 file:// 等其它 scheme
3. 大小上限 64MB + 下载超时(默认 120s)
4. **先备份再原子替换**,失败可回滚(返回 backup_path)
5. `dry_run` 只下载校验、不动文件 —— 用于「先确认能不能更」
6. 不用 fetch 的内置代理(Node 未暴露 undici):国内访问 GitHub 不稳时,
   把 url 指向**自建镜像/加速地址**即可,能力本身接受任意 http(s)

## 真机/本地验证(全程真跑,非纸面)
- e2e 新增:本地 HTTP 服务真跑下载 —— 正确哈希通过 / **错误哈希被拒** / 404 明确报错 /
  非 http(s) scheme 被拒 / 缺 sha256 协议层拒绝 ✓
- 完整流程本地实测:`dry-run 预检` → `真更新`(966KB)→ 备份逐字节一致 → 重启 →
  **复核到新指纹** → 能力 42 项 ✓
- 顺带修:返回值漏 `incoming_hash` 导致 CLI 复核被跳过(真机踩过)
- 单测 126 / e2e 28 全绿

## 已知约束(写进文档)
1. **首次仍要一次性安装**(install.ps1)—— 自更新能力本身得先在机器上,鸡生蛋无解
2. `node.exe`(Node 运行时)不在自更新范围,只换 agent.mjs
2026-10-04 23:03:37 +08:00

171 lines
6.3 KiB
TypeScript

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 { parseArgs } from 'node:util';
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 { type Options } from './types.js';
// 命令行参数解析(从 index.ts 拆出)
export function parseOptions(rest: string[]): { opts: Options; positionals: string[] } {
const { values, positionals } = parseArgs({
args: rest,
options: {
json: { type: 'boolean', default: false },
insecure: { type: 'boolean', default: false },
port: { type: 'string' },
key: { type: 'string' },
id: { type: 'string' },
limit: { type: 'string' },
args: { type: 'string' },
config: { type: 'boolean', default: false },
// v2 图形操作
out: { type: 'string' },
format: { type: 'string' },
scale: { type: 'string' },
region: { type: 'string' },
button: { type: 'string' },
duration: { type: 'string' },
interval: { type: 'string' },
'auth-mode': { type: 'string' },
since: { type: 'string' },
type: { type: 'string' },
'client-id': { type: 'string' },
wait: { type: 'string' },
'discovery-port': { type: 'string' },
node: { type: 'string' },
name: { type: 'string' },
note: { type: 'string' },
recursive: { type: 'boolean', default: false },
pattern: { type: 'string' },
'create-dirs': { type: 'boolean', default: false },
'hub-token': { type: 'string' },
'hub-node': { type: 'string' },
// v7 受控重启
delay: { type: 'string' },
// v10 后台任务 / 剪贴板
'timeout-ms': { type: 'string' },
kill: { type: 'boolean', default: false },
offset: { type: 'string' },
set: { type: 'string' },
'image-file': { type: 'string' },
fps: { type: 'string' },
nodes: { type: 'string' },
path: { type: 'string' },
kind: { type: 'string' },
var: { type: 'string', multiple: true },
mode: { type: 'string' },
iface: { type: 'string' },
ip: { type: 'string' },
mask: { type: 'string' },
gateway: { type: 'string' },
dns: { type: 'string' },
command: { type: 'string' },
'confirm-within': { type: 'string' },
'task-name': { type: 'string' },
yes: { type: 'boolean', default: false },
url: { type: 'string' },
sha256: { type: 'string' },
'dry-run': { type: 'boolean', default: false },
check: { type: 'boolean', default: false },
force: { type: 'boolean', default: false },
mute: { type: 'string' },
volume: { type: 'string' },
seconds: { type: 'string' },
'interval-ms': { type: 'string' },
},
allowPositionals: true,
strict: false,
});
return {
opts: {
json: Boolean(values['json']),
insecure: Boolean(values['insecure']),
port: values['port'] as string | undefined,
key: values['key'] as string | undefined,
id: values['id'] as string | undefined,
limit: values['limit'] as string | undefined,
args: values['args'] as string | undefined,
out: values['out'] as string | undefined,
format: values['format'] as string | undefined,
scale: values['scale'] as string | undefined,
region: values['region'] as string | undefined,
button: values['button'] as string | undefined,
duration: values['duration'] as string | undefined,
interval: values['interval'] as string | undefined,
authMode: values['auth-mode'] as string | undefined,
since: values['since'] as string | undefined,
type: values['type'] as string | undefined,
clientId: values['client-id'] as string | undefined,
wait: values['wait'] as string | undefined,
discoveryPort: values['discovery-port'] as string | undefined,
node: values['node'] as string | undefined,
delay: values['delay'] as string | undefined,
timeoutMs: values['timeout-ms'] as string | undefined,
kill: Boolean(values['kill']),
offset: values['offset'] as string | undefined,
set: values['set'] as string | undefined,
imageFile: values['image-file'] as string | undefined,
fps: values['fps'] as string | undefined,
nodes: values['nodes'] as string | undefined,
path: values['path'] as string | undefined,
kind: values['kind'] as string | undefined,
vars: (values['var'] as string[] | undefined) ?? [],
mode: values['mode'] as string | undefined,
iface: values['iface'] as string | undefined,
ip: values['ip'] as string | undefined,
mask: values['mask'] as string | undefined,
gateway: values['gateway'] as string | undefined,
dns: values['dns'] as string | undefined,
command: values['command'] as string | undefined,
confirmWithin: values['confirm-within'] as string | undefined,
taskName: values['task-name'] as string | undefined,
yes: Boolean(values['yes']),
url: values['url'] as string | undefined,
sha256: values['sha256'] as string | undefined,
dryRun: Boolean(values['dry-run']),
check: Boolean(values['check']),
force: Boolean(values['force']),
mute: values['mute'] as string | undefined,
volume: values['volume'] as string | undefined,
seconds: values['seconds'] as string | undefined,
intervalMs: values['interval-ms'] as string | undefined,
name: values['name'] as string | undefined,
note: values['note'] as string | undefined,
recursive: Boolean(values['recursive']),
pattern: values['pattern'] as string | undefined,
createDirs: Boolean(values['create-dirs']),
hubToken: values['hub-token'] as string | undefined,
hubNode: values['hub-node'] as string | undefined,
},
positionals,
};
}