diff --git a/src/bootstrap/node-extra-ca-certs.test.ts b/src/bootstrap/node-extra-ca-certs.test.ts index cf852307bbb..61895b606ca 100644 --- a/src/bootstrap/node-extra-ca-certs.test.ts +++ b/src/bootstrap/node-extra-ca-certs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { - isNvmNode, + isNodeVersionManagerRuntime, LINUX_CA_BUNDLE_PATHS, resolveAutoNodeExtraCaCerts, resolveLinuxSystemCaBundle, @@ -34,17 +34,19 @@ describe("resolveLinuxSystemCaBundle", () => { }); }); -describe("isNvmNode", () => { +describe("isNodeVersionManagerRuntime", () => { it("detects nvm via NVM_DIR", () => { - expect(isNvmNode({ NVM_DIR: "/home/test/.nvm" }, "/usr/bin/node")).toBe(true); + expect(isNodeVersionManagerRuntime({ NVM_DIR: "/home/test/.nvm" }, "/usr/bin/node")).toBe(true); }); it("detects nvm via execPath", () => { - expect(isNvmNode({}, "/home/test/.nvm/versions/node/v22/bin/node")).toBe(true); + expect(isNodeVersionManagerRuntime({}, "/home/test/.nvm/versions/node/v22/bin/node")).toBe( + true, + ); }); it("returns false for non-nvm node paths", () => { - expect(isNvmNode({}, "/usr/bin/node")).toBe(false); + expect(isNodeVersionManagerRuntime({}, "/usr/bin/node")).toBe(false); }); }); diff --git a/src/bootstrap/node-extra-ca-certs.ts b/src/bootstrap/node-extra-ca-certs.ts index 5052167ec89..e96a7e264ae 100644 --- a/src/bootstrap/node-extra-ca-certs.ts +++ b/src/bootstrap/node-extra-ca-certs.ts @@ -6,7 +6,7 @@ export const LINUX_CA_BUNDLE_PATHS = [ "/etc/ssl/ca-bundle.pem", ] as const; -type EnvMap = Record; +export type EnvMap = Record; type AccessSyncFn = (path: string, mode?: number) => void; export function resolveLinuxSystemCaBundle( @@ -32,7 +32,7 @@ export function resolveLinuxSystemCaBundle( return undefined; } -export function isNvmNode( +export function isNodeVersionManagerRuntime( env: EnvMap = process.env as EnvMap, execPath: string = process.execPath, ): boolean { @@ -57,7 +57,7 @@ export function resolveAutoNodeExtraCaCerts( const platform = params.platform ?? process.platform; const execPath = params.execPath ?? process.execPath; - if (platform !== "linux" || !isNvmNode(env, execPath)) { + if (platform !== "linux" || !isNodeVersionManagerRuntime(env, execPath)) { return undefined; } diff --git a/src/bootstrap/node-startup-env.test.ts b/src/bootstrap/node-startup-env.test.ts new file mode 100644 index 00000000000..6173faaa769 --- /dev/null +++ b/src/bootstrap/node-startup-env.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { LINUX_CA_BUNDLE_PATHS } from "./node-extra-ca-certs.js"; +import { resolveNodeStartupTlsEnvironment } from "./node-startup-env.js"; + +function allowOnly(path: string) { + return (candidate: string) => { + if (candidate !== path) { + throw new Error("ENOENT"); + } + }; +} + +describe("resolveNodeStartupTlsEnvironment", () => { + it("defaults macOS launch env values", () => { + expect( + resolveNodeStartupTlsEnvironment({ + env: {}, + platform: "darwin", + }), + ).toEqual({ + NODE_EXTRA_CA_CERTS: "/etc/ssl/cert.pem", + NODE_USE_SYSTEM_CA: "1", + }); + }); + + it("keeps user-provided env values", () => { + expect( + resolveNodeStartupTlsEnvironment({ + env: { + NODE_EXTRA_CA_CERTS: "/custom/ca.pem", + NODE_USE_SYSTEM_CA: "0", + }, + platform: "darwin", + }), + ).toEqual({ + NODE_EXTRA_CA_CERTS: "/custom/ca.pem", + NODE_USE_SYSTEM_CA: "0", + }); + }); + + it("resolves Linux CA env for version-manager Node runtimes", () => { + expect( + resolveNodeStartupTlsEnvironment({ + env: { NVM_DIR: "/home/test/.nvm" }, + platform: "linux", + execPath: "/usr/bin/node", + accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[1]), + }), + ).toEqual({ + NODE_EXTRA_CA_CERTS: LINUX_CA_BUNDLE_PATHS[1], + NODE_USE_SYSTEM_CA: undefined, + }); + }); + + it("can skip macOS defaults for CLI-only pre-start planning", () => { + expect( + resolveNodeStartupTlsEnvironment({ + env: {}, + platform: "darwin", + includeDarwinDefaults: false, + }), + ).toEqual({ + NODE_EXTRA_CA_CERTS: undefined, + NODE_USE_SYSTEM_CA: undefined, + }); + }); + + it("uses the Linux CA bundle heuristic when available", () => { + const value = resolveNodeStartupTlsEnvironment({ + env: { NVM_DIR: "/home/test/.nvm" }, + platform: "linux", + execPath: "/usr/bin/node", + accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[2]), + }).NODE_EXTRA_CA_CERTS; + if (value !== undefined) { + expect(LINUX_CA_BUNDLE_PATHS).toContain(value); + } + }); +}); diff --git a/src/bootstrap/node-startup-env.ts b/src/bootstrap/node-startup-env.ts new file mode 100644 index 00000000000..a66ae87e798 --- /dev/null +++ b/src/bootstrap/node-startup-env.ts @@ -0,0 +1,38 @@ +import { type EnvMap, resolveAutoNodeExtraCaCerts } from "./node-extra-ca-certs.js"; + +export type NodeStartupTlsEnvironment = { + NODE_EXTRA_CA_CERTS?: string; + NODE_USE_SYSTEM_CA?: string; +}; + +export function resolveNodeStartupTlsEnvironment( + params: { + env?: EnvMap; + platform?: NodeJS.Platform; + execPath?: string; + includeDarwinDefaults?: boolean; + accessSync?: (path: string, mode?: number) => void; + } = {}, +): NodeStartupTlsEnvironment { + const env = params.env ?? (process.env as EnvMap); + const platform = params.platform ?? process.platform; + const includeDarwinDefaults = params.includeDarwinDefaults ?? true; + + const nodeExtraCaCerts = + env.NODE_EXTRA_CA_CERTS ?? + (platform === "darwin" && includeDarwinDefaults + ? "/etc/ssl/cert.pem" + : resolveAutoNodeExtraCaCerts({ + env, + platform, + execPath: params.execPath, + accessSync: params.accessSync, + })); + const nodeUseSystemCa = + env.NODE_USE_SYSTEM_CA ?? (platform === "darwin" && includeDarwinDefaults ? "1" : undefined); + + return { + NODE_EXTRA_CA_CERTS: nodeExtraCaCerts, + NODE_USE_SYSTEM_CA: nodeUseSystemCa, + }; +} diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index f5585929f89..7e424ca48ce 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { captureFullEnv } from "../../test-utils/env.js"; import type { DaemonActionResponse } from "./response.js"; +import { captureFullEnv } from "../../test-utils/env.js"; -const resolveAutoNodeExtraCaCertsMock = vi.hoisted(() => vi.fn()); +const resolveNodeStartupTlsEnvironmentMock = vi.hoisted(() => vi.fn()); const loadConfigMock = vi.hoisted(() => vi.fn()); const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789)); @@ -51,8 +51,8 @@ const service = vi.hoisted(() => ({ readRuntime: vi.fn(async () => ({ status: "stopped" as const })), })); -vi.mock("../../bootstrap/node-extra-ca-certs.js", () => ({ - resolveAutoNodeExtraCaCerts: resolveAutoNodeExtraCaCertsMock, +vi.mock("../../bootstrap/node-startup-env.js", () => ({ + resolveNodeStartupTlsEnvironment: resolveNodeStartupTlsEnvironmentMock, })); vi.mock("../../config/config.js", () => ({ @@ -156,7 +156,7 @@ const envSnapshot = captureFullEnv(); describe("runDaemonInstall", () => { beforeEach(() => { loadConfigMock.mockReset(); - resolveAutoNodeExtraCaCertsMock.mockReset(); + resolveNodeStartupTlsEnvironmentMock.mockReset(); readConfigFileSnapshotMock.mockReset(); resolveGatewayPortMock.mockClear(); writeConfigFileMock.mockReset(); @@ -198,7 +198,10 @@ describe("runDaemonInstall", () => { installDaemonServiceAndEmitMock.mockResolvedValue(undefined); service.isLoaded.mockResolvedValue(false); service.readCommand.mockResolvedValue(null); - resolveAutoNodeExtraCaCertsMock.mockReturnValue(undefined); + resolveNodeStartupTlsEnvironmentMock.mockReturnValue({ + NODE_EXTRA_CA_CERTS: undefined, + NODE_USE_SYSTEM_CA: undefined, + }); delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.CLAWDBOT_GATEWAY_TOKEN; }); @@ -300,7 +303,10 @@ describe("runDaemonInstall", () => { it("returns already-installed when the service already has the expected TLS env", async () => { service.isLoaded.mockResolvedValue(true); - resolveAutoNodeExtraCaCertsMock.mockReturnValue("/etc/ssl/certs/ca-certificates.crt"); + resolveNodeStartupTlsEnvironmentMock.mockReturnValue({ + NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt", + NODE_USE_SYSTEM_CA: undefined, + }); service.readCommand.mockResolvedValue({ programArguments: ["openclaw", "gateway", "run"], environment: { @@ -316,7 +322,10 @@ describe("runDaemonInstall", () => { it("reinstalls when an existing service is missing the nvm TLS CA bundle", async () => { service.isLoaded.mockResolvedValue(true); - resolveAutoNodeExtraCaCertsMock.mockReturnValue("/etc/ssl/certs/ca-certificates.crt"); + resolveNodeStartupTlsEnvironmentMock.mockReturnValue({ + NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt", + NODE_USE_SYSTEM_CA: undefined, + }); service.readCommand.mockResolvedValue({ programArguments: ["openclaw", "gateway", "run"], environment: {}, @@ -329,11 +338,13 @@ describe("runDaemonInstall", () => { it("reinstalls when the installed service still runs from nvm even if the installer runtime does not", async () => { service.isLoaded.mockResolvedValue(true); - resolveAutoNodeExtraCaCertsMock.mockImplementation(({ execPath }) => - typeof execPath === "string" && execPath.includes("/.nvm/") - ? "/etc/ssl/certs/ca-certificates.crt" - : undefined, - ); + resolveNodeStartupTlsEnvironmentMock.mockImplementation(({ execPath }) => ({ + NODE_EXTRA_CA_CERTS: + typeof execPath === "string" && execPath.includes("/.nvm/") + ? "/etc/ssl/certs/ca-certificates.crt" + : undefined, + NODE_USE_SYSTEM_CA: undefined, + })); service.readCommand.mockResolvedValue({ programArguments: ["/home/test/.nvm/versions/node/v22.18.0/bin/node", "dist/entry.js"], environment: {}, @@ -342,7 +353,7 @@ describe("runDaemonInstall", () => { await runDaemonInstall({ json: true }); expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); - expect(resolveAutoNodeExtraCaCertsMock).toHaveBeenCalledWith( + expect(resolveNodeStartupTlsEnvironmentMock).toHaveBeenCalledWith( expect.objectContaining({ execPath: "/home/test/.nvm/versions/node/v22.18.0/bin/node", }), diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index aa2ca5b2e45..941946935da 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -1,4 +1,6 @@ -import { resolveAutoNodeExtraCaCerts } from "../../bootstrap/node-extra-ca-certs.js"; +import type { DaemonInstallOptions } from "./types.js"; +import type { DaemonInstallOptions } from "./types.js"; +import { resolveNodeStartupTlsEnvironment } from "../../bootstrap/node-startup-env.js"; import { buildGatewayInstallPlan } from "../../commands/daemon-install-helpers.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, @@ -16,7 +18,6 @@ import { failIfNixDaemonInstallMode, parsePort, } from "./shared.js"; -import type { DaemonInstallOptions } from "./types.js"; export async function runDaemonInstall(opts: DaemonInstallOptions) { const { json, stdout, warnings, emit, fail } = createDaemonInstallActionContext(opts.json); @@ -146,14 +147,15 @@ async function gatewayServiceNeedsAutoNodeExtraCaCertsRefresh(params: { } const currentEnvironment = currentCommand.environment ?? {}; const currentNodeExtraCaCerts = currentEnvironment.NODE_EXTRA_CA_CERTS?.trim(); - const expectedNodeExtraCaCerts = resolveAutoNodeExtraCaCerts({ + const expectedNodeExtraCaCerts = resolveNodeStartupTlsEnvironment({ env: { ...params.env, ...currentEnvironment, NODE_EXTRA_CA_CERTS: undefined, }, execPath: currentExecPath, - }); + includeDarwinDefaults: false, + }).NODE_EXTRA_CA_CERTS; if (!expectedNodeExtraCaCerts) { return false; } diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 93dc67b3a95..d07ee439ee4 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -8,7 +8,7 @@ import { buildServiceEnvironment, getMinimalServicePathParts, getMinimalServicePathPartsFromEnv, - isNvmNode, + isNodeVersionManagerRuntime, resolveLinuxSystemCaBundle, } from "./service-env.js"; @@ -534,17 +534,19 @@ describe("resolveGatewayStateDir", () => { }); }); -describe("isNvmNode", () => { +describe("isNodeVersionManagerRuntime", () => { it("returns true when NVM_DIR env var is set", () => { - expect(isNvmNode({ NVM_DIR: "/home/user/.nvm" })).toBe(true); + expect(isNodeVersionManagerRuntime({ NVM_DIR: "/home/user/.nvm" })).toBe(true); }); it("returns true when execPath contains /.nvm/", () => { - expect(isNvmNode({}, "/home/user/.nvm/versions/node/v22.22.0/bin/node")).toBe(true); + expect(isNodeVersionManagerRuntime({}, "/home/user/.nvm/versions/node/v22.22.0/bin/node")).toBe( + true, + ); }); it("returns false when neither NVM_DIR nor nvm execPath", () => { - expect(isNvmNode({}, "/usr/bin/node")).toBe(false); + expect(isNodeVersionManagerRuntime({}, "/usr/bin/node")).toBe(false); }); }); diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index 5957449adee..220db26da1b 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -1,10 +1,10 @@ import os from "node:os"; import path from "node:path"; import { - isNvmNode, - resolveAutoNodeExtraCaCerts, + isNodeVersionManagerRuntime, resolveLinuxSystemCaBundle, } from "../bootstrap/node-extra-ca-certs.js"; +import { resolveNodeStartupTlsEnvironment } from "../bootstrap/node-startup-env.js"; import { VERSION } from "../version.js"; import { GATEWAY_SERVICE_KIND, @@ -20,7 +20,7 @@ import { resolveNodeWindowsTaskName, } from "./constants.js"; -export { isNvmNode, resolveLinuxSystemCaBundle }; +export { isNodeVersionManagerRuntime, resolveLinuxSystemCaBundle }; export type MinimalServicePathOptions = { platform?: NodeJS.Platform; @@ -346,16 +346,11 @@ function resolveSharedServiceEnvironmentFields( // cannot locate the system CA bundle. Default to /etc/ssl/cert.pem so TLS verification // works correctly when running as a LaunchAgent without extra user configuration. // On Linux, nvm-installed Node may need the host CA bundle injected before startup. - const nodeCaCerts = - env.NODE_EXTRA_CA_CERTS ?? - (platform === "darwin" - ? "/etc/ssl/cert.pem" - : resolveAutoNodeExtraCaCerts({ - env, - platform, - execPath, - })); - const nodeUseSystemCa = env.NODE_USE_SYSTEM_CA ?? (platform === "darwin" ? "1" : undefined); + const startupTlsEnv = resolveNodeStartupTlsEnvironment({ + env, + platform, + execPath, + }); return { stateDir, configPath, @@ -367,7 +362,7 @@ function resolveSharedServiceEnvironmentFields( ? undefined : buildMinimalServicePath({ env, platform, extraDirs: extraPathDirs }), proxyEnv, - nodeCaCerts, - nodeUseSystemCa, + nodeCaCerts: startupTlsEnv.NODE_EXTRA_CA_CERTS, + nodeUseSystemCa: startupTlsEnv.NODE_USE_SYSTEM_CA, }; } diff --git a/src/entry.respawn.ts b/src/entry.respawn.ts index 0092c710652..70eea3772f7 100644 --- a/src/entry.respawn.ts +++ b/src/entry.respawn.ts @@ -1,4 +1,4 @@ -import { resolveAutoNodeExtraCaCerts } from "./bootstrap/node-extra-ca-certs.js"; +import { resolveNodeStartupTlsEnvironment } from "./bootstrap/node-startup-env.js"; import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js"; import { isTruthyEnvValue } from "./infra/env.js"; @@ -45,10 +45,11 @@ export function buildCliRespawnPlan( const autoNodeExtraCaCerts = params.autoNodeExtraCaCerts ?? - resolveAutoNodeExtraCaCerts({ + resolveNodeStartupTlsEnvironment({ env, execPath, - }); + includeDarwinDefaults: false, + }).NODE_EXTRA_CA_CERTS; if ( autoNodeExtraCaCerts && !isTruthyEnvValue(env[OPENCLAW_NODE_EXTRA_CA_CERTS_READY]) &&