Files
nodeagent/scripts/startup-guard.mjs
T
jkooe aad137f04a fix(startup-guard): 强制向被看守进程注入 NODEAGENT_HOME = --data-dir
全局一致性审查发现:Windows 计划任务的 New-ScheduledTaskAction 无法
携带环境变量,若安装进程持有进程级 NODEAGENT_HOME,agent 运行时的
agentDir()(LKG/状态文件所在地)会与守卫的 --data-dir 漂移 ——
守卫读不到 LKG/状态文件(安全退化为 no-lkg 放行,但看门狗白养)。

由 spawnEnv 在 spawn 时把 data-dir 显式注入被看守进程,三端行为与
supervisor 环境解耦且一致(Linux unit / macOS plist 的
EnvironmentVariables 本就是同值,双保险,幂等无害)。
新增 spawnEnv 单测;真机验证子进程可见注入值。
2026-10-26 00:17:15 +08:00

481 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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;
/** 状态文件路径:<data-dir>/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 日志:<data-dir>/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 记录(<data-dir>/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);
}
}
/**
* 枚举备份候选:<lkg-dir> 下 `<entry>.bak` / `<entry>.bak-<ts>`,按 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/<entry>(同 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)}` };
}
}
// ---------- 被看守进程的环境 ----------
/**
* 被看守进程(agent)的运行环境:**强制注入 NODEAGENT_HOME = data-dir**。
*
* 为什么强制:agent 的 `agentDir()`(数据目录、LKG 文件、归零状态文件所在地)
* 由环境变量 NODEAGENT_HOME 决定。而 Windows 计划任务的
* `New-ScheduledTaskAction` **无法携带环境变量**,若安装进程恰好持有
* **进程级** NODEAGENT_HOME,agent 运行时的 agentDir() 就会与守卫的
* --data-dir 漂移:守卫读不到 LKG/状态文件(安全退化:no-lkg 放行,
* 但看门狗白养)。由守卫把自己认定的 data-dir 显式传给被看守进程,
* 三端行为即与 supervisor 环境解耦且一致(Linux unit / macOS plist 的
* EnvironmentVariables 本就是同值,双保险,幂等无害)。
*/
export function spawnEnv(dataDir, baseEnv = process.env) {
return { ...baseEnv, NODEAGENT_HOME: dataDir };
}
// ---------- 端口探活 ----------
/**
* 解析被控端端口:--port 参数优先,否则读 <data-dir>/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;
// NODEAGENT_HOME 强制为 data-dir:agentDir()(LKG/状态文件所在地)必须与
// 守卫读的 --data-dir 是同一个目录,否则恢复链路静默失效(见 spawnEnv 注释)
const child = spawn(cmdName, cmdArgs, {
stdio: 'inherit', cwd: opts.installDir, env: spawnEnv(opts.dataDir),
});
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);
});
}