From f88f078cd2e8df85a1486a75b621cf8c47569fbe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 08:32:06 -0700 Subject: [PATCH] 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. --- src/commands/daemon-install-helpers.ts | 1 - src/commands/node-daemon-install-helpers.ts | 1 - src/daemon/program-args.test.ts | 163 +++++++++----------- src/daemon/program-args.ts | 46 +----- src/daemon/runtime-paths.test.ts | 22 ++- 5 files changed, 98 insertions(+), 135 deletions(-) diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 25429ea3f0da..ccb98072536b 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -756,7 +756,6 @@ export async function buildGatewayInstallPlan(params: { const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({ port: params.port, dev: devMode, - runtime: params.runtime, nodePath, wrapperPath, }); diff --git a/src/commands/node-daemon-install-helpers.ts b/src/commands/node-daemon-install-helpers.ts index 9f7fdb669ef4..8f6e1e0dfdbe 100644 --- a/src/commands/node-daemon-install-helpers.ts +++ b/src/commands/node-daemon-install-helpers.ts @@ -60,7 +60,6 @@ export async function buildNodeInstallPlan(params: { displayName: params.displayName, installedAppsSharing: params.installedAppsSharing, dev: devMode, - runtime: params.runtime, nodePath, }); diff --git a/src/daemon/program-args.test.ts b/src/daemon/program-args.test.ts index 13e002b47a12..1ed8f3fc1fbf 100644 --- a/src/daemon/program-args.test.ts +++ b/src/daemon/program-args.test.ts @@ -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("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> | 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", diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index 5dee6fa9de33..77508316c8d6 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -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 { const argv1 = process.argv[1]; @@ -156,32 +150,6 @@ function resolveRepoRootForDev(): string { return parts.slice(0, srcIndex).join(path.sep); } -async function resolveNodePath(): Promise { - const nodePath = await resolveBinaryPath("node"); - return nodePath; -} - -async function resolveBinaryPath(binary: string): Promise { - 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 { @@ -211,7 +179,6 @@ export async function resolveOpenClawWrapperPath( async function resolveCliProgramArguments(params: { args: string[]; dev?: boolean; - runtime?: GatewayRuntimePreference; nodePath?: string; wrapperPath?: string; }): Promise { @@ -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 { @@ -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 { 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, }); } diff --git a/src/daemon/runtime-paths.test.ts b/src/daemon/runtime-paths.test.ts index d0d6283c1ecb..d7937f3c83ae 100644 --- a/src/daemon/runtime-paths.test.ts +++ b/src/daemon/runtime-paths.test.ts @@ -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 () => {