fix(runtime): keep diagnostics and update usable on an unsupported Node (#143344)

* fix(runtime): keep diagnostics and update usable on an unsupported Node

Share CLI admission between the launcher and source guard, retain private-copy
SQLite recovery, and keep live database writers and repair agents refused.
Route plain Doctor to lint and refuse update before its run-ledger admission
when the CLI Node is unsupported. Report CLI/service runtime findings and the
reporting Node version in update-failure reports.

Refs #140672 #142742

* docs(install): explain private Node recovery and diagnostic access

* test(runtime): preserve Node recovery coverage with diagnostic admission

* fix(runtime): offer Node recovery once per update invocation

* fix(build): emit Node 22 syntax for recovery diagnostics

* fix(update): check runtime at the state preparation boundary

* fix(doctor): preserve migration order before runtime diagnostics

* fix(runtime): recover before admitting unsupported Node diagnostics

* fix(runtime): preserve capability admission in diagnostic recovery

* test(cli): retain capability checks in startup fixtures

* refactor(runtime): share Node findings across diagnostic commands

* fix(doctor): omit absent repair hints in lint errors
This commit is contained in:
Peter Steinberger 2026-09-09 15:26:27 -07:00 • committed by GitHub
parent fda2de6060
commit e4a6d50073
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1138 additions and 78 deletions

View file

@ -4,6 +4,7 @@ read_when:
- You want to understand `openclaw.ai/install.sh`
- You want to automate installs (CI / headless)
- You want to install from a GitHub checkout
- You want to install a private Node runtime without reinstalling OpenClaw
title: "Installer internals"
---
@ -23,6 +24,24 @@ On npm 12, local `.tgz` and `.tar.gz` installs and updates need a comma-free arc
Install-method switches verify the replacement before retiring the current owner. Source wrappers use a same-directory atomic replacement; when an npm shim shares that path, the installer moves only an identity-matched source wrapper aside and restores it if npm installation, lifecycle checks, or candidate verification fails. On upgrades, `install.sh` and `install.ps1` run `openclaw doctor --fix`; repair or final verification failure exits nonzero, and the success banner appears only after those steps complete.
## Private Node recovery
When the active Node.js is unsupported, the CLI can offer `Update NodeJS: Y/N [N]:` before loading OpenClaw. Enter **Y** to install a checksum-verified private runtime and retry the same command. Provisioning leaves system Node.js, shell settings, OpenClaw packages, and Gateway services unchanged; the retried command keeps its normal behavior. Enter **N**, press Enter, or cancel to receive manual upgrade instructions.
The installation offer requires both stdin and stderr to be interactive terminals. The CLI never prompts or installs a runtime in CI or with `--json`, `--yes`, or `--non-interactive`. Recovery supports x64/ARM64 macOS, Windows, and glibc Linux; Alpine/musl and other architectures require manual installation. Commands with an exact process identity requirement, including `hooks relay` and `webhooks gmail run`, keep their existing runtime requirement.
The CLI stores the private runtime under `~/.openclaw/tools/cli-node`, using `OPENCLAW_HOME` in place of the home directory when set. Later launches that need a supported runtime reuse a compatible runtime from that location, including non-interactive launches, without another installation prompt. A supported active Node.js takes precedence. The Node-only installer examples below populate this location explicitly; adjust their home paths if you use `OPENCLAW_HOME`. See [Node.js](/install/node) for manual installation guidance.
### Diagnostics on an unsupported Node
The launcher first reuses a compatible private runtime, including for diagnostics. Without one, diagnostics require Node 22 or newer with `node:sqlite` available. Older runtimes retain the interactive recovery offer or non-interactive refusal before any diagnostic code loads.
On a capable unsupported runtime, `openclaw --version`, `-V`, `--help`, `gateway status`, `doctor --lint`, `update status`, and `triage --json` or `triage --non-interactive` remain available. Plain `doctor` runs read-only lint checks on an unsupported Node. Repair flags, Gateway startup, and triage agent execution still require a supported runtime. `openclaw update` can report the exact Node installation instructions before admitting an update or writing its run ledger.
These commands print `Running on an unsupported Node (<version>); diagnostics may show truncated text`. Findings remain visible, including the CLI and recorded service Node versions and their repair instructions. `update status --json` includes `runtimeFindings` when present, and update-failure issue reports record the reporting process's Node version. A successful npm installation alone does not establish runtime compatibility: npm may skip the preinstall check.
Diagnostic readers preserve the live SQLite files. They may recover a disposable private copy so committed state remains readable after a crash; the runtime exemption does not permit writable live database access.
## Source build toolchain
For source installs, the installer selects pnpm after choosing the checkout ref.
@ -256,6 +275,8 @@ by default, plus git-checkout installs under the same prefix flow.
</Step>
</Steps>
With `--node-only`, `install-cli.sh` stops after provisioning Node into `<prefix>/tools/node-v<version>` and updating the `<prefix>/tools/node` alias. It skips Git, OpenClaw installation, onboarding, and Gateway service work. This mode refuses musl Linux before any system package-manager changes.
### Examples (install-cli.sh)
<Tabs>
@ -269,6 +290,11 @@ by default, plus git-checkout installs under the same prefix flow.
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install-cli.sh | bash -s -- --prefix /opt/openclaw --version latest
```
</Tab>
<Tab title="Node only">
```bash
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install-cli.sh | bash -s -- --node-only --prefix "$HOME/.openclaw/tools/cli-node"
```
</Tab>
<Tab title="Git install">
```bash
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install-cli.sh | bash -s -- --install-method git --git-dir ~/openclaw
@ -289,22 +315,23 @@ by default, plus git-checkout installs under the same prefix flow.
<AccordionGroup>
<Accordion title="Flags reference">
| Flag | Description |
| --------------------------------------- | ------------------------------------------------------------------------------- |
| `--prefix <path>` | Install prefix (default: `~/.openclaw`) |
| `--install-method \| --method npm\|git` | Choose install method (default: `npm`) |
| `--npm` | Shortcut for npm method |
| `--git \| --github` | Shortcut for git method |
| `--git-dir \| --dir <path>` | Git checkout directory (default: `~/openclaw`) |
| `--no-git-update` | Skip `git pull` for an existing git checkout |
| `--version <ver>` | OpenClaw version or dist-tag (default: `latest`) |
| `--compatible-with <ver>` | Refuse a CLI that cannot modify config written by `<ver>` |
| `--node-version <ver>` | Node version (default: `24.19.0`) |
| `--json` | Emit NDJSON events |
| `--onboard` | Run `openclaw onboard` after install |
| `--no-onboard` | Skip onboarding (default) |
| `--set-npm-prefix` | On Linux, force npm prefix to `~/.npm-global` if current prefix is not writable |
| `--help \| -h` | Show usage |
| Flag | Description |
| --------------------------------------- | --------------------------------------------------------------------------------- |
| `--prefix <path>` | Install prefix (default: `~/.openclaw`) |
| `--install-method \| --method npm\|git` | Choose install method (default: `npm`) |
| `--npm` | Shortcut for npm method |
| `--git \| --github` | Shortcut for git method |
| `--git-dir \| --dir <path>` | Git checkout directory (default: `~/openclaw`) |
| `--no-git-update` | Skip `git pull` for an existing git checkout |
| `--version <ver>` | OpenClaw version or dist-tag (default: `latest`) |
| `--compatible-with <ver>` | Refuse a CLI that cannot modify config written by `<ver>` |
| `--node-version <ver>` | Node version (default: `24.19.0`) |
| `--node-only` | Install only the private Node runtime under `--prefix`; no system package changes |
| `--json` | Emit NDJSON events |
| `--onboard` | Run `openclaw onboard` after install |
| `--no-onboard` | Skip onboarding (default) |
| `--set-npm-prefix` | On Linux, force npm prefix to `~/.npm-global` if current prefix is not writable |
| `--help \| -h` | Show usage |
</Accordion>
@ -360,6 +387,12 @@ by default, plus git-checkout installs under the same prefix flow.
</Step>
</Steps>
With `-NodeOnly`, `install.ps1` downloads the official Node archive, verifies its SHA-256 checksum and runtime compatibility, then installs Node with its matching npm/npx into `-NodePrefix`. The prefix must be an absolute private directory, not a filesystem root. This mode skips package managers, OpenClaw installation, onboarding, and Gateway service work, and leaves process, user, and machine PATH unchanged. `-NodePrefix` requires `-NodeOnly`; `-DryRun` previews the destination without installing.
<Note>
The complete native Windows launcher → PowerShell → downloaded Node handoff remains unproven on native Windows. PowerShell installer fixtures cover checksum failures and installation isolation, but do not establish that complete recovery flow.
</Note>
### Examples (install.ps1)
<Tabs>
@ -368,6 +401,11 @@ by default, plus git-checkout installs under the same prefix flow.
iwr -useb https://openclaw.ai/install.ps1 | iex
```
</Tab>
<Tab title="Node only">
```powershell
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -NodeOnly -NodePrefix "$HOME\.openclaw\tools\cli-node\tools\node"
```
</Tab>
<Tab title="Git install">
```powershell
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git
@ -401,6 +439,8 @@ by default, plus git-checkout installs under the same prefix flow.
| `-NoOnboard` | Skip onboarding |
| `-NoGitUpdate` | Skip `git pull` |
| `-DryRun` | Print actions only |
| `-NodeOnly` | Install only a private Node runtime; leave PATH unchanged |
| `-NodePrefix <path>` | Required absolute private directory for `-NodeOnly` |
| `-Help` | Show usage for downloaded scriptblock invocation |
</Accordion>

View file

@ -56,7 +56,7 @@ function confirmNodeUpdate() {
}
/** Returns a verified private runtime, or null when recovery was declined/unavailable. */
export async function resolveUpdatedNodeRuntime(homeDir) {
export async function resolveUpdatedNodeRuntime(homeDir, { allowInstall = true } = {}) {
if (process.env.OPENCLAW_NODE_UPDATE_RESPAWNED === "1") {
return null;
}
@ -72,6 +72,7 @@ export async function resolveUpdatedNodeRuntime(homeDir) {
return nodePath;
}
if (
!allowInstall ||
!process.stdin.isTTY ||
!process.stderr.isTTY ||
process.env.CI ||

View file

@ -13,3 +13,9 @@ export function isSupportedOpenClawNodeVersion(value: unknown): boolean;
export const PROCESS_NODE_VERSION_CHECK: string;
export const SUPPORTED_NODE_VERSIONS: string;
export function formatUnsupportedNodeVersionMessage(version: string | null): string;
export function formatUnsupportedNodeDiagnosticWarning(version: string | null): string;
export function classifyUnsupportedNodeCommand(
argv: readonly string[],
): "diagnostic" | "update" | null;
export function canRunOpenClawNodeDiagnostics(value: unknown, hasNodeSqlite: boolean): boolean;

View file

@ -16,6 +16,132 @@ export const SUPPORTED_NODE_VERSIONS = `${NODE_RELEASE_FLOORS.map(
.join(", ")
.replace(/, ([^,]+)$/, ", or $1")} (Node 26 recommended)`;
export function formatUnsupportedNodeVersionMessage(version) {
return [
`Node ${version ?? "unknown"} is unsupported; OpenClaw requires ${SUPPORTED_NODE_VERSIONS}.`,
"npm can finish installing OpenClaw without running its preinstall check; a successful install does not mean Node is supported.",
"Re-run the installer: curl -fsSL https://openclaw.ai/install.sh | bash",
"On Windows: iwr -useb https://openclaw.ai/install.ps1 | iex",
"Or with nvm: nvm install 26 && nvm use 26 && nvm alias default 26",
"Then rerun openclaw update. See https://docs.openclaw.ai/install/node",
].join("\n");
}
export function formatUnsupportedNodeDiagnosticWarning(version) {
return `Running on an unsupported Node (${version}); diagnostics may show truncated text`;
}
const ROOT_BOOLEAN_OPTIONS = ["--dev", "--no-color"];
const ROOT_VALUE_OPTIONS = ["--profile", "--log-level", "--container"];
function consumeOption(args, index, booleanOptions, valueOptions) {
const arg = args[index];
if (booleanOptions.includes(arg)) {
return 1;
}
const equals = arg.indexOf("=");
if (valueOptions.includes(equals < 0 ? arg : arg.slice(0, equals))) {
return equals >= 0 ? 1 : args[index + 1] === undefined ? 0 : 2;
}
return 0;
}
function diagnosticOptions(args, booleanOptions, valueOptions = []) {
const flags = new Set();
for (let index = 0; index < args.length;) {
const consumed = consumeOption(
args,
index,
[...ROOT_BOOLEAN_OPTIONS, ...booleanOptions],
[...ROOT_VALUE_OPTIONS, ...valueOptions],
);
if (!consumed) {
return null;
}
flags.add(args[index]);
index += consumed;
}
return flags;
}
/** Shared by the packaged launcher and source guard before either loads command state. */
export function classifyUnsupportedNodeCommand(argv) {
// On an unsupported runtime, a command may run only if it never opens a LIVE
// OpenClaw database writable and never starts a Gateway or a repair agent.
// Private-copy recovery is allowed; artifact-preserving readers never write the live file.
const args = argv.slice(2);
let index = 0;
while (index < args.length) {
const consumed = consumeOption(args, index, ROOT_BOOLEAN_OPTIONS, ROOT_VALUE_OPTIONS);
if (!consumed) {
break;
}
index += consumed;
}
const command = args[index++];
const tail = args.slice(index);
if (["--version", "-V", "--help"].includes(command)) {
return diagnosticOptions(tail, []) ? "diagnostic" : null;
}
if (command === "gateway") {
while (index < args.length) {
const consumed = consumeOption(args, index, ROOT_BOOLEAN_OPTIONS, ROOT_VALUE_OPTIONS);
if (!consumed) {
break;
}
index += consumed;
}
return args[index] === "status" ? "diagnostic" : null;
}
if (command === "doctor") {
return diagnosticOptions(
tail,
["--lint", "--json", "--deep", "--all", "--non-interactive", "--no-workspace-suggestions"],
["--only", "--skip", "--severity-min"],
)
? "diagnostic"
: null;
}
if (command === "triage") {
const flags = diagnosticOptions(
tail,
["--json", "--non-interactive", "--no-export"],
["--update-result"],
);
return flags && (flags.has("--json") || flags.has("--non-interactive")) ? "diagnostic" : null;
}
if (command === "update") {
while (index < args.length) {
const consumed = consumeOption(args, index, ROOT_BOOLEAN_OPTIONS, ROOT_VALUE_OPTIONS);
if (!consumed) {
break;
}
index += consumed;
}
if (args[index] === "status") {
return diagnosticOptions(args.slice(index + 1), ["--json"], ["--timeout"])
? "diagnostic"
: null;
}
return diagnosticOptions(
tail,
["--json", "--yes", "--dry-run", "--no-restart", "--accept-capabilities"],
["--channel", "--tag", "--timeout"],
)
? "update"
: null;
}
return null;
}
/** Diagnostic bundles target Node 22 syntax and require the native SQLite reader. */
export function canRunOpenClawNodeDiagnostics(value, hasNodeSqlite) {
return (
hasNodeSqlite &&
isNodeVersionAtLeast(parseNodeReleaseVersion(value), { major: 22, minor: 0, patch: 0 })
);
}
/** Parses an anchored release SemVer, allowing a leading v and valid build metadata. */
export function parseNodeReleaseVersion(value) {
if (typeof value !== "string") {

View file

@ -7,6 +7,11 @@ import module from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
canRunOpenClawNodeDiagnostics,
classifyUnsupportedNodeCommand,
formatUnsupportedNodeDiagnosticWarning,
} from "./node-version.mjs";
const isSourceCheckoutLauncher = () =>
existsSync(new URL("./.git", import.meta.url)) ||
@ -47,15 +52,21 @@ const ensureSupportedRuntimeVersion = async () => {
}
return false;
}
process.stderr.write(`openclaw: ${failure}\n`);
const unsupportedCommand = classifyUnsupportedNodeCommand(process.argv);
const canRunDiagnostics = canRunOpenClawNodeDiagnostics(process.versions.node, probe.available);
const diagnosticExemption = unsupportedCommand === "diagnostic" && canRunDiagnostics;
if (!diagnosticExemption) {
process.stderr.write(`openclaw: ${failure}\n`);
}
// These invocations have an exact-PID contract and cannot acquire a wrapper process.
if (
!isForegroundGmailRunInvocation(process.argv) &&
!(process.platform !== "win32" && isNativeHookRelayInvocation(process.argv))
) {
const { resolveUpdatedNodeRuntime } = await import("./node-runtime-update.mjs");
const nodePath = await resolveUpdatedNodeRuntime(resolveLauncherHomeDir());
const nodePath = await resolveUpdatedNodeRuntime(resolveLauncherHomeDir(), {
allowInstall: !diagnosticExemption,
});
if (nodePath) {
const env = { ...process.env, OPENCLAW_NODE_UPDATE_RESPAWNED: "1" };
const pathKey =
@ -70,12 +81,20 @@ const ensureSupportedRuntimeVersion = async () => {
);
}
}
if (diagnosticExemption) {
return false;
}
process.stderr.write(
"If you use nvm, run:\n" +
` nvm install ${RECOMMENDED_NODE_MAJOR}\n` +
` nvm use ${RECOMMENDED_NODE_MAJOR}\n` +
` nvm alias default ${RECOMMENDED_NODE_MAJOR}\n`,
);
if (unsupportedCommand === "update" && canRunDiagnostics) {
// A later CLI startup respawn must not repeat this invocation's recovery offer.
process.env.OPENCLAW_NODE_UPDATE_RESPAWNED = "1";
return false;
}
return process.exit(1);
};
@ -785,9 +804,14 @@ const tryOutputPrecomputedCommandHelp = () => {
// Resolve Node before loading pending package lifecycle code or any built runtime modules.
const waitingForNodeUpdateRespawn = await ensureSupportedRuntimeVersion();
const currentNodeRuntimeFailure = process.versions.bun
? null
: nodeRuntimeFailure(process.versions.node, detectCurrentSqliteCapabilities());
if (!waitingForNodeUpdateRespawn) {
// Diagnostics must not replay package lifecycle scripts under an unsupported Node.
if (
!currentNodeRuntimeFailure &&
!isSourceCheckoutLauncher() &&
(existsSync(new URL("./.openclaw-lifecycle-pending", import.meta.url)) ||
existsSync(new URL("./dist/openclaw-install-guard", import.meta.url)))
@ -805,6 +829,9 @@ if (!waitingForNodeUpdateRespawn) {
}
}
if (tryOutputLauncherVersion(process.argv)) {
if (currentNodeRuntimeFailure) {
process.stderr.write(`${formatUnsupportedNodeDiagnosticWarning(process.versions.node)}\n`);
}
process.exit(0);
}
}
@ -833,7 +860,9 @@ if (
if (!waitingForCompileCacheRespawn) {
if (!isHelpFastPathDisabled() && (await tryOutputBareRootHelp())) {
// OK
if (currentNodeRuntimeFailure) {
process.stderr.write(`${formatUnsupportedNodeDiagnosticWarning(process.versions.node)}\n`);
}
} else if (!isHelpFastPathDisabled() && tryOutputPrecomputedCommandHelp()) {
// OK
} else {

View file

@ -1,6 +1,7 @@
// Register maintenance tests cover maintenance command registration in the CLI program.
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as nodeSqlite from "../../../node-sqlite.mjs";
import { ExitError } from "../../runtime.js";
import { registerMaintenanceCommands } from "./register.maintenance.js";
@ -89,6 +90,10 @@ function jsonFailure(message: string) {
}
describe("registerMaintenanceCommands doctor action", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
async function runMaintenanceCli(args: string[]) {
const program = new Command();
registerMaintenanceCommands(program);
@ -106,6 +111,19 @@ describe("registerMaintenanceCommands doctor action", () => {
vi.clearAllMocks();
});
it.each(["22.23.2", "26.0.0"])("keeps plain doctor read-only on Node %s", async (node) => {
vi.stubGlobal("process", { ...process, versions: { ...process.versions, node } });
vi.spyOn(nodeSqlite, "detectCurrentSqliteCapabilities").mockReturnValue({
...nodeSqlite.detectCurrentSqliteCapabilities(),
text: false,
});
runDoctorLintCli.mockResolvedValue(1);
await runMaintenanceCli(["doctor"]);
expect(runDoctorLintCli).toHaveBeenCalledOnce();
expect(doctorCommand).not.toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("exits with code 0 after successful doctor run", async () => {
doctorCommand.mockResolvedValue(undefined);

View file

@ -1,5 +1,6 @@
// Maintenance command registration: doctor, triage, dashboard, reset, and uninstall.
import type { Command } from "commander";
import { detectCurrentSqliteCapabilities, nodeRuntimeFailure } from "../../../node-sqlite.mjs";
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { defaultRuntime } from "../../runtime.js";
@ -132,7 +133,11 @@ export function registerMaintenanceCommands(program: Command) {
opts.postUpgrade !== true &&
typeof opts.stateSqlite !== "string" &&
typeof opts.sessionSqlite !== "string";
const lintMode = opts.lint === true ? "--lint" : jsonImpliesLint ? "--json" : undefined;
const unsupportedNode =
!process.versions.bun &&
Boolean(nodeRuntimeFailure(process.versions.node, detectCurrentSqliteCapabilities()));
const lintMode =
opts.lint === true || unsupportedNode ? "--lint" : jsonImpliesLint ? "--json" : undefined;
const mutationOption =
opts.repair === true || opts.fix === true || opts.force === true
? "--repair, --fix, or --force"

View file

@ -319,7 +319,8 @@ vi.mock("../infra/path-env.js", () => ({
ensureOpenClawCliOnPath: ensurePathMock,
}));
vi.mock("../infra/runtime-guard.js", () => ({
vi.mock("../infra/runtime-guard.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../infra/runtime-guard.js")>()),
assertSupportedRuntime: assertRuntimeMock,
}));

View file

@ -72,7 +72,8 @@ vi.mock("../infra/env.js", async (importOriginal) => ({
normalizeEnv: vi.fn(),
}));
vi.mock("../infra/runtime-guard.js", () => ({
vi.mock("../infra/runtime-guard.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../infra/runtime-guard.js")>()),
assertSupportedRuntime: vi.fn(async () => {}),
}));

View file

@ -1109,8 +1109,9 @@ async function runCliWithPreparedOutputMode(
startupTrace.mark("argv");
// Enforce the minimum supported runtime before gateway selection can read or recover config.
const { assertSupportedRuntime } = await import("../infra/runtime-guard.js");
await assertSupportedRuntime();
const { assertSupportedRuntime, isCurrentRuntimeSupported } =
await import("../infra/runtime-guard.js");
await assertSupportedRuntime(undefined, undefined, normalizedArgv);
if (
!isHelpOrVersionInvocation &&
@ -1192,6 +1193,7 @@ async function runCliWithPreparedOutputMode(
env: process.env,
});
const useSourceOnlyBestEffortConfig =
!isCurrentRuntimeSupported() ||
normalizedInvocation.primary === "update" ||
(normalizedInvocation.primary === "doctor" && hasFlag(normalizedArgv, "--lint"));
const readBestEffortCliConfig = async (): Promise<OpenClawConfig> => {

View file

@ -346,7 +346,8 @@ vi.mock("../daemon/runtime-paths.js", async (importOriginal) => ({
resolveNodeRuntimeInfo,
}));
vi.mock("../infra/runtime-guard.js", () => ({
vi.mock("../infra/runtime-guard.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../infra/runtime-guard.js")>()),
nodeVersionSatisfiesEngine,
parseSemver: (version: string | null) => {
if (!version) {

View file

@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTempDirTracker } from "../../../test/helpers/temp-dir.js";
import * as runtimeGuard from "../../infra/runtime-guard.js";
import { readUpdateRunDriver } from "../../infra/update-run-driver.js";
import {
createUpdateRun,
@ -17,6 +18,22 @@ const runtime = vi.hoisted(() => ({
exit: vi.fn(),
}));
const service = vi.hoisted(() => ({
readCommand: vi.fn(),
resolveNodeRuntimeInfo: vi.fn(),
}));
vi.mock("../../daemon/service.js", () => ({
resolveGatewayService: () => ({ readCommand: service.readCommand }),
}));
vi.mock("../../daemon/runtime-paths.js", () => ({
resolveNodeRuntimeInfo: service.resolveNodeRuntimeInfo,
}));
vi.mock("../../config/paths.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../config/paths.js")>()),
isDefaultInstallIdentity: () => true,
}));
vi.mock("../../runtime.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../runtime.js")>()),
defaultRuntime: runtime,
@ -42,13 +59,116 @@ const tempDirs = createTempDirTracker();
beforeEach(() => {
vi.clearAllMocks();
service.readCommand.mockResolvedValue(null);
vi.stubEnv("OPENCLAW_STATE_DIR", tempDirs.make("openclaw-update-status-"));
});
describe("update status Node runtime findings", () => {
it.each(["cli", "service"])(
"renders admitted %s runtime information without a missing hint",
async (source) => {
if (source === "cli") {
vi.spyOn(runtimeGuard, "detectRuntime").mockReturnValue({
kind: "node",
version: "24.15.0",
execPath: "/fixture/node",
pathEnv: "/fixture",
hasNodeSqlite: true,
sqliteVersion: "3.53.4",
sqliteProbe: { available: true, version: "3.53.4", text: true, blob: true, json: true },
});
} else {
service.readCommand.mockResolvedValue({ programArguments: ["/fixture/node", "gateway"] });
service.resolveNodeRuntimeInfo.mockResolvedValue({
status: "supported",
version: "24.15.0",
note: "Node 24.15.0: unsupported version, capability probe passed.",
});
}
await updateStatusCommand({});
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("capability probe passed"));
expect(runtime.log).not.toHaveBeenCalledWith(undefined);
},
);
it.each([
{ version: "22.23.2", source: "cli" },
{ version: "26.0.0", source: "cli" },
{ version: "22.23.2", source: "gateway-service" },
{ version: "26.0.0", source: "gateway-service" },
])(
"reports unsupported $source Node $version with recovery instructions",
async ({ version, source }) => {
vi.stubGlobal("process", {
...process,
versions: { ...process.versions, node: source === "cli" ? version : "26.8.1" },
});
if (source === "cli") {
vi.spyOn(runtimeGuard, "detectRuntime").mockReturnValue({
kind: "node",
version,
execPath: "/fixture/node",
pathEnv: "/fixture",
hasNodeSqlite: true,
sqliteVersion: "3.53.4",
sqliteProbe: { available: true, version: "3.53.4", text: false, blob: true, json: true },
});
}
if (source === "gateway-service") {
service.readCommand.mockResolvedValue({
programArguments: ["/fixture/node", "openclaw.mjs", "gateway"],
});
service.resolveNodeRuntimeInfo.mockResolvedValue({
status: "unsupported",
version,
sqliteVersion: "3.50.2",
nodeSharedSqlite: false,
});
}
await updateStatusCommand({ json: true });
expect(runtime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
runtimeFindings: [
expect.objectContaining({
source,
message: expect.stringContaining(version),
requirement: expect.stringContaining(">=24.16.0 <25, or >=26.1.0"),
fixHint: expect.stringContaining("https://openclaw.ai/install.sh"),
}),
],
}),
);
await updateStatusCommand({});
const output = runtime.log.mock.calls.map(([line]) => String(line)).join("\n");
expect(output).toContain(version);
expect(output).toContain("npm");
expect(output).toContain("nvm install 26");
},
);
it("does not report a supported CLI or recorded service Node", async () => {
vi.stubGlobal("process", { ...process, versions: { ...process.versions, node: "26.8.1" } });
service.readCommand.mockResolvedValue({
programArguments: ["/fixture/node", "openclaw.mjs", "gateway"],
});
service.resolveNodeRuntimeInfo.mockResolvedValue({
status: "supported",
version: "26.8.1",
sqliteVersion: "3.53.0",
nodeSharedSqlite: false,
});
await updateStatusCommand({ json: true });
expect(runtime.writeJson.mock.lastCall?.[0].runtimeFindings ?? []).toEqual([]);
});
});
afterEach(() => {
closeOpenClawStateDatabaseForTest();
vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
tempDirs.cleanup();
});

View file

@ -1,6 +1,7 @@
// `openclaw update status`: combines install metadata, configured channel, and remote update checks.
import { getTerminalTableWidth, renderTable } from "../../../packages/terminal-core/src/table.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { collectNodeRuntimeFindings } from "../../commands/node-runtime-diagnostics.js";
import {
formatUpdateAvailableHint,
formatUpdateOneLiner,
@ -30,7 +31,11 @@ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise<vo
return;
}
const [root, config] = await Promise.all([resolveUpdateRoot(), readSourceConfigBestEffort()]);
const [root, config, runtimeFindings] = await Promise.all([
resolveUpdateRoot(),
readSourceConfigBestEffort(),
collectNodeRuntimeFindings(),
]);
const configChannel = normalizeUpdateChannel(config.update?.channel);
const update = await checkUpdateStatus({
@ -73,6 +78,7 @@ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise<vo
config: configChannel,
},
availability: updateAvailability,
...(runtimeFindings.length > 0 ? { runtimeFindings } : {}),
...(activeRun ? { activeRun } : {}),
...(lastRun ? { lastRun } : {}),
...(staleGuidance && activeRun
@ -107,6 +113,19 @@ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise<vo
defaultRuntime.log(theme.heading("OpenClaw update status"));
defaultRuntime.log("");
for (const finding of runtimeFindings) {
const color =
finding.severity === "error"
? theme.error
: finding.severity === "warning"
? theme.warn
: theme.muted;
defaultRuntime.log(color(finding.message));
if (finding.fixHint) {
defaultRuntime.log(finding.fixHint);
}
defaultRuntime.log("");
}
defaultRuntime.log(
renderTable({
width: tableWidth,

View file

@ -1,3 +1,5 @@
import { detectCurrentSqliteCapabilities, nodeRuntimeFailure } from "../../../node-sqlite.mjs";
import { formatUnsupportedNodeVersionMessage } from "../../../node-version.mjs";
import { assertConfigWriteAllowedInCurrentMode } from "../../config/config.js";
import { disableCurrentOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js";
import { mergeGatewayServiceEnv } from "../../daemon/service-env-merge.js";
@ -41,9 +43,11 @@ import {
} from "../../infra/update-run-ledger.js";
import { summarizeUpdateStepFailure, type UpdateRunStep } from "../../infra/update-run-record.js";
import type { UpdateRunResult, UpdateStepProgress } from "../../infra/update-runner.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
import { assertOpenClawStateWriteAllowedAtPath } from "../../state/openclaw-state-ownership.js";
import { VERSION } from "../../version.js";
import { exitCliAfterOutput } from "../one-shot-exit.js";
import { parseUpdateTimeoutMs, resolveUpdateRoot, type UpdateCommandOptions } from "./shared.js";
import { suppressDeprecations } from "./suppress-deprecations.js";
import {
@ -250,6 +254,26 @@ export function readDevUpdateTarget(): DevUpdateTarget | undefined {
}
export async function prepareUpdateCommand(opts: UpdateCommandOptions) {
// Refuse before preflight can inspect write ownership or admit a live run ledger.
const runtimeFailure = process.versions.bun
? null
: nodeRuntimeFailure(process.versions.node, detectCurrentSqliteCapabilities());
if (runtimeFailure) {
const error = `${runtimeFailure}\n${formatUnsupportedNodeVersionMessage(process.versions.node)}`;
if (opts.json) {
defaultRuntime.writeJson({
status: "error",
mode: "unknown",
reason: "node-runtime-preflight",
error,
steps: [],
durationMs: 0,
});
} else {
defaultRuntime.error(`node-runtime-preflight: ${error}`);
}
exitCliAfterOutput(defaultRuntime, 1);
}
const startedAt = Date.now();
suppressDeprecations();
const postCoreUpdateResume = process.env[POST_CORE_UPDATE_ENV] === "1";

View file

@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as nodeSqlite from "../../../node-sqlite.mjs";
import { ExitError } from "../../runtime.js";
import { updateCommand } from "./update-command.js";
const mocks = vi.hoisted(() => ({
stateAdmission: vi.fn(() => {
throw new Error("state admission reached on unsupported Node");
}),
runtime: { error: vi.fn(), writeJson: vi.fn(), exit: vi.fn() },
}));
vi.mock("../../state/openclaw-state-ownership.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../state/openclaw-state-ownership.js")>()),
assertOpenClawStateWriteAllowedAtPath: mocks.stateAdmission,
}));
vi.mock("../../runtime.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../runtime.js")>()),
defaultRuntime: mocks.runtime,
}));
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("unsupported CLI Node update admission", () => {
it("admits a capability-passing Node build outside the release table", async () => {
vi.stubGlobal("process", { ...process, versions: { ...process.versions, node: "24.15.0" } });
await expect(updateCommand({ json: true })).rejects.toThrow("state admission reached");
expect(mocks.stateAdmission).toHaveBeenCalledOnce();
expect(mocks.runtime.writeJson).not.toHaveBeenCalled();
});
it.each(["22.23.2", "26.0.0"])("refuses Node %s before stateful preparation", async (node) => {
vi.stubGlobal("process", { ...process, versions: { ...process.versions, node } });
vi.spyOn(nodeSqlite, "detectCurrentSqliteCapabilities").mockReturnValue({
...nodeSqlite.detectCurrentSqliteCapabilities(),
text: false,
});
await expect(updateCommand({ json: true })).rejects.toEqual(new ExitError(1));
expect(mocks.stateAdmission).not.toHaveBeenCalled();
expect(mocks.runtime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
status: "error",
mode: "unknown",
reason: "node-runtime-preflight",
error: expect.stringContaining("nvm install 26"),
}),
);
});
});

View file

@ -169,7 +169,12 @@ async function executeDoctorLint(
): Promise<DoctorLintExecution> {
const snapshot = await stateView.readConfigSnapshot();
if (snapshot.exists && !snapshot.valid) {
const findings = configValidationIssuesToHealthFindings(snapshot.issues);
const { collectNodeRuntimeFindings } = await import("./node-runtime-diagnostics.js");
const runtimeFindings = await collectNodeRuntimeFindings(stateView.sourceEnv);
const findings = [
...configValidationIssuesToHealthFindings(snapshot.issues),
...runtimeFindings,
];
const visible = findings.filter((finding) => healthFindingMeetsSeverity(finding, sevMin));
return {
exitCode: exitCodeFromFindings(findings, sevMin),
@ -189,6 +194,13 @@ async function executeDoctorLint(
const issuePath = issue.path || "<root>";
runtime.error(`- ${issuePath}: ${issue.message}`);
}
for (const finding of runtimeFindings.filter((entry) =>
healthFindingMeetsSeverity(entry, sevMin),
)) {
runtime.error(
finding.fixHint ? `${finding.message}\n${finding.fixHint}` : finding.message,
);
}
},
};
}
@ -383,12 +395,12 @@ async function withDoctorLintStateEnv<T>(
}
}
function createStateSnapshotFailureExecution(
async function createStateSnapshotFailureExecution(
runtime: RuntimeEnv,
opts: DoctorLintCliOptions,
sevMin: NonNullable<ReturnType<typeof parseHealthFindingSeverity>>,
error: DoctorLintStateSnapshotError,
): DoctorLintExecution {
): Promise<DoctorLintExecution> {
const finding: HealthFinding = {
checkId: "core/doctor/lint-state-inspection",
severity: "error",
@ -401,9 +413,11 @@ function createStateSnapshotFailureExecution(
fixHint:
"Keep the current Gateway running, resolve the state database inspection error, then rerun this check.",
};
const visible = healthFindingMeetsSeverity(finding, sevMin) ? [finding] : [];
const { collectNodeRuntimeFindings } = await import("./node-runtime-diagnostics.js");
const findings = [finding, ...(await collectNodeRuntimeFindings())];
const visible = findings.filter((entry) => healthFindingMeetsSeverity(entry, sevMin));
return {
exitCode: exitCodeFromFindings([finding], sevMin),
exitCode: exitCodeFromFindings(findings, sevMin),
findings: visible,
writeOutput() {
if (detectMode(opts) === "json") {
@ -415,8 +429,12 @@ function createStateSnapshotFailureExecution(
});
return;
}
runtime.error(`doctor --lint: ${finding.message}`);
runtime.error(`fix: ${finding.fixHint}`);
for (const entry of visible) {
runtime.error(`doctor --lint: ${entry.message}`);
if (entry.fixHint) {
runtime.error(`fix: ${entry.fixHint}`);
}
}
},
};
}

View file

@ -0,0 +1,209 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveDoctorContributionHealthChecks } from "../flows/doctor-health-contributions.js";
import * as runtimeGuard from "../infra/runtime-guard.js";
import { runDoctorLintCli } from "./doctor-lint.js";
import { statusCommand } from "./status.command.js";
const mocks = vi.hoisted(() => ({
readCommand: vi.fn(),
readConfigFileSnapshot: vi.fn(),
resolveNodeRuntimeInfo: vi.fn(),
}));
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
vi.mock("../config/config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../config/config.js")>()),
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
}));
vi.mock("../config/paths.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../config/paths.js")>()),
isDefaultInstallIdentity: () => true,
}));
vi.mock("../daemon/service.js", () => ({
resolveGatewayService: () => ({ readCommand: mocks.readCommand }),
}));
vi.mock("../daemon/runtime-paths.js", () => ({
resolveNodeRuntimeInfo: mocks.resolveNodeRuntimeInfo,
}));
vi.mock("./status-json-command.ts", () => ({
assertStatusUsageAgentScope: () => {},
runStatusJsonCommand: async () => {},
}));
function mockCliRuntime(version: string, text = true) {
vi.spyOn(runtimeGuard, "detectRuntime").mockReturnValue({
kind: "node",
version,
execPath: "/fixture/node",
pathEnv: "/fixture",
hasNodeSqlite: true,
sqliteVersion: "3.53.4",
sqliteProbe: { available: true, version: "3.53.4", text, blob: true, json: true },
});
}
beforeEach(() => {
vi.clearAllMocks();
mocks.readCommand.mockResolvedValue({
programArguments: ["/fixture/node", "openclaw.mjs", "gateway"],
});
mocks.resolveNodeRuntimeInfo.mockResolvedValue({
status: "unsupported",
version: "22.23.2",
sqliteVersion: "3.50.2",
nodeSharedSqlite: false,
});
mockCliRuntime("26.8.1");
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("Node runtime diagnostics command surfaces", () => {
it.each(["invalid config", "snapshot failure"])(
"renders informational Node findings without a missing fix hint after %s",
async (failure) => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-node-note-"));
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json"));
const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
mockCliRuntime("24.15.0");
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
config: {},
issues: [{ path: "gateway.mode", message: "Required" }],
});
if (failure === "snapshot failure") {
vi.spyOn(fs, "mkdtempSync").mockImplementationOnce(() => {
throw new Error("No space left for private snapshot");
});
}
try {
expect(
await runDoctorLintCli(runtime, {
severityMin: "info",
...(failure === "snapshot failure" ? { updateReadiness: "post-plugin" } : {}),
}),
).toBe(1);
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining(
failure === "snapshot failure"
? "No space left for private snapshot"
: "config file exists but does not parse cleanly",
),
);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Node 24.15.0:"));
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("nvm install 26"));
expect(runtime.error).not.toHaveBeenCalledWith(expect.stringContaining("undefined"));
} finally {
if (originalIsTTY) {
Object.defineProperty(process.stdout, "isTTY", originalIsTTY);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
vi.unstubAllEnvs();
fs.rmSync(stateDir, { recursive: true, force: true });
}
},
);
it("keeps Node repair guidance visible when config validation fails", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
config: {},
path: "/tmp/openclaw.json",
issues: [{ path: "gateway.mode", message: "Required" }],
});
mockCliRuntime("22.23.2", false);
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
try {
expect(await runDoctorLintCli(runtime, { json: true })).toBe(1);
const payload = JSON.parse(String(stdout.mock.calls.at(-1)?.[0]));
expect(payload.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "core/doctor/final-config-validation" }),
expect.objectContaining({
checkId: "core/doctor/node-runtime",
fixHint: expect.stringContaining("nvm install 26"),
}),
]),
);
} finally {
stdout.mockRestore();
vi.unstubAllGlobals();
}
});
it("registers canonical Doctor findings for CLI and recorded service runtimes", async () => {
mockCliRuntime("26.0.0", false);
const checks = await resolveDoctorContributionHealthChecks();
const check = checks.find((entry) => entry.id === "core/doctor/node-runtime");
expect(check).toBeDefined();
const findings = await check?.detect({ mode: "lint", cfg: {}, runtime, env: {} });
expect(findings).toEqual([
expect.objectContaining({
message: expect.stringContaining("26.0.0"),
fixHint: expect.stringContaining("nvm install 26"),
}),
expect.objectContaining({
source: "gateway-service",
message: expect.stringContaining("22.23.2"),
fixHint: expect.stringContaining("nvm install 26"),
}),
]);
});
it("warns about a stale service Node without mixing text into status JSON", async () => {
await statusCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("Gateway service Node 22.23.2"),
);
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("https://openclaw.ai/install.sh"),
);
expect(runtime.log).not.toHaveBeenCalled();
});
it.each(["cli", "service"])(
"renders an admitted out-of-table %s runtime as information",
async (source) => {
mocks.readCommand.mockResolvedValue(null);
if (source === "cli") {
mockCliRuntime("24.15.0");
} else {
mocks.readCommand.mockResolvedValue({ programArguments: ["/fixture/node", "gateway"] });
mocks.resolveNodeRuntimeInfo.mockResolvedValue({
status: "supported",
version: "24.15.0",
note: "Node 24.15.0: unsupported version, capability probe passed.",
});
}
await statusCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("[info] Node 24.15.0:"));
expect(runtime.error).not.toHaveBeenCalledWith(expect.stringContaining("[warning]"));
expect(runtime.error).not.toHaveBeenCalledWith(expect.stringContaining("undefined"));
},
);
it("reports an uninspectable service without claiming its Node is unsupported", async () => {
mocks.resolveNodeRuntimeInfo.mockResolvedValue({
status: "probe-failed",
error: new Error("unavailable"),
});
await statusCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("could not be inspected"));
expect(runtime.error).not.toHaveBeenCalledWith(expect.stringContaining("is unsupported"));
});
});

