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.
This commit is contained in:
Peter Steinberger 2026-08-28 11:43:11 -07:00 committed by GitHub
parent d43520b04c
commit cd90e0619e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 503 additions and 202 deletions

View file

@ -155,6 +155,16 @@ Options:
- `--runtime <node|bun>`: 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 <user>` 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.

View file

@ -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;

View file

@ -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") {

View file

@ -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);

View file

@ -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,

View file

@ -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,

View file

@ -822,12 +822,6 @@ export async function buildGatewayInstallPlan(params: {
>;
}): Promise<GatewayInstallPlan> {
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<string, string | undefined> = wrapperPath
? { ...params.env, [OPENCLAW_WRAPPER_ENV_KEY]: wrapperPath }
: wrapperPointsAtWindowsTaskScript

View file

@ -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"

View file

@ -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: {} });

View file

@ -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;

View file

@ -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({

View file

@ -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<NodeInstallPlan> {
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({

View file

@ -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<typeof import("node:fs/promises")>();
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<string, string | undefined> = {}) =>
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",
);
});
});

View file

@ -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.";

View file

@ -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<GatewayProgramArgs> {
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,
});
}

View file

@ -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 () => {

View file

@ -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<NodeRuntimeInfo> {
): Promise<RuntimeInfo> {
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<BunRuntimeInfo> {
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<string, string | undefined>;
runtime?: string;
platform?: NodeJS.Platform;
execFile?: ExecFileAsync;
execPath?: string;
}): Promise<string | undefined> {
};
/** Resolves the Node binary the daemon should use for a node runtime. */
export async function resolvePreferredNodePath(
params: RuntimePathOptions,
): Promise<string | undefined> {
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<string, string | undefined>;
runtime?: string;
platform?: NodeJS.Platform;
execFile?: ExecFileAsync;
execPath?: string;
}): Promise<string | undefined> {
export async function resolvePreferredBunPath(
params: RuntimePathOptions,
): Promise<string | undefined> {
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;
}

View file

@ -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" },

View file

@ -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",
});
}