feat(v18): 更新链路可靠化 —— 构建指纹 + 备份 + 轮询校验(附 6 个真机 bug 修复)

## 皇上的问题
「这个版本如果有更新,可以在客户端上便捷的更新吗?」
答案:本来就有 `nodeagent deploy`(比最初的一键升级还早),但**它不可信**:
部署后只看「PID 变了 / 能力数对了」,证明不了真的换成了新代码(旧版可能能力数一样),
而且备份与失败判定都存在问题。本轮把这条链路做成**可信**的。

## 新增
1. **构建指纹**:agent 上报 `build.hash`(运行中脚本的 sha256 前 12 位)+ bytes/mtime/node/uptime。
   部署后按指纹校验「远端真的是新代码」,而非靠 PID/能力数猜。
2. **`deploy --check`**:只比对指纹不动文件(退出码 2 = 需要更新),适合脚本/巡检。
3. **`deploy --force`**:指纹相同也强制覆盖。
4. **`pnpm update:win [设备名]`**:一条命令完成「打包 → 校验产物确实更新 → 部署」
   (脚本刻意防呆:pack 后产物时间戳没变就拒绝部署,避免部署旧包 —— 真机踩过)。
5. **NODEAGENT_DEBUG=1**:CLI 打印异常堆栈(异步链问题必备)。

## 修掉的 6 个真机 bug(本轮逐个实测暴露)
1. **备份用 `Copy-Item`**(Windows 专用 shell 命令)→ macOS 被控端静默不备份。
   改为协议自带 fs.read/fs.write,跨平台。
2. **备份读取漏 `encoding: 'base64'`** → 二进制被按 utf8 读(损坏),再当 base64 写回
   → 备份文件只有 33 字节垃圾(实测:`fs.read` 返回 524288 字符而非 base64)。
3. **备份循环用 fs.read 返回的 `offset` 当前进量** → 那是「本次读取的起始位置」,
   于是原地打转 → 死循环 + 文件暴涨 + 部署卡死 200s。
   改为自算 len(对齐 cmdPush 成熟写法)+ 无进展即跳出。
4. **轮询用 withClient → 直接退出进程**:`withClientDirect` 在连接失败且是 ClientError 时
   调 `fail()` 退出 —— 轮询第一次连接必失败(agent 正在重启)→ CLI 立刻退出,
   报 E_NODE_OFFLINE,而**部署其实已成功**(本轮 3 次「失败」全是这个假象)。
   新增 `probeOnce()`:永不抛出的探测函数,专用于「等待上线/重启」类重试。
5. **盲等 9 秒**判定部署结果 → 改为**按墙钟**限时轮询指纹(90s),失败时报真实耗时。
6. **重启会切断连接**:该中断属预期,此前未处理会把成功误判为失败 → 显式吞掉并说明。

## 验证(本地全程真跑,非纸面)
用「起本地 agent → 造一个改动过的 bundle → 部署」的方式把整条链路跑通:
- 同版本 `--check` → 「已是最新」退出码 0 ✓
- 不同版本 `--check` → 报不一致退出码 2 ✓
- 完整部署 → 备份(979578B,`cmp` 逐字节一致)→ 上传 → 重启 → **指纹校验通过** ✓
- 防呆:文件过小/不存在被拒 ✓
- 单测 125 / e2e 27 全绿
This commit is contained in:
jkooe committed 2026-10-04 22:54:45 +08:00
1 parent 9853ee8cdb
commit e6a9a3d7ea
10 files changed
+292 -35

No files matched your search

