openclaw/node-runtime-recovery.mjs
Jason (Json) 97c7b35933
fix: complete Gateway upgrades after Node prefix changes (#145335)
Complete managed Gateway upgrades after Node prefix changes while preserving the verified service during preparation and recovering only owned failed activations.

Keep requester/executor and original/candidate ownership through native children; retain uncertain cleanup and original failure outcomes. Source and isolated native component checks are documented in the canonical PR. The wider first-hop installer and other platform journeys remain separate program work.

Closes #107930. Canonical PR history retains the original contributor commits; repository-supported squash preserves explicit contributor credit.

Co-authored-by: Jason (Json) <263060202+fuller-stack-dev@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-09-19 16:58:05 -06:00

766 lines
24 KiB
JavaScript

// 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 stdioIsTerminal = process.stdin.isTTY || process.stdout.isTTY;
const child = spawn(command, args, {
stdio: "inherit",
env,
windowsHide: !stdioIsTerminal,
});
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, acceptVersion } = {},
) {
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;
}
}
try {
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"],
},
);
const details = JSON.parse(result.stdout);
return (
result.status === 0 &&
!nodeRuntimeFailure(details.version, details.probe) &&
(!acceptVersion || acceptVersion(details.version))
);
} 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}`];
}
}
}
}
/** Select a verified runtime without respawning; callers own target admission and activation. */
export async function findUsableNodeRuntime({
homeDir,
allowInstall = false,
env = process.env,
acceptVersion,
nodeVersion,
installCommand,
} = {}) {
// 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,
acceptVersion,
installCommand,
})
: 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, acceptVersion })) {
nodePath = realPath;
reason = source;
break;
}
}
}
if (!nodePath && allowInstall && recoveryRoot) {
nodePath = await resolveUpdatedNodeRuntime(recoveryRoot, {
env,
acceptVersion,
nodeVersion,
installCommand,
});
reason = "private OpenClaw runtime";
}
return nodePath ? { nodePath, reason } : null;
}
/** 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;
}
const selected = await findUsableNodeRuntime({ homeDir, allowInstall, env });
const nodePath = selected?.nodePath;
const reason = selected?.reason;
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(() => {});
}