openclaw/node-runtime-update.mjs
Peter Steinberger 05963f8b5e
fix(runtime): re-exec under an available supported Node before refusing (#143464)
* fix(runtime): reuse an available compatible Node at startup

Share startup recovery between the launcher and legacy CLI runtime admission
so older updaters can run target Doctor through dist/index.js under an
already installed compatible Node. Preserve process-contract exclusions,
arguments, environment, standard streams, and exit status.

Refs #140465

* test(runtime): include recovery proof in E2E routing

* fix(runtime): secure Node discovery and decode service scripts

Reject relative candidates and cwd-resolved runtimes before probing, except
for explicit absolute PATH directories. Parse generated Windows command
quoting and recorded code pages without loading application dependencies.

Skip CP850 and CP949 with a diagnostic instead of guessing executable paths.
Use real task-writer fixtures for encoding, quoting, and fallback coverage.

Refs #140465

* fix(runtime): reject cwd-local manager symlinks

* test(runtime): keep recovery home outside launcher cwd

* fix(runtime): isolate recovery from dotenv environment

* fix(runtime): canonicalize discovery paths before use

* fix(runtime): preserve private Node recovery from home

* refactor(runtime): trim Node recovery comments and aliases

Behavior-neutral cleanup of the recovery launcher module: fold the serviceHome and managerHome aliases into homeDir and shorten five comment blocks to the invariant they protect.
2026-09-10 01:35:39 -07:00

115 lines
3.7 KiB
JavaScript

// This module must run on unsupported Node versions, before importing dist or dependencies.
import { spawnSync } from "node:child_process";
import path from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { isUsableNode, resolveRecoveryPath } from "./node-runtime-recovery.mjs";
function canInstallPrivateNode() {
if (!["x64", "arm64"].includes(process.arch)) {
return false;
}
if (process.platform === "linux") {
// The existing Alpine installer uses apk/sudo; private CLI recovery must not.
return Boolean(process.report?.getReport().header.glibcVersionRuntime);
}
return process.platform === "darwin" || process.platform === "win32";
}
function confirmNodeUpdate() {
return new Promise((resolve) => {
const prompt = createInterface({ input: process.stdin, output: process.stderr });
let settled = false;
const finish = (answer) => {
if (settled) {
return;
}
settled = true;
prompt.close();
resolve(/^(y|yes)$/i.test(answer.trim()));
};
prompt.once("close", () => finish(""));
prompt.once("SIGINT", () => finish(""));
prompt.question("Update NodeJS: Y/N [N]: ", finish);
});
}
/** Returns a verified private runtime, or null when recovery was declined/unavailable. */
export async function resolveUpdatedNodeRuntime(
recoveryRoot,
{ allowInstall = true, env = process.env } = {},
) {
if (env.OPENCLAW_NODE_UPDATE_RESPAWNED === "1") {
return null;
}
const privatePaths = { allowMissing: true, trustedRoot: recoveryRoot };
const prefix = resolveRecoveryPath(
path.join(recoveryRoot, "tools", "cli-node"),
undefined,
privatePaths,
);
const nodeRoot =
prefix && resolveRecoveryPath(path.join(prefix, "tools", "node"), undefined, privatePaths);
if (!prefix || !nodeRoot) {
return null;
}
const nodePath =
process.platform === "win32"
? path.join(nodeRoot, "node.exe")
: path.join(nodeRoot, "bin", "node");
// An earlier explicit opt-in is durable, but an incompatible cache is never trusted.
if (isUsableNode(nodePath, { env, trustedRoot: recoveryRoot })) {
return nodePath;
}
if (
!allowInstall ||
!process.stdin.isTTY ||
!process.stderr.isTTY ||
env.CI ||
process.argv.some((arg) => ["--non-interactive", "--json", "--yes"].includes(arg)) ||
!canInstallPrivateNode()
) {
return null;
}
process.stderr.write(
"Install a compatible Node.js for OpenClaw only and retry this command.\n" +
"The Node.js installation will not change system Node.js, shell settings, or Gateway services.\n",
);
if (!(await confirmNodeUpdate())) {
return null;
}
const windows = process.platform === "win32";
const installer = fileURLToPath(
new URL(windows ? "./scripts/install.ps1" : "./scripts/install-cli.sh", import.meta.url),
);
const command = windows
? (await import("./scripts/windows-cmd-helpers.mjs")).resolveWindowsPowerShellPath(env)
: process.platform === "darwin"
? "/bin/bash"
: "bash";
const args = windows
? [
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
installer,
"-NodeOnly",
"-NodePrefix",
nodeRoot,
]
: [installer, "--node-only", "--prefix", prefix];
const result = spawnSync(command, args, { stdio: "inherit", env });
if (result.status !== 0 || !isUsableNode(nodePath, { env, trustedRoot: recoveryRoot })) {
process.stderr.write(
"openclaw: Node.js update failed; install a compatible Node.js manually.\n",
);
return null;
}
process.stderr.write("openclaw: Node.js updated. Retrying your command.\n");
return nodePath;
}