UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

592 lines (580 loc) • 20.2 kB
// The node supervisor must select its runtime before importing config or SQLite. import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; const CHILD_ARGUMENT = "--openclaw-node-host-child"; const MANAGED_CHILD_ARGUMENT = "--openclaw-node-host-managed-child"; const CHILD_MARKER = Symbol.for("openclaw.node-host.launcher-child"); const MANAGED_STATE_MARKER = Symbol.for("openclaw.node-host.managed-state-path"); const RESTART_INTERVAL_MS = 12 * 60 * 60 * 1_000; const READY_TIMEOUT_MS = 5 * 60 * 1_000; const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level", "--container"]); const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]); const normalize = (value) => { const trimmed = value?.trim(); return trimmed && trimmed !== "undefined" && trimmed !== "null" ? trimmed : undefined; }; function parseInvocation(args, env) { const words = []; const rootArgs = []; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--") { break; } if (["--help", "-h", "--version", "-V", "-v"].includes(arg)) { return null; } if (arg === "--ephemeral") { return null; } const [flag, ...parts] = arg.split("="); if (flag === "--container" || normalize(env.OPENCLAW_CONTAINER)) { return null; } if (ROOT_VALUE_FLAGS.has(flag)) { const rawValue = parts.length ? parts.join("=") : args[++index]; const value = rawValue?.trim(); if (!value || value.startsWith("-")) { return null; } rootArgs.push(arg, ...(parts.length ? [] : [rawValue])); continue; } if (ROOT_BOOLEAN_FLAGS.has(arg)) { rootArgs.push(arg); continue; } if (words.length === 1 && words[0] === "node") { if (flag === "--commands") { if (!parts.length) { index += 1; } continue; } if (arg === "--all-commands") { continue; } } words.push(arg); } const nodeRun = words[0] === "node" && words[1] === "run"; const connect = words[0] === "connect" && !words.includes("--service"); return nodeRun || connect ? { rootArgs } : null; } function readPackage(packageRoot) { const manifest = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")); if (manifest.name !== "openclaw" || typeof manifest.version !== "string") { throw new Error("The selected node runtime is not an OpenClaw package."); } return manifest; } function resolveManagedRuntime(runtimeRoot, runtimeDirectory, expectedVersion) { const releases = realpathSync(path.join(runtimeDirectory, "releases")); const resolved = realpathSync(runtimeRoot); if (path.dirname(resolved) !== releases) { throw new Error("The selected node runtime is outside the managed releases directory."); } const packageRoot = [ path.join(resolved, "lib", "node_modules", "openclaw"), path.join(resolved, "node_modules", "openclaw"), ].find((candidate) => existsSync(path.join(candidate, "package.json"))); if (!packageRoot || !realpathSync(packageRoot).startsWith(`${resolved}${path.sep}`)) { throw new Error("The selected node runtime has no private OpenClaw package."); } const manifest = readPackage(packageRoot); if (expectedVersion && manifest.version !== expectedVersion) { throw new Error("The selected node runtime version changed before activation."); } for (const filename of ["openclaw.mjs", "node-host-launcher.mjs"]) { if (!existsSync(path.join(packageRoot, filename))) { throw new Error(`The selected node runtime is missing ${filename}.`); } } if ( !existsSync(path.join(packageRoot, "dist", "entry.js")) && !existsSync(path.join(packageRoot, "dist", "entry.mjs")) ) { throw new Error("The selected node runtime is missing its built entry point."); } return { runtimeRoot: resolved, packageRoot, manifest, entryPath: path.join(packageRoot, "openclaw.mjs"), }; } function resolveCurrentRuntime(runtimeDirectory) { // A Windows junction swap can leave the previous selector until publication completes. // Retry current after the backup in case a concurrent publisher finished between reads. for (const name of ["current", "current.previous", "current"]) { const selector = path.join(runtimeDirectory, name); try { const activatedAt = lstatSync(selector).mtimeMs; return { ...resolveManagedRuntime(selector, runtimeDirectory), activatedAt }; } catch (error) { if (error.code !== "ENOENT") { throw error; } } } return undefined; } function assertCompatibleSchemas(previous, candidate) { const before = previous.openclaw?.schemaVersions; const after = candidate.openclaw?.schemaVersions; if ( !before || !after || !Number.isInteger(before.state) || !Number.isInteger(before.agent) || before.state !== after.state || before.agent !== after.agent ) { throw new Error("Automatic node activation cannot change database schema versions."); } } function claimActivation(runtimeDirectory) { mkdirSync(runtimeDirectory, { recursive: true }); const lock = path.join(runtimeDirectory, "activation.lock"); const owner = `${process.pid}:${randomUUID()}`; let descriptor; try { descriptor = openSync(lock, "wx", 0o600); } catch (error) { if (error.code !== "EEXIST") { throw error; } throw new Error( `A node activation lock already exists at ${lock}. If no node update is running, remove that stale lock and retry.`, { cause: error }, ); } const identity = fstatSync(descriptor); try { writeFileSync(descriptor, owner); } finally { closeSync(descriptor); } return () => { try { const observed = lstatSync(lock); if ( observed.dev === identity.dev && observed.ino === identity.ino && readFileSync(lock, "utf8") === owner ) { unlinkSync(lock); } } catch (error) { if (error.code !== "ENOENT") { process.stderr.write( `openclaw: could not release node activation lock: ${error.message}\n`, ); } } }; } function assertRestartInterval(runtimeDirectory, lastAcceptedRestartAt) { if ( lastAcceptedRestartAt !== undefined && performance.now() - lastAcceptedRestartAt < RESTART_INTERVAL_MS ) { throw new Error("An automatic node restart was attempted within the last 12 hours."); } const current = resolveCurrentRuntime(runtimeDirectory); if (current && Date.now() - current.activatedAt < RESTART_INTERVAL_MS) { throw new Error("A node runtime was activated within the last 12 hours."); } } function activateRuntime(currentPath, runtimeRoot) { const temporary = `${currentPath}.${process.pid}.next`; const previous = `${currentPath}.previous`; const removePrevious = () => { try { unlinkSync(previous); } catch (error) { if (error.code !== "ENOENT") { throw error; } } }; try { symlinkSync(runtimeRoot, temporary, process.platform === "win32" ? "junction" : "dir"); try { renameSync(temporary, currentPath); } catch (error) { if ( !["EPERM", "EACCES", "EEXIST", "ENOTEMPTY"].includes(error.code) || !lstatSync(currentPath, { throwIfNoEntry: false })?.isSymbolicLink() ) { throw error; } // Windows cannot replace a directory junction. Keep its timestamp and target recoverable. removePrevious(); renameSync(currentPath, previous); try { renameSync(temporary, currentPath); } catch (publishError) { try { renameSync(previous, currentPath); } catch (restoreError) { throw new AggregateError( [publishError, restoreError], `${publishError.message}; could not restore the previous selector at ${previous}: ${restoreError.message}`, { cause: restoreError }, ); } throw publishError; } } try { removePrevious(); } catch (error) { // The new selector is committed; a later activation can clean up the backup. process.stderr.write( `openclaw: could not remove the previous node selector: ${error.message}\n`, ); } } finally { try { unlinkSync(temporary); } catch (error) { if (error.code !== "ENOENT") { process.stderr.write( `openclaw: could not remove the staged node selector at ${temporary}: ${error.message}\n`, ); } } } } export const isNodeHostLauncherChild = () => process[CHILD_MARKER] === true; export async function runNodeHostLauncher({ entryPath, packageRoot }) { const managedChild = process.argv[2] === MANAGED_CHILD_ARGUMENT; if (process.argv[2] === CHILD_ARGUMENT || managedChild) { if (!process.send || !process.connected) { throw new Error("The private node launcher argument requires a supervisor connection."); } const managedStatePath = managedChild ? process.argv[3] : undefined; if (managedChild && (!managedStatePath || !path.isAbsolute(managedStatePath))) { throw new Error( "The private managed node launcher argument requires an absolute state database path.", ); } process.argv.splice(2, managedChild ? 2 : 1); process[CHILD_MARKER] = true; if (managedStatePath) { process[MANAGED_STATE_MARKER] = managedStatePath; } process.once("disconnect", () => process.kill(process.pid, "SIGTERM")); process.channel?.unref(); return false; } if (isNodeHostLauncherChild()) { return false; } const invocation = parseInvocation(process.argv.slice(2), process.env); if ( !invocation || existsSync(path.join(packageRoot, "src", "entry.ts")) || existsSync(path.join(packageRoot, ".git")) ) { return false; } const { resolveNodeHostLauncherStateDir, compareOpenClawReleaseVersions, resolveOpenClawStateSqlitePath, watchNodeHostParentStdin, } = await import( pathToFileURL(path.join(packageRoot, "dist", "node-host-launcher-bootstrap.js")).href ); const stateDir = resolveNodeHostLauncherStateDir(process.argv, process.env); if (!stateDir) { return false; } const statePath = resolveOpenClawStateSqlitePath({ ...process.env, OPENCLAW_STATE_DIR: stateDir, }); const compareVersions = (left, right) => { const compared = compareOpenClawReleaseVersions(left, right); if (compared === null) { throw new Error("The node runtime package has an invalid OpenClaw release version."); } return compared; }; const runtimeDirectory = path.join(stateDir, "node-runtime"); const currentPath = path.join(runtimeDirectory, "current"); let active = { entryPath, packageRoot, manifest: readPackage(packageRoot) }; const assertCurrentVersionAdvances = (next) => { if (compareVersions(next.manifest.version, active.manifest.version) <= 0) { throw new Error("The selected node runtime must be newer than the running version."); } const current = resolveCurrentRuntime(runtimeDirectory); if ( current && compareVersions(current.manifest.version, active.manifest.version) > 0 && compareVersions(next.manifest.version, current.manifest.version) <= 0 ) { throw new Error("Another node already selected this runtime version or a newer one."); } }; try { const managed = resolveCurrentRuntime(runtimeDirectory); // A normal operator update must be able to advance beyond a private runtime. if (managed && compareVersions(managed.manifest.version, active.manifest.version) >= 0) { active = managed; } } catch (error) { if (error.code !== "ENOENT") { process.stderr.write( `openclaw: ignoring an invalid managed node runtime: ${error.message}\n`, ); } } let restartArgs = process.argv.slice(2); let childExecArgv = process.execArgv; let childEnv = process.env; let pending; let lastAcceptedRestartAt; let releaseActivation; let stoppingSignal; let child; let shutdownTimer; const signals = process.platform === "win32" ? ["SIGTERM", "SIGINT", "SIGBREAK"] : ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"]; const listeners = new Map(); for (const signal of signals) { const listener = () => { stoppingSignal ??= signal; try { child?.kill(signal); } catch { // A child can finish between signal delivery and forwarding. } shutdownTimer ??= setTimeout(() => child?.kill("SIGKILL"), 30_000); shutdownTimer.unref(); }; process.on(signal, listener); listeners.set(signal, listener); } const stopWatchingParent = process.argv.includes("--parent-stdin") ? watchNodeHostParentStdin(() => listeners.get("SIGTERM")()) : () => {}; try { while (!stoppingSignal) { let candidate; if (pending) { try { const next = resolveManagedRuntime( pending.runtimeRoot, runtimeDirectory, pending.manifest.version, ); assertCompatibleSchemas(active.manifest, next.manifest); assertCurrentVersionAdvances(next); candidate = next; } catch (error) { process.stderr.write( `openclaw: staged node runtime changed before restart: ${error.message}\n`, ); pending = undefined; releaseActivation?.(); releaseActivation = undefined; } } const selected = candidate ?? active; const childArguments = selected.runtimeRoot ? [MANAGED_CHILD_ARGUMENT, statePath] : [CHILD_ARGUMENT]; let ready = false; let candidateFailed = false; let bootstrap; let readyTimer; let readyKillTimer; const result = await new Promise((resolve, reject) => { child = spawn( process.execPath, [...childExecArgv, selected.entryPath, ...childArguments, ...restartArgs], { env: childEnv, stdio: ["inherit", "inherit", "inherit", "ipc"], }, ); if (candidate) { readyTimer = setTimeout(() => { candidateFailed = true; process.stderr.write( "openclaw: updated node did not reconnect within five minutes; restoring the previous runtime.\n", ); child.kill("SIGTERM"); readyKillTimer = setTimeout(() => child.kill("SIGKILL"), 30_000); readyKillTimer.unref(); }, READY_TIMEOUT_MS); readyTimer.unref(); } child.on("message", (message) => { if (!message || typeof message !== "object") { return; } if ( message.type === "openclaw.node.bootstrap" && !ready && !candidateFailed && !stoppingSignal ) { if ( Array.isArray(message.execArgv) && message.execArgv.every((arg) => typeof arg === "string") && message.env && typeof message.env === "object" && Object.values(message.env).every( (value) => typeof value === "string" || value === undefined, ) ) { bootstrap = { execArgv: message.execArgv, env: message.env }; child.send({ type: "openclaw.node.bootstrap-result", ok: true }, () => {}); } return; } if (message.type === "openclaw.node.restart-args") { if ( Array.isArray(message.argv) && message.argv.every((arg) => typeof arg === "string") && message.argv[0] === "node" && message.argv[1] === "run" && parseInvocation(message.argv, process.env) ) { restartArgs = [...invocation.rootArgs, ...message.argv]; } return; } if ( message.type === "openclaw.node.ready" && message.version === selected.manifest.version && !ready && !candidateFailed && !stoppingSignal ) { clearTimeout(readyTimer); if (candidate) { try { activateRuntime(currentPath, candidate.runtimeRoot); process.stderr.write( `openclaw: node auto-update activated ${candidate.manifest.version}\n`, ); } catch (error) { process.stderr.write( `openclaw: could not record the updated node runtime: ${error.message}. The connected candidate will keep running, but its selection was not persisted; the next launch may use the previous runtime.\n`, ); } // Authenticated readiness already admits work; persistence failure cannot revoke it. active = candidate; pending = undefined; releaseActivation?.(); releaseActivation = undefined; } ready = true; return; } if (message.type !== "openclaw.node.restart") { return; } let claimed; try { if (!ready || pending || stoppingSignal) { throw new Error("The node launcher is not ready for an update restart."); } if (typeof message.runtimeRoot !== "string" || typeof message.version !== "string") { throw new Error("The node restart request is missing its runtime identity."); } claimed = claimActivation(runtimeDirectory); assertRestartInterval(runtimeDirectory, lastAcceptedRestartAt); const next = resolveManagedRuntime( message.runtimeRoot, runtimeDirectory, message.version, ); assertCompatibleSchemas(active.manifest, next.manifest); assertCurrentVersionAdvances(next); pending = next; releaseActivation = claimed; lastAcceptedRestartAt = performance.now(); child.send({ type: "openclaw.node.restart-result", ok: true }, () => {}); } catch (error) { claimed?.(); child.send( { type: "openclaw.node.restart-result", ok: false, error: error.message }, () => {}, ); } }); child.on( "error", /** @param {Error} error */ (error) => { if (child.pid === undefined) { reject(error); } }, ); child.once("exit", (code, signal) => resolve({ code, signal })); }); clearTimeout(readyTimer); clearTimeout(readyKillTimer); if (stoppingSignal) { process.exitCode = result.code ?? 1; break; } if (bootstrap && result.code === 0 && !result.signal) { childExecArgv = bootstrap.execArgv; childEnv = bootstrap.env; continue; } if (candidate && !ready) { process.stderr.write( "openclaw: updated node failed to reconnect; restarting the previous runtime.\n", ); pending = undefined; releaseActivation?.(); releaseActivation = undefined; continue; } if (pending && result.code === 0 && !result.signal) { continue; } process.exitCode = result.code ?? 1; stoppingSignal = result.signal; break; } } finally { stopWatchingParent(); clearTimeout(shutdownTimer); releaseActivation?.(); for (const [signal, listener] of listeners) { process.off(signal, listener); } } if (stoppingSignal && process.platform !== "win32") { process.kill(process.pid, stoppingSignal); } return true; }