mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 05:54:09 +08:00
* fix(gateway): preserve work during recovered Node restarts Let Unix Node recovery wrappers respect the existing Gateway stop budget. Share startup-safe command classification and deadline facts, and resolve managed restart intent against the live serving owner in its write transaction with current update authority. Preserve the existing public PID intent API, record format, and Windows behavior. Keep cold lifecycle test preparation outside timed hooks. Inspired by @ly85206559's wrapper-grace approach in #147054; this repair is independently authored from main. Related: #146956. Windows cooperative shutdown remains a follow-up. * refactor(gateway): stabilize restart callback and test setup Declare receiver-independent restart intent callbacks as arrows and retain typed signal spy handles for lint-safe assertions. Move cold lifecycle imports into test collection so worker preparation cannot consume timed setup hooks.
752 lines
24 KiB
JavaScript
752 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 { consumeRootOptionToken as consumeLauncherRootOptionToken } from "./cli-root-options.mjs";
|
|
import { isForegroundGatewayRunArgv } from "./gateway-run-argv.mjs";
|
|
import {
|
|
GATEWAY_SERVICE_STOP_TIMEOUT_MS,
|
|
LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS,
|
|
} from "./gateway-shutdown-budget.mjs";
|
|
import {
|
|
detectCurrentSqliteCapabilities,
|
|
nodeRuntimeFailure,
|
|
SQLITE_CAPABILITY_PROBE,
|
|
} from "./node-sqlite.mjs";
|
|
|
|
export { consumeLauncherRootOptionToken };
|
|
export const isNativeHookRelayInvocation = (argv) => argv[2] === "hooks" && argv[3] === "relay";
|
|
|
|
// 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 launchdService = env.OPENCLAW_LAUNCHD_LABEL?.trim();
|
|
const serviceStopTimeoutMs =
|
|
process.platform === "darwin" && launchdService && env.XPC_SERVICE_NAME === launchdService
|
|
? LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS * 1_000
|
|
: GATEWAY_SERVICE_STOP_TIMEOUT_MS;
|
|
// The serving Gateway owns drain and cleanup. Reap a stuck child only in the
|
|
// supervisor's exit margin, after that owner has had its full shutdown budget.
|
|
const signalExitGraceMs =
|
|
process.platform !== "win32" && isForegroundGatewayRunArgv(process.argv)
|
|
? serviceStopTimeoutMs - respawnSignalForceKillGraceMs - respawnSignalHardExitGraceMs
|
|
: respawnSignalExitGraceMs;
|
|
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();
|
|
}, signalExitGraceMs);
|
|
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(() => {});
|
|
}
|