mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
fda2de6060
commit
e4a6d50073
30 changed files with 1138 additions and 78 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
126
node-version.mjs
126
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") {
|
||||
|
|
|
|||
37
openclaw.mjs
37
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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {}),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
52
src/cli/update-cli/update-command-runtime.test.ts
Normal file
52
src/cli/update-cli/update-command-runtime.test.ts
Normal 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"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
209
src/commands/node-runtime-diagnostics.test.ts
Normal file
209
src/commands/node-runtime-diagnostics.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
112
src/commands/node-runtime-diagnostics.ts
Normal file
112
src/commands/node-runtime-diagnostics.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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") } : {}),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"',
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const config = {
|
|||
format: "esm",
|
||||
outDir: "packages/ai/dist",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
deps: {
|
||||
neverBundle(id) {
|
||||
return externalDependencies.some(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue