diff --git a/docs/install/installer.md b/docs/install/installer.md index bcce5bafc4c4..985c31fbaef9 100644 --- a/docs/install/installer.md +++ b/docs/install/installer.md @@ -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 (); 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. +With `--node-only`, `install-cli.sh` stops after provisioning Node into `/tools/node-v` and updating the `/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) @@ -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 ``` + + ```bash + curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install-cli.sh | bash -s -- --node-only --prefix "$HOME/.openclaw/tools/cli-node" + ``` + ```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. -| Flag | Description | -| --------------------------------------- | ------------------------------------------------------------------------------- | -| `--prefix ` | 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 ` | Git checkout directory (default: `~/openclaw`) | -| `--no-git-update` | Skip `git pull` for an existing git checkout | -| `--version ` | OpenClaw version or dist-tag (default: `latest`) | -| `--compatible-with ` | Refuse a CLI that cannot modify config written by `` | -| `--node-version ` | 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 ` | 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 ` | Git checkout directory (default: `~/openclaw`) | +| `--no-git-update` | Skip `git pull` for an existing git checkout | +| `--version ` | OpenClaw version or dist-tag (default: `latest`) | +| `--compatible-with ` | Refuse a CLI that cannot modify config written by `` | +| `--node-version ` | 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 | @@ -360,6 +387,12 @@ by default, plus git-checkout installs under the same prefix flow. +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. + + +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. + + ### Examples (install.ps1) @@ -368,6 +401,11 @@ by default, plus git-checkout installs under the same prefix flow. iwr -useb https://openclaw.ai/install.ps1 | iex ``` + + ```powershell + & ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -NodeOnly -NodePrefix "$HOME\.openclaw\tools\cli-node\tools\node" + ``` + ```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 ` | Required absolute private directory for `-NodeOnly` | | `-Help` | Show usage for downloaded scriptblock invocation | diff --git a/node-runtime-update.mjs b/node-runtime-update.mjs index b8726a935d25..92fafed602a5 100644 --- a/node-runtime-update.mjs +++ b/node-runtime-update.mjs @@ -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 || diff --git a/node-version.d.mts b/node-version.d.mts index c177b55e6aa3..b2b73489506f 100644 --- a/node-version.d.mts +++ b/node-version.d.mts @@ -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; diff --git a/node-version.mjs b/node-version.mjs index 1979da11c5c3..f0f1f1f451cf 100644 --- a/node-version.mjs +++ b/node-version.mjs @@ -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") { diff --git a/openclaw.mjs b/openclaw.mjs index 53ca82df8fd0..f78aa4c87f9b 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -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 { diff --git a/src/cli/program/register.maintenance.test.ts b/src/cli/program/register.maintenance.test.ts index 610e3057623b..bdc5fe1e0501 100644 --- a/src/cli/program/register.maintenance.test.ts +++ b/src/cli/program/register.maintenance.test.ts @@ -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); diff --git a/src/cli/program/register.maintenance.ts b/src/cli/program/register.maintenance.ts index 28f7a8353f4d..12b7dcb49b51 100644 --- a/src/cli/program/register.maintenance.ts +++ b/src/cli/program/register.maintenance.ts @@ -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" diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 1edf257f1138..5b39d1ed6104 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -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()), assertSupportedRuntime: assertRuntimeMock, })); diff --git a/src/cli/run-main.profile-env.test.ts b/src/cli/run-main.profile-env.test.ts index 8bb918ae91c5..ee2bb74fb571 100644 --- a/src/cli/run-main.profile-env.test.ts +++ b/src/cli/run-main.profile-env.test.ts @@ -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()), assertSupportedRuntime: vi.fn(async () => {}), })); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index ede14070ba7d..2cfd4b844503 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -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 => { diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 2c4120a0405b..37a6a947ffb9 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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()), nodeVersionSatisfiesEngine, parseSemver: (version: string | null) => { if (!version) { diff --git a/src/cli/update-cli/status.test.ts b/src/cli/update-cli/status.test.ts index 867024e6cb82..f9f9142cc621 100644 --- a/src/cli/update-cli/status.test.ts +++ b/src/cli/update-cli/status.test.ts @@ -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()), + isDefaultInstallIdentity: () => true, +})); + vi.mock("../../runtime.js", async (importOriginal) => ({ ...(await importOriginal()), 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(); }); diff --git a/src/cli/update-cli/status.ts b/src/cli/update-cli/status.ts index 5f431bb20986..d47d921e2d19 100644 --- a/src/cli/update-cli/status.ts +++ b/src/cli/update-cli/status.ts @@ -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 0 ? { runtimeFindings } : {}), ...(activeRun ? { activeRun } : {}), ...(lastRun ? { lastRun } : {}), ...(staleGuidance && activeRun @@ -107,6 +113,19 @@ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise ({ + 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()), + assertOpenClawStateWriteAllowedAtPath: mocks.stateAdmission, +})); +vi.mock("../../runtime.js", async (importOriginal) => ({ + ...(await importOriginal()), + 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"), + }), + ); + }); +}); diff --git a/src/commands/doctor-lint.ts b/src/commands/doctor-lint.ts index 2436442e913c..47e1ac7704b2 100644 --- a/src/commands/doctor-lint.ts +++ b/src/commands/doctor-lint.ts @@ -169,7 +169,12 @@ async function executeDoctorLint( ): Promise { 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 || ""; 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( } } -function createStateSnapshotFailureExecution( +async function createStateSnapshotFailureExecution( runtime: RuntimeEnv, opts: DoctorLintCliOptions, sevMin: NonNullable>, error: DoctorLintStateSnapshotError, -): DoctorLintExecution { +): Promise { 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}`); + } + } }, }; } diff --git a/src/commands/node-runtime-diagnostics.test.ts b/src/commands/node-runtime-diagnostics.test.ts new file mode 100644 index 000000000000..7721a72d10ae --- /dev/null +++ b/src/commands/node-runtime-diagnostics.test.ts @@ -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()), + readConfigFileSnapshot: mocks.readConfigFileSnapshot, +})); +vi.mock("../config/paths.js", async (importOriginal) => ({ + ...(await importOriginal()), + 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")); + }); +}); diff --git a/src/commands/node-runtime-diagnostics.ts b/src/commands/node-runtime-diagnostics.ts new file mode 100644 index 000000000000..f26692b42d71 --- /dev/null +++ b/src/commands/node-runtime-diagnostics.ts @@ -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 { + 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 { + 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; +} diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 7940259d6dc9..262da0a6d586 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -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 diff --git a/src/entry.ts b/src/entry.ts index 92b13a372885..71b191b1dd48 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -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); diff --git a/src/flows/doctor-core-checks.runtime.test.ts b/src/flows/doctor-core-checks.runtime.test.ts index 6db0f6f157d1..333df7f0d8d9 100644 --- a/src/flows/doctor-core-checks.runtime.test.ts +++ b/src/flows/doctor-core-checks.runtime.test.ts @@ -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") } : {}), }), ]); }, diff --git a/src/flows/doctor-core-checks.runtime.ts b/src/flows/doctor-core-checks.runtime.ts index 20262227992e..2eb7854387c7 100644 --- a/src/flows/doctor-core-checks.runtime.ts +++ b/src/flows/doctor-core-checks.runtime.ts @@ -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, ): Promise { @@ -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"), + } : {}), }); } diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index a116b848c6f4..ee3e9c2a4907 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -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); }, }; diff --git a/src/infra/runtime-guard.test.ts b/src/infra/runtime-guard.test.ts index 27556f76eeca..f9a76018ad58 100644 --- a/src/infra/runtime-guard.test.ts +++ b/src/infra/runtime-guard.test.ts @@ -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); diff --git a/src/infra/runtime-guard.ts b/src/infra/runtime-guard.ts index 7a0b1e3fc109..bcd0e4b9f797 100644 --- a/src/infra/runtime-guard.ts +++ b/src/infra/runtime-guard.ts @@ -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 { 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) { diff --git a/src/infra/update-failure-report-prepare.test.ts b/src/infra/update-failure-report-prepare.test.ts index c38070bcf78c..e09dce60ff33 100644 --- a/src/infra/update-failure-report-prepare.test.ts +++ b/src/infra/update-failure-report-prepare.test.ts @@ -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"', diff --git a/src/infra/update-failure-report-prepare.ts b/src/infra/update-failure-report-prepare.ts index 1b5bd476f850..35267e7b82a8 100644 --- a/src/infra/update-failure-report-prepare.ts +++ b/src/infra/update-failure-report-prepare.ts @@ -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}`, diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index f5ab5b2744c3..fdc6ee3d2930 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -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, diff --git a/tsdown.ai.config.ts b/tsdown.ai.config.ts index 2ece9e23eeb4..d095b7a5fd1e 100644 --- a/tsdown.ai.config.ts +++ b/tsdown.ai.config.ts @@ -38,6 +38,7 @@ const config = { format: "esm", outDir: "packages/ai/dist", platform: "node", + target: "node22", deps: { neverBundle(id) { return externalDependencies.some( diff --git a/tsdown.config.ts b/tsdown.config.ts index 43828078c0c6..c058726ac470 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -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,