diff --git a/apps/agent/src/index.ts b/apps/agent/src/index.ts index 2eab652..99d7c6b 100644 --- a/apps/agent/src/index.ts +++ b/apps/agent/src/index.ts @@ -1,7 +1,9 @@ import os from 'node:os'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { warmupWindowHelper } from './capabilities/window.js'; import { CAPABILITY_MANIFEST } from '@nodeagent/protocol'; -import { loadAgentConfig, agentConfigPath } from './config.js'; +import { agentDir, loadAgentConfig, agentConfigPath } from './config.js'; import { ensureCert } from './certs.js'; import { createAgentServer } from './server.js'; import { initAudit, audit, auditFilePath } from './audit.js'; @@ -19,6 +21,28 @@ function listLocalIps(): string[] { return out; } +/** + * 看门狗失败计数归零(v2.1.0,决策 R-1;scripts/startup-guard.mjs)。 + * + * 看门狗用 `<数据目录>/state/startup-state.json` 记录「连续启动失败次数」, + * 达到阈值即从 LKG 恢复入口。计数必须在**启动成功后**归零 —— 与 LKG 同一 + * 铁律:不把「启动中」当成功,否则坏版本每崩一次都会被上一次的成功洗白, + * 阈值形同虚设。写入失败一律吞掉:重置计数不能影响启动(与 recordLKG 同哲学)。 + */ +function resetStartupGuardState(): void { + try { + const dir = join(agentDir(), 'state'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'startup-state.json'), + `${JSON.stringify({ failures: 0, updated_at: new Date().toISOString() })}\n`, + 'utf8', + ); + } catch { + /* 重置失败不影响启动 */ + } +} + // --------------------------------------------------------------------------- // v2.0.3:进程级最后一道防线(崩溃链兜底) // --------------------------------------------------------------------------- @@ -196,6 +220,9 @@ async function main(): Promise { // 走到这里说明「这个版本至少能启动」,故记为最后已知可用版本(LKG)。 // 注意**不在更新写完时就记**:那会把尚未验证的版本当成已知可用,LKG 就失去意义。 recordLKG(); + // 同理(v2.1.0 看门狗):启动成功才把「连续启动失败」计数归零。 + // 顺序在 recordLKG 之后、启动横幅之前 —— 两者都是「起来了」的注脚。 + resetStartupGuardState(); console.log(''); console.log(' nodeagent 被控端已启动'); diff --git a/docs/DELIVERY-PIPELINE.md b/docs/DELIVERY-PIPELINE.md index 653d903..9664733 100644 --- a/docs/DELIVERY-PIPELINE.md +++ b/docs/DELIVERY-PIPELINE.md @@ -210,29 +210,65 @@ nodeagent 无服务端、无多环境:`agent` 跑在用户自己的机器上 | **先备份再原子替换**(同目录 rename,失败可回滚) | ✅ 已有 | | **sha256 强制校验**(不校验不落盘) | ✅ 已有 | | 更新后**重启并轮询校验新指纹**上线 | ✅ 已有(`deploy` 闭环) | -| **保留「最后已知可用版本」标记(LKG)** | ✅ **本次落地**(`apps/agent/src/lkg.ts`) | -| **更新后健康检查失败 → 自动回滚到备份** | ⚠️ **刻意不做**,见下 | +| **保留「最后已知可用版本」标记(LKG)** | ✅ 已有(`apps/agent/src/lkg.ts`) | +| **控制端手动回滚入口**(`nodeagent rollback`,读 LKG → 覆盖回入口 → 重启 → 复核) | ✅ **v2.1.0 新增**(需被控端可达) | +| **启动期自动恢复**(看门狗:启动连续失败 N 次 → 从 LKG 恢复) | ✅ **v2.1.0 新增**(`scripts/startup-guard.mjs`,三端 supervisor 已接入) | +| **运行期自动回滚**(更新后健康检查失败即自动回滚到备份) | ⚠️ **刻意不做**,见下 | -### LKG 的设计(本次落地) +### LKG 的设计(v2.0.x 落地,v2.1.0 补全量指纹) - **写入时机**:agent **成功启动后**(服务器已 listen、心跳已起)才写 `agent-lkg.json`。 **不在更新写完时就写** —— 那会把尚未验证的版本当成已知可用,LKG 就失去意义。 -- **内容**:`hash`(与 `system.info` 的 `build.hash` 同口径)、`bytes`、`script_path`、 +- **内容**:`hash`(与 `system.info` 的 `build.hash` 同口径,前 12 位)、 + `hash_full`(sha256 **全量 64 位**,v2.1.0 起记录 —— 备份指纹匹配用全值, + 消除 12 位前缀碰撞的理论风险)、`bytes`、`script_path`、 + `backup_glob`(v2.1.0 起,回滚内容候选的文件名模式 `.bak*`)、 `recorded_at`、`node_version`、`platform`。 + **老记录(无 `hash_full`/`backup_glob`)不猜**:消费方回落 12 位前缀比对与 + `.bak*` 约定,向后兼容。 - **暴露**:`system.info.lkg = { lkg, current_hash, matches, byte_delta }`。 `matches=false` = 当前版本还没被证明能启动 → 控制端应**暂缓向其余设备放量**。 -### 为什么**不做**自动回滚(明确的设计取舍) +### 回滚通道:手动 rollback + 看门狗自动恢复 + 边界明示(v2.1.0 定稿) -自动回滚听起来更安全,但在远程被控端上会引入更坏的失败形态: +| 场景 | 通道 | 前提 | +|---|---|---| +| 新版本能启动但需回退(行为异常 / 主动避险) | **手动** `nodeagent rollback [--dry-run] [--backup <远端路径>]`:指纹比对定位备份 → 覆盖回入口 → 受控重启 → 复核 `build.hash == LKG` | **被控端可达**(RPC 命令;`--dry-run` 只读零写) | +| 新版本进程**完全起不来**(无监听可连,CLI 救不了) | **看门狗自动恢复** `scripts/startup-guard.mjs`:supervisor 每次拉起先过守卫,启动连续失败 N 次(默认 3)自动从 LKG 恢复入口 | 外层 supervisor 在位(Windows 计划任务 / Linux systemd / macOS launchd,三端均已接入) | +| agent.mjs 自身损坏到 Node 无法加载 / Node 运行时缺失 | **带外恢复**(见附录 C):备份文件 + LKG 文件手工还原 | 物理或远程桌面可达 | + +**为什么恢复目标只有 LKG 一个**:回滚循环(回滚 → 启动 → 又判定失败 → 再回滚)在 +无人值守设备上比「停在坏版本」更难恢复。看门狗因此只做「回到上一个已知能启动的版本」 +这一件事,恢复后计数归零;**不做多版本链式回滚、不做版本选择**。 + +> **边界(明示,任何文案不得暗示「失联也能一条命令救回」)**: +> ① `nodeagent rollback` 是 RPC 命令,**需被控端可达** —— 它救的是「新版本能启动但需回退」; +> ② 看门狗自身也是 Node 脚本 —— **agent.mjs 损坏到 Node 无法解析、或 Node 运行时缺失时, +> 守卫同样无法运行**,只能带外恢复(附录 C); +> ③ 看门狗只守护「启动」这一时刻;运行期崩溃由外层 supervisor 的重启策略负责 +> (systemd `Restart=always` / 计划任务 `RestartCount=10`)。 + +**看门狗的判据(为什么不会误回滚)**: + +- 只计**启动期**死亡(子进程存活 < 30s 宽限期);运行期死亡(计划内重启杀的是长命进程)不计; +- 端口已被别的实例服务 → 判定「使命达成」,退 0 不计数(防 supervisor 重启造成的撞车被误记成启动失败); +- 无 LKG / 无指纹匹配的备份 → **记日志后放行,绝不擦除 install-dir**; +- 计数在 agent **listen 成功后**由 agent 自身归零(与 LKG 同一铁律:不把「启动中」当成功); +- 恢复动作全量记 `<数据目录>/logs/startup-guard.log`(JSONL:时间、动作、命中备份、结果)。 + +### 为什么**运行期**不做自动回滚(明确的设计取舍,v2.1.0 收窄) + +原三问(无法归因 / 回滚循环 / 失控)在**启动期**已被看门狗以受控方式回答 +(判据见上:只看启动期死亡、单一 LKG 目标、恢复后归零、全程日志、无匹配不动盘)。 +但**运行期**仍维持原取舍、刻意不做: 1. **无法归因**:远程设备上区分不出「更新本身坏了」与「网络抖动 / 端口被占 / 依赖服务未起」。 -2. **回滚循环**:回滚 → 启动 → 又判定失败 → 再回滚。在无人值守设备上,这比「停在坏版本」更难恢复。 -3. **失控**:被控端一旦自作主张改自己的程序,控制端就失去对「当前跑的到底是什么」的掌控。 +2. **回滚循环**:见上 —— 运行期自动回滚会把「停一次服务」放大成「来回换版本」。 +3. **失控**:被控端一旦在运行期自作主张改自己的程序,控制端就失去对「当前跑的到底是什么」的掌控。 -故定位为:**LKG 提供确定的回滚目标,决策权留给控制端**(`nodeagent rollback` 读 LKG → 覆盖回入口 → 重启)。 -现有 `system.agent.update` 已具备备份、哈希校验、写后校验、不一致即回滚 —— 那是**写入期**的闭环; -**运行期**的可用性判断交给人,不交给一个可能误判的自动机。 +故定位为:**LKG 提供确定的回滚目标;启动期决策交给看门狗(有界、可审计), +运行期决策交给人**(`nodeagent rollback`)。写入期的闭环(备份、哈希校验、写后校验、 +不一致即回滚)由 `system.agent.update` 既有实现承担,本文不改动它。 --- @@ -374,10 +410,14 @@ chore(privacy): 公开仓库隐私脱敏 —— 清除可定位到个人与设 **回滚动作** -1. 登录目标机(物理或带外方式),用备份恢复:`agent.mjs.bak-` → `agent.mjs`。 -2. 若需回退发布通道:把 `latest.json` 指回上一版本 URL + sha256。 -3. 重启 agent(计划任务或 `run-temp.cmd`)。 -4. 复核:`verify.mjs` 全绿 → 记录时间线 → 写复盘 → **把根因变成一条会红的断言**。 +1. **被控端可达**(能连上)→ `nodeagent rollback`(指纹比对定位 LKG 备份 → 覆盖回入口 → 受控重启 → 复核 `build.hash == LKG`;`--dry-run` 可先只读比对)。 +2. **被控端起不来**(连不上)→ 看门狗应已介入:启动连续失败 3 次后自动从 LKG 恢复,过程见 `<数据目录>/logs/startup-guard.log`。若看门狗未恢复(无 LKG / 无指纹匹配的备份)或状态异常,登录目标机(物理或带外方式),用备份恢复:`agent.mjs.bak`(或 `agent.mjs.bak-`)→ `agent.mjs`。 +3. 若需回退发布通道:把 `latest.json` 指回上一版本 URL + sha256。 +4. 重启 agent(计划任务或 `run-temp.cmd`)。 +5. 复核:`verify.mjs` 全绿 → 记录时间线 → 写复盘 → **把根因变成一条会红的断言**。 + +> 手动回滚与看门狗的分工:rollback 是「人决策、被控端可达」;看门狗是「机器决策、只限启动期」。 +> 两者都只认 LKG 一个目标;边界(agent.mjs/Node 损坏时两者都无能为力)见 §6 的明示块。 --- @@ -386,3 +426,4 @@ chore(privacy): 公开仓库隐私脱敏 —— 清除可定位到个人与设 | 日期 | 内容 | |---|---| | 2026-10-17 | 初版。基于《代码审计 → 上线 完整交付方案》v1.0 适配,基线 `590f62e`,单测 341 全绿。 | +| 2026-10-25 | v2.1.0:§6 定稿回滚通道 —— 手动 `nodeagent rollback`(需被控端可达)+ 看门狗自动恢复(`scripts/startup-guard.mjs`,启动连续失败 N 次从 LKG 恢复,三端 supervisor 接入)+ 边界明示(agent.mjs/Node 损坏时守卫也无法运行,只能带外恢复);「运行期自动回滚」维持刻意不做(三问论证收窄到运行期)。LKG 记录补 `hash_full`/`backup_glob` 只增字段(老记录回落 12 位前缀)。附录 C 同步。 | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 17acf04..6747a32 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -728,6 +728,65 @@ const timer = setTimeout(() => { | 证书 | 自签证书(两端固定校验,防中间人) | | 连接方向 | 控制端主动 → 被控端监听 | +### 7.4 被控端自启与看门狗(v2.1.0,决策 R-1) + +被控端的启动链是:**外层 supervisor → 看门狗(`scripts/startup-guard.mjs`)→ agent**。 +看门狗解决 `nodeagent rollback` 覆盖不到的场景:进程**完全起不来**时没有监听可连, +RPC 命令无能为力;看门狗跑在 supervisor 链上,启动连续失败 N 次(默认 3)自动从 +LKG 恢复入口文件。判据与边界见 `docs/DELIVERY-PIPELINE.md` §6。 + +| 平台 | supervisor | 接入点(仓库文件) | +|---|---|---| +| Windows | 计划任务 `nodeagent` | `scripts/install.ps1` 的 `New-ScheduledTaskAction`(启动动作包一层 guard,`RestartCount 10`) | +| Linux | systemd unit `nodeagent` | `scripts/install-linux.sh` 的 `ExecStart`(包一层 guard,`Restart=always` 负责重启) | +| macOS | launchd LaunchAgent | **无安装器** —— 手工按下方 plist 接入(仓库不含 macOS 被控端安装器) | + +**Windows / Linux 的接入由安装器自动完成**,无需手工配置;`install.ps1` 找不到看门狗 +脚本时按原样直启 agent(兜底,不因缺脚本而装不上)。 + +**macOS 手工接入(launchd)**——被控端以 LaunchAgent 形式常驻时,把 +`ProgramArguments` 包一层看门狗(`~/Library/LaunchAgents/com.nodeagent.agent.plist`): + +```xml + + + + + Labelcom.nodeagent.agent + ProgramArguments + + /opt/nodeagent/runtime/bin/node + /opt/nodeagent/scripts/startup-guard.mjs + --data-dir/Users/Shared/.nodeagent + --lkg-dir/opt/nodeagent + --install-dir/opt/nodeagent + --threshold3 + -- + /opt/nodeagent/runtime/bin/node + /opt/nodeagent/agent.mjs + + WorkingDirectory/opt/nodeagent + EnvironmentVariables + NODEAGENT_HOME/Users/Shared/.nodeagent + RunAtLoad + + KeepAlive + SuccessfulExit + ThrottleInterval10 + StandardOutPath/opt/nodeagent/logs/agent.out.log + StandardErrorPath/opt/nodeagent/logs/agent.err.log + + +``` + +加载:`launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nodeagent.agent.plist`。 +看门狗日志(恢复动作、失败计数)在 `/logs/startup-guard.log`(JSONL)。 + +**铁律**:看门狗自身必须「裸环境可跑」——零项目依赖、不 import 项目内模块, +只依赖 Node ≥22 内置模块 + 同目录 `lib/startup-guard-core.mjs`(纯函数)。 +分发时两个文件必须保持相对位置一起拷贝(pack.mjs / install-linux.sh 已保证)。 + --- ## 第 8 章 工程结构 diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh index 4f43f73..f0ac6f3 100755 --- a/scripts/install-linux.sh +++ b/scripts/install-linux.sh @@ -213,6 +213,26 @@ mkdir -p "$INSTALL_DIR" "$DATA_DIR" install -m 0644 "$AGENT_JS_SRC" "$INSTALL_DIR/agent.mjs" ok "已安装:$INSTALL_DIR/agent.mjs($(wc -c < "$INSTALL_DIR/agent.mjs" | tr -d ' ') 字节)" +# ---------------- 看门狗(v2.1.0,决策 R-1) ---------------- +# +# 启动连续失败 N 次自动从 LKG 恢复入口文件。 +# 为什么需要:nodeagent rollback 是 RPC 命令,被控端「完全起不来」时无监听可连, +# CLI 救不了(ARCH A-P0-6);看门狗跑在 supervisor 链上,不要求被控端可达。 +# ⚠️ 两个文件必须保持相对位置一起安装:guard 同目录的 lib/ 下是它的核心纯函数。 +GUARD_MJS="$PROJECT_ROOT/scripts/startup-guard.mjs" +GUARD_LIB="$PROJECT_ROOT/scripts/lib/startup-guard-core.mjs" +if [ -f "$GUARD_MJS" ] && [ -f "$GUARD_LIB" ]; then + mkdir -p "$INSTALL_DIR/scripts/lib" + install -m 0644 "$GUARD_MJS" "$INSTALL_DIR/scripts/startup-guard.mjs" + install -m 0644 "$GUARD_LIB" "$INSTALL_DIR/scripts/lib/startup-guard-core.mjs" + # 被看守的是「完整启动命令」(-- 之后原样 spawn):node agent.mjs + AGENT_CMD="$NODE_BIN $INSTALL_DIR/scripts/startup-guard.mjs --data-dir $DATA_DIR --lkg-dir $INSTALL_DIR --install-dir $INSTALL_DIR --threshold 3 -- $NODE_BIN $INSTALL_DIR/agent.mjs" + ok "看门狗就绪:启动连续失败 3 次自动从 LKG 恢复(日志 $DATA_DIR/logs/startup-guard.log)" +else + AGENT_CMD="$NODE_BIN $INSTALL_DIR/agent.mjs" + warn "未找到看门狗脚本($GUARD_MJS)—— 按原样直启 agent(无自动恢复)" +fi + # ---------------- 配置:复用既有密钥,否则生成 ---------------- CFG="$DATA_DIR/agent.json" @@ -279,12 +299,15 @@ Wants=network-online.target Type=simple # 数据目录(含密钥与审计日志)—— 与 agent 侧 agentDir() 一致 Environment=NODEAGENT_HOME=$DATA_DIR -ExecStart=$NODE_BIN $INSTALL_DIR/agent.mjs +# v2.1.0:ExecStart 包一层看门狗($AGENT_CMD,见上文「看门狗」节)。 +# 崩溃自拉起由 Restart=always 负责;看门狗负责「连续失败 N 次从 LKG 恢复」。 +ExecStart=$AGENT_CMD WorkingDirectory=$INSTALL_DIR # 崩溃自拉起(对齐 Windows 计划任务的重启语义) Restart=always RestartSec=3 -# 优雅停止:先 SIGTERM(agent 会关掉监听与审计写入),超时再 SIGKILL +# 优雅停止:先 SIGTERM(agent 会关掉监听与审计写入),超时再 SIGKILL。 +# 看门狗收到 SIGTERM 会转发给子进程后退出,不会被误记成「启动失败」。 KillSignal=SIGTERM TimeoutStopSec=15 # 日志进 journald:journalctl -u $UNIT_NAME -f diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 1e491b9..43adba8 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -206,7 +206,25 @@ Write-Ok "Inbound rule allowed on TCP $Port" # 6. Scheduled task Write-Step "Registering scheduled task..." $taskName = "nodeagent" -$action = New-ScheduledTaskAction -Execute $nodeExe -Argument "`"$agentJs`"" -WorkingDirectory $ProjectRoot +# v2.1.0 看门狗(决策 R-1):启动动作包一层 scripts/startup-guard.mjs —— +# 被控端「启动连续失败 N 次」时自动从 LKG 恢复入口文件。 +# 为什么需要:nodeagent rollback 是 RPC 命令,进程完全起不来时无监听可连, +# CLI 救不了(ARCH A-P0-6);看门狗跑在 supervisor 链上,不需要被控端可达。 +# 脚本位置:便携包内随 scripts/ 一起分发;开发模式用仓库 scripts/。 +# 缺文件时**兜底直启 agent**(不因少一个脚本而装不上),行为与旧版一致。 +$guardScript = Join-Path $PSScriptRoot "scripts\startup-guard.mjs" +if (-not (Test-Path $guardScript)) { $guardScript = Join-Path $ProjectRoot "scripts\startup-guard.mjs" } +$guardLib = Join-Path (Split-Path -Parent $guardScript) "lib\startup-guard-core.mjs" +if ((Test-Path $guardScript) -and (Test-Path $guardLib)) { + $agentHome = Split-Path -Parent $agentJs + # 被看守的是「完整启动命令」(-- 之后原样 spawn):node + $guardArgs = "`"$guardScript`" --data-dir `"$cfgDir`" --lkg-dir `"$agentHome`" --install-dir `"$agentHome`" --threshold 3 -- `"$nodeExe`" `"$agentJs`"" + $action = New-ScheduledTaskAction -Execute $nodeExe -Argument $guardArgs -WorkingDirectory $ProjectRoot + Write-Ok "启动动作已包一层看门狗(连续失败 3 次自动从 LKG 恢复)" +} else { + Write-Warn "未找到看门狗脚本($guardScript)—— 按原样直启 agent(无自动恢复)" + $action = New-ScheduledTaskAction -Execute $nodeExe -Argument "`"$agentJs`"" -WorkingDirectory $ProjectRoot +} # 触发器:登录时(默认)+ 可选开机时;无人值守模式下开机触发器才真正有意义 $triggers = @(New-ScheduledTaskTrigger -AtLogOn) @@ -214,9 +232,12 @@ if ($AtStartup -or $Unattended) { $triggers += New-ScheduledTaskTrigger -AtStartup } -# 自愈:异常退出后自动重启(最多 3 次,间隔 1 分钟),且不设执行时长上限 +# 自愈:异常退出后自动重启(间隔 1 分钟),且不设执行时长上限。 +# v2.1.0:RestartCount 3 → 10 —— 看门狗要「连续失败 3 次」才触发 LKG 恢复, +# 加上恢复本身可能需要再起一次,3 次不够用(会在恢复前就放弃)。 +# 良性撞车(端口已被别的实例服务)时看门狗直接退 0,不消耗重启次数。 $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` - -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) ` + -RestartCount 10 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) ` -StartWhenAvailable # 登录类型决定「能干什么」: diff --git a/scripts/lib/startup-guard-core.mjs b/scripts/lib/startup-guard-core.mjs new file mode 100644 index 0000000..4760821 --- /dev/null +++ b/scripts/lib/startup-guard-core.mjs @@ -0,0 +1,175 @@ +/** + * 看门狗核心(startup-guard-core.mjs)—— 纯函数 + 零依赖 + 零 IO。 + * + * 本文件是 scripts/startup-guard.mjs 的「大脑」:所有**判断**都在这里, + * 且全部是可单测的纯函数;文件系统、子进程、网络都在 startup-guard.mjs 里 + * 以薄封装注入。铁律:本文件**不 import 任何东西**(连 node: 内置都不用), + * 保证它在任何 Node ≥22 环境里都能被加载、被测试。 + * + * ## 这个看门狗解决什么问题(决策 R-1) + * `nodeagent rollback` 是 RPC 命令,**被控端进程完全起不来时无监听可连**, + * CLI 救不了(ARCH A-P0-6)。故加一层启动看护:启动连续失败 N 次, + * 自动从 LKG(最后已知可用版本)恢复入口文件。 + * + * ## 与 DELIVERY-PIPELINE §6 的关系 + * §6 原取舍「不做自动回滚」的三问(无法归因/回滚循环/失控)在这里的答复: + * - 无法归因 → 只看**启动期**死亡(存活 < STARTUP_GRACE_MS), + * 运行期死亡不计(计划内重启杀的是长命进程,见 decideAfterExit); + * - 回滚循环 → 恢复后计数归零,且恢复目标只有一个(LKG),不滚动; + * - 失控 → 恢复动作全量记 JSONL 日志,且**无 LKG/无匹配时绝不擦除 install-dir**。 + */ + +/** + * 启动宽限期(ms):子进程存活超过它再死,视为**运行期事件**而非启动失败。 + * + * 为什么需要:本项目的受控端重启机制(system.agent.restart)是 + * 「detached 进程杀旧起新」——旧进程是被 kill -9/taskkill 打死的**长命**进程。 + * 若不区分启动期/运行期,一次计划内重启就会被记成「启动失败」, + * 累积 N 次后触发**误回滚**(把好版本回滚掉,比坏版本停摆更糟)。 + * 30s 的依据:agent 正常启动(含首次生成证书)在秒级完成; + * 坏版本(语法错/缺模块/端口冲突)在 1-2s 内死亡。 + */ +export const STARTUP_GRACE_MS = 30_000; + +/** 全新状态(文件缺失时的起点)。 */ +export function initialState(nowIso = '') { + return { failures: 0, updated_at: nowIso }; +} + +/** 启动成功后的状态:计数归零(与 agent 侧 listen 成功后写状态同口径)。 */ +export function zeroFailures(nowIso = '') { + return { failures: 0, updated_at: nowIso }; +} + +/** + * 解析状态文件文本 → `{failures, updated_at}`。 + * + * 缺失/损坏/字段非法一律按 0 —— **不猜、但也不因此拒绝服务**: + * 状态文件只是计数,坏了的代价从「重新数」开始,而不是「不启动」。 + * nowIso 仅在文本合法时保留原 updated_at;调用方需要时自行覆盖。 + */ +export function readState(rawText) { + if (typeof rawText !== 'string' || rawText.trim() === '') return initialState(); + let obj; + try { + obj = JSON.parse(rawText); + } catch { + return initialState(); + } + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return initialState(); + // 只认 number 型的非负整数:字符串 "3"、布尔、null 一律按 0(不猜) + const n = obj.failures; + const failures = typeof n === 'number' && Number.isInteger(n) && n >= 0 ? n : 0; + const updatedAt = typeof obj.updated_at === 'string' ? obj.updated_at : ''; + return { failures, updated_at: updatedAt }; +} + +/** 失败一次后的新状态(nowIso 由调用方注入,保证可测)。 */ +export function nextFailures(state, nowIso = '') { + const prev = Number.isInteger(state?.failures) && state.failures >= 0 ? state.failures : 0; + return { failures: prev + 1, updated_at: nowIso }; +} + +/** + * 是否达到恢复阈值:连续失败 **>= threshold** 才恢复。 + * + * 阈值非法(非正整数)→ 不恢复(放行启动)—— 守卫自身配置错误时, + * 宁可多试几次,也不能乱动入口文件。 + */ +export function decideRestore(state, threshold) { + const t = Number(threshold); + if (!Number.isInteger(t) || t < 1) return false; + return (state?.failures ?? 0) >= t; +} + +/** + * 子进程退出后的处置决策(纯函数,本模块最重要的判断)。 + * + * @param {object} input + * @param {number|null} [input.code] 子进程退出码(被信号打死时为 null) + * @param {string|null} [input.signal] 打死子进程的信号名(正常退出为 null) + * @param {number} input.livedMs 子进程存活时长 + * @param {boolean} input.stopping 本 guard 是否正在被要求停止 + * (systemctl stop / 任务停止 → 已转发信号给子进程) + * @param {boolean} input.serviceUp 端口是否已被(别的实例)服务 + * @param {object} input.state 进入本次决策前的状态 + * @param {string} input.nowIso 当前时间(注入) + * @returns {{state: object, counted: boolean, persist: boolean, exitCode: number, reason: string}} + * + * 优先级(从高到低): + * 1. serviceUp —— 服务已由别的实例提供(撞车/接管):使命达成,归零退出 0; + * 2. stopping —— 本 guard 被要求停:不计数,按子进程退出码退出; + * 3. 干净退出 0 —— 成功,归零退出 0; + * 4. 运行期死亡(livedMs >= 宽限期)—— 不计「启动失败」,退出码交回外层 supervisor; + * 5. 启动期死亡且无人接管 —— 连续启动失败 +1,退出码交回外层 supervisor。 + * + * persist 表示「本次决策是否要落盘」(stopping / runtime-exit 不写: + * 状态没变,写了反而把 updated_at 搅乱)。 + */ +export function decideAfterExit(input) { + const { + code = 0, + signal = null, + livedMs = 0, + stopping = false, + serviceUp = false, + state = initialState(), + nowIso = '', + } = input ?? {}; + + if (serviceUp) { + return { state: zeroFailures(nowIso), counted: false, persist: true, exitCode: 0, reason: 'service-up' }; + } + if (stopping) { + return { state, counted: false, persist: false, exitCode: code ?? 1, reason: 'stopping' }; + } + if (!signal && code === 0) { + return { state: zeroFailures(nowIso), counted: false, persist: true, exitCode: 0, reason: 'clean-exit' }; + } + const lived = Number.isFinite(livedMs) ? livedMs : 0; + if (lived >= STARTUP_GRACE_MS) { + return { state, counted: false, persist: false, exitCode: code ?? 1, reason: 'runtime-exit' }; + } + return { state: nextFailures(state, nowIso), counted: true, persist: true, exitCode: code ?? 1, reason: 'startup-failure' }; +} + +/** + * 备份文件名匹配:`.bak`(deploy 固定名)与 `.bak-`(selfupdate 时间戳名)。 + * + * 与控制端 `apps/cli/src/cmd/rollback.ts` 的 `backupNameRegex` **同口径** —— + * 两边认不出同一批备份 = 看门狗恢复的文件与 rollback 枚举的文件不一致。 + * (tests/unit/startup-guard.test.mjs 有跨端一致性守卫。) + * 文件名里的正则元字符一律转义;Windows 反斜杠不当分隔符处理(文件名内不出现)。 + */ +export function backupNameRegex(entryName) { + const base = String(entryName ?? ''); + const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${esc}\\.bak(-.*)?$`); +} + +/** + * 指纹比对:全量相等,或老 LKG(12 位)按前缀比。 + * + * 与 CLI 的 `hashMatches` 同口径:LkgRecord 老数据只有 12 位 hash + * (ARCH A-P0-3/R-2),不得要求被控端先升级才能被看门狗恢复。 + */ +export function hashMatches(got, want) { + if (typeof got !== 'string' || typeof want !== 'string' || !got || !want) return false; + if (got === want) return true; + return want.length < got.length && got.startsWith(want); +} + +/** + * 从「已算好指纹」的备份候选里挑恢复源(纯函数;mtime 新→早的排序在外层做)。 + * + * @param {Array<{path: string, hash: string, mtime?: number}>} cands + * @param {string} wantHash LKG 指纹(hash_full ?? hash) + * @returns {{path: string, hash: string}|null} 第一个匹配者;无匹配返回 null + */ +export function planRestore(cands, wantHash) { + if (!wantHash || !Array.isArray(cands)) return null; + for (const c of cands) { + if (c && hashMatches(c.hash, wantHash)) return { path: c.path, hash: c.hash }; + } + return null; +} diff --git a/scripts/pack.mjs b/scripts/pack.mjs index 21b5474..d209e9b 100644 --- a/scripts/pack.mjs +++ b/scripts/pack.mjs @@ -122,6 +122,17 @@ async function main() { copyFileSync(join(root, 'scripts', 'control.ps1'), join(PKG_DIR, 'control.ps1')); copyFileSync(join(root, 'scripts', 'uninstall.ps1'), join(PKG_DIR, 'uninstall.ps1')); + // 看门狗(v2.1.0,决策 R-1):install.ps1 把计划任务的启动动作包一层它 —— + // 被控端「启动连续失败 3 次」时自动从 LKG 恢复入口文件。 + // ⚠️ 必须保持 scripts/lib/ 的相对位置:startup-guard.mjs 以 './lib/startup-guard-core.mjs' + // 相对导入其核心纯函数(裸环境零依赖,见该文件头)。 + mkdirSync(join(PKG_DIR, 'scripts', 'lib'), { recursive: true }); + copyFileSync(join(root, 'scripts', 'startup-guard.mjs'), join(PKG_DIR, 'scripts', 'startup-guard.mjs')); + copyFileSync( + join(root, 'scripts', 'lib', 'startup-guard-core.mjs'), + join(PKG_DIR, 'scripts', 'lib', 'startup-guard-core.mjs'), + ); + // 一键入口:.cmd 必须**纯 ASCII**。CMD 的代码页(中文 Windows = 936/GBK) // 无法可靠往返 UTF-8,中文提示一律交给带 BOM 的 .ps1 去输出。 for (const cmd of ['install.cmd', 'control.cmd', 'run-portable.cmd']) { diff --git a/scripts/startup-guard.mjs b/scripts/startup-guard.mjs new file mode 100644 index 0000000..5e2b6d0 --- /dev/null +++ b/scripts/startup-guard.mjs @@ -0,0 +1,458 @@ +#!/usr/bin/env node +/** + * 启动看门狗(startup-guard.mjs)—— 被控端「启动连续失败 N 次」自动从 LKG 恢复。 + * + * ## 为什么需要(决策 R-1,ARCH A-P0-6) + * `nodeagent rollback` 是 RPC 命令:被控端**完全起不来时没有监听可连**,CLI 无能为力。 + * 本脚本补上这条链路:外层 supervisor(Windows 计划任务 / Linux systemd / + * macOS launchd)每次拉起被控端都先过本守卫;启动连续失败达到阈值时, + * 自动用 LKG 记录 + 备份指纹匹配恢复入口文件,再放行启动。 + * + * ## 裸环境铁律 + * **零项目依赖、不 import 项目内模块**(连 @nodeagent/* 都不碰)—— + * 它要在被控端的裸环境里跑:只依赖 Node ≥22 内置模块 + 同目录的 + * `lib/startup-guard-core.mjs`(纯函数,也无依赖)。分发时两个文件必须 + * 保持相对位置一起拷贝(见 install-linux.sh / install.ps1 / pack.mjs)。 + * + * ## 用法 + * node scripts/startup-guard.mjs \ + * --data-dir <数据目录> --lkg-dir <备份所在目录> --install-dir <安装目录> \ + * [--entry agent.mjs] [--threshold 3] [--port 8765] \ + * -- <被看守的完整启动命令...> + * + * ## 行为契约(与 supervisor 的退出码约定) + * - 退出 0:服务已由别的实例提供 / 干净停止 / 本守卫被要求停; + * - 退出非 0:启动失败(已计数)或运行期退出 —— supervisor 据此重启, + * 重启后本守卫重新读计数,达到阈值即执行 LKG 恢复。 + * + * ## 安全边界(写进 DELIVERY-PIPELINE §6 的明示) + * - **无 LKG / 无指纹匹配的备份 → 记日志后放行,绝不擦除 install-dir**; + * - agent.mjs 自身损坏到 Node 都无法解析、或 Node 运行时缺失时, + * 本守卫同样无法运行 —— 那种场景只能带外恢复(附录 C)。 + */ +import { createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { + closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, statSync, writeFileSync, +} from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { + STARTUP_GRACE_MS, + backupNameRegex, + decideAfterExit, + decideRestore, + initialState, + planRestore, + readState, + zeroFailures, +} from './lib/startup-guard-core.mjs'; + +// ---------- 路径与状态 ---------- + +const LKG_FILE = 'agent-lkg.json'; +const HASH_CHUNK = 512 * 1024; + +/** 状态文件路径:/state/startup-state.json */ +export function statePath(dataDir) { + return join(dataDir, 'state', 'startup-state.json'); +} + +/** 读失败计数;文件缺失/损坏按 0(不猜、不拒绝服务)。 */ +export function readStateFile(dataDir) { + try { + const p = statePath(dataDir); + if (!existsSync(p)) return initialState(); + return readState(readFileSync(p, 'utf8')); + } catch { + return initialState(); + } +} + +/** 写失败计数(含目录创建);失败返回 false(不抛——计数丢了下次重新数)。 */ +export function writeState(dataDir, state) { + try { + mkdirSync(join(dataDir, 'state'), { recursive: true }); + writeFileSync(statePath(dataDir), `${JSON.stringify(state, null, 2)}\n`, 'utf8'); + return true; + } catch { + return false; + } +} + +/** 追加一行 JSONL 日志:/logs/startup-guard.log(失败静默——日志不拦路)。 */ +export function appendGuardLog(dataDir, obj) { + try { + mkdirSync(join(dataDir, 'logs'), { recursive: true }); + const line = `${JSON.stringify({ ts: new Date().toISOString(), ...obj })}\n`; + writeFileSync(join(dataDir, 'logs', 'startup-guard.log'), line, { flag: 'a' }); + } catch { + /* 日志失败不影响启动 */ + } +} + +// ---------- LKG 与备份 ---------- + +/** 读 LKG 记录(/agent-lkg.json);缺失/损坏返回 null。 */ +export function readLkgRecord(dataDir) { + try { + const p = join(dataDir, LKG_FILE); + if (!existsSync(p)) return null; + const rec = JSON.parse(readFileSync(p, 'utf8')); + if (!rec || typeof rec.hash !== 'string' || !rec.hash) return null; + return rec; + } catch { + return null; + } +} + +/** 流式计算文件 sha256(分块读,不吃内存)。 */ +export function hashFileSync(p) { + const fd = openSync(p, 'r'); + try { + const h = createHash('sha256'); + const buf = Buffer.alloc(HASH_CHUNK); + for (;;) { + const n = readSync(fd, buf, 0, buf.length, null); + if (n === 0) break; + h.update(buf.subarray(0, n)); + } + return h.digest('hex'); + } finally { + closeSync(fd); + } +} + +/** + * 枚举备份候选: 下 `.bak` / `.bak-`,按 mtime 新→早。 + * + * 与 CLI rollback 的 backupNameRegex 同口径(tests 有跨端一致性守卫): + * deploy 固定名 + selfupdate 时间戳名两套命名都要认。 + */ +export function listBackupCandidates(lkgDir, entryName) { + const re = backupNameRegex(entryName); + let items; + try { + items = readdirSync(lkgDir, { withFileTypes: true }); + } catch { + return []; + } + const out = []; + for (const it of items) { + if (!it.isFile() || !re.test(it.name)) continue; + try { + const st = statSync(join(lkgDir, it.name)); + out.push({ path: join(lkgDir, it.name), name: it.name, mtime: st.mtimeMs, size: st.size }); + } catch { + /* 单个文件 stat 失败就跳过 */ + } + } + out.sort((a, b) => b.mtime - a.mtime); + return out; +} + +/** + * 从 LKG 恢复入口文件(达到阈值时调用)。 + * + * 流程:读 LKG → 枚举备份 → 流式指纹匹配(hash_full 全量优先,老记录 12 位前缀回落) + * → 命中则**就地覆写** install-dir/(同 selfupdate 姿势:不 rename, + * Windows 上 rename 会被占用的文件/杀软挡下,真机教训)→ 写后校验。 + * + * 安全边界:无 LKG / 无候选 / 无匹配 / 读失败 → 原样返回不恢复, + * **绝不清空或部分擦写 install-dir**。任何异常都吞掉返回 error —— + * 守卫自己坏掉时也要放行启动,不能把「恢复」变成「致人死亡」。 + * + * @returns {{restored: boolean, backup: string|null, reason: string, entry?: string, + * hash?: string, candidates?: string[]}} + */ +export function restoreFromLkg({ dataDir, lkgDir, installDir, entry }) { + try { + const lkg = readLkgRecord(dataDir); + if (!lkg) return { restored: false, backup: null, reason: 'no-lkg' }; + const want = lkg.hash_full ?? lkg.hash; + const entryName = entry || basename(lkg.script_path || '') || 'agent.mjs'; + const cands = listBackupCandidates(lkgDir, entryName); + if (cands.length === 0) return { restored: false, backup: null, reason: 'no-candidates', entry: entryName }; + + const hashed = []; + for (const c of cands) { + try { + hashed.push({ path: c.path, hash: hashFileSync(c.path), mtime: c.mtime }); + } catch { + /* 跳过读不了的候选 */ + } + } + const picked = planRestore(hashed, want); + if (!picked) { + return { + restored: false, + backup: null, + reason: 'no-match', + entry: entryName, + candidates: hashed.map((h) => `${h.path} ${h.hash.slice(0, 12)}`), + }; + } + + const target = join(installDir, entryName); + const buf = readFileSync(picked.path); + writeFileSync(target, buf); + // 写后校验:目标内容必须等于备份内容(防写一半/被杀软改坏) + const after = hashFileSync(target); + if (after !== picked.hash) { + return { restored: false, backup: picked.path, reason: 'verify-failed', entry: entryName }; + } + return { restored: true, backup: picked.path, hash: picked.hash, entry: entryName, bytes: buf.length }; + } catch (err) { + return { restored: false, backup: null, reason: `error: ${err instanceof Error ? err.message : String(err)}` }; + } +} + +// ---------- 端口探活 ---------- + +/** + * 解析被控端端口:--port 参数优先,否则读 /agent.json 的 port 字段。 + * 都没有(如 Hub 模式不监听本地端口)→ null,此时跳过所有端口判断。 + */ +export function resolvePort(dataDir, portArg) { + if (Number.isInteger(portArg) && portArg > 0 && portArg < 65536) return portArg; + try { + const p = join(dataDir, 'agent.json'); + if (existsSync(p)) { + const cfg = JSON.parse(readFileSync(p, 'utf8')); + const n = Number(cfg.port); + if (Number.isInteger(n) && n > 0 && n < 65536) return n; + } + } catch { + /* 忽略:端口未知就不做端口判断 */ + } + return null; +} + +/** + * 端口是否已被服务(TCP 能连上即算,不做协议握手 —— 只要「有人在听」)。 + * + * 用途有两个,都指向同一目的「别添乱」: + * ① 启动前:服务已由别的实例提供 → 本守卫直接退出 0,不再 spawn(避免撞车); + * ② 子进程死后:服务已由别的实例接管 → 不算启动失败(撞车输的那次不算)。 + */ +export function serviceUp(port, host = '127.0.0.1', timeoutMs = 500) { + if (!port) return Promise.resolve(false); + return new Promise((done) => { + const s = net.connect({ host, port }); + const finish = (v) => { + s.destroy(); + done(v); + }; + s.setTimeout(timeoutMs, () => finish(false)); + s.once('connect', () => finish(true)); + s.once('error', () => finish(false)); + }); +} + +// ---------- 参数解析 ---------- + +/** + * 解析守卫自己的参数(纯函数,可单测)。 + * `--` 之后的所有 token 原样作为「被看守的完整启动命令」。 + */ +export function parseGuardArgs(argv) { + const sep = argv.indexOf('--'); + const own = sep >= 0 ? argv.slice(0, sep) : argv.slice(); + const out = { + dataDir: '', + lkgDir: '', + installDir: '', + entry: '', + threshold: 3, + port: null, + cmd: sep >= 0 ? argv.slice(sep + 1) : [], + help: false, + }; + for (let i = 0; i < own.length; i += 1) { + const a = own[i]; + const take = () => { + const v = own[i + 1]; + if (v === undefined || v.startsWith('--')) throw new Error(`参数 ${a} 缺少取值`); + i += 1; + return v; + }; + switch (a) { + case '--data-dir': out.dataDir = take(); break; + case '--lkg-dir': out.lkgDir = take(); break; + case '--install-dir': out.installDir = take(); break; + case '--entry': out.entry = take(); break; + case '--threshold': out.threshold = Number(take()); break; + case '--port': out.port = Number(take()); break; + case '-h': case '--help': out.help = true; break; + default: throw new Error(`未知参数: ${a}`); + } + } + return out; +} + +const USAGE = '用法: node startup-guard.mjs --data-dir <目录> --install-dir <目录> [--lkg-dir <目录>] ' + + '[--entry agent.mjs] [--threshold 3] [--port 8765] -- <启动命令...>'; + +// ---------- 主流程 ---------- + +/** + * 看守一个子进程直到它退出,并按 decideAfterExit 的决策处理计数与退出码。 + * (spawn / 信号转发都在这里;IO 之外的判断全部在 core 纯函数里。) + */ +async function supervise(opts, port) { + const [cmdName, ...cmdArgs] = opts.cmd; + const child = spawn(cmdName, cmdArgs, { stdio: 'inherit', cwd: opts.installDir, env: process.env }); + + let stopping = false; + const forwardSignal = (sig) => { + stopping = true; + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill(sig); + } catch { + /* 已退出 */ + } + // 兜底:子进程不理信号就强杀(守卫不能陪着赖着不走) + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill('SIGKILL'); + } catch { + /* 已退出 */ + } + } + }, 10_000).unref(); + } + }; + process.on('SIGTERM', () => forwardSignal('SIGTERM')); + process.on('SIGINT', () => forwardSignal('SIGINT')); + + const startedAt = Date.now(); + const exit = await new Promise((done) => { + child.on('exit', (code, signal) => done({ code, signal })); + child.on('error', (err) => { + // spawn 本身失败(命令不存在等)= 启动期失败,按 code=1 计数 + appendGuardLog(opts.dataDir, { action: 'spawn-error', error: err.message }); + done({ code: 1, signal: null }); + }); + }); + + const livedMs = Date.now() - startedAt; + const up = await serviceUp(port); + const decision = decideAfterExit({ + code: exit.code, + signal: exit.signal, + livedMs, + stopping, + serviceUp: up, + state: readStateFile(opts.dataDir), + nowIso: new Date().toISOString(), + }); + appendGuardLog(opts.dataDir, { + action: 'child-exit', + code: exit.code, + signal: exit.signal, + lived_ms: livedMs, + counted: decision.counted, + persist: decision.persist, + reason: decision.reason, + service_up: up, + failures: decision.state.failures, + grace_ms: STARTUP_GRACE_MS, + }); + if (decision.persist) writeState(opts.dataDir, decision.state); + console.error( + `[startup-guard] 子进程退出(code=${exit.code ?? 'null'} signal=${exit.signal ?? 'null'},` + + `存活 ${Math.round(livedMs / 1000)}s,${decision.reason},失败计数 ${decision.state.failures})`, + ); + return decision.exitCode; +} + +async function main() { + let opts; + try { + opts = parseGuardArgs(process.argv.slice(2)); + } catch (err) { + console.error(`[startup-guard] ${err instanceof Error ? err.message : String(err)}\n${USAGE}`); + return 2; + } + if (opts.help) { + console.log(USAGE); + return 0; + } + if (!opts.dataDir || !opts.installDir || opts.cmd.length === 0) { + console.error(`[startup-guard] 缺少必要参数(--data-dir / --install-dir / -- 启动命令)\n${USAGE}`); + return 2; + } + if (!Number.isInteger(opts.threshold) || opts.threshold < 1) { + console.error(`[startup-guard] --threshold 需为正整数,收到: ${opts.threshold}`); + return 2; + } + const lkgDir = opts.lkgDir || opts.installDir; + const port = resolvePort(opts.dataDir, opts.port); + const dataDir = opts.dataDir; + + // ① 服务已由别的实例提供(撞车/接管场景)→ 使命达成,不再 spawn + if (await serviceUp(port)) { + appendGuardLog(dataDir, { action: 'already-up', port, reason: 'service-up' }); + console.error(`[startup-guard] 端口 ${port} 已被服务,本实例不重复启动`); + return 0; + } + + // ② 读失败计数;达到阈值 → 尝试从 LKG 恢复(无 LKG/无匹配 → 放行,不动 install-dir) + let state = readStateFile(dataDir); + if (decideRestore(state, opts.threshold)) { + const r = restoreFromLkg({ dataDir, lkgDir, installDir: opts.installDir, entry: opts.entry }); + appendGuardLog(dataDir, { + action: 'restore', + failures: state.failures, + threshold: opts.threshold, + restored: r.restored, + backup: r.backup, + entry: r.entry, + reason: r.reason, + candidates: r.candidates, + }); + if (r.restored) { + state = zeroFailures(new Date().toISOString()); + writeState(dataDir, state); + console.error(`[startup-guard] 已从 LKG 恢复入口: ${r.backup} → ${join(opts.installDir, r.entry)}(失败计数归零)`); + } else { + console.error(`[startup-guard] 未执行恢复(${r.reason})—— 按原状尝试启动`); + } + } + + // ③ 看守启动命令(退出码契约见文件头) + console.error(`[startup-guard] 看守中: ${opts.cmd.join(' ')}(阈值 ${opts.threshold},当前失败 ${state.failures} 次)`); + appendGuardLog(dataDir, { + action: 'spawn', + cmd: opts.cmd, + failures: state.failures, + threshold: opts.threshold, + port, + }); + return supervise(opts, port); +} + +// ---------- 入口 ---------- + +const isMain = (() => { + try { + const self = resolve(fileURLToPath(import.meta.url)); + const argv1 = process.argv[1] ? resolve(process.argv[1]) : ''; + return !!argv1 && self === argv1; + } catch { + return false; + } +})(); + +if (isMain) { + main() + .then((code) => process.exit(code)) + .catch((err) => { + // 守卫自身崩溃:放行一次(不让守卫成为启动瓶颈),但留下证据 + console.error(`[startup-guard] 守卫自身异常(已放行): ${err instanceof Error ? err.message : String(err)}`); + process.exit(0); + }); +} diff --git a/tests/unit/startup-guard.test.mjs b/tests/unit/startup-guard.test.mjs new file mode 100644 index 0000000..9a0c92e --- /dev/null +++ b/tests/unit/startup-guard.test.mjs @@ -0,0 +1,439 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + STARTUP_GRACE_MS, + backupNameRegex, + decideAfterExit, + decideRestore, + hashMatches, + initialState, + nextFailures, + planRestore, + readState, + zeroFailures, +} from '../../scripts/lib/startup-guard-core.mjs'; +import { + appendGuardLog, + hashFileSync, + listBackupCandidates, + parseGuardArgs, + readLkgRecord, + readStateFile, + resolvePort, + restoreFromLkg, + statePath, + writeState, +} from '../../scripts/startup-guard.mjs'; +// 跨端一致性:守卫与 CLI rollback 必须认同一批备份、同一套指纹规则 +import { backupNameRegex as cliBackupNameRegex, hashMatches as cliHashMatches } from '../../apps/cli/dist/cmd/rollback.js'; + +/** + * 看门狗守卫(决策 R-1 / ARCH A-P0-6)。 + * + * 为什么需要守卫:rollback 是 RPC 命令,被控端「完全起不来」时无监听可连。 + * 这里钉死看门狗的**判断**不漂移: + * 1. 阈值边界:N-1 不恢复、N 才恢复; + * 2. 有 LKG 且指纹匹配才恢复;无 LKG / 无匹配 → 放行且**不擦除 install-dir**; + * 3. 状态文件损坏按 0(不猜、不拒绝服务); + * 4. 启动期死亡计数、运行期死亡不计数(防计划内重启误触发回滚); + * 5. 成功后归零; + * 6. 恢复日志必须落盘; + * 7. 与 CLI rollback 的备份命名/指纹规则同口径(否则两边认不出同一批文件)。 + */ + +// ---------- 纯函数:状态机 ---------- + +test('readState:缺失/空串/损坏 JSON/字段非法一律按 0', () => { + assert.deepEqual(readState(undefined), { failures: 0, updated_at: '' }); + assert.deepEqual(readState(''), { failures: 0, updated_at: '' }); + assert.deepEqual(readState(' '), { failures: 0, updated_at: '' }); + assert.deepEqual(readState('{ not json'), { failures: 0, updated_at: '' }); + assert.deepEqual(readState('[]'), { failures: 0, updated_at: '' }); + assert.deepEqual(readState('{"failures":"3"}'), { failures: 0, updated_at: '' }); + assert.deepEqual(readState('{"failures":-2}'), { failures: 0, updated_at: '' }); + assert.deepEqual(readState('{"failures":2.5}'), { failures: 0, updated_at: '' }); + assert.deepEqual(readState('{"failures":3,"updated_at":"2026-10-25T00:00:00Z"}'), { + failures: 3, + updated_at: '2026-10-25T00:00:00Z', + }); +}); + +test('readState:CRLF 状态文件同样可解析(Windows 手工编辑场景)', () => { + const raw = '{\r\n "failures": 2,\r\n "updated_at": "t"\r\n}\r\n'; + assert.deepEqual(readState(raw), { failures: 2, updated_at: 't' }); +}); + +test('decideRestore:阈值边界 N-1 不恢复、N 恢复', () => { + assert.equal(decideRestore({ failures: 0 }, 3), false); + assert.equal(decideRestore({ failures: 2 }, 3), false, 'N-1 必须不恢复'); + assert.equal(decideRestore({ failures: 3 }, 3), true, 'N 必须恢复'); + assert.equal(decideRestore({ failures: 9 }, 3), true); + // 阈值非法 → 不恢复(守卫自身配置错误时不许乱动入口文件) + assert.equal(decideRestore({ failures: 99 }, 0), false); + assert.equal(decideRestore({ failures: 99 }, -1), false); + assert.equal(decideRestore({ failures: 99 }, 2.5), false); + assert.equal(decideRestore({ failures: 99 }, 'x'), false); +}); + +test('nextFailures:递增且带时间戳;zeroFailures 归零', () => { + const s1 = nextFailures({ failures: 0, updated_at: '' }, 'T1'); + assert.deepEqual(s1, { failures: 1, updated_at: 'T1' }); + const s2 = nextFailures(s1, 'T2'); + assert.deepEqual(s2, { failures: 2, updated_at: 'T2' }); + assert.deepEqual(zeroFailures('T3'), { failures: 0, updated_at: 'T3' }); +}); + +test('decideAfterExit:启动期死亡(<宽限期)→ 计数 +1', () => { + const d = decideAfterExit({ + code: 1, signal: null, livedMs: 800, stopping: false, serviceUp: false, + state: { failures: 1, updated_at: '' }, nowIso: 'T', + }); + assert.equal(d.counted, true); + assert.equal(d.persist, true); + assert.equal(d.state.failures, 2); + assert.equal(d.exitCode, 1); + assert.equal(d.reason, 'startup-failure'); +}); + +test('decideAfterExit:运行期死亡(>=宽限期)→ 不计数(计划内重启杀的是长命进程)', () => { + const d = decideAfterExit({ + code: null, signal: 'SIGKILL', livedMs: STARTUP_GRACE_MS + 1, stopping: false, serviceUp: false, + state: { failures: 2, updated_at: '' }, nowIso: 'T', + }); + assert.equal(d.counted, false); + assert.equal(d.persist, false, '状态没变就不写盘'); + assert.equal(d.state.failures, 2, '计数不许被计划内重启搅动'); + assert.equal(d.exitCode, 1, '退出码仍交回 supervisor 重启'); + assert.equal(d.reason, 'runtime-exit'); +}); + +test('decideAfterExit:服务已被别的实例提供 → 归零退出 0(撞车不算失败)', () => { + const d = decideAfterExit({ + code: 1, signal: null, livedMs: 500, stopping: false, serviceUp: true, + state: { failures: 2, updated_at: '' }, nowIso: 'T', + }); + assert.equal(d.counted, false); + assert.equal(d.state.failures, 0); + assert.equal(d.exitCode, 0); + assert.equal(d.reason, 'service-up'); +}); + +test('decideAfterExit:本守卫被要求停(systemctl stop)→ 不计数', () => { + const d = decideAfterExit({ + code: 1, signal: null, livedMs: 60_000, stopping: true, serviceUp: false, + state: { failures: 1, updated_at: '' }, nowIso: 'T', + }); + assert.equal(d.counted, false); + assert.equal(d.persist, false); + assert.equal(d.state.failures, 1); + assert.equal(d.reason, 'stopping'); +}); + +test('decideAfterExit:干净退出 0 → 成功后归零', () => { + const d = decideAfterExit({ + code: 0, signal: null, livedMs: 5, stopping: false, serviceUp: false, + state: { failures: 2, updated_at: '' }, nowIso: 'T', + }); + assert.equal(d.counted, false); + assert.equal(d.state.failures, 0); + assert.equal(d.exitCode, 0); + assert.equal(d.reason, 'clean-exit'); +}); + +// ---------- 纯函数:备份命名与指纹(含跨端一致性) ---------- + +test('backupNameRegex:认两套备份命名(deploy 固定名 + selfupdate 时间戳名)', () => { + const re = backupNameRegex('agent.mjs'); + assert.ok(re.test('agent.mjs.bak'), 'deploy 固定名'); + assert.ok(re.test('agent.mjs.bak-1729300000000'), 'selfupdate 时间戳名'); + assert.ok(!re.test('agent.mjs.bakX'), '后缀必须整段匹配'); + assert.ok(!re.test('agent.mjs'), '入口自己不是备份'); + assert.ok(!re.test('other.mjs.bak'), '别名的备份不认'); + const re2 = backupNameRegex('index.js'); + assert.ok(re2.test('index.js.bak')); +}); + +test('跨端一致:守卫与 CLI rollback 的备份命名/指纹规则同口径', () => { + // 两边认不出同一批备份 = 看门狗恢复的文件与 rollback 枚举的文件不一致 + const names = ['agent.mjs.bak', 'agent.mjs.bak-123', 'agent.mjs.bakX', 'agent.mjs', 'x.bak', 'a+b.c.bak']; + for (const entry of ['agent.mjs', 'index.js', 'a+b.c']) { + const a = backupNameRegex(entry); + const b = cliBackupNameRegex(entry); + for (const n of names) { + assert.equal(a.test(n), b.test(n), `口径漂移: entry=${entry} name=${n}`); + } + } + const full = createHash('sha256').update('x').digest('hex'); + assert.equal(hashMatches(full, full.slice(0, 12)), cliHashMatches(full, full.slice(0, 12))); + assert.equal(hashMatches(full, full), cliHashMatches(full, full)); + assert.equal(hashMatches('', 'x'), cliHashMatches('', 'x')); +}); + +test('hashMatches:全量相等/12 位前缀回落;空值与反向不匹配', () => { + const full = createHash('sha256').update('content').digest('hex'); + assert.equal(hashMatches(full, full), true); + assert.equal(hashMatches(full, full.slice(0, 12)), true, '老 LKG 12 位前缀回落'); + assert.equal(hashMatches(full.slice(0, 12), full), false, '不得反向匹配'); + assert.equal(hashMatches('', full), false); + assert.equal(hashMatches(full, ''), false); +}); + +test('planRestore:无匹配返回 null;按给定顺序取第一个匹配者', () => { + const good = createHash('sha256').update('good').digest('hex'); + const bad = createHash('sha256').update('bad').digest('hex'); + assert.equal(planRestore([], good), null); + assert.equal(planRestore([{ path: '/a', hash: bad }], good), null); + assert.deepEqual(planRestore([{ path: '/a', hash: good }], good), { path: '/a', hash: good }); + assert.deepEqual( + planRestore([{ path: '/new', hash: bad }, { path: '/old', hash: good }], good), + { path: '/old', hash: good }, + '匹配优先于新近(新备份不是 LKG 时不许被抢)', + ); +}); + +// ---------- 参数解析 ---------- + +test('parseGuardArgs:-- 之后是完整启动命令;缺值/未知参数报错', () => { + const o = parseGuardArgs([ + '--data-dir', '/d', '--lkg-dir', '/l', '--install-dir', '/i', + '--threshold', '3', '--port', '8765', '--entry', 'agent.mjs', + '--', 'node', '/i/agent.mjs', + ]); + assert.equal(o.dataDir, '/d'); + assert.equal(o.lkgDir, '/l'); + assert.equal(o.installDir, '/i'); + assert.equal(o.threshold, 3); + assert.equal(o.port, 8765); + assert.equal(o.entry, 'agent.mjs'); + assert.deepEqual(o.cmd, ['node', '/i/agent.mjs']); + assert.equal(parseGuardArgs([]).cmd.length, 0, '没有 -- 就没有启动命令'); + assert.throws(() => parseGuardArgs(['--data-dir']), /缺少取值/); + assert.throws(() => parseGuardArgs(['--wat']), /未知参数/); +}); + +// ---------- IO 集成(真实临时目录) ---------- + +const GOOD = Buffer.from('GOOD-AGENT-LKG-CONTENT'); +const BAD = Buffer.from('BROKEN-AGENT-CONTENT'); +const goodHash = createHash('sha256').update(GOOD).digest('hex'); + +function makeBox() { + const root = mkdtempSync(join(tmpdir(), 'na-guard-')); + const dataDir = join(root, 'data'); + const lkgDir = join(root, 'install'); + const installDir = lkgDir; + mkdirSync(dataDir, { recursive: true }); + mkdirSync(installDir, { recursive: true }); + return { root, dataDir, lkgDir, installDir }; +} + +function writeLkg(dataDir, rec) { + writeFileSync(join(dataDir, 'agent-lkg.json'), `${JSON.stringify(rec, null, 2)}\n`, 'utf8'); +} + +test('restoreFromLkg:有 LKG 且指纹匹配 → 就地覆写入口 + 内容正确', () => { + const { dataDir, lkgDir, installDir } = makeBox(); + writeLkg(dataDir, { + hash: goodHash.slice(0, 12), hash_full: goodHash, bytes: GOOD.length, + script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux', + }); + writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD); + writeFileSync(join(installDir, 'agent.mjs'), BAD); + + const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' }); + assert.equal(r.restored, true); + assert.equal(r.backup, join(lkgDir, 'agent.mjs.bak')); + assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), GOOD, '入口必须被还原成 LKG 内容'); +}); + +test('restoreFromLkg:新备份不是 LKG 时不许被抢(匹配优先于新近)', () => { + const { dataDir, lkgDir, installDir } = makeBox(); + writeLkg(dataDir, { + hash: goodHash.slice(0, 12), hash_full: goodHash, bytes: GOOD.length, + script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux', + }); + const stale = join(lkgDir, 'agent.mjs.bak-1000'); + const fresh = join(lkgDir, 'agent.mjs.bak-2000'); + writeFileSync(stale, GOOD); + writeFileSync(fresh, BAD); + // fresh 更新(mtime 更大),但它不是 LKG + const now = Date.now() / 1000; + utimesSync(fresh, now, now); + utimesSync(stale, now - 3600, now - 3600); + writeFileSync(join(installDir, 'agent.mjs'), BAD); + + const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' }); + assert.equal(r.restored, true); + assert.equal(r.backup, stale, '必须命中指纹匹配的旧备份,而不是更新的那个'); +}); + +test('restoreFromLkg:老 LKG 只有 12 位 hash 也能按前缀匹配', () => { + const { dataDir, lkgDir, installDir } = makeBox(); + writeLkg(dataDir, { + hash: goodHash.slice(0, 12), bytes: GOOD.length, // 无 hash_full(v2.1.0 之前的记录) + script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'win32', + }); + writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD); + writeFileSync(join(installDir, 'agent.mjs'), BAD); + const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' }); + assert.equal(r.restored, true); +}); + +test('restoreFromLkg:无 LKG → 放行且 install-dir 一个字节都不动', () => { + const { dataDir, lkgDir, installDir } = makeBox(); + writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD); + writeFileSync(join(installDir, 'agent.mjs'), BAD); + const before = readFileSync(join(installDir, 'agent.mjs')); + const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' }); + assert.equal(r.restored, false); + assert.equal(r.reason, 'no-lkg'); + assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), before, '无 LKG 绝不清空入口'); +}); + +test('restoreFromLkg:有 LKG 但无指纹匹配 → 放行且 install-dir 不动', () => { + const { dataDir, lkgDir, installDir } = makeBox(); + writeLkg(dataDir, { + hash: 'aaaaaaaaaaaa', hash_full: createHash('sha256').update('other').digest('hex'), + script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux', + }); + writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD); + writeFileSync(join(installDir, 'agent.mjs'), BAD); + const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' }); + assert.equal(r.restored, false); + assert.equal(r.reason, 'no-match'); + assert.equal(r.candidates.length, 1, '候选指纹要带回供排查'); + assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), BAD, '无匹配绝不清空入口'); +}); + +test('restoreFromLkg:无备份候选 → no-candidates,不动入口', () => { + const { dataDir, lkgDir, installDir } = makeBox(); + writeLkg(dataDir, { + hash: goodHash.slice(0, 12), hash_full: goodHash, + script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux', + }); + writeFileSync(join(installDir, 'agent.mjs'), BAD); + const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' }); + assert.equal(r.restored, false); + assert.equal(r.reason, 'no-candidates'); + assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), BAD); +}); + +test('restoreFromLkg:目标不可写(异常路径)→ 吞掉返回 error,不抛', () => { + const { dataDir, lkgDir, installDir } = makeBox(); + writeLkg(dataDir, { + hash: goodHash.slice(0, 12), hash_full: goodHash, + script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux', + }); + writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD); + mkdirSync(join(installDir, 'agent.mjs')); // 入口是目录 → 写必失败 + const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' }); + assert.equal(r.restored, false); + assert.ok(r.reason.startsWith('error:'), '异常必须被吞成 error 返回'); +}); + +test('listBackupCandidates:按 mtime 新→早,只收 .bak*', () => { + const { lkgDir } = makeBox(); + writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD); + writeFileSync(join(lkgDir, 'agent.mjs.bak-1'), GOOD); + writeFileSync(join(lkgDir, 'agent.json'), '{}'); + writeFileSync(join(lkgDir, 'other.bak'), GOOD); + const now = Date.now() / 1000; + utimesSync(join(lkgDir, 'agent.mjs.bak'), now - 100, now - 100); + utimesSync(join(lkgDir, 'agent.mjs.bak-1'), now, now); + const cands = listBackupCandidates(lkgDir, 'agent.mjs'); + assert.deepEqual(cands.map((c) => c.name), ['agent.mjs.bak-1', 'agent.mjs.bak']); +}); + +test('hashFileSync:与 createHash 全量一致', () => { + const { lkgDir } = makeBox(); + writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD); + assert.equal(hashFileSync(join(lkgDir, 'agent.mjs.bak')), goodHash); +}); + +// ---------- 状态文件与日志 ---------- + +test('readStateFile/writeState:落盘可读回;损坏文件按 0', () => { + const { dataDir } = makeBox(); + writeState(dataDir, { failures: 2, updated_at: 'T' }); + assert.deepEqual(readStateFile(dataDir), { failures: 2, updated_at: 'T' }); + assert.ok(existsSync(statePath(dataDir))); + // 损坏 → 0(不猜) + writeFileSync(statePath(dataDir), 'garbage', 'utf8'); + assert.deepEqual(readStateFile(dataDir), { failures: 0, updated_at: '' }); + // 缺失 → 0 + rmSync(statePath(dataDir), { force: true }); + assert.deepEqual(readStateFile(dataDir), { failures: 0, updated_at: '' }); +}); + +test('appendGuardLog:JSONL 日志落盘且含关键字段', () => { + const { dataDir } = makeBox(); + appendGuardLog(dataDir, { action: 'restore', restored: true, backup: '/x/agent.mjs.bak' }); + appendGuardLog(dataDir, { action: 'child-exit', code: 1, counted: true, failures: 2 }); + const p = join(dataDir, 'logs', 'startup-guard.log'); + assert.ok(existsSync(p), '恢复/退出事件必须留日志'); + const lines = readFileSync(p, 'utf8').trim().split('\n'); + assert.equal(lines.length, 2); + const first = JSON.parse(lines[0]); + assert.equal(first.action, 'restore'); + assert.equal(first.restored, true); + assert.ok(first.ts, '日志必须带时间戳'); +}); + +test('readLkgRecord:损坏/缺字段 → null', () => { + const { dataDir } = makeBox(); + assert.equal(readLkgRecord(dataDir), null); + writeFileSync(join(dataDir, 'agent-lkg.json'), 'nope', 'utf8'); + assert.equal(readLkgRecord(dataDir), null); + writeFileSync(join(dataDir, 'agent-lkg.json'), JSON.stringify({ bytes: 1 }), 'utf8'); + assert.equal(readLkgRecord(dataDir), null, '缺 hash 字段视为无效'); +}); + +test('resolvePort:--port 优先 → agent.json 回落 → 都没有返回 null', () => { + const { dataDir } = makeBox(); + assert.equal(resolvePort(dataDir, 9000), 9000); + writeFileSync(join(dataDir, 'agent.json'), JSON.stringify({ port: 8765 }), 'utf8'); + assert.equal(resolvePort(dataDir, null), 8765, '未传 --port 时读 agent.json'); + assert.equal(resolvePort(dataDir, 9000), 9000, '传了就以参数为准'); + rmSync(join(dataDir, 'agent.json'), { force: true }); + assert.equal(resolvePort(dataDir, null), null, 'Hub 模式无端口 → 跳过端口判断'); +}); + +// ---------- 端到端小剧场:连续崩溃 → 恢复 → 再起 ---------- + +test('场景:崩溃三次后第四次守卫执行恢复并归零计数', () => { + const { root, dataDir, lkgDir, installDir } = makeBox(); + writeLkg(dataDir, { + hash: goodHash.slice(0, 12), hash_full: goodHash, bytes: GOOD.length, + script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux', + }); + writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD); + writeFileSync(join(installDir, 'agent.mjs'), BAD); + + // 前三次:坏版本启动即死 → 计数 1、2、3(均未达阈值,不恢复) + let state = initialState('T0'); + for (let i = 1; i <= 3; i += 1) { + const d = decideAfterExit({ + code: 1, signal: null, livedMs: 600, stopping: false, serviceUp: false, state, nowIso: `T${i}`, + }); + assert.equal(d.counted, true); + state = d.state; + assert.equal(decideRestore(state, 3), i === 3, `第 ${i} 次后阈值判定`); + } + assert.equal(state.failures, 3); + + // 第四次:达到阈值 → 恢复 + 归零 + const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' }); + assert.equal(r.restored, true); + state = zeroFailures('T4'); + assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), GOOD); + assert.equal(state.failures, 0); + + // 恢复日志已在盘上 + appendGuardLog(dataDir, { action: 'restore', restored: true, backup: r.backup }); + assert.ok(readFileSync(join(dataDir, 'logs', 'startup-guard.log'), 'utf8').includes('restore')); + rmSync(root, { recursive: true, force: true }); +});