mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-31 02:18:08 +00:00
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:
parent
d43520b04c
commit
cd90e0619e
19 changed files with 503 additions and 202 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: {} });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
100
src/commands/node-daemon-runtime.test.ts
Normal file
100
src/commands/node-daemon-runtime.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue