mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
f798f9999b
commit
f88f078cd2
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user