+41
View File
@@ -1,3 +1,5 @@
import { createHash } from 'node:crypto';
import { readFileSync, statSync } from 'node:fs';
import { spawn } from 'node:child_process';
import os from 'node:os';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
@@ -44,6 +46,43 @@ async function isAdmin(): Promise<boolean> {
// ---------------- system.info ----------------
// ---------- 构建指纹(v18)----------
const STARTED_AT = Date.now();
let buildCache: { hash: string; bytes: number; mtime_ms: number } | null = null;
/**
* 计算「正在运行的这一份 agent 脚本」的指纹(sha256 前 12 位)。
*
* 用途:部署后**校验远端真的换成了新代码**。仅靠「PID 变了 / 能力数对了」证明不了这点
* (旧版可能恰好也是同样的能力数);内容哈希是唯一可靠的判据。
* 懒计算 + 缓存:文件约 1MB,只在首次 system.info 时读一次。
*/
function buildInfo(): { hash: string; bytes: number; mtime_ms: number; node: string; started_at: number; uptime_ms: number } {
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,
};
}
export async function systemInfo(args: Args): Promise<unknown> {
const fields = args['fields'] as string[] | undefined;
const info: Record<string, unknown> = {
@@ -63,6 +102,8 @@ export async function systemInfo(args: Args): Promise<unknown> {
node_path: process.execPath,
// v15:PowerShell 常驻助手状态(排查 GUI 能力性能/降级时用)
ps_helper: allPsShellStats(),
// v18:构建指纹(部署校验用)
build: buildInfo(),
};
if (fields && fields.length > 0) {
+145 -32
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
@@ -35,6 +36,7 @@ import {
fail,
getClientConfig,
getNodeOverride,
probeOnce,
humanSize,
printJson,
riskIcon,
@@ -208,35 +210,111 @@ export async function cmdRecord(opts: Options): Promise<void> {
* 全程无需手工拷文件或起计划任务。
*/
/**
* 部署/更新被控端 agent(v18 起带指纹校验)。
*
* 流程:本地算 sha256 → 问远端构建指纹 → 相同则跳过(--force 可强制)→
* 备份 → 分块上传 → 受控重启 → **轮询直到远端指纹等于本地**(替代盲等)。
*
* 为什么必须比指纹:只看「PID 变了 / 能力数对了」证明不了换成了新代码
* (旧版可能恰好有同样多的能力)。内容哈希是唯一可靠判据。
*/
export async function cmdDeploy(localFile: string, opts: Options): Promise<void> {
if (!existsSync(localFile)) fail(`本地文件不存在: ${localFile}`);
const buf = readFileSync(localFile);
if (buf.length < 10_000) fail(`文件过小,疑似不是 agent 包: ${localFile}`);
const localHash = createHash('sha256').update(buf).digest('hex').slice(0, 12);
await withClient(async (c) => {
const info = await c.invoke<Record<string, unknown>>(CapabilityNames.SystemInfo, {});
if (info.status === 'failed') fail('无法读取被控端信息');
const d = info.data as {
agent_script?: string;
pid?: number;
node_path?: string;
agent_home?: string;
};
const target = opts.path ?? d.agent_script;
if (!target) fail('被控端未上报入口路径,请用 --path <远端路径> 指定');
console.log(`目标: ${target}(当前 PID ${d.pid ?? '?'})`);
// 单条 RPC 的安全分块(与上传保持一致)
const BACKUP_CHUNK = 512 * 1024;
// 备份现有文件(便于回滚)
const backup = `${target}.bak-${Date.now()}`;
if (d.node_path) {
const cp = await c.invoke(CapabilityNames.ShellExec, {
command: `Copy-Item '${target}' '${backup}' -Force -ErrorAction SilentlyContinue; Write-Output 'ok'`,
timeout_ms: 15_000,
});
if (cp.status === 'ok') console.log(`已备份: ${backup}`);
type Info = {
agent_script?: string;
pid?: number;
node_path?: string;
build?: { hash?: string; node?: string; started_at?: number };
};
const remote = await withClient((c) => c.invoke<Info>(CapabilityNames.SystemInfo, {}));
if (remote.status === 'failed') fail('无法读取被控端信息');
const info: Info = remote.data ?? {};
const remoteHash = info.build?.hash ?? 'unknown';
const target = opts.path ?? info.agent_script;
if (!target) fail('被控端未上报入口路径,请用 --path <远端路径> 指定');
console.log(`本地 : ${localHash} (${(buf.length / 1024).toFixed(0)} KB)`);
console.log(`远端 : ${remoteHash} ${info.build?.node ? `(node ${info.build.node})` : ''}`);
console.log(`目标 : ${target}(当前 PID ${info.pid ?? '?'})`);
const unknownRemote = remoteHash === 'unknown';
if (unknownRemote) {
console.log('ℹ️ 远端未上报构建指纹(多为 v18 之前的旧版)—— 无法比对,建议直接部署');
}
if (opts.check === true) {
if (remoteHash === localHash) {
console.log('✓ 已是最新,无需更新');
} else if (unknownRemote) {
console.log('⚠️ 无法比对(远端过旧),建议执行 `nodeagent deploy <文件>` 更新');
process.exitCode = 2;
} else {
console.log('⚠️ 版本不一致 —— 执行 `nodeagent deploy <文件>` 更新(加 --force 可强制覆盖)');
process.exitCode = 2;
}
return;
}
if (!unknownRemote && remoteHash === localHash && opts.force !== true) {
console.log('✓ 远端指纹与本地一致,已是最新(如需强制覆盖请加 --force)');
return;
}
// 备份 + 上传 + 重启
const uploadInfo = await withClient(async (c) => {
// 备份走协议自带的 fs 能力(而非 shell 的 Copy-Item)——
// Copy-Item 是 Windows 专用,在 macOS 被控端上会静默失败(真机踩过:以为备份了,其实没有)。
const backup = `${target}.bak-${Date.now()}`;
let backupPath = '';
try {
const st = await c.invoke<{ size?: number; exists?: boolean }>(CapabilityNames.FsStat, { path: target });
const size = st.status === 'ok' ? (st.data?.size ?? 0) : 0;
if (size > 0) {
let off = 0;
let first = true;
while (off < size) {
// 自行计算本次长度并据此前进(对齐 cmdPush 的成熟写法):
// fs.read 返回的 offset 是**本次读取的起始位置**,拿它当前进量会原地打转
// → 死循环 + 文件暴涨(真机踩过:备份 33B→268KB 且部署卡死 200s)。
const len = Math.min(BACKUP_CHUNK, size - off);
const r = await c.invoke<{ data?: string }>(CapabilityNames.FsRead, {
path: target,
offset: off,
max_bytes: len,
// ⚠️ 必须显式 base64:默认 utf8 会把二进制按文本读(损坏),
// 再当 base64 写回就得到几十字节的垃圾备份(真机踩过:备份 33 字节)
encoding: 'base64',
});
if (r.status !== 'ok') break;
const chunk = r.data?.data ?? '';
if (!chunk) break;
const w = await c.invoke(CapabilityNames.FsWrite, {
path: backup,
data: chunk,
encoding: 'base64',
append: !first,
});
if (w.status !== 'ok') break;
first = false;
off += len;
}
if (!first) backupPath = backup;
}
} catch {
/* 备份失败不阻断部署,但下面会提示 */
}
if (backupPath) console.log(`已备份: ${backupPath}`);
else console.log('⚠️ 备份未成功(部署继续,但无自动回滚点)');
// 上传(fs.write 分块;由 agent 内部处理,无需本地→远端路径映射)
const CHUNK = 512 * 1024;
let offset = 0;
while (offset < buf.length) {
@@ -251,20 +329,55 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
offset += part.length;
}
console.log(`✓ 已上传 ${(buf.length / 1024).toFixed(0)} KB`);
return { backupPath };
});
// 受控重启(复用 v7 能力,无需外部计划任务)
const r = await c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'deploy' });
// 触发重启:**用独立连接且吞掉连接中断**。
// 重启会把本连接切断,这属于预期行为;若当成失败抛出,整个部署会被误判为失败
// (真机踩过:文件其实已换好、新进程也起来了,CLI 却报 E_NODE_OFFLINE)。
try {
const r = await withClient((c) =>
c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'deploy' }),
);
if (r.status === 'failed') fail(`重启失败: ${r.error?.message}`);
console.log('✓ 已触发重启,等待恢复…');
});
} catch (err) {
console.log(`(重启切断了连接,属预期:${err instanceof Error ? err.message : String(err)})`);
}
console.log('✓ 已触发重启,校验新版本上线…');
// 等待新版本上线并复验
await new Promise((t) => setTimeout(t, 9000));
await withClient(async (c) => {
const info = await c.invoke<{ capabilities?: unknown[] }>(CapabilityNames.SystemInfo, {});
const caps = c.listCapabilities();
console.log(`✓ 新版本已上线:PID ${(info.data as { pid?: number })?.pid ?? '?'},能力 ${caps.length} 项`);
});
// 轮询校验:直到远端指纹 == 本地指纹。
// 用 probeOnce(永不退出),**不能用 withClient** —— 后者连不上会 fail() 退出,
// 使得「等待重连」根本无法实现(真机踩过:部署成功却报 E_NODE_OFFLINE)。
// 按墙钟限时而非固定次数:每次连接耗时不定,按次数算总时长不可预期。
let verified = false;
let lastSeen = '';
let pidSeen: number | undefined;
let capsSeen = 0;
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
await new Promise((t) => setTimeout(t, 1500));
const p = await probeOnce();
if (!p.ok) continue;
lastSeen = p.buildHash ?? 'unknown';
pidSeen = p.pid;
capsSeen = p.caps;
if (lastSeen === localHash) {
verified = true;
console.log(`✓ 新版本已上线并校验通过:指纹 ${lastSeen},PID ${pidSeen ?? '?'},能力 ${capsSeen} 项`);
break;
}
}
if (!verified) {
const waited = Math.round((Date.now() - (deadline - 90_000)) / 1000);
console.error(`✗ ${waited}s 内未校验到新版本(当前远端指纹 ${lastSeen || '连不上'})`);
if (uploadInfo.backupPath) {
console.error(` 回滚方式:把备份复制回目标即可 ——`);
console.error(` nodeagent exec "Copy-Item '${uploadInfo.backupPath}' '${target}' -Force"`);
}
console.error(' 排查:被控端杀软(主流杀软/杀软)可能处置了被覆盖的 agent 文件');
process.exitCode = 1;
}
}
/**
+38
View File
@@ -218,3 +218,41 @@ export function humanSize(bytes: number): string {
export function riskIcon(risk: string): string {
return risk === 'high' ? '🔴' : risk === 'medium' ? '🟡' : '🟢';
}
/**
* 探测一次被控端(用于「等待重启/上线」这类**需要反复重试**的场景)。
*
* ⚠️ 不能用 withClient:它在连接失败时会直接 `fail()` **退出进程**(见 withClientDirect),
* 于是重试循环第一次连不上就整个 CLI 退出了 —— 真机踩过:部署其实已成功,
* CLI 却报 E_NODE_OFFLINE,且看不到任何自定义提示。本函数**永不抛出**。
*/
export async function probeOnce(nodeName?: string): Promise<{
ok: boolean;
pid?: number;
buildHash?: string;
caps: number;
error?: string;
}> {
try {
const cfg = getClientConfig();
const target = resolveTarget(cfg, nodeName ?? getNodeOverride());
const keys = target.profile.auth_mode === 'ed25519' ? loadKeys() : null;
const client = new NodeAgentClient({
url: toWsUrl(target.profile),
key: target.profile.key ?? '',
clientId: target.clientId,
insecure: target.profile.insecure,
authMode: target.profile.auth_mode,
privateKey: keys?.privateKey,
hub: target.profile.hub ? { token: target.profile.hub.token, nodeId: target.profile.hub.node_id } : undefined,
});
await client.connect();
const r = await client.invoke<{ pid?: number; build?: { hash?: string } }>(CapabilityNames.SystemInfo, {});
const caps = client.listCapabilities().length;
client.close();
const d = r.data ?? {};
return { ok: r.status === 'ok', pid: d.pid, buildHash: d.build?.hash, caps };
} catch (err) {
return { ok: false, caps: 0, error: err instanceof Error ? err.message : String(err) };
}
}
+3 -2
View File
@@ -49,8 +49,9 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
订阅事件并实时接收推送(无 --kind 则列出订阅)(v12)
nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h]
录屏为帧序列(有 ffmpeg 则封装 mp4)(v11)
nodeagent deploy <agent.mjs> [--path <远端路径>]
一键升级被控端(备份 → 上传 → 重启 → 复验)(v11)
nodeagent deploy <agent.mjs> [--path <远端路径>] [--check] [--force]
一键升级被控端 (v11/v18):比指纹 → 备份 → 上传 → 重启
→ 轮询校验;--check 只比对不部署
nodeagent group [add <组名> <设备...> | remove <组名>] 设备分组管理 (v12)
nodeagent fanout <能力名> [--nodes a,b|@组名] [--args JSON]
多设备并发调用并汇总 (v11)
+4
View File
@@ -314,5 +314,9 @@ async function main(): Promise<void> {
main().catch((err: unknown) => {
console.error(`✗ ${err instanceof Error ? err.message : String(err)}`);
// NODEAGENT_DEBUG=1 时打印堆栈 —— CLI 定位问题(尤其是异步链里的异常)必备
if (process.env['NODEAGENT_DEBUG'] === '1' && err instanceof Error && err.stack) {
console.error(err.stack);
}
process.exit(1);
});
+4
View File
@@ -91,6 +91,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
'confirm-within': { type: 'string' },
'task-name': { type: 'string' },
yes: { type: 'boolean', default: false },
check: { type: 'boolean', default: false },
force: { type: 'boolean', default: false },
mute: { type: 'string' },
volume: { type: 'string' },
seconds: { type: 'string' },
@@ -143,6 +145,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
confirmWithin: values['confirm-within'] as string | undefined,
taskName: values['task-name'] as string | undefined,
yes: Boolean(values['yes']),
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,
+3
View File
@@ -55,6 +55,9 @@ export interface Options {
kind?: string;
/** v12 宏变量 */
vars?: string[];
/** v18 部署校验 */
check?: boolean;
force?: boolean;
/** v17 音频 */
mute?: string;
volume?: string;
+2 -1
View File
@@ -19,7 +19,8 @@
"test:windows": "node tests/e2e/windows.mjs",
"verify": "node scripts/verify.mjs",
"pack:win": "node scripts/pack.mjs",
"pack:mac": "node scripts/pack-macos.mjs"
"pack:mac": "node scripts/pack-macos.mjs",
"update:win": "node scripts/update-win.mjs"
},
"devDependencies": {
"@types/node": "^22.10.2",
@@ -40,6 +40,12 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
type: 'object',
description: 'PowerShell 常驻助手状态(v15):spawned/hits/failures/consecutiveFailures/avg_ms 等',
},
build: {
type: 'object',
description:
'v18 构建指纹:hash(运行中 agent 脚本的 sha256 前 12 位,部署校验用)、bytes、mtime_ms、' +
'node(运行时版本)、started_at、uptime_ms',
},
},
},
},
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/**
* 一条命令更新被控端(Windows):打包 → 部署 → 校验。
*
* 用法:
* node scripts/update-win.mjs [设备名] [--check] [--force]
* 设备名省略时用控制端配置里的 current
*
* 等价于:
* pnpm pack:win
* nodeagent --node <设备名> deploy release/nodeagent-win-x64/agent.mjs
*
* 为什么要有它:手动流程要记住「先 pack、产物在 release/nodeagent-win-x64/、再 deploy」
* 三个知识点,容易出错(尤其 pack 失败时 deploy 的是旧包 —— 真机踩过)。
* 本脚本在 pack 之后**校验产物确已更新**,再部署。
*/
import { execFileSync } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
const flags = args.filter((a) => a.startsWith('--'));
const node = args.find((a) => !a.startsWith('--')) ?? '';
const bundle = join(root, 'release', 'nodeagent-win-x64', 'agent.mjs');
const before = existsSync(bundle) ? statSync(bundle).mtimeMs : 0;
console.log('=== ① 打包(pnpm pack:win)===');
execFileSync('pnpm', ['pack:win'], { cwd: root, stdio: 'inherit' });
if (!existsSync(bundle)) {
console.error('✗ 打包后仍找不到产物:', bundle);
process.exit(1);
}
const after = statSync(bundle).mtimeMs;
if (after === before) {
console.error('✗ 产物时间戳未变化 —— 打包可能失败,拒绝部署旧包(这是刻意的保护)');
process.exit(1);
}
console.log(`✓ 产物已更新:${bundle}`);
console.log('=== ② 部署到被控端 ===');
const target = node ? ['--node', node] : [];
execFileSync('nodeagent', [...target, 'deploy', bundle, ...flags], { cwd: root, stdio: 'inherit' });