diff --git a/apps/agent/src/capabilities/system.ts b/apps/agent/src/capabilities/system.ts index 5744dcb..25f507f 100644 --- a/apps/agent/src/capabilities/system.ts +++ b/apps/agent/src/capabilities/system.ts @@ -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 { // ---------------- 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 { const fields = args['fields'] as string[] | undefined; const info: Record = { @@ -63,6 +102,8 @@ export async function systemInfo(args: Args): Promise { node_path: process.execPath, // v15:PowerShell 常驻助手状态(排查 GUI 能力性能/降级时用) ps_helper: allPsShellStats(), + // v18:构建指纹(部署校验用) + build: buildInfo(), }; if (fields && fields.length > 0) { diff --git a/apps/cli/src/cmd/ops.ts b/apps/cli/src/cmd/ops.ts index 32d98a8..1346c34 100644 --- a/apps/cli/src/cmd/ops.ts +++ b/apps/cli/src/cmd/ops.ts @@ -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 { * 全程无需手工拷文件或起计划任务。 */ +/** + * 部署/更新被控端 agent(v18 起带指纹校验)。 + * + * 流程:本地算 sha256 → 问远端构建指纹 → 相同则跳过(--force 可强制)→ + * 备份 → 分块上传 → 受控重启 → **轮询直到远端指纹等于本地**(替代盲等)。 + * + * 为什么必须比指纹:只看「PID 变了 / 能力数对了」证明不了换成了新代码 + * (旧版可能恰好有同样多的能力)。内容哈希是唯一可靠判据。 + */ export async function cmdDeploy(localFile: string, opts: Options): Promise { 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>(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(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 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; + } } /** diff --git a/apps/cli/src/core.ts b/apps/cli/src/core.ts index eef85d3..3c7363f 100644 --- a/apps/cli/src/core.ts +++ b/apps/cli/src/core.ts @@ -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) }; + } +} diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts index e6b041f..a1c789d 100644 --- a/apps/cli/src/help.ts +++ b/apps/cli/src/help.ts @@ -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 [--path <远端路径>] - 一键升级被控端(备份 → 上传 → 重启 → 复验)(v11) + nodeagent deploy [--path <远端路径>] [--check] [--force] + 一键升级被控端 (v11/v18):比指纹 → 备份 → 上传 → 重启 + → 轮询校验;--check 只比对不部署 nodeagent group [add <组名> <设备...> | remove <组名>] 设备分组管理 (v12) nodeagent fanout <能力名> [--nodes a,b|@组名] [--args JSON] 多设备并发调用并汇总 (v11) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 34a4d44..80623fb 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -314,5 +314,9 @@ async function main(): Promise { 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); }); diff --git a/apps/cli/src/options.ts b/apps/cli/src/options.ts index 45835a2..e7fda49 100644 --- a/apps/cli/src/options.ts +++ b/apps/cli/src/options.ts @@ -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, diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index 2fae622..38696e9 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -55,6 +55,9 @@ export interface Options { kind?: string; /** v12 宏变量 */ vars?: string[]; + /** v18 部署校验 */ + check?: boolean; + force?: boolean; /** v17 音频 */ mute?: string; volume?: string; diff --git a/package.json b/package.json index 26e5432..94fdde3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/protocol/src/capabilities/manifest/system.ts b/packages/protocol/src/capabilities/manifest/system.ts index 6688ac8..6cded33 100644 --- a/packages/protocol/src/capabilities/manifest/system.ts +++ b/packages/protocol/src/capabilities/manifest/system.ts @@ -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', + }, }, }, }, diff --git a/scripts/update-win.mjs b/scripts/update-win.mjs new file mode 100644 index 0000000..db9b8e1 --- /dev/null +++ b/scripts/update-win.mjs @@ -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' });