From cd90e0619ead12919f0c4df6fcd1fb29796facaa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 28 Aug 2026 11:43:11 -0700 Subject: [PATCH] fix(daemon): node install reports a supported Node runtime as unsupported when the working directory is unreadable (#131998) * fix(daemon): stop reporting failed runtime probes as unsupported runtimes The daemon runtime probe wrapped its exec in a bare catch that returned `supported: false`, so any failure to *run* the probe was laundered into a verdict that the runtime itself was unsupported. Operators on a perfectly good Node install were told to install a Node version they already had. Observed on Ubuntu 26.04 with Node 26.8.1: `openclaw node install` failed with "No supported Node runtime was selected for the daemon" whenever the process cwd was not readable by the service user (e.g. `runuser -u openclaw` inheriting root's 0700 home over SSH), because every child spawn then fails EACCES. The version logic was never wrong -- resolveSystemNodeInfo returned supported:true and resolvePreferredNodePath returned /usr/bin/node when probed directly on the affected host. Node and Bun probes now share one resolver returning a closed supported | unsupported | probe-failed union. A failed probe retains its cause, executable, and cwd, and selection propagates that instead of falling through to Node-upgrade advice. Also: - Derive the supported-version wording from NODE_RELEASE_FLOORS via a new exported SUPPORTED_NODE_VERSIONS, replacing six hand-copied spellings that omitted the >=25.9.0 line and told operators to downgrade. - Forward OPENCLAW_WRAPPER through the node-host install path; the documented escape hatch was previously gateway-daemon-only. Production LOC net +6 (+156/-150); consolidating the duplicated Node/Bun probes paid for the new failure handling. * docs(cli): drop machine-local path from node probe-failure guidance ClawSweeper P3: docs/AGENTS.md requires generic docs content with no local paths. The probe-failure recovery example prescribed a specific directory; state the readability requirement instead. --- docs/cli/node.md | 10 + node-version.d.mts | 2 + node-version.mjs | 8 + src/cli/daemon-cli/start-repair.test.ts | 23 +- src/cli/daemon-cli/start-repair.ts | 11 +- src/commands/daemon-install-helpers.test.ts | 5 +- src/commands/daemon-install-helpers.ts | 13 +- src/commands/daemon-install-plan.shared.ts | 4 + src/commands/doctor-gateway-services.test.ts | 44 +++- src/commands/doctor-gateway-services.ts | 8 +- .../node-daemon-install-helpers.test.ts | 13 +- src/commands/node-daemon-install-helpers.ts | 6 +- src/commands/node-daemon-runtime.test.ts | 100 ++++++++ src/daemon/program-args.test.ts | 2 +- src/daemon/program-args.ts | 5 +- src/daemon/runtime-paths.test.ts | 169 ++++++++++--- src/daemon/runtime-paths.ts | 231 ++++++++---------- src/daemon/service-audit.test.ts | 33 ++- src/daemon/service-audit.ts | 18 +- 19 files changed, 503 insertions(+), 202 deletions(-) create mode 100644 src/commands/node-daemon-runtime.test.ts diff --git a/docs/cli/node.md b/docs/cli/node.md index 7d170f448b4..c3c4348eaf9 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -155,6 +155,16 @@ Options: - `--runtime `: Service runtime (default: `node`). Bun 1.4+ with WAL-reset-safe `node:sqlite` is an explicit opt-in; Node remains recommended. - `--force`: Reinstall/overwrite if already installed +Set `OPENCLAW_WRAPPER` to an executable wrapper file to use it instead of the +selected runtime and CLI entrypoint. The wrapper receives `node run` and the +connection arguments; it must launch OpenClaw and forward those arguments. + +If installation reports a runtime probe failure, check the executable and +working directory named in the error. For example, when switching users with +`runuser`, first change to a directory that the target user can read. A failed +probe does not mean that the installed Node version is unsupported; upgrade +advice is reserved for missing or unsupported runtimes. + > **Linux (systemd user service):** Run `sudo loginctl enable-linger ` after > install. Without lingering, `systemd --user` tears down the node service when > your last SSH session ends, so the node silently goes offline after logout. diff --git a/node-version.d.mts b/node-version.d.mts index dbd28c36c75..c177b55e6aa 100644 --- a/node-version.d.mts +++ b/node-version.d.mts @@ -11,3 +11,5 @@ export function isNodeVersionAtLeast( ): boolean; export function isSupportedOpenClawNodeVersion(value: unknown): boolean; export const PROCESS_NODE_VERSION_CHECK: string; + +export const SUPPORTED_NODE_VERSIONS: string; diff --git a/node-version.mjs b/node-version.mjs index dc7876dd0ce..e693e0009fb 100644 --- a/node-version.mjs +++ b/node-version.mjs @@ -9,6 +9,14 @@ const NODE_RELEASE_FLOORS = [ ]; const HIGHEST_RELEASE_FLOOR = NODE_RELEASE_FLOORS[NODE_RELEASE_FLOORS.length - 1]; +// Render diagnostics from the same release floors used by the runtime guard. +export const SUPPORTED_NODE_VERSIONS = `${NODE_RELEASE_FLOORS.map( + ({ major, minor, patch }, index) => + `>=${major}.${minor}.${patch}${index < NODE_RELEASE_FLOORS.length - 1 ? ` <${major + 1}` : ""}`, +) + .join(", ") + .replace(/, ([^,]+)$/, ", or $1")} (Node 26 recommended)`; + /** Parses an anchored release SemVer, allowing a leading v and valid build metadata. */ export function parseNodeReleaseVersion(value) { if (typeof value !== "string") { diff --git a/src/cli/daemon-cli/start-repair.test.ts b/src/cli/daemon-cli/start-repair.test.ts index bd1a0f5db09..e4280a33e44 100644 --- a/src/cli/daemon-cli/start-repair.test.ts +++ b/src/cli/daemon-cli/start-repair.test.ts @@ -126,7 +126,7 @@ describe("repairLoadedGatewayServiceForStart", () => { defaultRuntimeLogMock.mockClear(); assertGatewayServiceMutationAllowedMock.mockReset(); resolveBunRuntimeInfoMock.mockReset(); - resolveBunRuntimeInfoMock.mockResolvedValue({ supported: true }); + resolveBunRuntimeInfoMock.mockResolvedValue({ status: "supported" }); resolveGatewayInstallTokenMock.mockResolvedValue({ tokenRefConfigured: false, @@ -254,12 +254,14 @@ describe("repairLoadedGatewayServiceForStart", () => { }); it.each([ - { supported: true, expectedRuntime: "bun" }, - { supported: false, expectedRuntime: "node" }, + { status: "supported", expectedRuntime: "bun" }, + { status: "unsupported", expectedRuntime: "node" }, + { status: "probe-failed", expectedRuntime: null }, ])( - "$expectedRuntime is selected when repairing an installed Bun Gateway with supported=$supported", - async ({ supported, expectedRuntime }) => { - resolveBunRuntimeInfoMock.mockResolvedValue({ supported }); + "repairs an installed Bun Gateway only when its probe result is known ($status)", + async ({ status, expectedRuntime }) => { + const error = new Error("Bun runtime probe failed (cwd /root): EACCES"); + resolveBunRuntimeInfoMock.mockResolvedValue({ status, error }); const service = { install: vi.fn(async () => {}), isLoaded: vi.fn(async () => true), @@ -281,13 +283,20 @@ describe("repairLoadedGatewayServiceForStart", () => { }, }; - await repairLoadedGatewayServiceForStart({ + const repair = repairLoadedGatewayServiceForStart({ service, state, issues: [{ code: "port-mismatch", message: "old port" }], json: true, stdout: process.stdout, }); + if (status === "probe-failed") { + await expect(repair).rejects.toBe(error); + expect(resolveGatewayInstallTokenMock).not.toHaveBeenCalled(); + expect(service.install).not.toHaveBeenCalled(); + return; + } + await repair; const plan = readFirstInstallPlanArg(); expect(plan.runtime).toBe(expectedRuntime); diff --git a/src/cli/daemon-cli/start-repair.ts b/src/cli/daemon-cli/start-repair.ts index 4a31f65e808..aa732372f32 100644 --- a/src/cli/daemon-cli/start-repair.ts +++ b/src/cli/daemon-cli/start-repair.ts @@ -190,10 +190,13 @@ export async function repairLoadedGatewayServiceForStart( const installedRuntime = resolveGatewayDaemonRuntime(managedCommand?.programArguments); const installedRuntimePath = installedRuntime === "bun" ? managedCommand?.programArguments[0] : undefined; - const runtime = - installedRuntimePath && (await resolveBunRuntimeInfo(installedRuntimePath)).supported - ? "bun" - : "node"; + const runtimeInfo = installedRuntimePath + ? await resolveBunRuntimeInfo(installedRuntimePath) + : undefined; + if (runtimeInfo?.status === "probe-failed") { + throw runtimeInfo.error; + } + const runtime = runtimeInfo?.status === "supported" ? "bun" : "node"; const tokenResolution = await resolveGatewayInstallToken({ config: cfg, diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index d90b1a4924f..d85437aa3bd 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -169,7 +169,7 @@ function mockNodeGatewayPlanFixture( mocks.resolveSystemNodeInfo.mockResolvedValue({ path: "/opt/node", version, - supported, + status: supported ? "supported" : "unsupported", }); mocks.renderSystemNodeWarning.mockReturnValue(warning); mocks.buildServiceEnvironment.mockReturnValue(serviceEnvironment); @@ -583,6 +583,9 @@ describe("buildGatewayInstallPlan", () => { firstMockArg(mocks.resolveGatewayProgramArguments, "resolveGatewayProgramArguments") .wrapperPath, ).toBeUndefined(); + expect(mocks.resolveGatewayProgramArguments).toHaveBeenCalledWith( + expect.objectContaining({ runtimePath: "/opt/node" }), + ); expect(mocks.buildServiceEnvironment).toHaveBeenCalledOnce(); expect( firstMockArg(mocks.buildServiceEnvironment, "buildServiceEnvironment").env?.OPENCLAW_WRAPPER, diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 66230ee7866..c9a059e115d 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -822,12 +822,6 @@ export async function buildGatewayInstallPlan(params: { >; }): Promise { const platform = params.platform ?? process.platform; - const { devMode, runtimePath } = await resolveDaemonInstallRuntimeInputs({ - env: params.env, - runtime: params.runtime, - devMode: params.devMode, - runtimePath: params.runtimePath, - }); const wrapperInput = params.wrapperPath ?? params.env[OPENCLAW_WRAPPER_ENV_KEY]; const wrapperPointsAtWindowsTaskScript = Boolean(wrapperInput?.trim()) && @@ -841,6 +835,13 @@ export async function buildGatewayInstallPlan(params: { const wrapperPath = wrapperPointsAtWindowsTaskScript ? undefined : await resolveOpenClawWrapperPath(wrapperInput); + const { devMode, runtimePath } = await resolveDaemonInstallRuntimeInputs({ + env: params.env, + runtime: params.runtime, + devMode: params.devMode, + runtimePath: params.runtimePath, + wrapperPath, + }); const serviceInputEnv: Record = wrapperPath ? { ...params.env, [OPENCLAW_WRAPPER_ENV_KEY]: wrapperPath } : wrapperPointsAtWindowsTaskScript diff --git a/src/commands/daemon-install-plan.shared.ts b/src/commands/daemon-install-plan.shared.ts index de77b35fcbe..33de7fddcdb 100644 --- a/src/commands/daemon-install-plan.shared.ts +++ b/src/commands/daemon-install-plan.shared.ts @@ -25,8 +25,12 @@ export async function resolveDaemonInstallRuntimeInputs(params: { runtime: GatewayDaemonRuntime; devMode?: boolean; runtimePath?: string; + wrapperPath?: string; }): Promise<{ devMode: boolean; runtimePath?: string }> { const devMode = params.devMode ?? resolveGatewayDevMode(); + if (params.wrapperPath?.trim()) { + return { devMode, runtimePath: params.runtimePath }; + } const runtimePath = params.runtimePath ?? (params.runtime === "bun" diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 22b0c44a7d9..5365c0d81ea 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -105,6 +105,7 @@ vi.mock("../daemon/service-audit.js", () => ({ gatewayPathNonMinimal: "gateway-path-nonminimal", gatewayPortMismatch: testServiceAuditCodes.gatewayPortMismatch, gatewayProxyEnvEmbedded: testServiceAuditCodes.gatewayProxyEnvEmbedded, + gatewayRuntimeProbeFailed: "gateway-runtime-probe-failed", gatewayTokenDrift: "gateway-token-drift", gatewayTokenEmbedded: "gateway-token-embedded", gatewayTokenMismatch: testServiceAuditCodes.gatewayTokenMismatch, @@ -601,7 +602,7 @@ describe("maybeRepairGatewayServiceConfig", () => { mocks.resolveSystemNodeInfo.mockResolvedValue({ path: "/usr/bin/node", version: "20.20.2", - supported: false, + status: "unsupported", }); mocks.renderSystemNodeWarning.mockReturnValue("duplicate doctor runtime warning"); @@ -616,6 +617,45 @@ describe("maybeRepairGatewayServiceConfig", () => { ); }); + it.each([false, true])( + "reports failed Bun probes without runtime migration (other repairable drift: %s)", + async (otherDrift) => { + const bunCommand = { + programArguments: ["/opt/bun", "/usr/local/bin/openclaw", "gateway", "--port", "18789"], + environment: {}, + }; + mocks.readCommand.mockResolvedValue(bunCommand); + mocks.buildGatewayInstallPlan.mockResolvedValue(bunCommand); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: false, + issues: [ + { + code: "gateway-runtime-probe-failed", + message: "Gateway service Bun runtime probe failed.", + detail: "/opt/bun (cwd /root): EACCES", + }, + ...(otherDrift + ? [{ code: "gateway-path-nonminimal", message: "Gateway PATH should be regenerated" }] + : []), + ], + }); + const prompter = makeDoctorPrompts(); + + await maybeRepairGatewayServiceConfig({ gateway: {} }, "local", makeDoctorIo(), prompter); + + expectNoteContaining("/opt/bun (cwd /root): EACCES", "Gateway service config"); + expectNoNoteContaining("unsupported", "Gateway service config"); + expect(mocks.resolveSystemNodeInfo).not.toHaveBeenCalled(); + expect(prompter.confirmRuntimeRepair).toHaveBeenCalledTimes(Number(otherDrift)); + expect(mocks.install).toHaveBeenCalledTimes(Number(otherDrift)); + for (const [options] of mocks.buildGatewayInstallPlan.mock.calls) { + expect(options).toEqual( + expect.objectContaining({ runtime: "bun", runtimePath: "/opt/bun" }), + ); + } + }, + ); + it("preserves a supported Bun runtime when repairing the Gateway service", async () => { const bunPath = "/home/test/.bun/bin/bun"; const bunCommand = { @@ -670,7 +710,7 @@ describe("maybeRepairGatewayServiceConfig", () => { mocks.resolveSystemNodeInfo.mockResolvedValue({ path: systemNodePath, version: "24.15.0", - supported: true, + status: "supported", }); await runRepair({ gateway: {} }); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index d50c24fa4d6..31cd6682778 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -6,6 +6,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; +import { SUPPORTED_NODE_VERSIONS } from "../../node-version.mjs"; import { note } from "../../packages/terminal-core/src/note.js"; import { replaceConfigFile, type OpenClawConfig } from "../config/config.js"; import { isDefaultInstallIdentity, resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; @@ -604,14 +605,14 @@ export async function maybeRepairGatewayServiceConfig( const systemNodeInfo = needsNodeRuntime ? await resolveSystemNodeInfo({ env: process.env }) : null; - const systemNodePath = systemNodeInfo?.supported ? systemNodeInfo.path : null; + const systemNodePath = systemNodeInfo?.status === "supported" ? systemNodeInfo.path : null; if (needsNodeRuntime && !systemNodePath && runtimeChoice !== "node") { const warning = renderSystemNodeWarning(systemNodeInfo); if (warning) { note(warning, "Gateway runtime"); } else { note( - "System Node 22 LTS (22.22.3+) or Node 24.15+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.", + `System Node ${SUPPORTED_NODE_VERSIONS} not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.`, "Gateway runtime", ); } @@ -683,6 +684,9 @@ export async function maybeRepairGatewayServiceConfig( ), ); note(consolidatedLines.join("\n"), "Gateway service config"); + if (audit.issues.every((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeProbeFailed)) { + return cfg; + } const aggressiveIssues = audit.issues.filter((issue) => issue.level === "aggressive"); const needsAggressive = aggressiveIssues.length > 0; diff --git a/src/commands/node-daemon-install-helpers.test.ts b/src/commands/node-daemon-install-helpers.test.ts index 96bc9e18944..3604cb36eb7 100644 --- a/src/commands/node-daemon-install-helpers.test.ts +++ b/src/commands/node-daemon-install-helpers.test.ts @@ -18,6 +18,7 @@ vi.mock("../daemon/runtime-paths.js", () => ({ })); vi.mock("../daemon/program-args.js", () => ({ + OPENCLAW_WRAPPER_ENV_KEY: "OPENCLAW_WRAPPER", resolveNodeProgramArguments: mocks.resolveNodeProgramArguments, })); @@ -39,8 +40,8 @@ describe("buildNodeInstallPlan", () => { }); mocks.resolveSystemNodeInfo.mockResolvedValue({ path: "/opt/node/bin/node", - version: "22.0.0", - supported: true, + version: "26.8.1", + status: "supported", }); mocks.renderSystemNodeWarning.mockReturnValue(undefined); mocks.buildNodeServiceEnvironment.mockReturnValue({ @@ -107,8 +108,8 @@ describe("buildNodeInstallPlan", () => { }); mocks.resolveSystemNodeInfo.mockResolvedValue({ path: "/usr/bin/node", - version: "22.0.0", - supported: true, + version: "26.8.1", + status: "supported", }); mocks.renderSystemNodeWarning.mockReturnValue(undefined); mocks.buildNodeServiceEnvironment.mockReturnValue({ @@ -136,8 +137,8 @@ describe("buildNodeInstallPlan", () => { }); mocks.resolveSystemNodeInfo.mockResolvedValue({ path: "/usr/bin/node", - version: "22.0.0", - supported: true, + version: "26.8.1", + status: "supported", }); mocks.renderSystemNodeWarning.mockReturnValue(undefined); mocks.buildNodeServiceEnvironment.mockReturnValue({ diff --git a/src/commands/node-daemon-install-helpers.ts b/src/commands/node-daemon-install-helpers.ts index 53cd11be10c..5d581799214 100644 --- a/src/commands/node-daemon-install-helpers.ts +++ b/src/commands/node-daemon-install-helpers.ts @@ -1,5 +1,5 @@ /** Managed node-host install plan builder. */ -import { resolveNodeProgramArguments } from "../daemon/program-args.js"; +import { OPENCLAW_WRAPPER_ENV_KEY, resolveNodeProgramArguments } from "../daemon/program-args.js"; import { buildNodeServiceEnvironment } from "../daemon/service-env.js"; import type { GatewayServiceEnvironmentValueSource } from "../daemon/service-types.js"; import { @@ -44,13 +44,16 @@ export async function buildNodeInstallPlan(params: { runtime: GatewayDaemonRuntime; devMode?: boolean; runtimePath?: string; + wrapperPath?: string; warn?: DaemonInstallWarnFn; }): Promise { + const wrapperPath = params.wrapperPath ?? params.env[OPENCLAW_WRAPPER_ENV_KEY]; const { devMode, runtimePath } = await resolveDaemonInstallRuntimeInputs({ env: params.env, runtime: params.runtime, devMode: params.devMode, runtimePath: params.runtimePath, + wrapperPath, }); const { programArguments, workingDirectory } = await resolveNodeProgramArguments({ host: params.host, @@ -64,6 +67,7 @@ export async function buildNodeInstallPlan(params: { dev: devMode, runtime: params.runtime, runtimePath, + wrapperPath, }); await emitDaemonInstallRuntimeWarning({ diff --git a/src/commands/node-daemon-runtime.test.ts b/src/commands/node-daemon-runtime.test.ts new file mode 100644 index 00000000000..f165c7aada7 --- /dev/null +++ b/src/commands/node-daemon-runtime.test.ts @@ -0,0 +1,100 @@ +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { runExec, access, stat } = vi.hoisted(() => ({ + runExec: vi.fn(), + access: vi.fn(), + stat: vi.fn(), +})); +vi.mock("../process/exec.js", () => ({ runExec })); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, access, stat, realpath: async (value: string) => value }, + }; +}); + +import { buildNodeInstallPlan } from "./node-daemon-install-helpers.js"; + +const originalExecPath = process.execPath; +const originalArgv = process.argv; +beforeEach(() => { + process.execPath = "/fixture/bun"; + process.argv = [process.execPath, path.resolve("/opt/openclaw/dist/index.js")]; + access.mockImplementation(async (value: string) => { + if ( + value === "/usr/bin/node" || + value === process.argv[1] || + value === "/opt/openclaw-wrapper" + ) { + return; + } + throw new Error("ENOENT"); + }); + stat.mockResolvedValue({ isFile: () => true }); +}); +afterEach(() => { + process.execPath = originalExecPath; + process.argv = originalArgv; + vi.resetAllMocks(); +}); + +const install = (env: Record = {}) => + buildNodeInstallPlan({ + env, + host: "gateway.example", + port: 18789, + runtime: "node", + devMode: false, + }); + +describe.skipIf(process.platform === "win32")("node-host runtime install boundary", () => { + it("accepts a Node 26 system runtime with safe embedded SQLite", async () => { + runExec.mockResolvedValue({ + stdout: JSON.stringify({ + nodeVersion: "26.8.1", + sqliteVersion: "3.53.4", + nodeSharedSqlite: false, + }), + stderr: "", + }); + const plan = await install(); + expect(plan.programArguments).toEqual([ + "/usr/bin/node", + process.argv[1], + "node", + "run", + "--host", + "gateway.example", + "--port", + "18789", + ]); + }); + + it("surfaces an exec failure through the install plan without Node upgrade advice", async () => { + runExec.mockRejectedValue(new Error("spawn EACCES")); + await expect(install()).rejects.toThrow(/Node runtime probe failed.*\/usr\/bin\/node.*EACCES/s); + }); + + it("uses OPENCLAW_WRAPPER even when native runtime probes cannot execute", async () => { + runExec.mockRejectedValue(new Error("spawn EACCES")); + const plan = await install({ OPENCLAW_WRAPPER: "/opt/openclaw-wrapper" }); + expect(plan.programArguments).toEqual([ + "/opt/openclaw-wrapper", + "node", + "run", + "--host", + "gateway.example", + "--port", + "18789", + ]); + }); + + it("rejects a node-host wrapper without execute permission", async () => { + access.mockRejectedValue(new Error("EACCES")); + await expect(install({ OPENCLAW_WRAPPER: "/opt/openclaw-wrapper" })).rejects.toThrow( + "OPENCLAW_WRAPPER must point to an executable file", + ); + }); +}); diff --git a/src/daemon/program-args.test.ts b/src/daemon/program-args.test.ts index 67a4d8d6c68..c98dfdcb01d 100644 --- a/src/daemon/program-args.test.ts +++ b/src/daemon/program-args.test.ts @@ -34,7 +34,7 @@ const originalExecPath = process.execPath; const validatedNodePath = "/opt/Validated Node/bin/node"; const validatedBunPath = "/opt/Validated Bun/bin/bun"; const missingSelectedNodeError = - "No supported Node runtime was selected for the daemon. Install Node 24.15+ (recommended) or Node 22 LTS (22.22.3+), then retry."; + "No supported Node runtime was selected for the daemon. Install Node >=22.22.3 <23, >=24.15.0 <25, or >=25.9.0 (Node 26 recommended), then retry."; const missingSelectedBunError = "No supported Bun runtime was selected for the daemon. Install Bun 1.4 or newer with WAL-reset-safe node:sqlite, then retry."; diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index 2a6dec9ee36..830c754912f 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -2,6 +2,7 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import { SUPPORTED_NODE_VERSIONS } from "../../node-version.mjs"; import type { GatewayDaemonRuntime } from "../commands/daemon-runtime.js"; import { buildGatewayDistEntrypointCandidates, @@ -195,7 +196,7 @@ async function resolveCliProgramArguments(params: { throw new Error( params.runtime === "bun" ? "No supported Bun runtime was selected for the daemon. Install Bun 1.4 or newer with WAL-reset-safe node:sqlite, then retry." - : "No supported Node runtime was selected for the daemon. Install Node 24.15+ (recommended) or Node 22 LTS (22.22.3+), then retry.", + : `No supported Node runtime was selected for the daemon. Install Node ${SUPPORTED_NODE_VERSIONS}, then retry.`, ); } const runtimePath = params.runtimePath; @@ -255,6 +256,7 @@ export async function resolveNodeProgramArguments(params: { dev?: boolean; runtime: GatewayDaemonRuntime; runtimePath?: string; + wrapperPath?: string; }): Promise { const args = ["node", "run", "--host", params.host, "--port", String(params.port)]; if (params.tls === false && !params.tlsFingerprint) { @@ -284,5 +286,6 @@ export async function resolveNodeProgramArguments(params: { dev: params.dev, runtime: params.runtime, runtimePath: params.runtimePath, + wrapperPath: params.wrapperPath, }); } diff --git a/src/daemon/runtime-paths.test.ts b/src/daemon/runtime-paths.test.ts index 3babe58f91c..80afa2f126d 100644 --- a/src/daemon/runtime-paths.test.ts +++ b/src/daemon/runtime-paths.test.ts @@ -21,6 +21,7 @@ vi.mock("node:fs/promises", async () => { }); import { resolveStableNodePath } from "../infra/stable-node-path.js"; +import { resolveNodeProgramArguments } from "./program-args.js"; import { renderSystemNodeWarning, resolveBunRuntimeInfo, @@ -43,7 +44,7 @@ function mockNodePathPresent(...nodePaths: string[]) { if (nodePaths.includes(target)) { return; } - throw new Error("missing"); + throw Object.assign(new Error("missing"), { code: "ENOENT" }); }); } @@ -69,12 +70,100 @@ function bunRuntime( }; } +describe.each(["node", "bun"] as const)("%s probe failures", (runtime) => { + it.each([ + { + name: "spawn failure", + execFile: async () => { + throw new Error("spawn EACCES"); + }, + }, + { + name: "timeout", + execFile: async () => { + throw new Error("timed out after 5000ms"); + }, + }, + { name: "invalid JSON", execFile: async () => ({ stdout: "not JSON", stderr: "" }) }, + { name: "missing metadata", execFile: async () => ({ stdout: "{}", stderr: "" }) }, + ])("keeps $name distinct from unsupported", async ({ execFile }) => { + mockNodePathPresent("/usr/bin/node"); + const result = + runtime === "node" + ? await resolveSystemNodeInfo({ env: {}, platform: "linux", execFile }) + : await resolveBunRuntimeInfo("/usr/bin/bun", execFile); + expect(result).toMatchObject({ status: "probe-failed", error: expect.any(Error) }); + expect(result).not.toHaveProperty("version"); + }); + + it("selects a working candidate after another probe fails", async () => { + mockNodePathPresent( + "/usr/local/bin/node", + "/usr/bin/node", + "/usr/local/bin/bun", + "/usr/bin/bun", + ); + const execFile = vi + .fn() + .mockRejectedValueOnce(new Error("EACCES")) + .mockResolvedValue( + runtime === "node" ? nodeRuntime("26.8.1", "3.53.4") : bunRuntime("1.4.0"), + ); + const resolve = runtime === "node" ? resolvePreferredNodePath : resolvePreferredBunPath; + expect( + await resolve({ env: {}, runtime, platform: "linux", execPath: "/fixture/other", execFile }), + ).toBe(`/usr/bin/${runtime}`); + }); + + it("retains failed-probe evidence when another candidate is unsupported", async () => { + mockNodePathPresent( + "/usr/local/bin/node", + "/usr/bin/node", + "/usr/local/bin/bun", + "/usr/bin/bun", + ); + const execFile = vi + .fn() + .mockResolvedValueOnce( + runtime === "node" ? nodeRuntime("20.0.0", null) : bunRuntime("1.3.0", false), + ) + .mockRejectedValue(new Error("EACCES")); + const resolve = runtime === "node" ? resolvePreferredNodePath : resolvePreferredBunPath; + await expect( + resolve({ env: {}, runtime, platform: "linux", execPath: "/fixture/other", execFile }), + ).rejects.toThrow(/probe failed.*EACCES/s); + }); +}); + describe("resolvePreferredNodePath", () => { const darwinNode = "/opt/homebrew/bin/node"; const fnmNode = "/Users/test/.fnm/node-versions/v24.15.0/installation/bin/node"; const linuxSystemNode = "/usr/bin/node"; const nvmNode = "/home/test/.nvm/versions/node/v24.15.0/bin/node"; + it("reports an exec failure instead of advising a Node upgrade during install", async () => { + mockNodePathPresent(linuxSystemNode); + const execFile = vi.fn().mockRejectedValue(new Error("spawn EACCES")); + const install = async () => { + const runtimePath = await resolvePreferredNodePath({ + runtime: "node", + platform: "linux", + env: {}, + execPath: linuxSystemNode, + execFile, + }); + return resolveNodeProgramArguments({ + host: "gateway.example", + port: 18789, + runtime: "node", + runtimePath, + }); + }; + await expect(install()).rejects.toThrow( + /Node runtime probe failed.*\/usr\/bin\/node.*cwd.*EACCES/s, + ); + }); + it("prefers supported system node over version-manager execPath", async () => { mockNodePathPresent(darwinNode); @@ -344,6 +433,27 @@ describe("resolvePreferredNodePath", () => { }); describe("resolvePreferredBunPath", () => { + it.each(["ENOENT", "EACCES"])( + "distinguishes %s candidate access from missing Bun", + async (code) => { + fsMocks.access.mockRejectedValue(Object.assign(new Error(code), { code })); + const execFile = vi.fn().mockRejectedValue(new Error("spawn EACCES")); + const result = resolvePreferredBunPath({ + env: {}, + runtime: "bun", + platform: "linux", + execPath: "/fixture/other", + execFile, + }); + if (code === "ENOENT") { + await expect(result).resolves.toBeUndefined(); + expect(execFile).not.toHaveBeenCalled(); + } else { + await expect(result).rejects.toThrow(/Bun runtime probe failed.*EACCES/s); + } + }, + ); + it("uses the stable BUN_INSTALL executable when Bun 1.4 provides WAL-safe node:sqlite", async () => { const bunPath = "/home/test/.bun/bin/bun"; const execFile = vi.fn().mockResolvedValue(bunRuntime("1.4.0")); @@ -420,7 +530,7 @@ describe("resolvePreferredBunPath", () => { ])("rejects a Bun executable when %s", async (_reason, probe) => { const info = await resolveBunRuntimeInfo("/opt/bun", vi.fn().mockResolvedValue(probe)); - expect(info.supported).toBe(false); + expect(info.status).toBe("unsupported"); }); }); @@ -496,6 +606,21 @@ describe("resolvePreferredNodePath — Homebrew Cellar", () => { describe("resolveSystemNodeInfo", () => { const darwinNode = "/opt/homebrew/bin/node"; + it("warns about the failed probe without declaring the runtime unsupported", async () => { + mockNodePathPresent(darwinNode); + const cause = new Error("spawn EACCES"); + const info = await resolveSystemNodeInfo({ + env: {}, + platform: "darwin", + execFile: vi.fn().mockRejectedValue(cause), + }); + const warning = renderSystemNodeWarning(info, "/selected/node"); + expect(warning).toContain("probe failed"); + expect(warning).toContain("EACCES"); + expect(warning).toContain(darwinNode); + expect(warning).not.toContain("Install Node"); + }); + it("returns supported info when version is new enough", async () => { mockNodePathPresent(darwinNode); @@ -513,7 +638,7 @@ describe("resolveSystemNodeInfo", () => { sqliteVersion: "3.51.3", version: "22.22.3", nodeSharedSqlite: false, - supported: true, + status: "supported", }); }); @@ -529,7 +654,7 @@ describe("resolveSystemNodeInfo", () => { execFile, }); - expect(result).toMatchObject({ version, supported: false }); + expect(result).toMatchObject({ version, status: "unsupported" }); }, ); @@ -560,7 +685,7 @@ describe("resolveSystemNodeInfo", () => { sqliteVersion: "3.51.3", version: "22.22.3", nodeSharedSqlite: false, - supported: true, + status: "supported", }); }); @@ -585,7 +710,7 @@ describe("resolveSystemNodeInfo", () => { sqliteVersion: "3.51.3", version: "24.15.0", nodeSharedSqlite: false, - supported: true, + status: "supported", }); expect(execFile).toHaveBeenCalledTimes(1); expect(execFile).toHaveBeenCalledWith( @@ -613,24 +738,6 @@ describe("resolveSystemNodeInfo", () => { expect(execFile).not.toHaveBeenCalled(); }); - it("reports an unavailable system Node version while preserving the selected runtime", () => { - const selectedNode = "/Users/me/.fnm/node-22/bin/node"; - const warning = renderSystemNodeWarning( - { - path: darwinNode, - sqliteVersion: null, - version: null, - nodeSharedSqlite: false, - supported: false, - }, - selectedNode, - ); - - expect(warning).toBe( - `System Node at ${darwinNode} is available, but its version could not be determined. Using ${selectedNode} for the daemon. Install Node 24.15+ (recommended) or Node 22.22.3+ from nodejs.org or Homebrew.`, - ); - }); - it("reports a known unsupported system Node version", () => { const selectedNode = "/Users/me/.fnm/node-22/bin/node"; const warning = renderSystemNodeWarning( @@ -639,13 +746,13 @@ describe("resolveSystemNodeInfo", () => { sqliteVersion: null, version: "18.19.0", nodeSharedSqlite: false, - supported: false, + status: "unsupported", }, selectedNode, ); expect(warning).toBe( - `System Node 18.19.0 at ${darwinNode} is outside the supported range. Using ${selectedNode} for the daemon. Install Node 24.15+ (recommended) or Node 22.22.3+ from nodejs.org or Homebrew.`, + `System Node 18.19.0 at ${darwinNode} is outside the supported range. Using ${selectedNode} for the daemon. Install Node >=22.22.3 <23, >=24.15.0 <25, or >=25.9.0 (Node 26 recommended) from nodejs.org or Homebrew.`, ); }); @@ -656,7 +763,7 @@ describe("resolveSystemNodeInfo", () => { sqliteVersion: "3.51.3", version: "24.15.0", nodeSharedSqlite: false, - supported: true, + status: "supported", }, "/Users/me/.fnm/node-22/bin/node", ); @@ -670,12 +777,12 @@ describe("resolveSystemNodeInfo", () => { sqliteVersion: "3.51.2", version: "24.17.0", nodeSharedSqlite: false, - supported: false, + status: "unsupported", }); expect(warning).toContain("uses SQLite 3.51.2"); expect(warning).toContain("not WAL-reset-safe"); - expect(warning).toContain("Install Node 24.15+"); + expect(warning).toContain("Install Node >=22.22.3"); }); it("renders a shared-system-SQLite remediation when Node is supported but the system library is unsafe", () => { @@ -684,13 +791,13 @@ describe("resolveSystemNodeInfo", () => { sqliteVersion: "3.51.2", version: "24.17.0", nodeSharedSqlite: true, - supported: false, + status: "unsupported", }); expect(warning).toContain("uses shared system SQLite 3.51.2"); expect(warning).toContain("not WAL-reset-safe"); expect(warning).toContain("Upgrade the system SQLite library"); - expect(warning).not.toContain("Install Node 24.15+"); + expect(warning).not.toContain("Install Node >=22.22.3"); }); it("uses validated custom Program Files roots on Windows", async () => { diff --git a/src/daemon/runtime-paths.ts b/src/daemon/runtime-paths.ts index 62797e558b9..60082939804 100644 --- a/src/daemon/runtime-paths.ts +++ b/src/daemon/runtime-paths.ts @@ -3,6 +3,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { SUPPORTED_NODE_VERSIONS } from "../../node-version.mjs"; +import { isMissingPathError } from "../infra/errno.js"; import { isSupportedBunVersion, isSupportedNodeVersion } from "../infra/runtime-guard.js"; import { isSqliteWalResetSafeVersion } from "../infra/sqlite-runtime-version.js"; import { resolveStableNodePath } from "../infra/stable-node-path.js"; @@ -135,7 +137,7 @@ const RUNTIME_PROBE_TIMEOUT_MS = 5_000; const execFileAsync: ExecFileAsync = async (file, args, options) => await runExec(file, [...args], { logOutput: false, timeoutMs: options.timeoutMs }); -const NODE_RUNTIME_PROBE = String.raw` +const RUNTIME_PROBE = String.raw` let sqliteVersion = null; try { const { DatabaseSync } = require("node:sqlite"); @@ -148,109 +150,74 @@ try { } catch {} const variables = (process.config && process.config.variables) || {}; const nodeSharedSqlite = variables.node_shared_sqlite === true || variables.node_shared_sqlite === "true"; -process.stdout.write(JSON.stringify({ nodeVersion: process.versions.node, sqliteVersion, nodeSharedSqlite })); +process.stdout.write(JSON.stringify({ nodeVersion: process.versions.node, bunVersion: process.versions.bun ?? null, sqliteVersion, nodeSharedSqlite })); `; -const BUN_RUNTIME_PROBE = String.raw` -let hasNodeSqlite = false; -let sqliteVersion = null; -try { - const { DatabaseSync } = require("node:sqlite"); - const db = new DatabaseSync(":memory:"); - try { - sqliteVersion = db.prepare("SELECT sqlite_version() AS version").get()?.version ?? null; - hasNodeSqlite = true; - } finally { - db.close(); - } -} catch {} -process.stdout.write(JSON.stringify({ bunVersion: process.versions.bun ?? null, hasNodeSqlite, sqliteVersion })); -`; +type RuntimeInfo = + | { + status: "supported" | "unsupported"; + version: string | null; + sqliteVersion: string | null; + nodeSharedSqlite: boolean; + } + | { status: "probe-failed"; error: Error }; -type NodeRuntimeInfo = { - nodeVersion: string | null; - sqliteVersion: string | null; - nodeSharedSqlite: boolean; - supported: boolean; -}; +type SystemNodeInfo = RuntimeInfo & { path: string }; -async function resolveNodeRuntimeInfo( - nodePath: string, +async function resolveRuntimeInfo( + runtimePath: string, + runtime: "node" | "bun", execFileImpl: ExecFileAsync, -): Promise { +): Promise { + const label = runtime === "node" ? "Node" : "Bun"; + let cwd: string | undefined; try { - const { stdout } = await execFileImpl(nodePath, ["-e", NODE_RUNTIME_PROBE], { + cwd = process.cwd(); + const { stdout } = await execFileImpl(runtimePath, ["-e", RUNTIME_PROBE], { encoding: "utf8", timeoutMs: RUNTIME_PROBE_TIMEOUT_MS, }); const parsed: unknown = JSON.parse(stdout); if (!isRecord(parsed)) { - throw new Error("Node runtime probe returned invalid output"); + throw new Error("Runtime probe returned invalid output"); } - const nodeVersion = typeof parsed.nodeVersion === "string" ? parsed.nodeVersion : null; - const sqliteVersion = typeof parsed.sqliteVersion === "string" ? parsed.sqliteVersion : null; - const nodeSharedSqlite = parsed.nodeSharedSqlite === true || parsed.nodeSharedSqlite === "true"; + const version = parsed[`${runtime}Version`]; + const sqliteVersion = parsed.sqliteVersion; + if ( + !(typeof version === "string" || (runtime === "bun" && version === null)) || + !(typeof sqliteVersion === "string" || sqliteVersion === null) + ) { + throw new Error("Runtime probe returned invalid version metadata"); + } + const supportedVersion = + runtime === "node" ? isSupportedNodeVersion(version) : isSupportedBunVersion(version); return { - nodeVersion, + status: + supportedVersion && sqliteVersion !== null && isSqliteWalResetSafeVersion(sqliteVersion) + ? "supported" + : "unsupported", + version, sqliteVersion, - nodeSharedSqlite, - supported: - isSupportedNodeVersion(nodeVersion) && - sqliteVersion !== null && - isSqliteWalResetSafeVersion(sqliteVersion), + nodeSharedSqlite: parsed.nodeSharedSqlite === true || parsed.nodeSharedSqlite === "true", }; - } catch { - return { nodeVersion: null, sqliteVersion: null, nodeSharedSqlite: false, supported: false }; + } catch (cause) { + // A failed exec says nothing about runtime support. Preserve its cause and launch context. + const error = new Error( + `${label} runtime probe failed for ${runtimePath} (cwd: ${cwd ?? "unavailable"}): ${String(cause)}. Check executable and working-directory access, then retry.`, + { cause }, + ); + return { status: "probe-failed", error }; } } -export type BunRuntimeInfo = { - version: string | null; - hasNodeSqlite: boolean; - sqliteVersion: string | null; - supported: boolean; -}; - /** Probes whether a Bun executable satisfies the managed daemon runtime contract. */ -export async function resolveBunRuntimeInfo( +export function resolveBunRuntimeInfo( bunPath: string, execFileImpl: ExecFileAsync = execFileAsync, -): Promise { - try { - const { stdout } = await execFileImpl(bunPath, ["-e", BUN_RUNTIME_PROBE], { - encoding: "utf8", - timeoutMs: RUNTIME_PROBE_TIMEOUT_MS, - }); - const parsed: unknown = JSON.parse(stdout); - if (!isRecord(parsed)) { - throw new Error("Bun runtime probe returned invalid output"); - } - const version = typeof parsed.bunVersion === "string" ? parsed.bunVersion : null; - const hasNodeSqlite = parsed.hasNodeSqlite === true; - const sqliteVersion = typeof parsed.sqliteVersion === "string" ? parsed.sqliteVersion : null; - return { - version, - hasNodeSqlite, - sqliteVersion, - supported: - isSupportedBunVersion(version) && - hasNodeSqlite && - sqliteVersion !== null && - isSqliteWalResetSafeVersion(sqliteVersion), - }; - } catch { - return { version: null, hasNodeSqlite: false, sqliteVersion: null, supported: false }; - } +) { + return resolveRuntimeInfo(bunPath, "bun", execFileImpl); } -type SystemNodeInfo = { - path: string; - sqliteVersion: string | null; - version: string | null; - nodeSharedSqlite: boolean; - supported: boolean; -}; - async function isVersionManagedRealNodePath( nodePath: string, platform: NodeJS.Platform, @@ -322,18 +289,13 @@ export async function resolveSystemNodeInfo(params: { if (await isVersionManagedRealNodePath(systemNode, platform)) { continue; } - const runtime = await resolveNodeRuntimeInfo(systemNode, execFileImpl); - const info = { - path: systemNode, - sqliteVersion: runtime.sqliteVersion, - version: runtime.nodeVersion, - nodeSharedSqlite: runtime.nodeSharedSqlite, - supported: runtime.supported, - }; - if (info.supported) { + const runtime = await resolveRuntimeInfo(systemNode, "node", execFileImpl); + const info = { path: systemNode, ...runtime }; + if (info.status === "supported") { return info; } - firstAvailable ??= info; + // If any available candidate could not be probed, lack of support is not established. + firstAvailable = info.status === "probe-failed" ? info : (firstAvailable ?? info); } return firstAvailable; } @@ -343,12 +305,12 @@ export function renderSystemNodeWarning( systemNode: SystemNodeInfo | null, selectedNodePath?: string, ): string | null { - if (!systemNode || systemNode.supported) { + if (!systemNode || systemNode.status === "supported") { return null; } const selectedLabel = selectedNodePath ? ` Using ${selectedNodePath} for the daemon.` : ""; - if (systemNode.version === null) { - return `System Node at ${systemNode.path} is available, but its version could not be determined.${selectedLabel} Install Node 24.15+ (recommended) or Node 22.22.3+ from nodejs.org or Homebrew.`; + if (systemNode.status === "probe-failed") { + return `${systemNode.error.message}${selectedLabel}`; } const versionLabel = systemNode.version; if (isSupportedNodeVersion(systemNode.version)) { @@ -359,18 +321,22 @@ export function renderSystemNodeWarning( "Upgrade the system SQLite library to 3.51.3+ (or patched 3.50.7+/3.44.6+), or install a Node build that embeds a safe version." ); } - return `System Node ${versionLabel} at ${systemNode.path} uses SQLite ${sqliteLabel}, which is not WAL-reset-safe.${selectedLabel} Install Node 24.15+ (recommended) or Node 22.22.3+ from nodejs.org or Homebrew.`; + return `System Node ${versionLabel} at ${systemNode.path} uses SQLite ${sqliteLabel}, which is not WAL-reset-safe.${selectedLabel} Install Node ${SUPPORTED_NODE_VERSIONS} from nodejs.org or Homebrew.`; } - return `System Node ${versionLabel} at ${systemNode.path} is outside the supported range.${selectedLabel} Install Node 24.15+ (recommended) or Node 22.22.3+ from nodejs.org or Homebrew.`; + return `System Node ${versionLabel} at ${systemNode.path} is outside the supported range.${selectedLabel} Install Node ${SUPPORTED_NODE_VERSIONS} from nodejs.org or Homebrew.`; } -/** Resolves the Node binary the daemon should use for a node runtime. */ -export async function resolvePreferredNodePath(params: { +type RuntimePathOptions = { env?: Record; runtime?: string; platform?: NodeJS.Platform; execFile?: ExecFileAsync; execPath?: string; -}): Promise { +}; + +/** Resolves the Node binary the daemon should use for a node runtime. */ +export async function resolvePreferredNodePath( + params: RuntimePathOptions, +): Promise { if (params.runtime !== "node") { return undefined; } @@ -378,43 +344,34 @@ export async function resolvePreferredNodePath(params: { const platform = params.platform ?? process.platform; const currentExecPath = params.execPath ?? process.execPath; const execFileImpl = params.execFile ?? execFileAsync; - if (currentExecPath && isNodeExecPath(currentExecPath, platform)) { - const runtime = await resolveNodeRuntimeInfo(currentExecPath, execFileImpl); - if (runtime.supported) { - const stableCurrentPath = await resolveStableNodePath(currentExecPath); - if (!isVersionManagedNodePath(currentExecPath, platform)) { - return stableCurrentPath; - } - // Prefer system Node over a version-manager shim so daemon launch survives - // shell setup differences and package manager upgrades. - const systemNode = await resolveSystemNodeInfo({ - env: params.env, - platform, - execFile: execFileImpl, - }); - if (systemNode?.supported) { - return systemNode.path; - } - return stableCurrentPath; - } + const currentNode = isNodeExecPath(currentExecPath, platform) + ? await resolveRuntimeInfo(currentExecPath, "node", execFileImpl) + : null; + if (currentNode?.status === "supported" && !isVersionManagedNodePath(currentExecPath, platform)) { + return resolveStableNodePath(currentExecPath); } - // Fall back to system Node when the current executable is unsupported or not Node. + // Prefer system Node over a version-manager shim, but retain a proven working runtime. const systemNode = await resolveSystemNodeInfo(params); - if (!systemNode?.supported) { - return undefined; + if (systemNode?.status === "supported") { + return systemNode.path; } - return systemNode.path; + if (currentNode?.status === "supported") { + return resolveStableNodePath(currentExecPath); + } + if (currentNode?.status === "probe-failed") { + throw currentNode.error; + } + if (systemNode?.status === "probe-failed") { + throw systemNode.error; + } + return undefined; } /** Resolves a stable Bun binary that satisfies the daemon runtime contract. */ -export async function resolvePreferredBunPath(params: { - env?: Record; - runtime?: string; - platform?: NodeJS.Platform; - execFile?: ExecFileAsync; - execPath?: string; -}): Promise { +export async function resolvePreferredBunPath( + params: RuntimePathOptions, +): Promise { if (params.runtime !== "bun") { return undefined; } @@ -423,11 +380,25 @@ export async function resolvePreferredBunPath(params: { const platform = params.platform ?? process.platform; const execFileImpl = params.execFile ?? execFileAsync; const currentExecPath = params.execPath ?? process.execPath; + let probeFailure: Error | undefined; for (const candidate of buildBunCandidates(env, platform, currentExecPath)) { + try { + await fs.access(candidate); + } catch (error) { + if (isMissingPathError(error)) { + continue; + } + } const runtime = await resolveBunRuntimeInfo(candidate, execFileImpl); - if (runtime.supported) { + if (runtime.status === "probe-failed") { + probeFailure ??= runtime.error; + } + if (runtime.status === "supported") { return candidate; } } + if (probeFailure) { + throw probeFailure; + } return undefined; } diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index de26905dd19..621fae16999 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { auditGatewayServiceConfig, checkTokenDrift, + needsNodeRuntimeMigration, SERVICE_AUDIT_CODES, } from "./service-audit.js"; import { buildServiceEnvironment } from "./service-env.js"; @@ -133,18 +134,18 @@ describe("auditGatewayServiceConfig", () => { resolveBunRuntimeInfo.mockReset(); resolveBunRuntimeInfo.mockResolvedValue({ version: "1.4.0", - hasNodeSqlite: true, sqliteVersion: "3.51.3", - supported: true, + nodeSharedSqlite: false, + status: "supported", }); }); it("flags Bun runtimes without WAL-safe SQLite", async () => { resolveBunRuntimeInfo.mockResolvedValue({ version: "1.4.0", - hasNodeSqlite: true, sqliteVersion: "3.51.2", - supported: false, + nodeSharedSqlite: false, + status: "unsupported", }); const audit = await auditGatewayServiceConfig({ env: { HOME: "/tmp" }, @@ -173,6 +174,30 @@ describe("auditGatewayServiceConfig", () => { expect(hasIssue(audit, SERVICE_AUDIT_CODES.gatewayRuntimeBun)).toBe(false); }); + it("reports a failed Bun probe without recommending runtime migration", async () => { + resolveBunRuntimeInfo.mockResolvedValue({ + status: "probe-failed", + error: new Error("Bun runtime probe failed at /opt/bun (cwd /root): EACCES"), + }); + const audit = await auditGatewayServiceConfig({ + env: { HOME: "/tmp" }, + platform: "darwin", + command: { + programArguments: ["/opt/bun", "gateway"], + environment: { PATH: "/usr/bin:/bin" }, + }, + }); + + expect(audit.issues).toContainEqual( + expect.objectContaining({ + code: SERVICE_AUDIT_CODES.gatewayRuntimeProbeFailed, + detail: expect.stringContaining("/opt/bun (cwd /root): EACCES"), + }), + ); + expect(needsNodeRuntimeMigration(audit.issues)).toBe(false); + expect(hasIssue(audit, SERVICE_AUDIT_CODES.gatewayRuntimeBun)).toBe(false); + }); + it("flags version-managed node paths", async () => { const audit = await auditGatewayServiceConfig({ env: { HOME: "/tmp" }, diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index 297a79d4af7..227ad6b13bb 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -6,6 +6,7 @@ import { normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { SUPPORTED_NODE_VERSIONS } from "../../node-version.mjs"; import { resolveInlineCommandMatch } from "../infra/shell-inline-command.js"; import { POSIX_SHELL_WRAPPERS } from "../infra/shell-wrapper-resolution.js"; import { parseTcpPort } from "../infra/tcp-port.js"; @@ -63,6 +64,7 @@ export const SERVICE_AUDIT_CODES = { gatewayProxyEnvEmbedded: "gateway-proxy-env-embedded", gatewayTokenMismatch: "gateway-token-mismatch", gatewayRuntimeBun: "gateway-runtime-bun", + gatewayRuntimeProbeFailed: "gateway-runtime-probe-failed", gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager", gatewayRuntimeNodeSystemMissing: "gateway-runtime-node-system-missing", gatewayTokenDrift: "gateway-token-drift", @@ -554,12 +556,17 @@ async function auditGatewayRuntime( if (isBunRuntime(execPath)) { const runtime = await resolveBunRuntimeInfo(execPath); - if (!runtime.supported) { + if (runtime.status !== "supported") { issues.push({ - code: SERVICE_AUDIT_CODES.gatewayRuntimeBun, + code: + runtime.status === "probe-failed" + ? SERVICE_AUDIT_CODES.gatewayRuntimeProbeFailed + : SERVICE_AUDIT_CODES.gatewayRuntimeBun, message: - "Gateway service uses an unsupported Bun runtime; Bun 1.4+ with WAL-reset-safe node:sqlite is required.", - detail: execPath, + runtime.status === "probe-failed" + ? "Gateway service Bun runtime probe failed." + : "Gateway service uses an unsupported Bun runtime; Bun 1.4+ with WAL-reset-safe node:sqlite is required.", + detail: runtime.status === "probe-failed" ? runtime.error.message : execPath, level: "recommended", }); } @@ -582,8 +589,7 @@ async function auditGatewayRuntime( if (!systemNode) { issues.push({ code: SERVICE_AUDIT_CODES.gatewayRuntimeNodeSystemMissing, - message: - "System Node 22 LTS (22.22.3+) or Node 24.15+ not found; install it before migrating away from version managers.", + message: `System Node ${SUPPORTED_NODE_VERSIONS} not found; install it before migrating away from version managers.`, level: "recommended", }); }