fix(daemon): require validated Node runtime (#122070)

Remove program-argument runtime rediscovery so managed Gateway and node-host services consume only the canonical validated Node path, while executable wrappers remain supported.\n\nRefs #122061.
This commit is contained in:
Peter Steinberger
2026-08-11 08:32:06 -07:00
committed by GitHub
parent f798f9999b
commit f88f078cd2
5 changed files with 98 additions and 135 deletions
-1
View File
@@ -756,7 +756,6 @@ export async function buildGatewayInstallPlan(params: {
const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({
port: params.port,
dev: devMode,
runtime: params.runtime,
nodePath,
wrapperPath,
});
@@ -60,7 +60,6 @@ export async function buildNodeInstallPlan(params: {
displayName: params.displayName,
installedAppsSharing: params.installedAppsSharing,
dev: devMode,
runtime: params.runtime,
nodePath,
});
+75 -88
View File
@@ -1,9 +1,6 @@
// Daemon program argument tests cover CLI argument construction for services.
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withMockedPlatform, withMockedWindowsPlatform } from "../test-utils/vitest-spies.js";
const execFileSyncMock = vi.hoisted(() => vi.fn());
const fsMocks = vi.hoisted(() => ({
access: vi.fn(),
@@ -27,21 +24,18 @@ vi.mock("node:fs/promises", async () => {
};
});
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return { ...actual, execFileSync: execFileSyncMock };
});
import { resolveGatewayProgramArguments, resolveNodeProgramArguments } from "./program-args.js";
const originalArgv = [...process.argv];
const originalExecPath = process.execPath;
const validatedNodePath = "/opt/Validated Node/bin/node";
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.";
afterEach(() => {
process.argv = [...originalArgv];
process.execPath = originalExecPath;
vi.resetAllMocks();
vi.unstubAllEnvs();
});
describe("resolveGatewayProgramArguments", () => {
@@ -52,10 +46,13 @@ describe("resolveGatewayProgramArguments", () => {
fsMocks.realpath.mockResolvedValue(entryPath);
fsMocks.access.mockResolvedValue(undefined);
const result = await resolveGatewayProgramArguments({ port: 18789 });
const result = await resolveGatewayProgramArguments({
port: 18789,
nodePath: validatedNodePath,
});
expect(result.programArguments).toEqual([
process.execPath,
validatedNodePath,
indexPath,
"gateway",
"--port",
@@ -75,10 +72,13 @@ describe("resolveGatewayProgramArguments", () => {
}
});
const result = await resolveGatewayProgramArguments({ port: 18789 });
const result = await resolveGatewayProgramArguments({
port: 18789,
nodePath: validatedNodePath,
});
expect(result.programArguments).toEqual([
process.execPath,
validatedNodePath,
entryPath,
"gateway",
"--port",
@@ -98,10 +98,13 @@ describe("resolveGatewayProgramArguments", () => {
throw new Error("missing");
});
const result = await resolveGatewayProgramArguments({ port: 18789 });
const result = await resolveGatewayProgramArguments({
port: 18789,
nodePath: validatedNodePath,
});
expect(result.programArguments).toEqual([
process.execPath,
validatedNodePath,
entryPath,
"gateway",
"--port",
@@ -122,9 +125,13 @@ describe("resolveGatewayProgramArguments", () => {
fsMocks.realpath.mockResolvedValue(realpathResolved);
fsMocks.access.mockResolvedValue(undefined); // Both paths exist
const result = await resolveGatewayProgramArguments({ port: 18789 });
const result = await resolveGatewayProgramArguments({
port: 18789,
nodePath: validatedNodePath,
});
// Should use the symlinked canonical index.js path, not the realpath-resolved versioned path
expect(result.programArguments[0]).toBe(validatedNodePath);
expect(result.programArguments[1]).toBe(
path.resolve("/Users/test/Library/pnpm/global/5/node_modules/openclaw/dist/index.js"),
);
@@ -143,10 +150,13 @@ describe("resolveGatewayProgramArguments", () => {
throw new Error("missing");
});
const result = await resolveGatewayProgramArguments({ port: 18789 });
const result = await resolveGatewayProgramArguments({
port: 18789,
nodePath: validatedNodePath,
});
expect(result.programArguments).toEqual([
process.execPath,
validatedNodePath,
indexPath,
"gateway",
"--port",
@@ -164,12 +174,11 @@ describe("resolveGatewayProgramArguments", () => {
const result = await resolveGatewayProgramArguments({
dev: true,
port: 18789,
runtime: "node",
nodePath: "/usr/local/bin/node",
nodePath: validatedNodePath,
});
expect(result.programArguments).toEqual([
"/usr/local/bin/node",
validatedNodePath,
"--import",
"tsx",
repoEntryPath,
@@ -180,79 +189,56 @@ describe("resolveGatewayProgramArguments", () => {
expect(result.workingDirectory).toBe(path.resolve("/repo"));
});
it("uses trusted Windows where.exe when resolving the Node runtime", async () => {
const repoIndexPath = path.resolve("/repo/src/index.ts");
const repoEntryPath = path.resolve("/repo/src/entry.ts");
const launcherPath = String.raw`D:\OpenClaw\openclaw.exe`;
process.argv = [launcherPath, repoIndexPath];
process.execPath = launcherPath;
vi.stubEnv("SystemRoot", String.raw`D:\Windows`);
fsMocks.realpath.mockResolvedValue(repoIndexPath);
fsMocks.access.mockResolvedValue(undefined);
execFileSyncMock.mockReturnValue(String.raw`D:\Tools\node.exe` + "\r\n");
let result: Awaited<ReturnType<typeof resolveGatewayProgramArguments>> | undefined;
await withMockedWindowsPlatform(async () => {
result = await resolveGatewayProgramArguments({
dev: true,
port: 18789,
runtime: "node",
});
});
expect(execFileSyncMock).toHaveBeenCalledWith(
path.win32.join(String.raw`D:\Windows`, "System32", "where.exe"),
["node"],
{ encoding: "utf8", timeout: 5_000, killSignal: "SIGKILL" },
);
expect(result?.programArguments).toEqual([
String.raw`D:\Tools\node.exe`,
"--import",
"tsx",
repoEntryPath,
"gateway",
"--port",
"18789",
]);
});
it("bounds POSIX Node runtime lookup", async () => {
const repoIndexPath = path.resolve("/repo/src/index.ts");
const repoEntryPath = path.resolve("/repo/src/entry.ts");
process.argv = ["/usr/local/bin/bun", repoIndexPath];
process.execPath = "/usr/local/bin/bun";
fsMocks.realpath.mockResolvedValue(repoIndexPath);
fsMocks.access.mockResolvedValue(undefined);
execFileSyncMock.mockReturnValue("/usr/local/bin/node\n");
const result = await withMockedPlatform(
"linux",
async () =>
await resolveGatewayProgramArguments({
it.each([
{
service: "gateway",
selection: "missing",
resolve: () =>
resolveGatewayProgramArguments({
dev: true,
port: 18789,
runtime: "node",
}),
);
},
{
service: "node host",
selection: "missing",
resolve: () =>
resolveNodeProgramArguments({
dev: true,
host: "gateway.example",
port: 18789,
}),
},
{
service: "gateway",
selection: "blank",
resolve: () =>
resolveGatewayProgramArguments({
dev: true,
port: 18789,
nodePath: " \t ",
}),
},
{
service: "node host",
selection: "blank",
resolve: () =>
resolveNodeProgramArguments({
dev: true,
host: "gateway.example",
port: 18789,
nodePath: " \t ",
}),
},
])("rejects a $selection selected Node path for the $service", async ({ resolve }) => {
process.execPath = "/usr/local/bin/bun";
expect(execFileSyncMock).toHaveBeenCalledWith("which", ["node"], {
encoding: "utf8",
timeout: 5_000,
killSignal: "SIGKILL",
});
expect(result.programArguments).toEqual([
"/usr/local/bin/node",
"--import",
"tsx",
repoEntryPath,
"gateway",
"--port",
"18789",
]);
await expect(resolve()).rejects.toThrow(missingSelectedNodeError);
});
it("uses an executable wrapper when provided", async () => {
it("uses an executable wrapper from Bun without a selected Node path", async () => {
const wrapperPath = path.resolve("/usr/local/bin/openclaw-doppler");
process.execPath = "/usr/local/bin/bun";
fsMocks.stat.mockResolvedValue({ isFile: () => true } as never);
fsMocks.access.mockResolvedValue(undefined);
@@ -291,10 +277,11 @@ describe("resolveNodeProgramArguments", () => {
host: "gateway.example",
port: 18789,
tls: false,
nodePath: validatedNodePath,
});
expect(result.programArguments).toEqual([
process.execPath,
validatedNodePath,
indexPath,
"node",
"run",
+6 -40
View File
@@ -1,25 +1,19 @@
/** Builds runtime command arguments for gateway and node service installs. */
import { execFileSync } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js";
import {
buildGatewayDistEntrypointCandidates,
findFirstAccessibleGatewayEntrypoint,
isGatewayDistEntrypointPath,
} from "./gateway-entrypoint.js";
import { isNodeRuntime } from "./runtime-binary.js";
type GatewayProgramArgs = {
programArguments: string[];
workingDirectory?: string;
};
type GatewayRuntimePreference = "auto" | "node";
export const OPENCLAW_WRAPPER_ENV_KEY = "OPENCLAW_WRAPPER";
const NODE_BINARY_LOOKUP_TIMEOUT_MS = 5_000;
async function resolveCliEntrypointPathForService(): Promise<string> {
const argv1 = process.argv[1];
@@ -156,32 +150,6 @@ function resolveRepoRootForDev(): string {
return parts.slice(0, srcIndex).join(path.sep);
}
async function resolveNodePath(): Promise<string> {
const nodePath = await resolveBinaryPath("node");
return nodePath;
}
async function resolveBinaryPath(binary: string): Promise<string> {
const cmd = process.platform === "win32" ? getWindowsSystem32ExePath("where.exe") : "which";
try {
const output = execFileSync(cmd, [binary], {
encoding: "utf8",
timeout: NODE_BINARY_LOOKUP_TIMEOUT_MS,
killSignal: "SIGKILL",
}).trim();
const resolved = output.split(/\r?\n/)[0]?.trim();
if (!resolved) {
throw new Error("empty");
}
await fs.access(resolved);
return resolved;
} catch {
throw new Error(
"Node not found in PATH. Install Node 24.15+ (recommended) or Node 22 LTS (22.22.3+).",
);
}
}
export async function resolveOpenClawWrapperPath(
inputPath: string | undefined,
): Promise<string | undefined> {
@@ -211,7 +179,6 @@ export async function resolveOpenClawWrapperPath(
async function resolveCliProgramArguments(params: {
args: string[];
dev?: boolean;
runtime?: GatewayRuntimePreference;
nodePath?: string;
wrapperPath?: string;
}): Promise<GatewayProgramArgs> {
@@ -220,9 +187,12 @@ async function resolveCliProgramArguments(params: {
return { programArguments: [wrapperPath, ...params.args] };
}
const execPath = process.execPath;
const nodePath =
params.nodePath ?? (isNodeRuntime(execPath) ? execPath : await resolveNodePath());
if (!params.nodePath?.trim()) {
throw new Error(
"No supported Node runtime was selected for the daemon. Install Node 24.15+ (recommended) or Node 22 LTS (22.22.3+), then retry.",
);
}
const nodePath = params.nodePath;
if (params.dev) {
const repoRoot = resolveRepoRootForDev();
@@ -243,7 +213,6 @@ async function resolveCliProgramArguments(params: {
export async function resolveGatewayProgramArguments(params: {
port: number;
dev?: boolean;
runtime?: GatewayRuntimePreference;
nodePath?: string;
wrapperPath?: string;
}): Promise<GatewayProgramArgs> {
@@ -251,7 +220,6 @@ export async function resolveGatewayProgramArguments(params: {
return resolveCliProgramArguments({
args: gatewayArgs,
dev: params.dev,
runtime: params.runtime,
nodePath: params.nodePath,
wrapperPath: params.wrapperPath,
});
@@ -267,7 +235,6 @@ export async function resolveNodeProgramArguments(params: {
displayName?: string;
installedAppsSharing?: boolean;
dev?: boolean;
runtime?: GatewayRuntimePreference;
nodePath?: string;
}): Promise<GatewayProgramArgs> {
const args = ["node", "run", "--host", params.host, "--port", String(params.port)];
@@ -296,7 +263,6 @@ export async function resolveNodeProgramArguments(params: {
return resolveCliProgramArguments({
args,
dev: params.dev,
runtime: params.runtime,
nodePath: params.nodePath,
});
}
+17 -5
View File
@@ -245,22 +245,34 @@ describe("resolvePreferredNodePath", () => {
expect(execFile).toHaveBeenCalledTimes(1);
});
it("skips system node when it is too old", async () => {
it.each([
{
reason: "its version is unsupported",
runtime: nodeRuntime("22.22.2", null),
},
{
reason: "its SQLite version is unsafe",
runtime: nodeRuntime("24.17.0", "3.51.2"),
},
])("returns undefined from Bun when the only system Node $reason", async ({ runtime }) => {
mockNodePathPresent(darwinNode);
// Node 22.22.2 is below minimum 22.22.3
const execFile = vi.fn().mockResolvedValue(nodeRuntime("22.22.2", null));
const execFile = vi.fn().mockResolvedValue(runtime);
const result = await resolvePreferredNodePath({
env: {},
runtime: "node",
platform: "darwin",
execFile,
execPath: "",
execPath: "/Users/test/.bun/bin/bun",
});
expect(result).toBeUndefined();
expect(execFile).toHaveBeenCalledTimes(1);
expect(execFile).toHaveBeenCalledWith(
darwinNode,
["-e", expect.stringContaining("SELECT sqlite_version() AS version")],
{ encoding: "utf8", timeoutMs: 5_000 },
);
});
it("keeps a safe version-manager runtime when system SQLite is unsafe", async () => {