mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
* refactor(runtime): probe SQLite startup capabilities in a worker
Move current-runtime capability SQL off the main thread and share one pending result across launcher and runtime callers. Await worker exit before admission, preserve failure caching and Bun library selection, and retain the native SQLite SDK and foreign-runtime probe contracts.
* test(config): expose include provenance in writer failures
Carry the bounded consumed-snapshot, provenance, and include-boundary assertions from 3de18f60bd (#143587). Preserve the observed snapshot and all existing writer/exclusion assertions. The hosted failure cause remains unresolved.
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
---------
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
732 lines
23 KiB
JavaScript
732 lines
23 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 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(() => {});
|
|
}
|