View file

@ -0,0 +1,112 @@
/** Read-only Node findings shared by Doctor and status commands. */
import { nodeRuntimeFailure, nodeRuntimeNote } from "../../node-sqlite.mjs";
import {
formatUnsupportedNodeVersionMessage,
SUPPORTED_NODE_VERSIONS,
} from "../../node-version.mjs";
import { isDefaultInstallIdentity } from "../config/paths.js";
import { isNodeRuntime } from "../daemon/runtime-binary.js";
import { resolveNodeRuntimeInfo } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import type { HealthFinding } from "../flows/health-checks.js";
import { detectRuntime } from "../infra/runtime-guard.js";
const CHECK_ID = "core/doctor/node-runtime";
function unsupportedNodeFinding(
version: string | null,
source: "cli" | "gateway-service",
capabilityError?: string,
): HealthFinding {
const label = source === "cli" ? "CLI" : "Gateway service";
return {
checkId: CHECK_ID,
severity: "warning",
source,
message: `${label} Node ${version ?? "unknown"} is unsupported. Required: ${SUPPORTED_NODE_VERSIONS}.`,
requirement: SUPPORTED_NODE_VERSIONS,
fixHint: [
...(capabilityError ? [capabilityError] : []),
formatUnsupportedNodeVersionMessage(version),
...(source === "gateway-service"
? [
"After switching Node, refresh a managed Gateway with `openclaw gateway install --force`; for an externally managed service, have its deployment owner update the launcher.",
]
: []),
].join("\n"),
};
}
function collectCurrentNodeRuntimeFindings(): readonly HealthFinding[] {
const runtime = detectRuntime();
if (runtime.kind !== "node" || !runtime.sqliteProbe) {
return [];
}
const failure = nodeRuntimeFailure(runtime.version, runtime.sqliteProbe);
const message = failure ?? nodeRuntimeNote(runtime.version, runtime.sqliteProbe);
return message
? [
{
checkId: CHECK_ID,
severity: failure ? "error" : "info",
source: "cli",
message,
requirement: SUPPORTED_NODE_VERSIONS,
target: runtime.execPath ?? undefined,
...(failure ? { fixHint: formatUnsupportedNodeVersionMessage(runtime.version) } : {}),
},
]
: [];
}
/** Inspect the CLI and recorded service without starting or repairing the service. */
export async function collectNodeRuntimeFindings(
env: NodeJS.ProcessEnv = process.env,
): Promise<HealthFinding[]> {
return [
...collectCurrentNodeRuntimeFindings(),
...(await collectServiceNodeRuntimeFindings(env)),
];
}
/** Inspect the recorded service executable without starting or repairing the service. */
async function collectServiceNodeRuntimeFindings(
env: NodeJS.ProcessEnv = process.env,
): Promise<HealthFinding[]> {
const findings: HealthFinding[] = [];
if (!isDefaultInstallIdentity(env)) {
return findings;
}
try {
const command = await resolveGatewayService().readCommand(env, { timeoutMs: 5_000 });
const executable = command?.programArguments[0];
if (executable && isNodeRuntime(executable)) {
const runtime = await resolveNodeRuntimeInfo(executable, { ...env, ...command.environment });
if (runtime.status === "probe-failed") {
throw runtime.error;
}
if (runtime.status === "unsupported") {
findings.push(
unsupportedNodeFinding(runtime.version, "gateway-service", runtime.capabilityError),
);
} else if (runtime.note) {
findings.push({
checkId: CHECK_ID,
severity: "info",
source: "gateway-service",
message: runtime.note,
target: executable,
});
}
}
} catch {
findings.push({
checkId: CHECK_ID,
severity: "warning",
source: "gateway-service",
message: "The recorded Gateway service Node runtime could not be inspected.",
fixHint: "Run `openclaw gateway status --deep` and check access to its recorded executable.",
});
}
return findings;
}

