## 结论:能,而且这是「控制端与被控端不可达」场景的正解 `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
171 lines
6.3 KiB
TypeScript
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,
|
|
};
|
|
}
|