diff --git a/Dockerfile b/Dockerfile index 3e7228799031..db11ff7ece84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,6 +75,7 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ COPY node-version.mjs ./ COPY node-sqlite.mjs ./ COPY node-runtime-update.mjs ./ +COPY node-runtime-recovery.mjs ./ COPY openclaw.mjs ./ COPY ui/package.json ./ui/package.json COPY patches ./patches @@ -277,6 +278,7 @@ COPY --from=runtime-assets --chown=node:node /app/patches ./patches COPY --from=runtime-assets --chown=node:node /app/node-version.mjs . COPY --from=runtime-assets --chown=node:node /app/node-sqlite.mjs . COPY --from=runtime-assets --chown=node:node /app/node-runtime-update.mjs . +COPY --from=runtime-assets --chown=node:node /app/node-runtime-recovery.mjs . COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs . COPY --from=runtime-assets --chown=node:node /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} ./${OPENCLAW_BUNDLED_PLUGIN_DIR} COPY --from=runtime-assets --chown=node:node /app/skills ./skills diff --git a/docs/install/node.md b/docs/install/node.md index df98fa73eb39..7743cabe099d 100644 --- a/docs/install/node.md +++ b/docs/install/node.md @@ -21,7 +21,39 @@ Upgrade Node before updating OpenClaw to avoid SQLite TEXT truncation. See [Node ### Update from the CLI -If you run `openclaw` with an incompatible Node.js in an interactive terminal, the CLI offers: +If you run `openclaw` with an incompatible Node.js, startup first checks for an +already available compatible runtime: the private OpenClaw runtime, the Node +recorded in the managed Gateway service, Node on PATH, then nvm, fnm, Volta, and +Homebrew defaults. Each candidate must pass the same SQLite capability checks as +normal startup. The first passing runtime retries the original command without +prompting, including non-interactive Doctor commands launched by older updaters. +Arguments, working directory, environment, standard streams, and exit status are +preserved. Commands with an exact process-identity requirement cannot use this +recovery. + +Runtime discovery uses the environment inherited when the CLI starts, before +OpenClaw loads any `.env` file. Configure version-manager roots in your shell environment; +workspace `.env` values cannot select a Node executable for recovery. + +Home-relative service and version-manager paths expand `~` against inherited +`HOME` or `USERPROFILE`. Service paths use that home even when `OPENCLAW_HOME` +selects a different private-runtime home. Bare relative paths and service or +manager metadata inside the current working directory are rejected. + +Recovery ignores relative PATH entries and runtimes that resolve inside the +current working directory, unless an absolute PATH entry explicitly names their +directory. OpenClaw's own private recovery directory is also allowed, so cached +runtime reuse and the installation offer work when you launch from your home +directory. This exception does not extend to other in-home executables or manager +roots. On Windows, the service reader honors recorded code pages and Unicode +byte-order marks. If the current Node build cannot decode a service script safely, +OpenClaw prints the code page and continues searching other sources. Unsupported +OEM pages such as CP850 are skipped rather than guessed. CP949 is also skipped: +Node's ICU `euc-kr` decoder silently misdecodes UHC extension characters. Neither +case probes the service executable; recovery continues with PATH and the other +available runtime sources. + +If none is available and you are in an interactive terminal, the CLI offers: ```text Update NodeJS: Y/N [N]: diff --git a/node-runtime-recovery.d.mts b/node-runtime-recovery.d.mts new file mode 100644 index 000000000000..fd1f07e6f35b --- /dev/null +++ b/node-runtime-recovery.d.mts @@ -0,0 +1,18 @@ +export function consumeLauncherRootOptionToken(args: string[], index: number): number; +export function isForegroundGmailRunInvocation(argv: string[]): boolean; +export function isNativeHookRelayInvocation(argv: string[]): boolean; +export function resolveRecoveryPath( + value: string | null | undefined, + homeDir?: string | null, + options?: { allowMissing?: boolean; allowCwd?: boolean; trustedRoot?: string }, +): string | null; +export function isUsableNode( + nodePath: string, + options?: { allowCwd?: boolean; trustedRoot?: string; env?: NodeJS.ProcessEnv }, +): boolean; +export function runRespawnedChild(command: string, args: string[], env: NodeJS.ProcessEnv): true; +export function recoverNodeRuntime(options?: { + homeDir?: string; + allowInstall?: boolean; + env?: NodeJS.ProcessEnv; +}): Promise; diff --git a/node-runtime-recovery.mjs b/node-runtime-recovery.mjs new file mode 100644 index 000000000000..f3bf978de225 --- /dev/null +++ b/node-runtime-recovery.mjs @@ -0,0 +1,728 @@ +// Startup-only recovery; this module cannot depend on dist or installed packages. +import { spawn, spawnSync } from "node:child_process"; +import { lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + detectCurrentSqliteCapabilities, + nodeRuntimeFailure, + SQLITE_CAPABILITY_PROBE, +} from "./node-sqlite.mjs"; + +const LAUNCHER_ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]); +const LAUNCHER_ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level", "--container"]); +export const isNativeHookRelayInvocation = (argv) => argv[2] === "hooks" && argv[3] === "relay"; + +const isLauncherRootOptionValueToken = (arg) => { + if (!arg || arg === "--") { + return false; + } + if (!arg.startsWith("-")) { + return true; + } + return /^-\d+(?:\.\d+)?$/.test(arg); +}; + +export const consumeLauncherRootOptionToken = (args, index) => { + const arg = args[index]; + if (!arg) { + return 0; + } + if (LAUNCHER_ROOT_BOOLEAN_FLAGS.has(arg)) { + return 1; + } + if ( + arg.startsWith("--profile=") || + arg.startsWith("--log-level=") || + arg.startsWith("--container=") + ) { + return 1; + } + if (LAUNCHER_ROOT_VALUE_FLAGS.has(arg)) { + return isLauncherRootOptionValueToken(args[index + 1]) ? 2 : 1; + } + return 0; +}; + +// Mirror the entry's foreground Gmail policy: a wrapper would kill that run before descendant cleanup finishes. +export const isForegroundGmailRunInvocation = (argv) => { + const args = argv.slice(2); + const commandPath = []; + for (let index = 0; index < args.length && commandPath.length < 3; index += 1) { + const consumed = consumeLauncherRootOptionToken(args, index); + if (consumed > 0) { + index += consumed - 1; + } else if (!args[index] || args[index].startsWith("-")) { + break; + } else { + commandPath.push(args[index]); + } + } + return commandPath.join(" ") === "webhooks gmail run"; +}; + +const respawnSignals = + process.platform === "win32" + ? ["SIGTERM", "SIGINT", "SIGBREAK"] + : ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"]; +const respawnSignalExitGraceMs = 1_000; +const respawnSignalForceKillGraceMs = 1_000; +const respawnSignalHardExitGraceMs = 1_000; + +export const runRespawnedChild = (command, args, env) => { + const child = spawn(command, args, { + stdio: "inherit", + env, + }); + const listeners = new Map(); + // Keep signal forwarding and bounded shutdown in sync with src/entry.compile-cache.ts. + let signalExitTimer = null; + let signalForceKillTimer = null; + let signalHardExitTimer = null; + let firstForwardedSignal = null; + let hardKillBackstopStarted = false; + const detach = () => { + for (const [signal, listener] of listeners) { + process.off(signal, listener); + } + listeners.clear(); + if (signalExitTimer) { + clearTimeout(signalExitTimer); + signalExitTimer = null; + } + if (signalForceKillTimer) { + clearTimeout(signalForceKillTimer); + signalForceKillTimer = null; + } + if (signalHardExitTimer) { + clearTimeout(signalHardExitTimer); + signalHardExitTimer = null; + } + }; + const forceKillChild = () => { + try { + child.kill(process.platform === "win32" ? "SIGTERM" : "SIGKILL"); + } catch { + // Best-effort shutdown fallback. + } + }; + const requestChildTermination = () => { + try { + child.kill("SIGTERM"); + } catch { + // Best-effort shutdown fallback. + } + signalForceKillTimer = setTimeout(() => { + hardKillBackstopStarted = true; + forceKillChild(); + signalHardExitTimer = setTimeout(() => { + process.exit(1); + }, respawnSignalHardExitGraceMs); + signalHardExitTimer.unref?.(); + }, respawnSignalForceKillGraceMs); + signalForceKillTimer.unref?.(); + }; + const scheduleParentExit = (signal) => { + firstForwardedSignal ??= signal; + if (signalExitTimer) { + return; + } + signalExitTimer = setTimeout(() => { + requestChildTermination(); + }, respawnSignalExitGraceMs); + signalExitTimer.unref?.(); + }; + for (const signal of respawnSignals) { + const listener = () => { + try { + child.kill(signal); + } catch { + // Best-effort signal forwarding. + } + scheduleParentExit(signal); + }; + try { + process.on(signal, listener); + listeners.set(signal, listener); + } catch { + // Unsupported signal on this platform. + } + } + child.once("exit", (code, signal) => { + detach(); + if (signal) { + const forwardedSignalExitCode = + !hardKillBackstopStarted && signal === firstForwardedSignal + ? signal === "SIGINT" + ? 130 + : signal === "SIGTERM" + ? 143 + : undefined + : undefined; + process.exit(forwardedSignalExitCode ?? 1); + } + process.exit(code ?? 1); + }); + child.once("error", (error) => { + detach(); + process.stderr.write( + `[openclaw] Failed to respawn launcher: ${ + error instanceof Error ? (error.stack ?? error.message) : String(error) + }\n`, + ); + process.exit(1); + }); + return true; +}; + +function readSmallFile(filename, encoding = "utf8") { + const resolved = resolveRecoveryPath(filename); + if (!resolved) { + return null; + } + try { + const info = statSync(resolved); + return info.isFile() && info.size <= 65_536 ? readFileSync(resolved, encoding) : null; + } catch { + return null; + } +} + +// Match windows-encoding.ts labels; skip CP850 (no decoder) and CP949 (corrupts UHC). +const WINDOWS_SERVICE_CODEPAGE_LABELS = { + 437: "cp437", + 720: "cp720", + 737: "cp737", + 775: "cp775", + 850: "cp850", + 852: "cp852", + 855: "cp855", + 857: "cp857", + 858: "cp858", + 860: "cp860", + 861: "cp861", + 862: "cp862", + 863: "cp863", + 865: "cp865", + 866: "ibm866", + 869: "cp869", + 874: "windows-874", + 932: "shift_jis", + 936: "gbk", + 949: "euc-kr", + 950: "big5", + 1200: "utf-16le", + 1201: "utf-16be", + 1250: "windows-1250", + 1251: "windows-1251", + 1252: "windows-1252", + 1253: "windows-1253", + 1254: "windows-1254", + 1255: "windows-1255", + 1256: "windows-1256", + 1257: "windows-1257", + 1258: "windows-1258", + 28591: "iso-8859-1", + 28592: "iso-8859-2", + 28593: "iso-8859-3", + 28594: "iso-8859-4", + 28595: "iso-8859-5", + 28596: "iso-8859-6", + 28597: "iso-8859-7", + 28598: "iso-8859-8", + 28599: "iso-8859-9", + 28600: "iso-8859-10", + 28603: "iso-8859-13", + 28604: "iso-8859-14", + 28605: "iso-8859-15", + 28606: "iso-8859-16", + 38598: "iso-8859-8-i", + 54936: "gb18030", + 65001: "utf-8", +}; + +function readWindowsServiceScript(filename) { + let buffer = readSmallFile(filename, null); + if (!buffer) { + return null; + } + let codePage = 65001; + if (buffer[0] === 0xff && buffer[1] === 0xfe) { + codePage = 1200; + } else if (buffer[0] === 0xfe && buffer[1] === 0xff) { + codePage = 1201; + } else { + if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) { + buffer = buffer.subarray(3); + } + let end = buffer.indexOf(0x0a); + const preamble = /^@chcp (\d+) >nul\s*$/.exec( + buffer.subarray(0, end < 0 ? buffer.length : end).toString("latin1"), + ); + if (preamble) { + codePage = Number(preamble[1]); + buffer = buffer.subarray(end < 0 ? buffer.length : end + 1); + end = buffer.indexOf(0x0a); + } + const marker = /^@rem openclaw-launcher-encoding=(\S+)\s*$/.exec( + buffer.subarray(0, end < 0 ? buffer.length : end).toString("latin1"), + ); + if (marker) { + if (!preamble) { + const label = marker[1].toLowerCase(); + const numeric = /^cp(\d+)$/.exec(label); + codePage = numeric + ? Number(numeric[1]) + : Number( + Object.entries(WINDOWS_SERVICE_CODEPAGE_LABELS).find( + ([, value]) => value === label, + )?.[0], + ); + } + buffer = buffer.subarray(end < 0 ? buffer.length : end + 1); + } + } + const label = WINDOWS_SERVICE_CODEPAGE_LABELS[codePage]; + try { + if (label && codePage !== 850 && codePage !== 949) { + const decoder = new TextDecoder(label, { fatal: true }); + return decoder.decode(buffer); + } + } catch { + // A missing decoder or invalid byte sequence must not select a guessed path. + } + process.stderr.write( + `openclaw: service script uses code page ${Number.isFinite(codePage) ? codePage : "unknown"}; not decodable here\n`, + ); + return null; +} + +function realNodePath(filename) { + try { + return realpathSync(filename); + } catch { + return null; + } +} + +function isPathWithin(filename, directory) { + const relative = path.relative(directory, filename); + return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); +} + +// Match daemon/paths.ts home expansion without resolving relative inputs against cwd. +export function resolveRecoveryPath( + value, + homeDir, + { allowMissing = false, allowCwd = false, trustedRoot } = {}, +) { + const expanded = value?.trim().replace(/^~(?=$|[\\/])/, () => homeDir ?? "~"); + if (!expanded || !path.isAbsolute(expanded)) { + return null; + } + const paths = /^(?:[a-zA-Z]:[\\/]|\\\\)/.test(expanded) ? path.win32 : path; + const absolute = paths.resolve(expanded); + const cwd = realNodePath(process.cwd()) ?? process.cwd(); + // Trust only the private recovery root when launching from HOME; reject other cwd symlinks. + const trusted = + trustedRoot && path.isAbsolute(trustedRoot) && isPathWithin(absolute, trustedRoot); + const excluded = (filename) => + !allowCwd && isPathWithin(filename, cwd) && !(trusted && isPathWithin(filename, trustedRoot)); + if (excluded(absolute)) { + return null; + } + if (!allowCwd) { + // A final symlink can hide a workspace-owned intermediate directory. + for (let prefix = paths.dirname(absolute); ;) { + if (trusted && !isPathWithin(prefix, trustedRoot)) { + break; + } + const real = realNodePath(prefix); + if (real && excluded(real)) { + return null; + } + const parent = paths.dirname(prefix); + if (parent === prefix) { + break; + } + prefix = parent; + } + } + let existing = absolute; + const missing = []; + for (;;) { + const real = realNodePath(existing); + if (real) { + const resolved = paths.resolve(real, ...missing); + return path.isAbsolute(resolved) && !excluded(resolved) ? resolved : null; + } + if (!allowMissing) { + return null; + } + // An existing dangling symlink or unreadable path is not a creatable suffix. + try { + lstatSync(existing); + return null; + } catch (error) { + if (error?.code !== "ENOENT") { + return null; + } + } + const parent = paths.dirname(existing); + if (parent === existing) { + return null; + } + missing.unshift(paths.basename(existing)); + existing = parent; + } +} + +// Do not pass preload hooks, native-library overrides, or application secrets to probes. +export function isUsableNode(nodePath, { allowCwd = false, trustedRoot, env = process.env } = {}) { + const resolved = resolveRecoveryPath(nodePath, undefined, { allowCwd, trustedRoot }); + if (!resolved || !/^node(?:\.exe)?$/i.test(path.basename(resolved))) { + return false; + } + const probeEnv = { NODE_NO_WARNINGS: "1" }; + for (const [key, value] of Object.entries(env)) { + if (/^(SystemRoot|WINDIR|TEMP|TMP|TMPDIR)$/i.test(key)) { + probeEnv[key] = value; + } + } + const result = spawnSync( + resolved, + [ + "-e", + `const probe = ${SQLITE_CAPABILITY_PROBE}; process.stdout.write(JSON.stringify({ version: process.versions.node, probe }));`, + ], + { + encoding: "utf8", + env: probeEnv, + timeout: 5_000, + killSignal: "SIGKILL", + maxBuffer: 65_536, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + try { + const details = JSON.parse(result.stdout); + return result.status === 0 && !nodeRuntimeFailure(details.version, details.probe); + } catch { + return false; + } +} + +function windowsServiceNode(text) { + for (const line of text.split(/\r?\n/)) { + const command = line.trimStart().replace(/^@/, ""); + let executable = ""; + let quoted = false; + // Mirror quoteCmdScriptArg: other Windows path backslashes stay literal. + for (let index = 0; index < command.length; index += 1) { + const char = command[index]; + if (char === "\\" && command[index + 1] === '"') { + executable += '"'; + index += 1; + } else if (char === '"') { + quoted = !quoted; + } else if (/\s/.test(char) && !quoted) { + break; + } else { + executable += char; + } + } + executable = executable.replace(/\^!/g, "!").replace(/%%/g, "%"); + if ( + !quoted && + path.win32.isAbsolute(executable) && + /^node\.exe$/i.test(path.win32.basename(executable)) + ) { + return executable; + } + } + return null; +} + +function managedServiceNode(homeDir, env) { + let profile = env.OPENCLAW_PROFILE?.trim(); + const args = process.argv.slice(2); + for (let index = 0; index < args.length;) { + const consumed = consumeLauncherRootOptionToken(args, index); + if (!consumed) { + break; + } + if (args[index] === "--dev") { + profile = "dev"; + } else if (args[index] === "--profile") { + profile = args[index + 1]; + } else if (args[index].startsWith("--profile=")) { + profile = args[index].slice("--profile=".length); + } + index += consumed; + } + const suffix = profile && profile.toLowerCase() !== "default" ? profile : ""; + let command; + if (process.platform === "darwin") { + if (!homeDir) { + return null; + } + const label = env.OPENCLAW_LAUNCHD_LABEL?.trim() || `ai.openclaw.${suffix || "gateway"}`; + if (!/^[A-Za-z0-9._-]+$/.test(label)) { + return null; + } + const text = readSmallFile(path.join(homeDir, "Library", "LaunchAgents", `${label}.plist`)); + const array = text?.match(/ProgramArguments<\/key>\s*([\s\S]*?)<\/array>/)?.[1]; + const recordedArgs = [...(array || "").matchAll(/([^<]*)<\/string>/g)].map( + ([, value]) => + value.replace( + /&(amp|lt|gt|quot|apos);/g, + (_, name) => ({ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" })[name], + ), + ); + const wrapperIndex = recordedArgs[0] === "/bin/sh" ? 1 : 0; + const generatedWrapper = + recordedArgs[wrapperIndex]?.endsWith(`${label}-env-wrapper.sh`) && + recordedArgs[wrapperIndex + 1]?.endsWith(`${label}.env`); + command = recordedArgs[generatedWrapper ? wrapperIndex + 2 : 0]; + } else if (process.platform === "linux") { + if (!homeDir) { + return null; + } + const name = + env.OPENCLAW_SYSTEMD_UNIT?.trim() || `openclaw-gateway${suffix ? `-${suffix}` : ""}`; + if (!/^[A-Za-z0-9._@-]+$/.test(name)) { + return null; + } + const filename = name.endsWith(".service") ? name : `${name}.service`; + const text = readSmallFile(path.join(homeDir, ".config", "systemd", "user", filename)); + const service = text?.split(/^\s*\[Service\]\s*$/m)[1]?.split(/^\s*\[/m)[0]; + const executable = service?.match(/^\s*ExecStart=\s*(?:"((?:[^"\\]|\\.)*)"|(\S+))/m); + command = (executable?.[1] ?? executable?.[2])?.replace(/\\(.)/g, "$1"); + } else if (process.platform === "win32") { + const scriptName = env.OPENCLAW_TASK_SCRIPT_NAME?.trim() || "gateway.cmd"; + if (/[/\\]|\.\./.test(scriptName)) { + return null; + } + const stateDir = resolveRecoveryPath( + env.OPENCLAW_STATE_DIR?.trim() || + (homeDir && path.join(homeDir, `.openclaw${suffix ? `-${suffix}` : ""}`)), + homeDir, + ); + const filename = resolveRecoveryPath( + env.OPENCLAW_TASK_SCRIPT?.trim() || (stateDir && path.join(stateDir, scriptName)), + homeDir, + ); + const text = readWindowsServiceScript(filename); + command = text && windowsServiceNode(text); + } + // Service definitions are data. Never execute a shell, service wrapper, or manager shim. + return command && path.isAbsolute(command) && /^node(?:\.exe)?$/i.test(path.basename(command)) + ? command + : null; +} + +function directoryNames(directory) { + const resolved = resolveRecoveryPath(directory); + if (!resolved) { + return []; + } + try { + return readdirSync(resolved).toSorted().slice(0, 256); + } catch { + return []; + } +} + +function resolveNvmDefault(root) { + let alias = readSmallFile(path.join(root, "alias", "default"))?.trim(); + for (let depth = 0; alias && depth < 8; depth += 1) { + if (/^v?\d+(?:\.\d+){0,2}$/.test(alias) || ["node", "stable"].includes(alias)) { + const prefix = alias.replace(/^v/, ""); + const version = directoryNames(path.join(root, "versions", "node")) + .filter((name) => /^v\d+\.\d+\.\d+$/.test(name)) + .filter( + (name) => + ["node", "stable"].includes(alias) || + name === `v${prefix}` || + name.startsWith(`v${prefix}.`), + ) + .toSorted((a, b) => b.localeCompare(a, "en", { numeric: true }))[0]; + return version ? path.join(root, "versions", "node", version, "bin", "node") : null; + } + if (alias === "lts/*") { + const versions = directoryNames(path.join(root, "alias", "lts")) + .map((name) => readSmallFile(path.join(root, "alias", "lts", name))?.trim()) + .filter((value) => value && /^v?\d+\.\d+\.\d+$/.test(value)) + .toSorted((a, b) => b.localeCompare(a, "en", { numeric: true })); + alias = versions[0]; + } else if (/^(?:lts\/)?[A-Za-z0-9_-]+$/.test(alias)) { + alias = readSmallFile(path.join(root, "alias", alias))?.trim(); + } else { + return null; + } + } + return null; +} + +// Discovery uses inherited roots; dotenv must never select an executable. +function* availableNodeCandidates(homeDir, env) { + yield [managedServiceNode(homeDir, env), "managed Gateway service"]; + const pathKey = + process.platform === "win32" + ? Object.keys(env).find((key) => key.toUpperCase() === "PATH") || "PATH" + : "PATH"; + const binary = process.platform === "win32" ? "node.exe" : "node"; + for (const directory of (env[pathKey] || "").split(path.delimiter)) { + if (path.isAbsolute(directory)) { + yield [path.join(directory, binary), "PATH"]; + } + } + for (const candidate of new Set([env.NVM_DIR, homeDir && path.join(homeDir, ".nvm")])) { + const root = resolveRecoveryPath(candidate, homeDir); + if (root) { + yield [resolveNvmDefault(root), "nvm default"]; + } + } + for (const candidate of new Set([ + env.FNM_DIR, + homeDir && path.join(homeDir, ".fnm"), + homeDir && path.join(homeDir, ".local", "share", "fnm"), + ...(process.platform === "darwin" && homeDir + ? [path.join(homeDir, "Library", "Application Support", "fnm")] + : []), + ])) { + const root = resolveRecoveryPath(candidate, homeDir); + if (root) { + yield [ + path.join( + root, + "aliases", + "default", + ...(process.platform === "win32" ? [] : ["bin"]), + binary, + ), + "fnm default", + ]; + } + } + for (const candidate of new Set([env.VOLTA_HOME, homeDir && path.join(homeDir, ".volta")])) { + const root = resolveRecoveryPath(candidate, homeDir); + if (!root) { + continue; + } + try { + const version = JSON.parse(readSmallFile(path.join(root, "tools", "user", "platform.json"))) + ?.node?.runtime; + if (typeof version === "string" && /^\d+\.\d+\.\d+$/.test(version)) { + yield [ + path.join( + root, + "tools", + "image", + "node", + version, + ...(process.platform === "win32" ? [] : ["bin"]), + binary, + ), + "Volta default", + ]; + } + } catch { + // Missing or incomplete manager metadata does not select a runtime. + } + } + for (const major of [26, 24]) { + for (const prefix of ["/opt/homebrew", "/usr/local"]) { + if (process.platform === "darwin" || process.platform === "linux") { + yield [path.join(prefix, "opt", `node@${major}`, "bin", "node"), `Homebrew node@${major}`]; + } + } + } +} + +/** Recover only at CLI startup, before reading config or state. */ +export async function recoverNodeRuntime({ + homeDir, + allowInstall = false, + env = process.env, +} = {}) { + if ( + process.versions.bun || + env.OPENCLAW_NODE_UPDATE_RESPAWNED === "1" || + !process.argv[1] || + isForegroundGmailRunInvocation(process.argv) || + (process.platform !== "win32" && isNativeHookRelayInvocation(process.argv)) || + !nodeRuntimeFailure(process.versions.node, detectCurrentSqliteCapabilities()) + ) { + return false; + } + // userInfo reads the account home without consulting the mutable process environment. + const inheritedHome = env.HOME?.trim() || env.USERPROFILE?.trim(); + let accountHome; + if (!inheritedHome || /^~(?=$|[\\/])/.test(inheritedHome)) { + try { + accountHome = os.userInfo().homedir; + } catch { + // Containers may have no account record; independent PATH discovery still works. + } + } + const osHome = resolveRecoveryPath(inheritedHome || accountHome, accountHome, { + allowMissing: true, + allowCwd: true, + }); + const recoveryHome = resolveRecoveryPath( + homeDir ?? (env.OPENCLAW_HOME?.trim() || osHome), + osHome, + { allowMissing: true, allowCwd: true }, + ); + const recoveryPath = recoveryHome && path.join(recoveryHome, ".openclaw"); + const recoveryRoot = + recoveryPath && + resolveRecoveryPath(recoveryPath, undefined, { + allowMissing: true, + trustedRoot: recoveryPath, + }); + const { resolveUpdatedNodeRuntime } = await import("./node-runtime-update.mjs"); + let nodePath = recoveryRoot + ? await resolveUpdatedNodeRuntime(recoveryRoot, { allowInstall: false, env }) + : null; + let reason = "cached OpenClaw runtime"; + const currentNode = realNodePath(process.execPath); + if (!nodePath) { + const seen = new Set([currentNode]); + for (const [candidate, source] of availableNodeCandidates(osHome, env)) { + // Only an explicitly named PATH directory may opt into cwd executables. + const target = source === "PATH" ? realNodePath(candidate) : null; + const allowCwd = Boolean( + target && realNodePath(path.dirname(candidate)) === path.dirname(target), + ); + const realPath = resolveRecoveryPath(candidate, undefined, { allowCwd }); + if (!realPath || seen.has(realPath)) { + continue; + } + seen.add(realPath); + if (isUsableNode(realPath, { allowCwd, env })) { + nodePath = realPath; + reason = source; + break; + } + } + } + if (!nodePath && allowInstall && recoveryRoot) { + nodePath = await resolveUpdatedNodeRuntime(recoveryRoot, { env }); + reason = "private OpenClaw runtime"; + } + if (!nodePath) { + return false; + } + process.stderr.write( + `openclaw: Retrying with ${JSON.stringify(nodePath)} (${reason}; current Node failed runtime admission).\n`, + ); + runRespawnedChild(nodePath, [...process.execArgv, process.argv[1], ...process.argv.slice(2)], { + ...env, + OPENCLAW_NODE_UPDATE_RESPAWNED: "1", + }); + // The original CLI must not continue while the replacement owns the invocation. + return await new Promise(() => {}); +} diff --git a/node-runtime-update.d.mts b/node-runtime-update.d.mts index cb88623ed82d..1564c0c71fb4 100644 --- a/node-runtime-update.d.mts +++ b/node-runtime-update.d.mts @@ -1 +1,4 @@ -export function resolveUpdatedNodeRuntime(homeDir: string): Promise; +export function resolveUpdatedNodeRuntime( + recoveryRoot: string, + options?: { allowInstall?: boolean; env?: NodeJS.ProcessEnv }, +): Promise; diff --git a/node-runtime-update.mjs b/node-runtime-update.mjs index 92fafed602a5..09a87557b065 100644 --- a/node-runtime-update.mjs +++ b/node-runtime-update.mjs @@ -1,30 +1,9 @@ // This module must run on unsupported Node versions, before importing dist or dependencies. import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; import path from "node:path"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; -import { nodeRuntimeFailure, SQLITE_CAPABILITY_PROBE } from "./node-sqlite.mjs"; - -function isUsableNode(nodePath) { - if (!existsSync(nodePath)) { - return false; - } - const result = spawnSync( - nodePath, - [ - "-e", - `const probe = ${SQLITE_CAPABILITY_PROBE}; process.stdout.write(JSON.stringify({ version: process.versions.node, probe }));`, - ], - { encoding: "utf8", timeout: 10_000, windowsHide: true }, - ); - try { - const details = JSON.parse(result.stdout); - return result.status === 0 && !nodeRuntimeFailure(details.version, details.probe); - } catch { - return false; - } -} +import { isUsableNode, resolveRecoveryPath } from "./node-runtime-recovery.mjs"; function canInstallPrivateNode() { if (!["x64", "arm64"].includes(process.arch)) { @@ -56,26 +35,38 @@ function confirmNodeUpdate() { } /** Returns a verified private runtime, or null when recovery was declined/unavailable. */ -export async function resolveUpdatedNodeRuntime(homeDir, { allowInstall = true } = {}) { - if (process.env.OPENCLAW_NODE_UPDATE_RESPAWNED === "1") { +export async function resolveUpdatedNodeRuntime( + recoveryRoot, + { allowInstall = true, env = process.env } = {}, +) { + if (env.OPENCLAW_NODE_UPDATE_RESPAWNED === "1") { + return null; + } + const privatePaths = { allowMissing: true, trustedRoot: recoveryRoot }; + const prefix = resolveRecoveryPath( + path.join(recoveryRoot, "tools", "cli-node"), + undefined, + privatePaths, + ); + const nodeRoot = + prefix && resolveRecoveryPath(path.join(prefix, "tools", "node"), undefined, privatePaths); + if (!prefix || !nodeRoot) { return null; } - const prefix = path.join(homeDir, ".openclaw", "tools", "cli-node"); - const nodeRoot = path.join(prefix, "tools", "node"); const nodePath = process.platform === "win32" ? path.join(nodeRoot, "node.exe") : path.join(nodeRoot, "bin", "node"); // An earlier explicit opt-in is durable, but an incompatible cache is never trusted. - if (isUsableNode(nodePath)) { + if (isUsableNode(nodePath, { env, trustedRoot: recoveryRoot })) { return nodePath; } if ( !allowInstall || !process.stdin.isTTY || !process.stderr.isTTY || - process.env.CI || + env.CI || process.argv.some((arg) => ["--non-interactive", "--json", "--yes"].includes(arg)) || !canInstallPrivateNode() ) { @@ -95,7 +86,7 @@ export async function resolveUpdatedNodeRuntime(homeDir, { allowInstall = true } new URL(windows ? "./scripts/install.ps1" : "./scripts/install-cli.sh", import.meta.url), ); const command = windows - ? (await import("./scripts/windows-cmd-helpers.mjs")).resolveWindowsPowerShellPath() + ? (await import("./scripts/windows-cmd-helpers.mjs")).resolveWindowsPowerShellPath(env) : process.platform === "darwin" ? "/bin/bash" : "bash"; @@ -112,8 +103,8 @@ export async function resolveUpdatedNodeRuntime(homeDir, { allowInstall = true } nodeRoot, ] : [installer, "--node-only", "--prefix", prefix]; - const result = spawnSync(command, args, { stdio: "inherit" }); - if (result.status !== 0 || !isUsableNode(nodePath)) { + const result = spawnSync(command, args, { stdio: "inherit", env }); + if (result.status !== 0 || !isUsableNode(nodePath, { env, trustedRoot: recoveryRoot })) { process.stderr.write( "openclaw: Node.js update failed; install a compatible Node.js manually.\n", ); diff --git a/openclaw.mjs b/openclaw.mjs index f78aa4c87f9b..3f097247df08 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -1,12 +1,18 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { access } from "node:fs/promises"; import module from "node:module"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + consumeLauncherRootOptionToken, + isForegroundGmailRunInvocation, + isNativeHookRelayInvocation, + recoverNodeRuntime, + runRespawnedChild, +} from "./node-runtime-recovery.mjs"; import { canRunOpenClawNodeDiagnostics, classifyUnsupportedNodeCommand, @@ -55,32 +61,12 @@ const ensureSupportedRuntimeVersion = async () => { const unsupportedCommand = classifyUnsupportedNodeCommand(process.argv); const canRunDiagnostics = canRunOpenClawNodeDiagnostics(process.versions.node, probe.available); const diagnosticExemption = unsupportedCommand === "diagnostic" && canRunDiagnostics; + await recoverNodeRuntime({ + allowInstall: !diagnosticExemption, + }); if (!diagnosticExemption) { process.stderr.write(`openclaw: ${failure}\n`); } - // These invocations have an exact-PID contract and cannot acquire a wrapper process. - if ( - !isForegroundGmailRunInvocation(process.argv) && - !(process.platform !== "win32" && isNativeHookRelayInvocation(process.argv)) - ) { - const { resolveUpdatedNodeRuntime } = await import("./node-runtime-update.mjs"); - const nodePath = await resolveUpdatedNodeRuntime(resolveLauncherHomeDir(), { - allowInstall: !diagnosticExemption, - }); - if (nodePath) { - const env = { ...process.env, OPENCLAW_NODE_UPDATE_RESPAWNED: "1" }; - const pathKey = - process.platform === "win32" - ? (await import("./scripts/windows-cmd-helpers.mjs")).resolvePathEnvKey(env) - : "PATH"; - env[pathKey] = `${path.dirname(nodePath)}${path.delimiter}${env[pathKey] ?? ""}`; - return runRespawnedChild( - nodePath, - [...process.execArgv, process.argv[1], ...process.argv.slice(2)], - env, - ); - } - } if (diagnosticExemption) { return false; } @@ -101,7 +87,6 @@ const ensureSupportedRuntimeVersion = async () => { const isNodeCompileCacheDisabled = () => process.env.NODE_DISABLE_COMPILE_CACHE !== undefined; const isNodeCompileCacheRequested = () => Boolean(process.env.NODE_COMPILE_CACHE) && !isNodeCompileCacheDisabled(); -const isNativeHookRelayInvocation = (argv) => argv[2] === "hooks" && argv[3] === "relay"; const sanitizeCompileCachePathSegment = (value) => { const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, ""); return normalized.length > 0 ? normalized : "unknown"; @@ -138,123 +123,6 @@ const resolvePackagedCompileCacheDirectory = () => { ); }; -const respawnSignals = - process.platform === "win32" - ? ["SIGTERM", "SIGINT", "SIGBREAK"] - : ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"]; -const respawnSignalExitGraceMs = 1_000; -const respawnSignalForceKillGraceMs = 1_000; -const respawnSignalHardExitGraceMs = 1_000; - -const runRespawnedChild = (command, args, env) => { - const child = spawn(command, args, { - stdio: "inherit", - env, - }); - const listeners = new Map(); - // This intentionally overlaps with src/entry.compile-cache.ts; keep the - // respawn supervision behavior in sync until the launcher can share TS code. - // Give the child a moment to honor forwarded signals, then exit the wrapper so - // a child that ignores SIGTERM cannot keep the launcher alive indefinitely. - let signalExitTimer = null; - let signalForceKillTimer = null; - let signalHardExitTimer = null; - let firstForwardedSignal = null; - let hardKillBackstopStarted = false; - const detach = () => { - for (const [signal, listener] of listeners) { - process.off(signal, listener); - } - listeners.clear(); - if (signalExitTimer) { - clearTimeout(signalExitTimer); - signalExitTimer = null; - } - if (signalForceKillTimer) { - clearTimeout(signalForceKillTimer); - signalForceKillTimer = null; - } - if (signalHardExitTimer) { - clearTimeout(signalHardExitTimer); - signalHardExitTimer = null; - } - }; - const forceKillChild = () => { - try { - child.kill(process.platform === "win32" ? "SIGTERM" : "SIGKILL"); - } catch { - // Best-effort shutdown fallback. - } - }; - const requestChildTermination = () => { - try { - child.kill("SIGTERM"); - } catch { - // Best-effort shutdown fallback. - } - signalForceKillTimer = setTimeout(() => { - hardKillBackstopStarted = true; - forceKillChild(); - signalHardExitTimer = setTimeout(() => { - process.exit(1); - }, respawnSignalHardExitGraceMs); - signalHardExitTimer.unref?.(); - }, respawnSignalForceKillGraceMs); - signalForceKillTimer.unref?.(); - }; - const scheduleParentExit = (signal) => { - firstForwardedSignal ??= signal; - if (signalExitTimer) { - return; - } - signalExitTimer = setTimeout(() => { - requestChildTermination(); - }, respawnSignalExitGraceMs); - signalExitTimer.unref?.(); - }; - for (const signal of respawnSignals) { - const listener = () => { - try { - child.kill(signal); - } catch { - // Best-effort signal forwarding. - } - scheduleParentExit(signal); - }; - try { - process.on(signal, listener); - listeners.set(signal, listener); - } catch { - // Unsupported signal on this platform. - } - } - child.once("exit", (code, signal) => { - detach(); - if (signal) { - const forwardedSignalExitCode = - !hardKillBackstopStarted && signal === firstForwardedSignal - ? signal === "SIGINT" - ? 130 - : signal === "SIGTERM" - ? 143 - : undefined - : undefined; - process.exit(forwardedSignalExitCode ?? 1); - } - process.exit(code ?? 1); - }); - child.once("error", (error) => { - detach(); - process.stderr.write( - `[openclaw] Failed to respawn launcher: ${ - error instanceof Error ? (error.stack ?? error.message) : String(error) - }\n`, - ); - process.exit(1); - }); - return true; -}; - const respawnWithoutCompileCacheIfNeeded = () => { if (!isSourceCheckoutLauncher()) { return false; @@ -402,8 +270,6 @@ const isBareRootHelpInvocation = (argv) => argv.length === 3 && (argv[2] === "--help" || argv[2] === "-h"); const LAUNCHER_HELP_FLAGS = new Set(["-h", "--help"]); -const LAUNCHER_ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]); -const LAUNCHER_ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level", "--container"]); const LAUNCHER_PRECOMPUTED_COMMAND_HELP = { browser: { command: "browser", metadataKey: "browserHelpText" }, secrets: { command: "secrets", metadataKey: "secretsHelpText" }, @@ -419,55 +285,6 @@ const LAUNCHER_PRECOMPUTED_SUBCOMMAND_HELP = new Set([ "tasks", ]); -const isLauncherRootOptionValueToken = (arg) => { - if (!arg || arg === "--") { - return false; - } - if (!arg.startsWith("-")) { - return true; - } - return /^-\d+(?:\.\d+)?$/.test(arg); -}; - -const consumeLauncherRootOptionToken = (args, index) => { - const arg = args[index]; - if (!arg) { - return 0; - } - if (LAUNCHER_ROOT_BOOLEAN_FLAGS.has(arg)) { - return 1; - } - if ( - arg.startsWith("--profile=") || - arg.startsWith("--log-level=") || - arg.startsWith("--container=") - ) { - return 1; - } - if (LAUNCHER_ROOT_VALUE_FLAGS.has(arg)) { - return isLauncherRootOptionValueToken(args[index + 1]) ? 2 : 1; - } - return 0; -}; - -// Mirror the entry's foreground Gmail policy before any built modules can load. -// A compile-cache wrapper would kill its owner before descendant cleanup finishes. -const isForegroundGmailRunInvocation = (argv) => { - const args = argv.slice(2); - const commandPath = []; - for (let index = 0; index < args.length && commandPath.length < 3; index += 1) { - const consumed = consumeLauncherRootOptionToken(args, index); - if (consumed > 0) { - index += consumed - 1; - } else if (!args[index] || args[index].startsWith("-")) { - break; - } else { - commandPath.push(args[index]); - } - } - return commandPath.join(" ") === "webhooks gmail run"; -}; - const hasLauncherContainerTarget = (argv) => { if (normalizeLauncherMetadataValue(process.env.OPENCLAW_CONTAINER)) { return true; diff --git a/package.json b/package.json index c429bfdf48c6..e3d35d37cafe 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "node-sqlite.mjs", "node-version.mjs", "node-runtime-update.mjs", + "node-runtime-recovery.mjs", "openclaw.mjs", "pnpm-workspace.yaml", "README.md", diff --git a/scripts/check-duplicates.mts b/scripts/check-duplicates.mts index 953e80d688a8..1779e8cac3dc 100644 --- a/scripts/check-duplicates.mts +++ b/scripts/check-duplicates.mts @@ -23,6 +23,7 @@ const targets = [ "skills", "config", "node-runtime-update.mjs", + "node-runtime-recovery.mjs", "node-sqlite.mjs", "node-version.mjs", "openclaw.mjs", diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile index a8ef4bac3c6b..e749f10595d4 100644 --- a/scripts/docker/cleanup-smoke/Dockerfile +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -18,6 +18,7 @@ COPY openclaw.mjs ./ COPY node-version.mjs ./ COPY node-sqlite.mjs ./ COPY node-runtime-update.mjs ./ +COPY node-runtime-recovery.mjs ./ COPY ui/package.json ./ui/package.json COPY packages ./packages COPY extensions ./extensions diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 2cfd4b844503..526d597a1304 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -964,8 +964,10 @@ export async function runCli( options: { additionalStartupTrace?: ReturnType; retainConsoleRoutingUntilProcessExit?: boolean; + runtimeRecoveryEnv?: NodeJS.ProcessEnv; } = {}, ) { + const runtimeRecoveryEnv = options.runtimeRecoveryEnv ?? { ...process.env }; const originalArgv = normalizeWindowsArgv(argv); const builtInMachineOutput = resolveBuiltInMachineOutput(originalArgv); return await withConsoleLogsRoutedToStderrForJson( @@ -975,6 +977,7 @@ export async function runCli( try { return await runCliWithPreparedOutputMode(originalArgv, { ...options, + runtimeRecoveryEnv, builtInMachineOutput, harnessCleanup, }); @@ -1027,6 +1030,7 @@ async function runCliWithPreparedOutputMode( additionalStartupTrace?: ReturnType; builtInMachineOutput: boolean; harnessCleanup?: CliHarnessCleanup; + runtimeRecoveryEnv: NodeJS.ProcessEnv; }, ) { const startupTrace = createGatewayDispatchStartupTrace(originalArgv, "cli.main"); @@ -1111,7 +1115,13 @@ async function runCliWithPreparedOutputMode( // Enforce the minimum supported runtime before gateway selection can read or recover config. const { assertSupportedRuntime, isCurrentRuntimeSupported } = await import("../infra/runtime-guard.js"); - await assertSupportedRuntime(undefined, undefined, normalizedArgv); + await assertSupportedRuntime( + undefined, + undefined, + normalizedArgv, + true, + options.runtimeRecoveryEnv, + ); if ( !isHelpOrVersionInvocation && diff --git a/src/cli/update-cli/update-command-repair-isolation.test-support.ts b/src/cli/update-cli/update-command-repair-isolation.test-support.ts index b35fef506ec2..e241a93c121d 100644 --- a/src/cli/update-cli/update-command-repair-isolation.test-support.ts +++ b/src/cli/update-cli/update-command-repair-isolation.test-support.ts @@ -143,6 +143,7 @@ export async function writeRepairCandidate(candidate: string, configChange: bool "node-version.mjs", "node-sqlite.mjs", "node-runtime-update.mjs", + "node-runtime-recovery.mjs", "package.json", ]) { await fs.copyFile(path.join(process.cwd(), file), path.join(candidate, file)); diff --git a/src/commands/doctor-config-preflight.process.test-support.ts b/src/commands/doctor-config-preflight.process.test-support.ts index e693b83f3c58..083d4def9c82 100644 --- a/src/commands/doctor-config-preflight.process.test-support.ts +++ b/src/commands/doctor-config-preflight.process.test-support.ts @@ -107,6 +107,7 @@ export function createSourceRuntime(root: string): string { "node-version.mjs", "node-sqlite.mjs", "node-runtime-update.mjs", + "node-runtime-recovery.mjs", "package.json", "tsconfig.json", ]) { diff --git a/src/entry.compile-cache.ts b/src/entry.compile-cache.ts index 2b319ea28b12..d13fb75f5b8b 100644 --- a/src/entry.compile-cache.ts +++ b/src/entry.compile-cache.ts @@ -112,8 +112,9 @@ function buildOpenClawCompileCacheRespawnPlan(params: { currentFile: string; installRoot: string; compileCacheDir?: string; + env?: NodeJS.ProcessEnv; }): OpenClawCompileCacheRespawnPlan | undefined { - const env = process.env; + const env = params.env ?? process.env; const argv = process.argv; const platform = process.platform; if (isForegroundGmailRunArgv(argv) || shouldKeepNativeHookRelayInProcess(argv, platform)) { @@ -145,12 +146,14 @@ function buildOpenClawCompileCacheRespawnPlan(params: { export async function respawnWithoutOpenClawCompileCacheIfNeeded(params: { currentFile: string; installRoot: string; + env?: NodeJS.ProcessEnv; prepareWriteError?: () => Promise<(message: string) => void | Promise>; }): Promise { const plan = buildOpenClawCompileCacheRespawnPlan({ currentFile: params.currentFile, installRoot: params.installRoot, compileCacheDir: getCompileCacheDir?.(), + env: params.env, }); if (!plan) { return false; diff --git a/src/entry.run-main.test.ts b/src/entry.run-main.test.ts index 63e283b6e287..c69ac9d0d215 100644 --- a/src/entry.run-main.test.ts +++ b/src/entry.run-main.test.ts @@ -12,6 +12,7 @@ describe("entry run-main boundary", () => { expect(runCli).toHaveBeenCalledWith(["node", "openclaw", "status"], { additionalStartupTrace: expect.any(Object), + runtimeRecoveryEnv: expect.any(Object), retainConsoleRoutingUntilProcessExit: true, }); }); diff --git a/src/entry.ts b/src/entry.ts index 71b191b1dd48..4f5088fa0864 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -34,6 +34,9 @@ import { ensureOpenClawExecMarkerOnProcess } from "./infra/openclaw-exec-env.js" import { installProcessWarningFilter } from "./infra/warning-filter.js"; import { defaultRuntime } from "./runtime.js"; +// Recovery must not select executables from workspace/global dotenv values. +const inheritedRuntimeEnv = { ...process.env }; + const ENTRY_WRAPPER_PAIRS = [ { wrapperBasename: "openclaw.mjs", entryBasename: "entry.js" }, { wrapperBasename: "openclaw.mjs", entryBasename: "entry.mjs" }, @@ -129,6 +132,7 @@ if ( if (earlyProfile.ok && earlyProfile.profile) { applyCliProfileEnv({ profile: earlyProfile.profile }); } + const startupEnv = { ...process.env }; const { assertSupportedRuntime, isCurrentRuntimeSupported } = await import("./infra/runtime-guard.js"); if (!isCurrentRuntimeSupported()) { @@ -136,12 +140,13 @@ if ( loadCliDotEnv({ quiet: true }); await configureGatewayStartupTraceConsoleFormatting(gatewayEntryStartupTrace); } - await assertSupportedRuntime(undefined, undefined, process.argv, false); + await assertSupportedRuntime(undefined, undefined, process.argv, false, inheritedRuntimeEnv); gatewayEntryStartupTrace.mark("bootstrap"); const waitingForCompileCacheRespawn = await respawnWithoutOpenClawCompileCacheIfNeeded({ currentFile: entryFile, installRoot, + env: startupEnv, prepareWriteError: async () => { // The child environment was already snapshotted. Load dotenv only to format // the parent trace; command-specific dotenv ordering remains child-owned. @@ -164,7 +169,7 @@ if ( } async function ensureCliRespawnReady(): Promise { - const plan = buildCliRespawnPlan(); + const plan = buildCliRespawnPlan({ env: startupEnv }); if (!plan) { return false; } @@ -179,7 +184,7 @@ if ( if (!(await ensureCliRespawnReady())) { // Only the final child emits the diagnostic warning; parents still enforce admission. - await assertSupportedRuntime(undefined, undefined, process.argv); + await assertSupportedRuntime(undefined, undefined, process.argv, true, inheritedRuntimeEnv); const parsedContainer = parseCliContainerArgs(process.argv); if (!parsedContainer.ok) { await writeCapturedCliArgumentError(parsedContainer.error); @@ -312,6 +317,7 @@ export async function runMainOrRootHelp( commandStarted = true; await runCli(argv, { additionalStartupTrace: gatewayEntryStartupTrace, + runtimeRecoveryEnv: inheritedRuntimeEnv, // Finalizers and process-exit hooks can still emit diagnostics after runCli settles. retainConsoleRoutingUntilProcessExit: true, }); diff --git a/src/gateway/worker-environments/node-bootstrap-artifact.test.ts b/src/gateway/worker-environments/node-bootstrap-artifact.test.ts index ad4b591e6e35..3ea053f1d4e7 100644 --- a/src/gateway/worker-environments/node-bootstrap-artifact.test.ts +++ b/src/gateway/worker-environments/node-bootstrap-artifact.test.ts @@ -62,6 +62,7 @@ async function fixture(mode: "source" | "package" | "external-plugin" = "source" await write(packageRoot, "node-version.mjs", "export const supported = true;"); await write(packageRoot, "node-sqlite.mjs", "export const probe = true;"); await write(packageRoot, "node-runtime-update.mjs", "export const update = true;"); + await write(packageRoot, "node-runtime-recovery.mjs", "export const recovery = true;"); await write(packageRoot, "scripts/preinstall.mjs", "export {};\n"); await write( packageRoot, diff --git a/src/gateway/worker-environments/node-bootstrap-artifact.ts b/src/gateway/worker-environments/node-bootstrap-artifact.ts index b3ecfd8d98b3..672cb9de3511 100644 --- a/src/gateway/worker-environments/node-bootstrap-artifact.ts +++ b/src/gateway/worker-environments/node-bootstrap-artifact.ts @@ -44,6 +44,7 @@ const BOOTSTRAP_LAUNCHER_FILES = [ "node-version.mjs", "node-sqlite.mjs", "node-runtime-update.mjs", + "node-runtime-recovery.mjs", ]; const READ_CONCURRENCY = 16; const IGNORED_PLUGIN_DIRECTORIES = new Set(["node_modules", "src", "test", "tests"]); diff --git a/src/gateway/worker-environments/node-enrollment.test.ts b/src/gateway/worker-environments/node-enrollment.test.ts index 99969167a79d..aaccf0ea25d0 100644 --- a/src/gateway/worker-environments/node-enrollment.test.ts +++ b/src/gateway/worker-environments/node-enrollment.test.ts @@ -122,6 +122,10 @@ describe("worker node enrollment", () => { path.join(packageRoot, "node-runtime-update.mjs"), "export const update = true;", ), + fs.writeFile( + path.join(packageRoot, "node-runtime-recovery.mjs"), + "export const recovery = true;", + ), fs.writeFile(path.join(packageRoot, "dist/entry.js"), "export const ready = true;"), fs.writeFile( path.join(packageRoot, "dist/build-info.json"), diff --git a/src/infra/node-runtime-recovery.test.ts b/src/infra/node-runtime-recovery.test.ts new file mode 100644 index 000000000000..b6a0ab27672e --- /dev/null +++ b/src/infra/node-runtime-recovery.test.ts @@ -0,0 +1,930 @@ +import { + ChildProcess, + type SpawnOptions, + type SpawnSyncOptionsWithStringEncoding, + type SpawnSyncReturns, +} from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expectDefined } from "@openclaw/normalization-core/expect"; +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; +import { isUsableNode, recoverNodeRuntime } from "../../node-runtime-recovery.mjs"; +import { SQLITE_CAPABILITY_PROBE } from "../../node-sqlite.mjs"; +import { buildTaskScript } from "../daemon/schtasks-layout.js"; +import { withTempDir } from "../test-utils/temp-dir.js"; +import { mockProcessPlatform } from "../test-utils/vitest-spies.js"; +import { encodeWindowsLauncherScript } from "./windows-launcher-encoding.js"; + +const mocks = vi.hoisted(() => ({ + currentAdmitted: false, + encoding: "utf-8", + admissible: new Set(), + virtualPaths: new Map(), + probe: + vi.fn< + ( + file: string, + args: string[], + options: SpawnSyncOptionsWithStringEncoding, + ) => SpawnSyncReturns + >(), + spawn: vi.fn<(file: string, args: string[], options: SpawnOptions) => ChildProcess>(), +})); + +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawn: mocks.spawn, + spawnSync: mocks.probe, +})); +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + realpathSync: (filename: string) => + mocks.virtualPaths.get(filename) ?? actual.realpathSync(filename), + }; +}); +vi.mock("../../node-sqlite.mjs", async (importOriginal) => ({ + ...(await importOriginal()), + detectCurrentSqliteCapabilities: () => ({ + available: true, + version: "3.51.3", + text: mocks.currentAdmitted, + blob: true, + json: true, + }), +})); +vi.mock("./windows-encoding.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveWindowsOemEncoding: () => mocks.encoding, + resolveWindowsOemCodePage: () => 437, +})); + +const originalArgv = process.argv; +const originalExecArgv = process.execArgv; +const hostPlatform = process.platform; +const windowsPath = { + isAbsolute: path.win32.isAbsolute.bind(path.win32), + basename: path.win32.basename.bind(path.win32), + dirname: path.win32.dirname.bind(path.win32), + relative: (from: string, to: string) => path.win32.relative(from, to).replaceAll("\\", path.sep), +}; +const exitSentinel = new Error("replacement exited"); +let child: ChildProcess; +let exitSpy: MockInstance; +let stderrSpy: MockInstance; + +beforeEach(() => { + mockProcessPlatform("linux"); + mocks.currentAdmitted = false; + mocks.encoding = "utf-8"; + mocks.admissible.clear(); + mocks.virtualPaths.clear(); + mocks.probe.mockReset(); + mocks.spawn.mockReset(); + mocks.probe.mockImplementation((filename) => ({ + pid: 100, + status: 0, + signal: null, + output: [], + stdout: JSON.stringify({ + version: "24.19.0", + probe: { + available: true, + version: "3.51.3", + text: mocks.admissible.has(filename), + blob: true, + json: true, + }, + }), + stderr: "", + })); + child = new ChildProcess(); + mocks.spawn.mockReturnValue(child); + exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw exitSentinel; + }); + stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + process.argv = [ + process.execPath, + "/fixture/dist/index.js", + "doctor", + "--non-interactive", + "--fix", + ]; + process.execArgv = []; + vi.stubEnv("CI", "1"); + for (const key of [ + "OPENCLAW_NODE_UPDATE_RESPAWNED", + "OPENCLAW_PROFILE", + "OPENCLAW_LAUNCHD_LABEL", + "OPENCLAW_SYSTEMD_UNIT", + "OPENCLAW_TASK_SCRIPT", + ]) { + vi.stubEnv(key, undefined); + } +}); + +afterEach(() => { + if (child.listenerCount("exit")) { + expect(() => child.emit("exit", 0, null)).toThrow(exitSentinel); + } + process.argv = originalArgv; + process.execArgv = originalExecArgv; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +async function writeFixture(filename: string, text = "") { + await fs.mkdir(path.dirname(filename), { recursive: true }); + await fs.writeFile(filename, text); + return filename; +} + +async function withRecoveryHome(run: (home: string) => Promise) { + await withTempDir("openclaw-node-recovery-", async (directory) => { + const home = await fs.realpath(directory); + vi.stubEnv("HOME", home); + vi.stubEnv("PATH", path.join(home, "bin")); + vi.stubEnv("NVM_DIR", path.join(home, ".nvm")); + vi.stubEnv("FNM_DIR", path.join(home, ".fnm")); + vi.stubEnv("VOLTA_HOME", path.join(home, ".volta")); + await run(home); + }); +} + +async function expectRecoveryStarted(home: string) { + void recoverNodeRuntime({ homeDir: home }); + await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledOnce()); +} + +describe("runtime recovery discovery", () => { + it.each(["HOME", "OPENCLAW_HOME"])( + "reuses a private runtime when cwd equals %s", + async (homeVariable) => { + await withRecoveryHome(async (home) => { + const candidate = await writeFixture( + path.join(home, ".openclaw/tools/cli-node/tools/node/bin/node"), + ); + vi.stubEnv("OPENCLAW_HOME", homeVariable === "OPENCLAW_HOME" ? home : undefined); + if (homeVariable === "OPENCLAW_HOME") { + vi.stubEnv("HOME", path.dirname(home)); + } + vi.stubEnv("PATH", ""); + vi.spyOn(process, "cwd").mockReturnValue(home); + mocks.admissible.add(candidate); + + void recoverNodeRuntime(); + await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledOnce()); + expect(mocks.spawn.mock.calls[0]?.[0]).toBe(candidate); + expect(mocks.probe.mock.calls.map(([file]) => file)).toEqual([candidate]); + }); + }, + ); + + it.each(["none", "executable", "parent", "root"])( + "rejects workspace and manager Nodes from HOME (private symlink escape=%s)", + async (privateEscape) => { + await withRecoveryHome(async (home) => { + vi.stubEnv("OPENCLAW_HOME", undefined); + vi.stubEnv("PATH", "."); + vi.spyOn(process, "cwd").mockReturnValue(home); + const workspaceNode = await writeFixture(path.join(home, "node")); + const managerNode = await writeFixture( + path.join(home, ".nvm/versions/node/v24.19.0/bin/node"), + ); + const plantedNodes = [workspaceNode, managerNode, await fs.realpath(process.execPath)]; + await writeFixture(path.join(home, ".nvm/alias/default"), "24"); + if (privateEscape !== "none") { + const privateNode = path.join(home, ".openclaw/tools/cli-node/tools/node/bin/node"); + if (privateEscape === "root") { + const workspaceRoot = path.join(home, "workspace-state"); + plantedNodes.push( + await writeFixture(path.join(workspaceRoot, "tools/cli-node/tools/node/bin/node")), + ); + await fs.symlink(workspaceRoot, path.join(home, ".openclaw"), "junction"); + } else if (privateEscape === "executable") { + await fs.mkdir(path.dirname(privateNode), { recursive: true }); + await fs.symlink(workspaceNode, privateNode); + } else { + const workspaceTools = path.join(home, "workspace-tools"); + const redirectedNode = path.join(workspaceTools, "cli-node/tools/node/bin/node"); + await fs.mkdir(path.dirname(redirectedNode), { recursive: true }); + await fs.symlink(process.execPath, redirectedNode); + await fs.mkdir(path.join(home, ".openclaw")); + await fs.symlink(workspaceTools, path.join(home, ".openclaw/tools"), "junction"); + } + } + mocks.admissible.add(workspaceNode); + mocks.admissible.add(managerNode); + + expect(await recoverNodeRuntime()).toBe(false); + const probed = mocks.probe.mock.calls.map(([file]) => file); + for (const planted of plantedNodes) { + expect(probed).not.toContain(planted); + } + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + }, + ); + + it.each([ + { name: "expands the Windows service state directory against home", source: "home" }, + { name: "never reads competing cwd tilde service metadata", source: "competing" }, + { name: "rejects a relative Windows service state directory", source: "relative" }, + { name: "expands an explicit Windows task script against home", source: "home-script" }, + { name: "rejects an explicit Windows task script under cwd", source: "cwd-script" }, + { name: "rejects a Windows task script resolving under cwd", source: "symlink-script" }, + { name: "rejects a task script through a cwd-owned parent", source: "parent-script" }, + ])("$name", async ({ source }) => { + await withRecoveryHome(async (root) => { + const state = source === "relative" ? "state" : "~/x"; + const home = path.join(root, "daemon $& home"); + const cwd = path.join(root, "checkout"); + await fs.mkdir(home); + await fs.mkdir(cwd); + const installedNode = await writeFixture(path.join(root, "installed", "node.exe")); + const workspaceNode = await writeFixture(path.join(root, "sibling", "node.exe")); + const homeScript = path.join(home, "x", "gateway.cmd"); + const competingScript = path.join(cwd, state, "gateway.cmd"); + const writeScript = async (filename: string, node: string) => { + await fs.mkdir(path.dirname(filename), { recursive: true }); + await fs.writeFile( + filename, + encodeWindowsLauncherScript({ + format: "cmd", + content: buildTaskScript({ programArguments: [node, "/fixture/entry.js", "gateway"] }), + }), + ); + }; + await writeScript(homeScript, installedNode); + if (source !== "home") { + await writeScript(competingScript, workspaceNode); + } + const scriptLink = path.join(root, "gateway.cmd"); + const outsideScript = path.join(root, "outside-gateway.cmd"); + const parentLink = path.join(root, "cwd-alias"); + if (source === "symlink-script") { + await fs.symlink(competingScript, scriptLink); + } else if (source === "parent-script") { + await fs.rename(competingScript, outsideScript); + await fs.symlink(outsideScript, competingScript); + await fs.symlink(cwd, parentLink, "junction"); + } + const scriptOverride = + source === "home-script" + ? "~/x/gateway.cmd" + : source === "cwd-script" + ? competingScript + : source === "symlink-script" + ? scriptLink + : source === "parent-script" + ? path.join(parentLink, state, "gateway.cmd") + : undefined; + const report = path.join(root, "discovery.json"); + const driver = await writeFixture( + path.join(root, "discover.mjs"), + ` + import childProcess from "node:child_process"; + import { EventEmitter } from "node:events"; + import fs from "node:fs"; + import path from "node:path"; + import { syncBuiltinESMExports } from "node:module"; + Object.defineProperty(process, "platform", { value: "win32" }); + Object.defineProperty(process.versions, "node", { value: "20.0.0" }); + const result = { reads: [], probes: [] }; + const readFileSync = fs.readFileSync; + fs.readFileSync = (filename, ...args) => { + result.reads.push(path.resolve(String(filename))); + return readFileSync(filename, ...args); + }; + childProcess.spawnSync = (command) => { + result.probes.push(command); + return { status: [${JSON.stringify(installedNode)}, ${JSON.stringify(workspaceNode)}].includes(command) ? 0 : 1, + stdout: JSON.stringify({ version: "24.19.0", probe: { available: true, version: "3.53.4", text: true, blob: true, json: true } }) }; + }; + childProcess.spawn = (command) => { + result.selected = command; + const child = new EventEmitter(); + child.kill = () => true; + setImmediate(() => child.emit("exit", 23, null)); + return child; + }; + process.on("exit", () => fs.writeFileSync(${JSON.stringify(report)}, JSON.stringify(result))); + syncBuiltinESMExports(); + const { recoverNodeRuntime } = await import(${JSON.stringify(new URL("../../node-runtime-recovery.mjs", import.meta.url).href)}); + await recoverNodeRuntime(); + `, + ); + const { spawnSync } = + await vi.importActual("node:child_process"); + const result = spawnSync(process.execPath, [driver, "doctor", "--fix", "--non-interactive"], { + cwd, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + OPENCLAW_HOME: path.join(root, "private-home"), + OPENCLAW_STATE_DIR: state, + OPENCLAW_TASK_SCRIPT: scriptOverride, + OPENCLAW_TASK_SCRIPT_NAME: undefined, + PATH: "", + NVM_DIR: undefined, + FNM_DIR: undefined, + VOLTA_HOME: undefined, + NODE_OPTIONS: undefined, + }, + encoding: "utf8", + timeout: 30_000, + }); + const observed = JSON.parse(await fs.readFile(report, "utf8")); + expect(observed.reads).not.toContain(competingScript); + expect(observed.reads).not.toContain(scriptLink); + expect(observed.reads).not.toContain(outsideScript); + expect(observed.probes).not.toContain(workspaceNode); + if (["home", "competing", "home-script"].includes(source)) { + expect(result.status, result.stderr).toBe(23); + expect(observed.reads).toContain(homeScript); + expect(observed.selected).toBe(installedNode); + } else { + expect(result.status, result.stderr).toBe(0); + expect(observed.selected).toBeUndefined(); + } + }); + }); + + it("never probes fnm through a cwd-owned parent directory", async () => { + await withRecoveryHome(async (home) => { + const cwd = path.join(home, "checkout"); + const candidate = await writeFixture(path.join(home, "outside", "node")); + await fs.mkdir(path.join(cwd, "bin"), { recursive: true }); + await fs.symlink(candidate, path.join(cwd, "bin", "node")); + const aliases = path.join(home, ".fnm", "aliases"); + await fs.mkdir(aliases, { recursive: true }); + await fs.symlink(cwd, path.join(aliases, "default"), "junction"); + vi.spyOn(process, "cwd").mockReturnValue(cwd); + + expect(await recoverNodeRuntime({ homeDir: home })).toBe(false); + expect(mocks.probe.mock.calls.map(([file]) => file)).not.toContain(candidate); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + }); + + it.each([true, false])("recovers without an OS account record (HOME=%s)", async (hasHome) => { + await withRecoveryHome(async (home) => { + const candidate = await writeFixture(path.join(home, "bin", "node")); + const account = vi.spyOn(os, "userInfo").mockImplementation(() => { + throw new Error("OS account record unavailable"); + }); + if (!hasHome) { + vi.stubEnv("HOME", undefined); + vi.stubEnv("USERPROFILE", undefined); + } + mocks.admissible.add(candidate); + await expect( + Promise.race([ + recoverNodeRuntime({ homeDir: home }), + vi.waitFor(() => { + expect(mocks.spawn).toHaveBeenCalledOnce(); + }), + ]), + ).resolves.toBeUndefined(); + expect(mocks.spawn.mock.calls[0]?.[0]).toBe(candidate); + expect(account).toHaveBeenCalledTimes(hasHome ? 0 : 1); + }); + }); + + it.each(["nvm", "fnm", "Volta"])("expands the %s manager root against home", async (manager) => { + await withRecoveryHome(async (home) => { + const root = path.join(home, "custom-manager"); + let candidate: string; + if (manager === "nvm") { + candidate = await writeFixture(path.join(root, "versions/node/v24.19.0/bin/node")); + await writeFixture(path.join(root, "alias/default"), "24"); + vi.stubEnv("NVM_DIR", "~/custom-manager"); + } else if (manager === "fnm") { + candidate = await writeFixture(path.join(root, "aliases/default/bin/node")); + vi.stubEnv("FNM_DIR", "~/custom-manager"); + } else { + candidate = await writeFixture(path.join(root, "tools/image/node/24.19.0/bin/node")); + await writeFixture( + path.join(root, "tools/user/platform.json"), + JSON.stringify({ node: { runtime: "24.19.0" } }), + ); + vi.stubEnv("VOLTA_HOME", "~/custom-manager"); + } + mocks.admissible.add(candidate); + await expectRecoveryStarted(home); + expect(mocks.spawn.mock.calls[0]?.[0]).toBe(candidate); + }); + }); + + it.each(["HOME", "USERPROFILE", "OPENCLAW_HOME"])( + "expands inherited %s before private discovery", + async (key) => { + await withRecoveryHome(async (home) => { + const candidate = await writeFixture( + path.join(home, "custom-home/.openclaw/tools/cli-node/tools/node/bin/node"), + ); + vi.spyOn(os, "userInfo").mockReturnValue({ + homedir: home, + username: "fixture", + uid: 1000, + gid: 1000, + shell: null, + }); + vi.stubEnv("OPENCLAW_HOME", undefined); + vi.stubEnv("USERPROFILE", undefined); + if (key === "USERPROFILE") { + vi.stubEnv("HOME", undefined); + } + vi.stubEnv(key, "~/custom-home"); + mocks.admissible.add(candidate); + + void recoverNodeRuntime(); + await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledOnce()); + expect(mocks.spawn.mock.calls[0]?.[0]).toBe(candidate); + }); + }, + ); + + it("uses inherited PATH and probe settings after process env changes", async () => { + await withRecoveryHome(async (home) => { + const inheritedNode = await writeFixture(path.join(home, "inherited/bin/node")); + const workspaceNode = await writeFixture(path.join(home, "workspace/bin/node")); + const env = { ...process.env, PATH: path.dirname(inheritedNode), TEMP: home }; + vi.stubEnv("PATH", path.dirname(workspaceNode)); + vi.stubEnv("TEMP", path.join(home, "workspace")); + vi.stubEnv("FNM_DIR", path.join(home, "workspace-fnm")); + mocks.admissible.add(inheritedNode); + mocks.admissible.add(workspaceNode); + + void recoverNodeRuntime({ homeDir: home, env }); + await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledOnce()); + + expect(mocks.probe.mock.calls.map(([file]) => file)).toEqual([inheritedNode]); + expect(mocks.probe.mock.calls[0]?.[2].env).toMatchObject({ TEMP: home }); + expect(mocks.spawn.mock.calls[0]?.[2].env).toEqual({ + ...env, + OPENCLAW_NODE_UPDATE_RESPAWNED: "1", + }); + }); + }); + + it.each([ + ["unquoted", "C:\\Node24\\node.exe", "utf-8", false, "cmd"], + ["quoted", "C:\\Program Files\\Node24\\node.exe", "utf-8", false, "cmd"], + ["cmd escapes", "C:\\Tools\\100% ready!\\node.exe", "utf-8", false, "cmd"], + ["GBK marker", "C:\\Node 隆\\node.exe", "gbk", false, "cmd"], + ["legacy GBK marker", "C:\\Node 隆\\node.exe", "gbk", false, "marker-only"], + ["UTF-8 BOM", "C:\\Node café\\node.exe", "utf-8", false, "utf8-bom"], + ["UTF-16 LE BOM", "C:\\Node café\\node.exe", "utf-8", false, "utf16le-bom"], + ["UTF-16 BE BOM", "C:\\Node café\\node.exe", "utf-8", false, "utf16be-bom"], + ["Big5 marker", "C:\\Node 文\\node.exe", "big5", false, "cmd"], + ["CP866 marker", "C:\\Node Я\\node.exe", "cp866", false, "cmd"], + ["CP1258 marker", "C:\\Node Đ\\node.exe", "windows-1258", false, "cmd"], + ["OEM marker", "C:\\Node café\\node.exe", "cp850", true, "cmd"], + ["UHC marker", "C:\\Node 똠이\\node.exe", "euc-kr", true, "cmd"], + ] as const)( + "reads the Windows writer's %s service executable", + async (_label, candidate, encoding, skip, format) => { + await withRecoveryHome(async (home) => { + const script = path.join(home, "gateway.cmd"); + mocks.encoding = encoding; + const content = buildTaskScript({ + programArguments: [candidate, "C:\\OpenClaw\\dist\\index.js", "gateway"], + }); + let bytes = encodeWindowsLauncherScript({ + format: format.startsWith("utf16") ? "vbs" : "cmd", + content, + }); + if (format === "marker-only") { + bytes = bytes.subarray(bytes.indexOf(0x0a) + 1); + } else if (format === "utf8-bom") { + bytes = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), bytes]); + } else if (format === "utf16be-bom") { + bytes = bytes.swap16(); + } + await fs.writeFile(script, bytes); + mockProcessPlatform("win32"); + vi.spyOn(path, "isAbsolute").mockImplementation(windowsPath.isAbsolute); + vi.spyOn(path, "basename").mockImplementation(windowsPath.basename); + vi.spyOn(path, "dirname").mockImplementation(windowsPath.dirname); + vi.spyOn(path, "relative").mockImplementation(windowsPath.relative); + vi.stubEnv("OPENCLAW_TASK_SCRIPT", script); + vi.stubEnv("PATH", ""); + mocks.virtualPaths.set(candidate, candidate); + mocks.admissible.add(candidate); + + if (skip) { + expect(await recoverNodeRuntime({ homeDir: home })).toBe(false); + expect(mocks.probe).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledExactlyOnceWith( + `openclaw: service script uses code page ${encoding === "euc-kr" ? 949 : 850}; not decodable here\n`, + ); + const fallback = await writeFixture(path.join(home, "fallback/bin/node.exe")); + vi.stubEnv("PATH", path.dirname(fallback)); + mocks.admissible.add(fallback); + await expectRecoveryStarted(home); + expect(mocks.probe.mock.calls.map(([file]) => file)).toEqual([fallback]); + expect(mocks.spawn.mock.calls[0]?.[0]).toBe(fallback); + } else { + await expectRecoveryStarted(home); + expect(mocks.probe.mock.calls.map(([file]) => file)).toEqual([candidate]); + expect(mocks.spawn.mock.calls[0]?.[0]).toBe(candidate); + } + }); + }, + ); + + it.each([".", "bin", ""])("never probes cwd Node through relative PATH %j", async (entry) => { + await withRecoveryHome(async (home) => { + const cwd = path.join(home, "untrusted-checkout"); + await fs.mkdir(cwd); + const candidate = await writeFixture(path.resolve(cwd, entry || ".", "node")); + vi.spyOn(process, "cwd").mockReturnValue(cwd); + vi.stubEnv("PATH", entry); + + expect(await recoverNodeRuntime({ homeDir: home })).toBe(false); + expect(mocks.probe.mock.calls.map(([file]) => file)).not.toContain(candidate); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + }); + + it("never probes an nvm symlink under cwd pointing outside cwd", async () => { + await withRecoveryHome(async (home) => { + const cwd = path.join(home, "untrusted-checkout"); + const root = path.join(cwd, ".nvm"); + const candidate = path.join(root, "versions/node/v24.19.0/bin/node"); + const target = await writeFixture(path.join(home, "outside/bin/node")); + await writeFixture(path.join(root, "alias/default"), "24"); + await fs.mkdir(path.dirname(candidate), { recursive: true }); + await fs.symlink(target, candidate); + vi.spyOn(process, "cwd").mockReturnValue(cwd); + vi.stubEnv("NVM_DIR", root); + vi.stubEnv("PATH", ""); + + expect(await recoverNodeRuntime({ homeDir: home })).toBe(false); + const probed = mocks.probe.mock.calls.map(([file]) => file); + expect(probed).not.toContain(candidate); + expect(probed).not.toContain(target); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + }); + + it("allows an absolute PATH entry explicitly naming the cwd", async () => { + await withRecoveryHome(async (home) => { + const cwd = path.join(home, "explicit-bin"); + const candidate = await writeFixture(path.join(cwd, "node")); + vi.spyOn(process, "cwd").mockReturnValue(cwd); + vi.stubEnv("PATH", cwd); + mocks.admissible.add(candidate); + await writeFixture( + path.join(home, ".config/systemd/user/openclaw-gateway.service"), + `[Service]\nExecStart="${candidate.replaceAll("\\", "\\\\")}" /fixture/dist/index.js gateway\n`, + ); + + await expectRecoveryStarted(home); + + expect(mocks.spawn.mock.calls[0]?.[0]).toBe(candidate); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("(PATH;")); + }); + }); + + it("keeps PATH discovery after rejecting a service executable's cwd parent", async () => { + await withRecoveryHome(async (home) => { + const cwd = path.join(home, "checkout"); + const candidate = await writeFixture(path.join(home, "outside", "node")); + await fs.mkdir(cwd); + await fs.symlink(candidate, path.join(cwd, "node")); + const alias = path.join(home, "service-alias"); + await fs.symlink(cwd, alias, "junction"); + await writeFixture( + path.join(home, ".config/systemd/user/openclaw-gateway.service"), + `[Service]\nExecStart="${path.join(alias, "node").replaceAll("\\", "\\\\")}" /fixture/entry.js gateway\n`, + ); + vi.spyOn(process, "cwd").mockReturnValue(cwd); + vi.stubEnv("PATH", path.dirname(candidate)); + mocks.admissible.add(candidate); + + await expectRecoveryStarted(home); + expect(mocks.spawn.mock.calls[0]?.[0]).toBe(candidate); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("(PATH;")); + }); + }); + + it.each(["private", "service", "nvm", "fnm", "Volta", "Homebrew"])( + "never probes a %s runtime resolving into cwd", + async (source) => { + await withRecoveryHome(async (home) => { + const cwd = path.join(home, "untrusted-checkout"); + const candidate = await writeFixture(path.join(cwd, "node")); + vi.spyOn(process, "cwd").mockReturnValue(cwd); + vi.stubEnv("PATH", ""); + const paths = { + private: path.join(home, ".openclaw/tools/cli-node/tools/node/bin/node"), + service: path.join(home, "service/bin/node"), + nvm: path.join(home, ".nvm/versions/node/v24.19.0/bin/node"), + fnm: path.join(home, ".fnm/aliases/default/bin/node"), + Volta: path.join(home, ".volta/tools/image/node/24.19.0/bin/node"), + Homebrew: path.join("/opt/homebrew", "opt/node@26/bin/node"), + }; + for (const [name, alias] of Object.entries(paths)) { + if (name === source) { + mocks.virtualPaths.set(alias, candidate); + } + } + await writeFixture( + path.join(home, ".config/systemd/user/openclaw-gateway.service"), + `[Service]\nExecStart="${paths.service.replaceAll("\\", "\\\\")}" /fixture/dist/index.js gateway\n`, + ); + await writeFixture(path.join(home, ".nvm/alias/default"), "24"); + await fs.mkdir(path.dirname(path.dirname(paths.nvm)), { recursive: true }); + await writeFixture( + path.join(home, ".volta/tools/user/platform.json"), + JSON.stringify({ node: { runtime: "24.19.0" } }), + ); + + expect(await recoverNodeRuntime({ homeDir: home })).toBe(false); + const probed = mocks.probe.mock.calls.map(([file]) => file); + expect(probed).not.toContain(candidate); + for (const [name, alias] of Object.entries(paths)) { + if (name === source) { + expect(probed).not.toContain(alias); + } + } + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + }, + ); + + it.each([ + [0, "cached OpenClaw runtime"], + [1, "managed Gateway service"], + [2, "PATH"], + [3, "nvm default"], + [4, "fnm default"], + [5, "Volta default"], + [6, "Homebrew node@26"], + [7, "Homebrew node@24"], + ] as const)("selects the first admissible runtime: %s %s", async (index, source) => { + await withRecoveryHome(async (home) => { + const candidates = await Promise.all( + [ + ".openclaw/tools/cli-node/tools/node/bin/node", + "service/bin/node", + "bin/node", + ".nvm/versions/node/v24.19.0/bin/node", + ".fnm/aliases/default/bin/node", + ".volta/tools/image/node/24.19.0/bin/node", + "brew26/bin/node", + "brew24/bin/node", + ].map((relative) => writeFixture(path.join(home, relative))), + ); + await writeFixture( + path.join(home, ".config/systemd/user/openclaw-gateway.service"), + `[Service]\nExecStart="${expectDefined(candidates[1], "service candidate").replaceAll("\\", "\\\\")}" /fixture/dist/index.js gateway run\n`, + ); + await writeFixture(path.join(home, ".nvm/alias/default"), "lts/test\n"); + await writeFixture(path.join(home, ".nvm/alias/lts/test"), "24\n"); + await writeFixture( + path.join(home, ".volta/tools/user/platform.json"), + JSON.stringify({ node: { runtime: "24.19.0" } }), + ); + for (const prefix of ["/opt/homebrew", "/usr/local"]) { + mocks.virtualPaths.set( + path.join(prefix, "opt", "node@26", "bin", "node"), + expectDefined(candidates[6], "Node 26 candidate"), + ); + mocks.virtualPaths.set( + path.join(prefix, "opt", "node@24", "bin", "node"), + expectDefined(candidates[7], "Node 24 candidate"), + ); + } + for (const candidate of candidates.slice(index)) { + mocks.admissible.add(candidate); + } + + await expectRecoveryStarted(home); + + expect(mocks.probe.mock.calls.map(([filename]) => filename)).toEqual( + candidates.slice(0, index + 1), + ); + expect(mocks.spawn.mock.calls[0]?.[0]).toBe(candidates[index]); + expect(stderrSpy).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining(`(${source}; current Node failed runtime admission)`), + ); + }); + }); + + it.each(["direct", "shell wrapper", "executable wrapper"])( + "finds the profiled launchd runtime through a %s command", + async (form) => { + mockProcessPlatform("darwin"); + await withRecoveryHome(async (home) => { + const candidate = await writeFixture(path.join(home, "service & runtime/bin/node")); + mocks.admissible.add(candidate); + process.argv = [ + process.execPath, + "/fixture/openclaw.mjs", + "--profile", + "fixture", + "doctor", + ]; + const wrapper = path.join(home, "service-env/ai.openclaw.fixture-env-wrapper.sh"); + const envFile = path.join(home, "service-env/ai.openclaw.fixture.env"); + const prefix = + form === "direct" + ? [] + : form === "shell wrapper" + ? ["/bin/sh", wrapper, envFile] + : [wrapper, envFile]; + const args = [...prefix, candidate, "/fixture/dist/index.js", "gateway"]; + await writeFixture( + path.join(home, "Library/LaunchAgents/ai.openclaw.fixture.plist"), + `ProgramArguments${args.map((arg) => `${arg.replaceAll("&", "&")}`).join("")}`, + ); + + await expectRecoveryStarted(home); + + expect(mocks.probe.mock.calls.map(([filename]) => filename)).toEqual([candidate]); + expect(mocks.spawn.mock.calls[0]?.[0]).toBe(candidate); + }); + }, + ); + + it("ignores PATH aliases for the running executable and probes each other binary once", async () => { + await withRecoveryHome(async (home) => { + await fs.mkdir(path.join(home, "bin")); + if (hostPlatform === "win32") { + mocks.virtualPaths.set(path.join(home, "bin/node"), await fs.realpath(process.execPath)); + } else { + await fs.symlink(process.execPath, path.join(home, "bin/node")); + } + const replacement = await writeFixture(path.join(home, "replacement/bin/node")); + const alternate = path.join(home, "alternate/bin/node"); + await fs.mkdir(path.dirname(alternate), { recursive: true }); + if (hostPlatform === "win32") { + mocks.virtualPaths.set(alternate, replacement); + } else { + await fs.symlink(replacement, alternate); + } + vi.stubEnv( + "PATH", + [path.join(home, "bin"), path.dirname(alternate), path.dirname(replacement)].join( + path.delimiter, + ), + ); + + expect(await recoverNodeRuntime({ homeDir: home })).toBe(false); + + expect(mocks.probe.mock.calls.filter(([filename]) => filename === replacement)).toHaveLength( + 1, + ); + expect(mocks.probe.mock.calls.some(([filename]) => filename === process.execPath)).toBe( + false, + ); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + }); + + it.each(["already admitted", "replacement child"])( + "does not discover for an %s", + async (reason) => { + await withRecoveryHome(async (home) => { + const candidate = await writeFixture(path.join(home, "bin/node")); + mocks.admissible.add(candidate); + mocks.currentAdmitted = reason === "already admitted"; + if (reason === "replacement child") { + vi.stubEnv("OPENCLAW_NODE_UPDATE_RESPAWNED", "1"); + } + + expect(await recoverNodeRuntime({ homeDir: home })).toBe(false); + expect(mocks.probe).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + }, + ); + + it.each([ + ["webhooks", "gmail", "run"], + ["--profile", "fixture", "webhooks", "gmail", "run"], + ["webhooks", "--log-level=debug", "gmail", "--no-color", "run"], + ["hooks", "relay", "--relay-id", "fixture"], + ])("keeps exact-PID invocation %j in its original process", async (...args) => { + await withRecoveryHome(async (home) => { + const candidate = await writeFixture(path.join(home, "bin/node")); + mocks.admissible.add(candidate); + process.argv = [process.execPath, "/fixture/openclaw.mjs", ...args]; + + expect(await recoverNodeRuntime({ homeDir: home })).toBe(false); + expect(mocks.probe).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + }); + + it.each([0, 7])( + "preserves the invocation and propagates replacement exit %s", + async (exitCode) => { + await withRecoveryHome(async (home) => { + const candidate = await writeFixture(path.join(home, "bin/node")); + mocks.admissible.add(candidate); + process.execArgv = ["--trace-warnings"]; + vi.stubEnv("OPENCLAW_TEST_VALUE", "preserved"); + vi.stubEnv("NODE_OPTIONS", "--no-warnings"); + const originalEnv = { ...process.env }; + const originalCwd = process.cwd(); + + await expectRecoveryStarted(home); + + expect(mocks.spawn).toHaveBeenCalledExactlyOnceWith( + candidate, + ["--trace-warnings", "/fixture/dist/index.js", "doctor", "--non-interactive", "--fix"], + { stdio: "inherit", env: { ...originalEnv, OPENCLAW_NODE_UPDATE_RESPAWNED: "1" } }, + ); + expect(process.cwd()).toBe(originalCwd); + expect(exitSpy).not.toHaveBeenCalled(); + expect(() => child.emit("exit", exitCode, null)).toThrow(exitSentinel); + expect(exitSpy).toHaveBeenCalledExactlyOnceWith(exitCode); + }); + }, + ); +}); + +describe("candidate admission probe", () => { + it("never probes a non-Node symlink target", async () => { + await withRecoveryHome(async (home) => { + const target = await writeFixture(path.join(home, "another-executable")); + const alias = path.join(home, "bin/node"); + mocks.virtualPaths.set(alias, target); + + expect(isUsableNode(alias)).toBe(false); + expect(mocks.probe).not.toHaveBeenCalled(); + }); + }); + + it("runs only the shared bounded probe with a sanitized environment", async () => { + await withRecoveryHome(async (home) => { + const candidate = await writeFixture(path.join(home, "bin/node")); + mocks.admissible.add(candidate); + for (const key of [ + "NODE_OPTIONS", + "NODE_PATH", + "LD_PRELOAD", + "DYLD_INSERT_LIBRARIES", + "OPENCLAW_TEST_SECRET", + ]) { + vi.stubEnv(key, "synthetic-untrusted-value"); + } + vi.stubEnv("SystemRoot", "/fixture/windows"); + vi.stubEnv("TMPDIR", path.join(home, "tmp")); + + expect(isUsableNode(candidate)).toBe(true); + + expect(mocks.probe).toHaveBeenCalledOnce(); + const [, args, options] = expectDefined(mocks.probe.mock.calls[0], "runtime probe call"); + expect(args).toEqual(["-e", expect.stringContaining(SQLITE_CAPABILITY_PROBE)]); + expect(options).toMatchObject({ + timeout: 5_000, + maxBuffer: 65_536, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + env: { + NODE_NO_WARNINGS: "1", + SystemRoot: "/fixture/windows", + TMPDIR: path.join(home, "tmp"), + }, + }); + expect( + Object.keys(options.env ?? {}).every((key) => + /^(SystemRoot|WINDIR|TEMP|TMP|TMPDIR|NODE_NO_WARNINGS)$/i.test(key), + ), + ).toBe(true); + }); + }); + + it.each(["timeout", "malformed response", "failed exit"])("rejects %s", async (failure) => { + await withRecoveryHome(async (home) => { + const candidate = await writeFixture(path.join(home, "bin/node")); + mocks.admissible.add(candidate); + mocks.probe.mockReturnValue({ + pid: 100, + status: failure === "timeout" ? null : failure === "failed exit" ? 1 : 0, + signal: failure === "timeout" ? "SIGTERM" : null, + output: [], + stdout: + failure === "malformed response" + ? "not JSON" + : JSON.stringify({ + version: "24.19.0", + probe: { available: true, version: "3.51.3", text: true, blob: true, json: true }, + }), + stderr: "", + }); + + expect(isUsableNode(candidate)).toBe(false); + }); + }); +}); diff --git a/src/infra/node-runtime-update.test.ts b/src/infra/node-runtime-update.test.ts index 65b99cfc4fa6..8e0132d2cc4e 100644 --- a/src/infra/node-runtime-update.test.ts +++ b/src/infra/node-runtime-update.test.ts @@ -7,7 +7,6 @@ import { resolveUpdatedNodeRuntime } from "../../node-runtime-update.mjs"; import { withTempDir } from "../test-utils/temp-dir.js"; const mocks = vi.hoisted(() => ({ - exists: vi.fn<(value: string) => boolean>(), spawn: vi.fn< ( @@ -17,10 +16,6 @@ const mocks = vi.hoisted(() => ({ ) => SpawnSyncReturns >(), })); -vi.mock("node:fs", async (importOriginal) => ({ - ...(await importOriginal()), - existsSync: mocks.exists, -})); vi.mock("node:child_process", async (importOriginal) => ({ ...(await importOriginal()), spawnSync: mocks.spawn, @@ -41,12 +36,15 @@ it.each([ vi.stubEnv("NODE_OPTIONS", undefined); const childProcess = await vi.importActual("node:child_process"); - await withTempDir("openclaw-node-recovery-", async (home) => { + await withTempDir("openclaw-node-recovery-", async (directory) => { + const home = await fs.realpath(directory); const nodeRoot = path.join(home, ".openclaw", "tools", "cli-node", "tools", "node"); const candidate = process.platform === "win32" ? path.join(nodeRoot, "node.exe") : path.join(nodeRoot, "bin", "node"); + await fs.mkdir(path.dirname(candidate), { recursive: true }); + await fs.writeFile(candidate, "synthetic runtime; the probe uses the current Node"); const preload = path.join(home, "binding.mjs"); await fs.writeFile( preload, @@ -70,7 +68,6 @@ it.each([ } `, ); - mocks.exists.mockImplementation((value) => value === candidate); mocks.spawn.mockImplementation((_file, args, options) => childProcess.spawnSync( process.execPath, @@ -79,9 +76,11 @@ it.each([ ), ); - expect(await resolveUpdatedNodeRuntime(home)).toBe(lossless ? candidate : null); + expect(await resolveUpdatedNodeRuntime(path.join(home, ".openclaw"))).toBe( + lossless ? candidate : null, + ); expect(mocks.spawn).toHaveBeenCalledOnce(); - expect(mocks.spawn.mock.calls[0]?.[2].timeout).toBe(10_000); + expect(mocks.spawn.mock.calls[0]?.[2].timeout).toBe(5_000); const result = mocks.spawn.mock.results[0]; if (result?.type !== "return") { throw new Error("Runtime probe did not return"); diff --git a/src/infra/runtime-guard.ts b/src/infra/runtime-guard.ts index bcd0e4b9f797..f09093d5b0dc 100644 --- a/src/infra/runtime-guard.ts +++ b/src/infra/runtime-guard.ts @@ -215,6 +215,7 @@ export async function assertSupportedRuntime( details: RuntimeDetails = detectRuntime(), argv?: readonly string[], emitDiagnosticWarning = true, + recoveryEnv?: NodeJS.ProcessEnv, ): Promise { if (runtimeSatisfies(details)) { const note = @@ -230,6 +231,11 @@ export async function assertSupportedRuntime( } return; } + // Only startup callers with a pre-dotenv snapshot may select another runtime. + if (details.kind === "node" && argv && recoveryEnv) { + const { recoverNodeRuntime } = await import("../../node-runtime-recovery.mjs"); + await recoverNodeRuntime({ env: recoveryEnv }); + } if ( details.kind === "node" && canRunOpenClawNodeDiagnostics(details.version, details.hasNodeSqlite) && diff --git a/test/openclaw-launcher-version.e2e.test.ts b/test/openclaw-launcher-version.e2e.test.ts index 946d3aaa0d89..92e3ab2f60f2 100644 --- a/test/openclaw-launcher-version.e2e.test.ts +++ b/test/openclaw-launcher-version.e2e.test.ts @@ -37,6 +37,10 @@ async function makeLauncherVersionFixture( path.resolve(process.cwd(), "node-runtime-update.mjs"), path.join(fixtureRoot, "node-runtime-update.mjs"), ); + await fs.copyFile( + path.resolve(process.cwd(), "node-runtime-recovery.mjs"), + path.join(fixtureRoot, "node-runtime-recovery.mjs"), + ); await fs.mkdir(path.join(fixtureRoot, "dist"), { recursive: true }); await fs.writeFile( path.join(fixtureRoot, "package.json"), diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index a3af4bbc8c0a..d9da10fa0ce0 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -24,6 +24,10 @@ async function makeLauncherFixture(fixtureRoots: string[]): Promise { path.resolve(process.cwd(), "node-runtime-update.mjs"), path.join(fixtureRoot, "node-runtime-update.mjs"), ); + await fs.copyFile( + path.resolve(process.cwd(), "node-runtime-recovery.mjs"), + path.join(fixtureRoot, "node-runtime-recovery.mjs"), + ); await fs.copyFile( path.resolve(process.cwd(), "node-sqlite.mjs"), path.join(fixtureRoot, "node-sqlite.mjs"), @@ -172,7 +176,7 @@ describe("openclaw launcher", () => { } = {}, ) { const root = await makeLauncherFixture(fixtureRoots); - const home = path.join(root, "home with spaces"); + const home = makeTempDir(fixtureRoots, "openclaw-launcher-home with spaces-"); const nodePath = path.join( home, ".openclaw", @@ -183,7 +187,6 @@ describe("openclaw launcher", () => { "bin", "node", ); - await fs.mkdir(home); if (params.cached) { await fs.mkdir(path.dirname(nodePath), { recursive: true }); await fs.symlink(process.execPath, nodePath); @@ -205,6 +208,14 @@ describe("openclaw launcher", () => { } Object.defineProperty(process.stdin, "isTTY", { value: ${params.tty ?? true} }); Object.defineProperty(process.stderr, "isTTY", { value: ${params.tty ?? true} }); + const realpath = fs.realpathSync; + fs.realpathSync = (filename, ...options) => { + const file = String(filename); + if (path.basename(file) === "node" && file !== process.execPath && file !== ${JSON.stringify(nodePath)} && !fs.statSync(file).isDirectory()) { + throw Object.assign(new Error("runtime absent from fixture"), { code: "ENOENT" }); + } + return realpath(filename, ...options); + }; const original = childProcess.spawnSync; childProcess.spawnSync = (command, args, options) => { if (command !== ${JSON.stringify(process.platform === "darwin" ? "/bin/bash" : "bash")}) return original(command, args, options); @@ -224,7 +235,7 @@ describe("openclaw launcher", () => { path.join(root, "dist", "entry.js"), ` if (!process.getBuiltinModule?.("node:sqlite")) throw new Error("native diagnostic reader loaded without node:sqlite"); - process.stdout.write(JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd(), path: process.env.PATH })); + process.stdout.write(JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd(), path: process.env.PATH, recovered: process.env.OPENCLAW_NODE_UPDATE_RESPAWNED === "1" })); process.exitCode = 17; `, ); @@ -236,12 +247,12 @@ describe("openclaw launcher", () => { 'if (process.env.OPENCLAW_NODE_UPDATE_RESPAWNED !== "1") throw new Error("legacy lifecycle loaded"); export function completePendingPackageLifecycle() {}', ); } - const run = (input: string, args = ["status"], env: NodeJS.ProcessEnv = {}) => + const run = (input: string, args = ["status"], env: NodeJS.ProcessEnv = {}, cwd = root) => spawnSync( process.execPath, ["--import", pathToFileURL(preload).href, path.join(root, "openclaw.mjs"), ...args], { - cwd: root, + cwd, env: { ...launcherEnv(), HOME: home, @@ -258,6 +269,49 @@ describe("openclaw launcher", () => { return { root, home, nodePath, installLog, run }; } + it.each( + ["HOME", "OPENCLAW_HOME"].flatMap((homeVariable) => + ["cached", "install", "decline", "non-interactive"].map((mode) => ({ + homeVariable, + mode, + })), + ), + )( + "preserves $mode private recovery when cwd equals $homeVariable", + async ({ homeVariable, mode }) => { + const fixture = await prepareRecovery({ + cached: mode === "cached", + tty: mode !== "non-interactive", + }); + const result = fixture.run( + mode === "install" ? "y\n" : "n\n", + ["status"], + { + HOME: homeVariable === "HOME" ? fixture.home : fixture.root, + OPENCLAW_HOME: homeVariable === "OPENCLAW_HOME" ? fixture.home : undefined, + PATH: "", + }, + fixture.home, + ); + const recovered = mode === "cached" || mode === "install"; + expect(result.status, result.stderr).toBe(recovered ? 17 : 1); + expect(result.stderr.includes("Update NodeJS: Y/N")).toBe( + mode === "install" || mode === "decline", + ); + if (recovered) { + expect(JSON.parse(result.stdout)).toMatchObject({ + recovered: true, + cwd: await fs.realpath(fixture.home), + }); + } else { + expect(result.stderr).toContain("nvm install"); + } + if (mode !== "install") { + await expect(fs.stat(fixture.installLog)).rejects.toMatchObject({ code: "ENOENT" }); + } + }, + ); + it("accepts Yes before pending lifecycle imports, installs only Node, and retries exact arguments", async () => { const fixture = await prepareRecovery({ pendingLifecycle: true }); const args = ["status", "--profile", "two words", "literal;argument"]; @@ -271,8 +325,9 @@ describe("openclaw launcher", () => { args, cwd: fixture.root, path: expect.any(String), + recovered: true, }); - expect(output.path.split(path.delimiter)[0]).toBe(path.dirname(fixture.nodePath)); + expect(output.path).toBe(process.env.PATH); expect(JSON.parse(await fs.readFile(fixture.installLog, "utf8"))).toEqual({ command: process.platform === "darwin" ? "/bin/bash" : "bash", args: [ @@ -347,9 +402,7 @@ describe("openclaw launcher", () => { const result = fixture.run("", args); expect(result.status, result.stderr).toBe(17); expect(result.stderr).not.toContain("Update NodeJS:"); - expect(JSON.parse(result.stdout).path.split(path.delimiter)[0]).toBe( - path.dirname(fixture.nodePath), - ); + expect(JSON.parse(result.stdout)).toMatchObject({ path: process.env.PATH, recovered: true }); await expect(fs.stat(fixture.installLog)).rejects.toMatchObject({ code: "ENOENT" }); }); diff --git a/test/scripts/node-runtime-recovery.built-cli.e2e.test.ts b/test/scripts/node-runtime-recovery.built-cli.e2e.test.ts new file mode 100644 index 000000000000..a01917f7e9ea --- /dev/null +++ b/test/scripts/node-runtime-recovery.built-cli.e2e.test.ts @@ -0,0 +1,188 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, expect, it } from "vitest"; +import { + createOpenClawTestInstance, + type OpenClawTestInstance, +} from "../helpers/openclaw-test-instance.js"; + +const instances: OpenClawTestInstance[] = []; +afterEach(async () => { + await Promise.all(instances.splice(0).map((instance) => instance.cleanup())); +}); + +it("recovers legacy dist/index.js Doctor before refusing its unsupported Node", async () => { + const instance = await createOpenClawTestInstance({ name: "legacy-doctor-node-recovery" }); + instances.push(instance); + const bin = path.join(instance.homeDir, "supported", "bin"); + const node = path.join(bin, process.platform === "win32" ? "node.exe" : "node"); + await fs.mkdir(bin, { recursive: true }); + await fs.writeFile(node, "synthetic runtime; execution is mocked"); + const preload = path.join(instance.homeDir, "unsupported-node.mjs"); + const calls = path.join(instance.homeDir, "reexec.json"); + await fs.writeFile( + preload, + `import childProcess from "node:child_process"; + import { EventEmitter } from "node:events"; + import fs from "node:fs"; + import { syncBuiltinESMExports } from "node:module"; + Object.defineProperty(process.versions, "node", { value: "22.23.2" }); + Object.defineProperty(process, "execPath", { value: ${JSON.stringify(path.join(instance.homeDir, "legacy", "node"))} }); + process.env.PATH = ${JSON.stringify(bin)}; + childProcess.spawnSync = (command, args) => ({ + status: command === ${JSON.stringify(node)} && args[0] === "-e" ? 0 : 1, + stdout: JSON.stringify({ version: "24.19.0", probe: { available: true, version: "3.53.4", text: true, blob: true, json: true } }), + }); + childProcess.spawn = (command, args, options) => { + fs.writeFileSync(${JSON.stringify(calls)}, JSON.stringify({ command, args, stdio: options.stdio, marker: options.env.OPENCLAW_NODE_UPDATE_RESPAWNED })); + const child = new EventEmitter(); + child.kill = () => true; + setImmediate(() => child.emit("exit", 23, null)); + return child; + }; + syncBuiltinESMExports();`, + ); + instance.env.NODE_OPTIONS = `--import=${pathToFileURL(preload).href}`; + delete instance.env.OPENCLAW_NODE_UPDATE_RESPAWNED; + const result = await instance.cli(["doctor", "--non-interactive", "--fix"]); + expect(result.code, result.stdout + result.stderr).toBe(23); + expect(JSON.parse(await fs.readFile(calls, "utf8"))).toMatchObject({ + command: node, + args: [ + expect.stringMatching(/dist[/\\]index\.(?:m?js)$/), + "doctor", + "--non-interactive", + "--fix", + ], + stdio: "inherit", + marker: "1", + }); + expect(result.stderr).not.toContain("Upgrade Node and re-run"); +}, 60_000); + +it.each([ + { name: "rejects workspace NVM_DIR before probing a sibling executable", source: "workspace" }, + { name: "honors inherited NVM_DIR without forwarding workspace roots", source: "inherited" }, + { name: "rejects workspace PATH before runtime discovery", source: "path" }, + { name: "checks launcher admission before loading workspace dotenv", source: "launcher" }, + { name: "keeps workspace roots out of compile-cache respawns", source: "compile-cache" }, + { name: "keeps workspace roots out of startup-environment respawns", source: "startup-env" }, +])( + "$name", + async ({ source }) => { + const instance = await createOpenClawTestInstance({ name: `runtime-env-${source}` }); + instances.push(instance); + const workspace = path.join(instance.homeDir, "checkout"); + const manager = path.join(instance.homeDir, "sibling-nvm"); + const bin = path.join(manager, "versions/node/v24.19.0/bin"); + const node = path.join(bin, "node"); + const workspaceFnm = path.join(instance.homeDir, "workspace-fnm"); + await fs.mkdir(workspace); + await fs.mkdir(bin, { recursive: true }); + await fs.mkdir(path.join(manager, "alias")); + await fs.writeFile(path.join(manager, "alias/default"), "24"); + await fs.writeFile(node, "synthetic executable; process boundary is instrumented"); + await fs.writeFile( + path.join(workspace, ".env"), + `${source === "path" ? "PATH" : "NVM_DIR"}=${source === "path" ? bin : manager}\nFNM_DIR=${workspaceFnm}\n`, + ); + const report = path.join(instance.homeDir, "runtime-env-report.json"); + const preload = path.join(instance.homeDir, "runtime-env-preload.mjs"); + const startupRespawn = source === "compile-cache" || source === "startup-env"; + await fs.writeFile( + preload, + `import childProcess from "node:child_process"; + import { EventEmitter } from "node:events"; + import fs from "node:fs"; + import { syncBuiltinESMExports } from "node:module"; + const result = { probes: [] }; + Object.defineProperty(process.versions, "node", { value: ${JSON.stringify(startupRespawn ? "22.23.2" : "20.0.0")} }); + if (process.versions.node.startsWith("20.")) { + const getBuiltinModule = process.getBuiltinModule; + process.getBuiltinModule = (name) => name === "node:sqlite" ? undefined : getBuiltinModule(name); + } + childProcess.spawnSync = (command) => { + result.probes.push(command); + return { + status: command === ${JSON.stringify(node)} ? 0 : 1, + stdout: JSON.stringify({ version: "24.19.0", probe: { available: true, version: "3.53.4", text: true, blob: true, json: true } }), + }; + }; + childProcess.spawn = (command, args, options) => { + result.child = { command, args, nvm: options.env.NVM_DIR, fnm: options.env.FNM_DIR, + marker: options.env.OPENCLAW_NODE_UPDATE_RESPAWNED, + cacheMarker: options.env.OPENCLAW_COMPILE_CACHE_DISABLED_RESPAWNED, + startupMarker: options.env.OPENCLAW_NODE_OPTIONS_READY }; + const child = new EventEmitter(); + child.kill = () => true; + setImmediate(() => child.emit("exit", 23, null)); + return child; + }; + process.on("exit", () => fs.writeFileSync(${JSON.stringify(report)}, JSON.stringify({ + ...result, loadedNvm: process.env.NVM_DIR, loadedPath: process.env.PATH, + }))); + syncBuiltinESMExports();`, + ); + const result = spawnSync( + process.execPath, + [ + "--import", + pathToFileURL(preload).href, + path.resolve(source === "launcher" ? "openclaw.mjs" : "dist/entry.js"), + ...(startupRespawn ? ["update", "status"] : ["doctor", "--non-interactive", "--fix"]), + ], + { + cwd: workspace, + env: { + ...instance.env, + HOME: instance.homeDir, + PATH: "", + NVM_DIR: source === "inherited" ? manager : undefined, + FNM_DIR: undefined, + VOLTA_HOME: undefined, + NODE_OPTIONS: undefined, + NODE_DISABLE_COMPILE_CACHE: source === "compile-cache" ? undefined : "1", + NODE_COMPILE_CACHE: + source === "compile-cache" ? path.join(instance.homeDir, "compile-cache") : undefined, + OPENCLAW_COMPILE_CACHE_DISABLED_RESPAWNED: undefined, + OPENCLAW_NODE_UPDATE_RESPAWNED: undefined, + OPENCLAW_NODE_OPTIONS_READY: undefined, + OPENCLAW_NO_RESPAWN: startupRespawn ? undefined : "1", + }, + encoding: "utf8", + timeout: 30_000, + }, + ); + const observed = JSON.parse(await fs.readFile(report, "utf8")); + if (source === "inherited") { + expect(result.status, result.stdout + result.stderr).toBe(23); + expect(observed.probes).toContain(node); + expect(observed.child).toMatchObject({ command: node, nvm: manager, marker: "1" }); + expect(observed.child.fnm).toBeUndefined(); + } else { + expect(observed.probes).not.toContain(node); + expect(observed.loadedNvm).toBe( + source === "path" || source === "launcher" ? undefined : manager, + ); + if (startupRespawn) { + expect(result.status, result.stdout + result.stderr).toBe(23); + expect(observed.child.nvm).toBeUndefined(); + expect(observed.child.fnm).toBeUndefined(); + if (source === "compile-cache") { + expect(observed.child.cacheMarker).toBe("1"); + } else if (process.platform === "win32") { + expect(observed.child.args).toContain("--stack-size=8192"); + } else { + expect(observed.child.startupMarker).toBe("1"); + } + } else { + expect(result.status, result.stdout + result.stderr).toBe(1); + expect(observed.child).toBeUndefined(); + } + } + expect(observed.loadedPath).toBe(""); + }, + 60_000, +); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index ea9d6bdd4406..3b71d70ad3c4 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -2484,6 +2484,7 @@ describe("scripts/test-projects changed-target routing", () => { forwardedArgs: [ "test/scripts/doctor-config-preflight-plugin-index.built-cli.e2e.test.ts", "test/scripts/mcp-channels-seed.built-cli.e2e.test.ts", + "test/scripts/node-runtime-recovery.built-cli.e2e.test.ts", "test/scripts/sqlite-sessions-transcripts-flip-proof.built-cli.e2e.test.ts", "test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts", ],