Download node-runtime-recovery.mjs from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 23.4 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/node-runtime-recovery.mjs
- Command line
-
hf download hf://SaylorTwift/openclaw/node-runtime-recovery.mjs
-
curl -L -o node-runtime-recovery.mjs https://huggingface.co/SaylorTwift/openclaw/resolve/main/node-runtime-recovery.mjs
23.4 kB
| // 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) { | |
| if (process.platform !== "win32") { | |
| process.kill(process.pid, signal); | |
| return; | |
| } | |
| 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(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/)?.[1]; | |
| const recordedArgs = [...(array || "").matchAll(/<string>([^<]*)<\/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, await 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(() => {}); | |
| } | |