View file

@ -13,6 +13,7 @@ import { OPENCLAW_WRAPPER_ENV_KEY } from "../daemon/program-args.js";
import { readRestartSentinelReadOnly } from "../infra/restart-sentinel.js";
import type { RuntimeEnv } from "../runtime.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { collectNodeRuntimeFindings } from "./node-runtime-diagnostics.js";
import { assertStatusUsageAgentScope, runStatusJsonCommand } from "./status-json-command.ts";
import { buildStatusOverviewSurfaceFromScan } from "./status-overview-surface.ts";
import {
@ -108,6 +109,12 @@ export async function statusCommand(
runtime: RuntimeEnv,
) {
assertStatusUsageAgentScope(opts);
for (const finding of await collectNodeRuntimeFindings()) {
const write = opts.json ? runtime.error : runtime.log;
write(
`[${finding.severity}] ${finding.message}${finding.fixHint ? `\n${finding.fixHint}` : ""}`,
);
}
if (opts.all && !opts.json) {
// Human `--all` has a dedicated report path; JSON `--all` stays on the JSON schema.
await statusAllModuleLoader

View file

@ -136,7 +136,7 @@ if (
loadCliDotEnv({ quiet: true });
await configureGatewayStartupTraceConsoleFormatting(gatewayEntryStartupTrace);
}
await assertSupportedRuntime();
await assertSupportedRuntime(undefined, undefined, process.argv, false);
gatewayEntryStartupTrace.mark("bootstrap");
const waitingForCompileCacheRespawn = await respawnWithoutOpenClawCompileCacheIfNeeded({
@ -178,6 +178,8 @@ if (
}
if (!(await ensureCliRespawnReady())) {
// Only the final child emits the diagnostic warning; parents still enforce admission.
await assertSupportedRuntime(undefined, undefined, process.argv);
const parsedContainer = parseCliContainerArgs(process.argv);
if (!parsedContainer.ok) {
await writeCapturedCliArgumentError(parsedContainer.error);

View file

@ -5,6 +5,7 @@ import { retainGatewayResponsePayload } from "../../packages/gateway-client/src/
import { createMcpProofPluginRegistry } from "../agents/mcp-connection-resolver.test-fixtures.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { GATEWAY_HEALTH_RATE_LIMITED_MESSAGE } from "../commands/gateway-health-auth-diagnostic.js";
import { collectNodeRuntimeFindings } from "../commands/node-runtime-diagnostics.js";
import { GatewaySecretRefUnavailableError } from "../gateway/credentials.js";
import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js";
import { setPluginToolMeta } from "../plugins/tool-metadata.js";
@ -101,7 +102,6 @@ vi.mock("../plugins/providers.runtime.js", () => ({
const {
collectGatewayDaemonFindings,
collectGatewayHealthFindings,
collectNodeRuntimeFindings,
collectProviderCatalogProjectionFindings,
collectRuntimeToolSchemaFindings,
} = await import("./doctor-core-checks.runtime.js");
@ -958,7 +958,7 @@ describe("doctor gateway runtime checks", () => {
},
])(
"reports current Node $version probe outcome as $severity",
({ version, text, severity, message }) => {
async ({ version, text, severity, message }) => {
mocks.detectRuntime.mockReturnValue({
kind: "node",
version,
@ -969,12 +969,13 @@ describe("doctor gateway runtime checks", () => {
sqliteProbe: { available: true, version: "3.53.4", text, blob: true, json: true },
});
expect(collectNodeRuntimeFindings()).toEqual([
expect(await collectNodeRuntimeFindings({ OPENCLAW_PROFILE: "diagnostic-fixture" })).toEqual([
expect.objectContaining({
checkId: "core/doctor/node-runtime",
severity,
message: expect.stringContaining(message),
target: "/opt/runtime/bin/node",
...(severity === "error" ? { fixHint: expect.stringContaining("nvm install 26") } : {}),
}),
]);
},
@ -1017,6 +1018,7 @@ describe("doctor gateway runtime checks", () => {
severity,
message,
target: "/opt/runtime/bin/node",
...(severity === "warning" ? { fixHint: expect.stringContaining("nvm install 26") } : {}),
}),
]);
},

View file

@ -1,6 +1,6 @@
// Doctor runtime checks inspect tool names, browser residue, and runtime state.
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { nodeRuntimeFailure, nodeRuntimeNote } from "../../node-sqlite.mjs";
import { formatUnsupportedNodeVersionMessage } from "../../node-version.mjs";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { assignSafeServerNames, TOOL_NAME_SEPARATOR } from "../agents/agent-bundle-mcp-names.js";
import { loadSessionMcpConfig } from "../agents/agent-bundle-mcp-runtime-config.js";
@ -55,7 +55,6 @@ import {
} from "../gateway/call.js";
import { isGatewaySecretRefUnavailableError } from "../gateway/credentials.js";
import { formatErrorMessage } from "../infra/errors.js";
import { detectRuntime } from "../infra/runtime-guard.js";
import {
formatLocalAudioSelection,
inspectLocalAudioSelection,
@ -207,25 +206,6 @@ function gatewayRuntimeStatus(runtime: GatewayServiceRuntime | undefined): strin
return runtime?.status ?? runtime?.state ?? runtime?.subState;
}
export function collectNodeRuntimeFindings(): readonly HealthFinding[] {
const runtime = detectRuntime();
if (runtime.kind !== "node" || !runtime.sqliteProbe) {
return [];
}
const failure = nodeRuntimeFailure(runtime.version, runtime.sqliteProbe);
const message = failure ?? nodeRuntimeNote(runtime.version, runtime.sqliteProbe);
return message
? [
{
checkId: "core/doctor/node-runtime",
severity: failure ? "error" : "info",
message,
target: runtime.execPath ?? undefined,
},
]
: [];
}
export async function collectGatewayDaemonFindings(
ctx: Pick<HealthCheckContext, "cfg">,
): Promise<readonly HealthFinding[]> {
@ -272,7 +252,14 @@ export async function collectGatewayDaemonFindings(
path: state.command?.sourcePath,
target: nodePath,
...(runtime.status !== "supported"
? { fixHint: "Repair the Node runtime, then run `openclaw gateway install`." }
? {
fixHint: [
...(runtime.status === "unsupported"
? [formatUnsupportedNodeVersionMessage(runtime.version)]
: []),
"Repair the Node runtime, then run `openclaw gateway install`.",
].join("\n"),
}
: {}),
});
}

View file

@ -1126,9 +1126,9 @@ const nodeRuntimeCheck: HealthCheck = {
description:
"Node SQLite capabilities and version support are represented as structured findings.",
source: "doctor",
async detect() {
const runtime = await loadDoctorCoreChecksRuntimeModule();
return runtime.collectNodeRuntimeFindings();
async detect(ctx) {
const { collectNodeRuntimeFindings } = await import("../commands/node-runtime-diagnostics.js");
return collectNodeRuntimeFindings(ctx.env);
},
};

View file

@ -50,6 +50,24 @@ vi.mock("../worker/worker-deploy-browser-runtime.js", () => ({ default: {} }));
vi.mock("../worker/worker-process.js", () => ({ runWorkerProcess: state.run }));
describe("runtime-guard", () => {
it("warns once while admitting capable Node 22 diagnostics", async () => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const details = {
kind: "node" as const,
version: "22.23.2",
execPath: "/usr/bin/node",
pathEnv: "/usr/bin",
hasNodeSqlite: true,
sqliteVersion: null,
};
await assertSupportedRuntime(runtime, details, ["node", "openclaw", "update", "status"]);
await assertSupportedRuntime(runtime, details, ["node", "openclaw", "update", "status"]);
expect(runtime.exit).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledExactlyOnceWith(
"Running on an unsupported Node (22.23.2); diagnostics may show truncated text",
);
});
it.each([
["24.16.0", true, true],
["24.16.0", false, false],
@ -73,6 +91,94 @@ describe("runtime-guard", () => {
}
});
it.each([
["--version"],
["-V"],
["--help"],
["gateway", "status"],
["gateway", "status", "--deep"],
["doctor"],
["doctor", "--lint"],
["doctor", "--lint", "--json"],
["update", "status"],
["update"],
["triage", "--json"],
["triage", "--non-interactive"],
["--profile", "fixture", "gateway", "status", "--deep"],
])("allows unsupported Node diagnostics: %j", async (...args) => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await assertSupportedRuntime(
runtime,
{
kind: "node",
version: "22.23.2",
execPath: "/usr/bin/node",
pathEnv: "/usr/bin",
hasNodeSqlite: true,
sqliteVersion: null,
},
["node", "openclaw", ...args],
);
expect(runtime.exit).not.toHaveBeenCalled();
});
it.each([
[],
["gateway"],
["gateway", "start"],
["gateway", "run"],
["status"],
["doctor", "--fix"],
["doctor", "--lint", "--repair"],
["doctor", "--yes"],
["doctor", "--state-sqlite", "compact"],
["triage"],
["triage", "--json", "--run"],
["triage", "--non-interactive", "--agent", "codex"],
["update", "repair"],
["database", "vacuum"],
["--profile", "--help", "gateway", "start"],
["agent", "--message", "--help"],
])("refuses unsupported Node mutation: %j", async (...args) => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await assertSupportedRuntime(
runtime,
{
kind: "node",
version: "26.0.0",
execPath: "/usr/bin/node",
pathEnv: "/usr/bin",
hasNodeSqlite: true,
sqliteVersion: null,
},
["node", "openclaw", ...args],
);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it.each([
{ version: "20.0.0", hasNodeSqlite: true },
{ version: "20.0.0", hasNodeSqlite: false },
{ version: "22.23.2", hasNodeSqlite: false },
])(
"refuses diagnostic execution without capability: $version SQLite=$hasNodeSqlite",
async (details) => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await assertSupportedRuntime(
runtime,
{
kind: "node",
execPath: "/usr/bin/node",
pathEnv: "/usr/bin",
sqliteVersion: null,
...details,
},
["node", "openclaw", "update", "status"],
);
expect(runtime.exit).toHaveBeenCalledWith(1);
},
);
it("keeps healthy runtime checks independent of diagnostic formatting", async () => {
await assertSupportedRuntime();
expect(state.diagnosticLoads).toBe(0);

View file

@ -9,6 +9,9 @@ import {
type SqliteCapabilities,
} from "../../node-sqlite.mjs";
import {
canRunOpenClawNodeDiagnostics,
classifyUnsupportedNodeCommand,
formatUnsupportedNodeDiagnosticWarning,
isNodeVersionAtLeast,
isSupportedOpenClawNodeVersion,
parseNodeReleaseVersion,
@ -43,6 +46,7 @@ type RuntimeDetails = {
};
const SEMVER_RE = /(\d+)\.(\d+)\.(\d+)/;
let diagnosticWarningPrinted = false;
/** Parses the first major/minor/patch triple from a runtime or package version label. */
export function parseSemver(version: string | null): Semver | null {
@ -209,6 +213,8 @@ export function nodeVersionSatisfiesEngine(
export async function assertSupportedRuntime(
providedRuntime?: RuntimeEnv,
details: RuntimeDetails = detectRuntime(),
argv?: readonly string[],
emitDiagnosticWarning = true,
): Promise<void> {
if (runtimeSatisfies(details)) {
const note =
@ -224,6 +230,23 @@ export async function assertSupportedRuntime(
}
return;
}
if (
details.kind === "node" &&
canRunOpenClawNodeDiagnostics(details.version, details.hasNodeSqlite) &&
argv &&
classifyUnsupportedNodeCommand(argv)
) {
if (emitDiagnosticWarning && !diagnosticWarningPrinted) {
const warning = formatUnsupportedNodeDiagnosticWarning(details.version);
if (providedRuntime) {
providedRuntime.error(warning);
} else {
process.stderr.write(`${warning}\n`);
}
diagnosticWarningPrinted = true;
}
return;
}
let runtime = providedRuntime;
// Healthy starts need no diagnostic graph; a supplied runtime already owns its error sink.
if (!runtime) {

View file

@ -14,6 +14,11 @@ function prepareDiagnosticReport(reason: string) {
}
describe("update report diagnostic command boundary", () => {
it("records the running Node version in the reviewed report", async () => {
const report = await prepareDiagnosticReport("node-runtime-preflight");
expect(report.body).toContain(`- Node version: ${process.versions.node}\n`);
});
it.each([
'Command failed: python -c "private-customer-text"',
'ruby -e "private-customer-text"',

View file

@ -210,6 +210,7 @@ export async function prepareUpdateFailureReport(
"",
`- OpenClaw version: ${version}`,
`- Platform: ${platform}`,
`- Node version: ${sanitizeReportField(process.versions.node ?? "unknown", context)}`,
`- Update target: ${target}`,
`- Failed phase: ${phase}`,
`- Rollback outcome: ${rollback}`,

View file

@ -199,6 +199,10 @@ describe("openclaw launcher", () => {
import { syncBuiltinESMExports } from "node:module";
if (process.env.OPENCLAW_NODE_UPDATE_RESPAWNED !== "1") {
Object.defineProperty(process.versions, "node", { value: ${JSON.stringify(params.version ?? "20.0.0")} });
if (process.versions.node.startsWith("20.")) {
const getBuiltinModule = process.getBuiltinModule;
process.getBuiltinModule = (id) => id === "node:sqlite" ? undefined : getBuiltinModule(id);
}
Object.defineProperty(process.stdin, "isTTY", { value: ${params.tty ?? true} });
Object.defineProperty(process.stderr, "isTTY", { value: ${params.tty ?? true} });
const original = childProcess.spawnSync;
@ -219,6 +223,7 @@ describe("openclaw launcher", () => {
await fs.writeFile(
path.join(root, "dist", "entry.js"),
`
if (!process.getBuiltinModule?.("node:sqlite")) throw new Error("native diagnostic reader loaded without node:sqlite");
process.stdout.write(JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd(), path: process.env.PATH }));
process.exitCode = 17;
`,
@ -295,17 +300,29 @@ describe("openclaw launcher", () => {
);
it.each([
{ label: "non-TTY", tty: false, args: ["status"], env: {} },
{ label: "CI", tty: true, args: ["status"], env: { CI: "1" } },
{ label: "JSON", tty: true, args: ["status", "--json"], env: {} },
{ label: "non-interactive", tty: true, args: ["onboard", "--non-interactive"], env: {} },
{ label: "yes flag", tty: true, args: ["update", "--yes"], env: {} },
{ label: "hook relay", tty: true, args: ["hooks", "relay"], env: {} },
{ label: "Gmail foreground", tty: true, args: ["webhooks", "gmail", "run"], env: {} },
])("does not prompt or install for $label", async ({ tty, args, env }) => {
{ label: "non-TTY", tty: false, args: ["status"], env: {}, exitCode: 1 },
{ label: "CI", tty: true, args: ["status"], env: { CI: "1" }, exitCode: 1 },
{ label: "JSON", tty: true, args: ["status", "--json"], env: {}, exitCode: 1 },
{
label: "non-interactive",
tty: true,
args: ["onboard", "--non-interactive"],
env: {},
exitCode: 1,
},
{ label: "yes flag", tty: true, args: ["update", "--yes"], env: {}, exitCode: 1 },
{ label: "hook relay", tty: true, args: ["hooks", "relay"], env: {}, exitCode: 1 },
{
label: "Gmail foreground",
tty: true,
args: ["webhooks", "gmail", "run"],
env: {},
exitCode: 1,
},
])("does not prompt or install for $label", async ({ tty, args, env, exitCode }) => {
const fixture = await prepareRecovery({ tty });
const result = fixture.run("y\n", args, env);
expect(result.status, result.stderr).toBe(1);
expect(result.status, result.stderr).toBe(exitCode);
expect(result.stderr).not.toContain("Update NodeJS:");
await expect(fs.stat(fixture.installLog)).rejects.toMatchObject({ code: "ENOENT" });
});
@ -321,9 +338,13 @@ describe("openclaw launcher", () => {
},
);
it("reuses a previously approved runtime without prompting or installing", async () => {
const fixture = await prepareRecovery({ cached: true, tty: false });
const result = fixture.run("");
it.each([
{ version: "20.0.0", args: ["status"] },
{ version: "20.0.0", args: ["update", "status"] },
{ version: "22.23.2", args: ["update", "status"] },
])("reuses a previously approved runtime for $version $args", async ({ version, args }) => {
const fixture = await prepareRecovery({ cached: true, tty: false, version });
const result = fixture.run("", args);
expect(result.status, result.stderr).toBe(17);
expect(result.stderr).not.toContain("Update NodeJS:");
expect(JSON.parse(result.stdout).path.split(path.delimiter)[0]).toBe(
@ -332,6 +353,85 @@ describe("openclaw launcher", () => {
await expect(fs.stat(fixture.installLog)).rejects.toMatchObject({ code: "ENOENT" });
});
it("runs capable diagnostics without offering an installation when no runtime is cached", async () => {
const fixture = await prepareRecovery({ version: "22.23.2" });
const result = fixture.run("y\n", ["update", "status"]);
expect(result.status, result.stderr).toBe(17);
expect(result.stderr).not.toContain("Update NodeJS:");
expect(JSON.parse(result.stdout).path.split(path.delimiter)[0]).not.toBe(
path.dirname(fixture.nodePath),
);
await expect(fs.stat(fixture.installLog)).rejects.toMatchObject({ code: "ENOENT" });
});
it.each([false, true])(
"preserves Node 20 diagnostic recovery without a cache (TTY=%s)",
async (tty) => {
const fixture = await prepareRecovery({ tty });
const result = fixture.run("n\n", ["update", "status"]);
expect(result.status, result.stderr).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("nvm install 26");
expect(result.stderr.includes("Update NodeJS:")).toBe(tty);
expect(result.stderr).not.toContain("native diagnostic reader loaded");
await expect(fs.stat(fixture.installLog)).rejects.toMatchObject({ code: "ENOENT" });
},
);
it("does not repeat a declined Node offer when update startup respawns", async () => {
const fixture = await prepareRecovery({ version: "22.23.2" });
await fs.writeFile(
path.join(fixture.root, "dist", "entry.js"),
`import { spawnSync } from "node:child_process";
if (!process.env.OPENCLAW_TEST_CLI_RESPAWN) {
const child = spawnSync(process.execPath, [...process.execArgv, ...process.argv.slice(1)], {
env: { ...process.env, OPENCLAW_TEST_CLI_RESPAWN: "1" }, stdio: "inherit",
});
process.exit(child.status ?? 1);
}
process.stdout.write("update-entry\\n");`,
);
const result = fixture.run("n\n", ["update"]);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("update-entry");
expect(result.stderr.match(/Update NodeJS:/g)).toHaveLength(1);
await expect(fs.stat(fixture.installLog)).rejects.toMatchObject({ code: "ENOENT" });
});
it("runs pending lifecycle for an admitted build outside the release table", async () => {
const fixture = await prepareRecovery({
version: "24.15.0",
tty: false,
pendingLifecycle: true,
});
await fs.writeFile(
path.join(fixture.root, "dist/infra/package-lifecycle.js"),
'export function completePendingPackageLifecycle() { process.stdout.write("lifecycle-completed\\n"); }',
);
const result = fixture.run("", ["update", "status"]);
expect(result.status, result.stderr).toBe(17);
expect(result.stdout).toContain("lifecycle-completed");
expect(result.stderr).not.toContain("diagnostics may show truncated text");
});
it("skips pending lifecycle for a broken in-range SQLite runtime", async () => {
const fixture = await prepareRecovery({
version: "26.8.1",
tty: false,
pendingLifecycle: true,
});
const preload = path.join(fixture.root, "broken-sqlite.mjs");
await fs.writeFile(
preload,
'globalThis[Symbol.for("openclaw.sqliteCapabilities")] = { available: true, version: "3.53.4", text: false, blob: true, json: true };',
);
const result = fixture.run("", ["update", "status"], {
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`,
});
expect(result.status, result.stderr).toBe(17);
expect(result.stderr).not.toContain("legacy lifecycle loaded");
});
it("keeps a supported active Node even when a private runtime exists", async () => {
const fixture = await prepareRecovery({ cached: true, version: process.versions.node });
const result = fixture.run("");
@ -373,6 +473,45 @@ describe("openclaw launcher", () => {
});
});
it.each([
["--version"],
["-V"],
["--help"],
["gateway", "status", "--deep"],
["doctor", "--lint"],
["doctor"],
["update", "status"],
["update"],
["triage", "--json"],
["triage", "--non-interactive"],
])("admits packaged diagnostics on unsupported Node: %j", async (...args) => {
const root = await makeLauncherFixture(fixtureRoots);
await fs.writeFile(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.9.3" }),
);
await fs.writeFile(
path.join(root, "dist", "entry.js"),
'process.stdout.write("diagnostic-entry\\n");',
);
const preload = path.join(root, "unsupported.mjs");
await fs.writeFile(
preload,
'Object.defineProperty(process.versions, "node", { value: "22.23.2" });',
);
const result = spawnSync(
process.execPath,
["--import", pathToFileURL(preload).href, path.join(root, "openclaw.mjs"), ...args],
{
cwd: root,
env: launcherEnv({ HOME: root, OPENCLAW_HOME: root, NODE_DISABLE_COMPILE_CACHE: "1" }),
encoding: "utf8",
},
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toMatch(/OpenClaw 2026\.9\.3|diagnostic-entry/);
});
it("admits lossless Node builds outside the support table while retaining the major floor", async () => {
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
await fs.writeFile(
@ -399,7 +538,8 @@ describe("openclaw launcher", () => {
"--import",
pathToFileURL(mockNodeVersionPath).href,
path.join(fixtureRoot, "openclaw.mjs"),
"--help",
"gateway",
"start",
],
{
cwd: fixtureRoot,

View file

@ -38,6 +38,7 @@ const config = {
format: "esm",
outDir: "packages/ai/dist",
platform: "node",
target: "node22",
deps: {
neverBundle(id) {
return externalDependencies.some(

View file

@ -171,6 +171,8 @@ function nodeBuildConfig(
): UserConfig {
return {
...config,
// Recovery diagnostics must parse on Node 22; runtime admission still guards live writers.
target: "node22",
dts: declarations,
hooks: createDeclarationBoundaryHooks(config.hooks),
env,