From 40f5305cd282da6b2ed47478e88b145bf3b8aaa2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Mar 2026 10:47:29 -0700 Subject: [PATCH] fix: handle Linux nvm CA env before startup (#51146) (thanks @GodsBoy) --- CHANGELOG.md | 1 + docs/help/environment.md | 23 ++++++ src/bootstrap/node-extra-ca-certs.test.ts | 86 ++++++++++++++++++++++ src/bootstrap/node-extra-ca-certs.ts | 68 +++++++++++++++++ src/cli/daemon-cli/install.test.ts | 39 +++++++++- src/cli/daemon-cli/install.ts | 57 ++++++++++---- src/daemon/service-env.test.ts | 90 +++++++++++++++++++++++ src/daemon/service-env.ts | 34 ++++++++- src/entry.respawn.test.ts | 75 +++++++++++++++++++ src/entry.respawn.ts | 79 ++++++++++++++++++++ src/entry.ts | 48 +++--------- 11 files changed, 544 insertions(+), 56 deletions(-) create mode 100644 src/bootstrap/node-extra-ca-certs.test.ts create mode 100644 src/bootstrap/node-extra-ca-certs.ts create mode 100644 src/entry.respawn.test.ts create mode 100644 src/entry.respawn.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dbc3de9537b..89879cf30d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Linux: auto-detect nvm-managed Node TLS CA bundle needs before CLI startup and refresh installed services that are missing `NODE_EXTRA_CA_CERTS`. (#51146) Thanks @GodsBoy. - CLI/config: make `config set --strict-json` enforce real JSON, prefer `JSON.parse` with JSON5 fallback for machine-written cron/subagent stores, and relabel raw config surfaces as `JSON/JSON5` to match actual compatibility. Related: #48415, #43127, #14529, #21332. Thanks @adhitShet and @vincentkoc. - CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD. - Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev. diff --git a/docs/help/environment.md b/docs/help/environment.md index 45faad7c66c..2947d408f4a 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -133,6 +133,29 @@ When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.home `OPENCLAW_HOME` can also be set to a tilde path (e.g. `~/svc`), which gets expanded using `$HOME` before use. +## nvm users: web_fetch TLS failures + +If Node.js was installed via **nvm** (not the system package manager), the built-in `fetch()` uses +nvm's bundled CA store, which may be missing modern root CAs (ISRG Root X1/X2 for Let's Encrypt, +DigiCert Global Root G2, etc.). This causes `web_fetch` to fail with `"fetch failed"` on most HTTPS sites. + +On Linux, OpenClaw automatically detects nvm and applies the fix in the actual startup environment: + +- `openclaw gateway install` writes `NODE_EXTRA_CA_CERTS` into the systemd service environment +- the `openclaw` CLI entrypoint re-execs itself with `NODE_EXTRA_CA_CERTS` set before Node startup + +**Manual fix (for older versions or direct `node ...` launches):** + +Export the variable before starting OpenClaw: + +```bash +export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt +openclaw gateway run +``` + +Do not rely on writing only to `~/.openclaw/.env` for this variable; Node reads +`NODE_EXTRA_CA_CERTS` at process startup. + ## Related - [Gateway configuration](/gateway/configuration) diff --git a/src/bootstrap/node-extra-ca-certs.test.ts b/src/bootstrap/node-extra-ca-certs.test.ts new file mode 100644 index 00000000000..cf852307bbb --- /dev/null +++ b/src/bootstrap/node-extra-ca-certs.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + isNvmNode, + LINUX_CA_BUNDLE_PATHS, + resolveAutoNodeExtraCaCerts, + resolveLinuxSystemCaBundle, +} from "./node-extra-ca-certs.js"; + +function allowOnly(path: string) { + return (candidate: string) => { + if (candidate !== path) { + throw new Error("ENOENT"); + } + }; +} + +describe("resolveLinuxSystemCaBundle", () => { + it("returns undefined on non-linux platforms", () => { + expect( + resolveLinuxSystemCaBundle({ + platform: "darwin", + accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[0]), + }), + ).toBeUndefined(); + }); + + it("returns the first readable Linux CA bundle", () => { + expect( + resolveLinuxSystemCaBundle({ + platform: "linux", + accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[1]), + }), + ).toBe(LINUX_CA_BUNDLE_PATHS[1]); + }); +}); + +describe("isNvmNode", () => { + it("detects nvm via NVM_DIR", () => { + expect(isNvmNode({ 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); + }); + + it("returns false for non-nvm node paths", () => { + expect(isNvmNode({}, "/usr/bin/node")).toBe(false); + }); +}); + +describe("resolveAutoNodeExtraCaCerts", () => { + it("returns undefined when NODE_EXTRA_CA_CERTS is already set", () => { + expect( + resolveAutoNodeExtraCaCerts({ + env: { + NVM_DIR: "/home/test/.nvm", + NODE_EXTRA_CA_CERTS: "/custom/ca.pem", + }, + platform: "linux", + accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[0]), + }), + ).toBeUndefined(); + }); + + it("returns undefined when node is not nvm-managed", () => { + expect( + resolveAutoNodeExtraCaCerts({ + env: {}, + platform: "linux", + execPath: "/usr/bin/node", + accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[0]), + }), + ).toBeUndefined(); + }); + + it("returns the readable Linux CA bundle for nvm-managed node", () => { + expect( + resolveAutoNodeExtraCaCerts({ + env: { NVM_DIR: "/home/test/.nvm" }, + platform: "linux", + execPath: "/usr/bin/node", + accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[2]), + }), + ).toBe(LINUX_CA_BUNDLE_PATHS[2]); + }); +}); diff --git a/src/bootstrap/node-extra-ca-certs.ts b/src/bootstrap/node-extra-ca-certs.ts new file mode 100644 index 00000000000..5052167ec89 --- /dev/null +++ b/src/bootstrap/node-extra-ca-certs.ts @@ -0,0 +1,68 @@ +import fs from "node:fs"; + +export const LINUX_CA_BUNDLE_PATHS = [ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", +] as const; + +type EnvMap = Record; +type AccessSyncFn = (path: string, mode?: number) => void; + +export function resolveLinuxSystemCaBundle( + params: { + platform?: NodeJS.Platform; + accessSync?: AccessSyncFn; + } = {}, +): string | undefined { + const platform = params.platform ?? process.platform; + if (platform !== "linux") { + return undefined; + } + + const accessSync = params.accessSync ?? fs.accessSync.bind(fs); + for (const candidate of LINUX_CA_BUNDLE_PATHS) { + try { + accessSync(candidate, fs.constants.R_OK); + return candidate; + } catch { + continue; + } + } + return undefined; +} + +export function isNvmNode( + env: EnvMap = process.env as EnvMap, + execPath: string = process.execPath, +): boolean { + if (env.NVM_DIR?.trim()) { + return true; + } + return execPath.includes("/.nvm/"); +} + +export function resolveAutoNodeExtraCaCerts( + params: { + env?: EnvMap; + platform?: NodeJS.Platform; + execPath?: string; + accessSync?: AccessSyncFn; + } = {}, +): string | undefined { + const env = params.env ?? (process.env as EnvMap); + if (env.NODE_EXTRA_CA_CERTS?.trim()) { + return undefined; + } + + const platform = params.platform ?? process.platform; + const execPath = params.execPath ?? process.execPath; + if (platform !== "linux" || !isNvmNode(env, execPath)) { + return undefined; + } + + return resolveLinuxSystemCaBundle({ + platform, + accessSync: params.accessSync, + }); +} diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 6d7b618a17a..b41a2d0f720 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -1,7 +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 loadConfigMock = vi.hoisted(() => vi.fn()); const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789)); @@ -50,6 +51,10 @@ const service = vi.hoisted(() => ({ readRuntime: vi.fn(async () => ({ status: "stopped" as const })), })); +vi.mock("../../bootstrap/node-extra-ca-certs.js", () => ({ + resolveAutoNodeExtraCaCerts: resolveAutoNodeExtraCaCertsMock, +})); + vi.mock("../../config/config.js", () => ({ loadConfig: loadConfigMock, readBestEffortConfig: loadConfigMock, @@ -151,6 +156,7 @@ const envSnapshot = captureFullEnv(); describe("runDaemonInstall", () => { beforeEach(() => { loadConfigMock.mockReset(); + resolveAutoNodeExtraCaCertsMock.mockReset(); readConfigFileSnapshotMock.mockReset(); resolveGatewayPortMock.mockClear(); writeConfigFileMock.mockReset(); @@ -191,6 +197,8 @@ describe("runDaemonInstall", () => { isGatewayDaemonRuntimeMock.mockReturnValue(true); installDaemonServiceAndEmitMock.mockResolvedValue(undefined); service.isLoaded.mockResolvedValue(false); + service.readCommand.mockResolvedValue(null); + resolveAutoNodeExtraCaCertsMock.mockReturnValue(undefined); delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.CLAWDBOT_GATEWAY_TOKEN; }); @@ -289,4 +297,33 @@ describe("runDaemonInstall", () => { expect(actionState.failed[0]?.message).toContain("read-only file system"); expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); }); + + 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"); + service.readCommand.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + environment: { + NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt", + }, + } as never); + + await runDaemonInstall({ json: true }); + + expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); + expect(actionState.emitted.at(-1)).toMatchObject({ result: "already-installed" }); + }); + + 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"); + service.readCommand.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + environment: {}, + } as never); + + await runDaemonInstall({ json: true }); + + expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 023ea5e520e..ec92674f990 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -1,3 +1,5 @@ +import type { DaemonInstallOptions } from "./types.js"; +import { resolveAutoNodeExtraCaCerts } from "../../bootstrap/node-extra-ca-certs.js"; import { buildGatewayInstallPlan } from "../../commands/daemon-install-helpers.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, @@ -15,7 +17,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); @@ -54,19 +55,28 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { } if (loaded) { if (!opts.force) { - emit({ - ok: true, - result: "already-installed", - message: `Gateway service already ${service.loadedText}.`, - service: buildDaemonServiceSnapshot(service, loaded), - }); - if (!json) { - defaultRuntime.log(`Gateway service already ${service.loadedText}.`); - defaultRuntime.log( - `Reinstall with: ${formatCliCommand("openclaw gateway install --force")}`, - ); + if (await gatewayServiceNeedsAutoNodeExtraCaCertsRefresh({ service, env: process.env })) { + const message = "Gateway service is missing the nvm TLS CA bundle; refreshing the install."; + if (json) { + warnings.push(message); + } else { + defaultRuntime.log(message); + } + } else { + emit({ + ok: true, + result: "already-installed", + message: `Gateway service already ${service.loadedText}.`, + service: buildDaemonServiceSnapshot(service, loaded), + }); + if (!json) { + defaultRuntime.log(`Gateway service already ${service.loadedText}.`); + defaultRuntime.log( + `Reinstall with: ${formatCliCommand("openclaw gateway install --force")}`, + ); + } + return; } - return; } } @@ -120,3 +130,24 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { }, }); } + +async function gatewayServiceNeedsAutoNodeExtraCaCertsRefresh(params: { + service: ReturnType; + env: Record; +}): Promise { + const expectedNodeExtraCaCerts = resolveAutoNodeExtraCaCerts({ + env: params.env, + execPath: process.execPath, + }); + if (!expectedNodeExtraCaCerts) { + return false; + } + + try { + const currentCommand = await params.service.readCommand(params.env); + const currentNodeExtraCaCerts = currentCommand?.environment?.NODE_EXTRA_CA_CERTS?.trim(); + return currentNodeExtraCaCerts !== expectedNodeExtraCaCerts; + } catch { + return false; + } +} diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index f8297a28554..93dc67b3a95 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -8,6 +8,8 @@ import { buildServiceEnvironment, getMinimalServicePathParts, getMinimalServicePathPartsFromEnv, + isNvmNode, + resolveLinuxSystemCaBundle, } from "./service-env.js"; describe("getMinimalServicePathParts - Linux user directories", () => { @@ -531,3 +533,91 @@ describe("resolveGatewayStateDir", () => { expect(resolveGatewayStateDir(env)).toBe("C:\\State\\openclaw"); }); }); + +describe("isNvmNode", () => { + it("returns true when NVM_DIR env var is set", () => { + expect(isNvmNode({ 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); + }); + + it("returns false when neither NVM_DIR nor nvm execPath", () => { + expect(isNvmNode({}, "/usr/bin/node")).toBe(false); + }); +}); + +describe("resolveLinuxSystemCaBundle", () => { + it("returns a known CA bundle path when one exists", () => { + const result = resolveLinuxSystemCaBundle(); + if (process.platform === "linux") { + expect(result).toMatch(/\.(crt|pem)$/); + } + }); +}); + +describe("shared Node TLS env defaults", () => { + it("sets macOS TLS defaults for gateway services", () => { + const env = buildServiceEnvironment({ + env: { HOME: "/Users/test" }, + port: 18789, + platform: "darwin", + }); + expect(env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/cert.pem"); + expect(env.NODE_USE_SYSTEM_CA).toBe("1"); + }); + + it("sets macOS TLS defaults for node services", () => { + const env = buildNodeServiceEnvironment({ + env: { HOME: "/Users/test" }, + platform: "darwin", + }); + expect(env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/cert.pem"); + expect(env.NODE_USE_SYSTEM_CA).toBe("1"); + }); + + it("defaults NODE_EXTRA_CA_CERTS on Linux when NVM_DIR is set", () => { + const expected = resolveLinuxSystemCaBundle(); + const env = buildServiceEnvironment({ + env: { HOME: "/home/user", NVM_DIR: "/home/user/.nvm" }, + port: 18789, + platform: "linux", + execPath: "/usr/bin/node", + }); + expect(env.NODE_EXTRA_CA_CERTS).toBe(expected); + }); + + it("defaults NODE_EXTRA_CA_CERTS on Linux when execPath is under nvm", () => { + const expected = resolveLinuxSystemCaBundle(); + const env = buildNodeServiceEnvironment({ + env: { HOME: "/home/user" }, + platform: "linux", + execPath: "/home/user/.nvm/versions/node/v22.22.0/bin/node", + }); + expect(env.NODE_EXTRA_CA_CERTS).toBe(expected); + }); + + it("does not default NODE_EXTRA_CA_CERTS on Linux without nvm", () => { + const env = buildServiceEnvironment({ + env: { HOME: "/home/user" }, + port: 18789, + platform: "linux", + execPath: "/usr/bin/node", + }); + expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined(); + }); + + it("respects user-provided NODE_EXTRA_CA_CERTS on Linux with nvm", () => { + const env = buildNodeServiceEnvironment({ + env: { + HOME: "/home/user", + NVM_DIR: "/home/user/.nvm", + NODE_EXTRA_CA_CERTS: "/custom/ca-bundle.crt", + }, + platform: "linux", + execPath: "/home/user/.nvm/versions/node/v22.22.0/bin/node", + }); + expect(env.NODE_EXTRA_CA_CERTS).toBe("/custom/ca-bundle.crt"); + }); +}); diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index cb26c210efb..5957449adee 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -1,5 +1,10 @@ import os from "node:os"; import path from "node:path"; +import { + isNvmNode, + resolveAutoNodeExtraCaCerts, + resolveLinuxSystemCaBundle, +} from "../bootstrap/node-extra-ca-certs.js"; import { VERSION } from "../version.js"; import { GATEWAY_SERVICE_KIND, @@ -15,6 +20,8 @@ import { resolveNodeWindowsTaskName, } from "./constants.js"; +export { isNvmNode, resolveLinuxSystemCaBundle }; + export type MinimalServicePathOptions = { platform?: NodeJS.Platform; extraDirs?: string[]; @@ -248,10 +255,16 @@ export function buildServiceEnvironment(params: { launchdLabel?: string; platform?: NodeJS.Platform; extraPathDirs?: string[]; + execPath?: string; }): Record { const { env, port, launchdLabel, extraPathDirs } = params; const platform = params.platform ?? process.platform; - const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs); + const sharedEnv = resolveSharedServiceEnvironmentFields( + env, + platform, + extraPathDirs, + params.execPath, + ); const profile = env.OPENCLAW_PROFILE; const resolvedLaunchdLabel = launchdLabel || (platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined); @@ -273,10 +286,16 @@ export function buildNodeServiceEnvironment(params: { env: Record; platform?: NodeJS.Platform; extraPathDirs?: string[]; + execPath?: string; }): Record { const { env, extraPathDirs } = params; const platform = params.platform ?? process.platform; - const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs); + const sharedEnv = resolveSharedServiceEnvironmentFields( + env, + platform, + extraPathDirs, + params.execPath, + ); const gatewayToken = env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim() || undefined; return { @@ -316,6 +335,7 @@ function resolveSharedServiceEnvironmentFields( env: Record, platform: NodeJS.Platform, extraPathDirs: string[] | undefined, + execPath?: string, ): SharedServiceEnvironmentFields { const stateDir = env.OPENCLAW_STATE_DIR; const configPath = env.OPENCLAW_CONFIG_PATH; @@ -325,8 +345,16 @@ function resolveSharedServiceEnvironmentFields( // On macOS, launchd services don't inherit the shell environment, so Node's undici/fetch // 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" : undefined); + 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); return { stateDir, diff --git a/src/entry.respawn.test.ts b/src/entry.respawn.test.ts new file mode 100644 index 00000000000..b495319afc7 --- /dev/null +++ b/src/entry.respawn.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildCliRespawnPlan, + EXPERIMENTAL_WARNING_FLAG, + OPENCLAW_NODE_EXTRA_CA_CERTS_READY, + OPENCLAW_NODE_OPTIONS_READY, +} from "./entry.respawn.js"; + +const shouldSkipRespawnForArgvMock = vi.hoisted(() => vi.fn(() => false)); +const isTruthyEnvValueMock = vi.hoisted(() => + vi.fn((value: string | undefined) => value === "1" || value === "true"), +); + +vi.mock("./cli/respawn-policy.js", () => ({ + shouldSkipRespawnForArgv: shouldSkipRespawnForArgvMock, +})); + +vi.mock("./infra/env.js", () => ({ + isTruthyEnvValue: isTruthyEnvValueMock, +})); + +describe("buildCliRespawnPlan", () => { + it("returns null when respawn policy skips the argv", () => { + shouldSkipRespawnForArgvMock.mockReturnValueOnce(true); + + expect( + buildCliRespawnPlan({ + argv: ["node", "openclaw", "status"], + env: {}, + execArgv: [], + autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt", + }), + ).toBeNull(); + }); + + it("adds NODE_EXTRA_CA_CERTS and warning suppression in one respawn", () => { + const plan = buildCliRespawnPlan({ + argv: ["node", "openclaw", "gateway", "run"], + env: {}, + execArgv: [], + autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt", + }); + + expect(plan).not.toBeNull(); + expect(plan?.argv[0]).toBe(EXPERIMENTAL_WARNING_FLAG); + expect(plan?.env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/ca-certificates.crt"); + expect(plan?.env[OPENCLAW_NODE_EXTRA_CA_CERTS_READY]).toBe("1"); + expect(plan?.env[OPENCLAW_NODE_OPTIONS_READY]).toBe("1"); + }); + + it("does not overwrite an existing NODE_EXTRA_CA_CERTS value", () => { + const plan = buildCliRespawnPlan({ + argv: ["node", "openclaw", "gateway", "run"], + env: { NODE_EXTRA_CA_CERTS: "/custom/ca.pem" }, + execArgv: [], + autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt", + }); + + expect(plan?.env.NODE_EXTRA_CA_CERTS).toBe("/custom/ca.pem"); + }); + + it("returns null when both respawn guards are already satisfied", () => { + expect( + buildCliRespawnPlan({ + argv: ["node", "openclaw", "gateway", "run"], + env: { + [OPENCLAW_NODE_EXTRA_CA_CERTS_READY]: "1", + [OPENCLAW_NODE_OPTIONS_READY]: "1", + }, + execArgv: [EXPERIMENTAL_WARNING_FLAG], + autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt", + }), + ).toBeNull(); + }); +}); diff --git a/src/entry.respawn.ts b/src/entry.respawn.ts new file mode 100644 index 00000000000..0092c710652 --- /dev/null +++ b/src/entry.respawn.ts @@ -0,0 +1,79 @@ +import { resolveAutoNodeExtraCaCerts } from "./bootstrap/node-extra-ca-certs.js"; +import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js"; +import { isTruthyEnvValue } from "./infra/env.js"; + +export const EXPERIMENTAL_WARNING_FLAG = "--disable-warning=ExperimentalWarning"; +export const OPENCLAW_NODE_OPTIONS_READY = "OPENCLAW_NODE_OPTIONS_READY"; +export const OPENCLAW_NODE_EXTRA_CA_CERTS_READY = "OPENCLAW_NODE_EXTRA_CA_CERTS_READY"; + +export function hasExperimentalWarningSuppressed( + params: { + env?: NodeJS.ProcessEnv; + execArgv?: string[]; + } = {}, +): boolean { + const env = params.env ?? process.env; + const execArgv = params.execArgv ?? process.execArgv; + const nodeOptions = env.NODE_OPTIONS ?? ""; + if (nodeOptions.includes(EXPERIMENTAL_WARNING_FLAG) || nodeOptions.includes("--no-warnings")) { + return true; + } + return execArgv.some((arg) => arg === EXPERIMENTAL_WARNING_FLAG || arg === "--no-warnings"); +} + +export function buildCliRespawnPlan( + params: { + argv?: string[]; + env?: NodeJS.ProcessEnv; + execArgv?: string[]; + execPath?: string; + autoNodeExtraCaCerts?: string | undefined; + } = {}, +): { argv: string[]; env: NodeJS.ProcessEnv } | null { + const argv = params.argv ?? process.argv; + const env = params.env ?? process.env; + const execArgv = params.execArgv ?? process.execArgv; + const execPath = params.execPath ?? process.execPath; + + if (shouldSkipRespawnForArgv(argv) || isTruthyEnvValue(env.OPENCLAW_NO_RESPAWN)) { + return null; + } + + const childEnv: NodeJS.ProcessEnv = { ...env }; + const childExecArgv = [...execArgv]; + let needsRespawn = false; + + const autoNodeExtraCaCerts = + params.autoNodeExtraCaCerts ?? + resolveAutoNodeExtraCaCerts({ + env, + execPath, + }); + if ( + autoNodeExtraCaCerts && + !isTruthyEnvValue(env[OPENCLAW_NODE_EXTRA_CA_CERTS_READY]) && + !env.NODE_EXTRA_CA_CERTS + ) { + childEnv.NODE_EXTRA_CA_CERTS = autoNodeExtraCaCerts; + childEnv[OPENCLAW_NODE_EXTRA_CA_CERTS_READY] = "1"; + needsRespawn = true; + } + + if ( + !isTruthyEnvValue(env[OPENCLAW_NODE_OPTIONS_READY]) && + !hasExperimentalWarningSuppressed({ env, execArgv }) + ) { + childEnv[OPENCLAW_NODE_OPTIONS_READY] = "1"; + childExecArgv.unshift(EXPERIMENTAL_WARNING_FLAG); + needsRespawn = true; + } + + if (!needsRespawn) { + return null; + } + + return { + argv: [...childExecArgv, ...argv.slice(1)], + env: childEnv, + }; +} diff --git a/src/entry.ts b/src/entry.ts index bee75ea2fcb..451cefd5d41 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -5,8 +5,8 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { isRootHelpInvocation, isRootVersionInvocation } from "./cli/argv.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; -import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; +import { buildCliRespawnPlan } from "./entry.respawn.js"; import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js"; import { isMainModule } from "./infra/is-main.js"; import { ensureOpenClawExecMarkerOnProcess } from "./infra/openclaw-exec-env.js"; @@ -65,46 +65,16 @@ if ( process.env.FORCE_COLOR = "0"; } - const EXPERIMENTAL_WARNING_FLAG = "--disable-warning=ExperimentalWarning"; - - function hasExperimentalWarningSuppressed(): boolean { - const nodeOptions = process.env.NODE_OPTIONS ?? ""; - if (nodeOptions.includes(EXPERIMENTAL_WARNING_FLAG) || nodeOptions.includes("--no-warnings")) { - return true; - } - for (const arg of process.execArgv) { - if (arg === EXPERIMENTAL_WARNING_FLAG || arg === "--no-warnings") { - return true; - } - } - return false; - } - - function ensureExperimentalWarningSuppressed(): boolean { - if (shouldSkipRespawnForArgv(process.argv)) { - return false; - } - if (isTruthyEnvValue(process.env.OPENCLAW_NO_RESPAWN)) { - return false; - } - if (isTruthyEnvValue(process.env.OPENCLAW_NODE_OPTIONS_READY)) { - return false; - } - if (hasExperimentalWarningSuppressed()) { + function ensureCliRespawnReady(): boolean { + const plan = buildCliRespawnPlan(); + if (!plan) { return false; } - // Respawn guard (and keep recursion bounded if something goes wrong). - process.env.OPENCLAW_NODE_OPTIONS_READY = "1"; - // Pass flag as a Node CLI option, not via NODE_OPTIONS (--disable-warning is disallowed in NODE_OPTIONS). - const child = spawn( - process.execPath, - [EXPERIMENTAL_WARNING_FLAG, ...process.execArgv, ...process.argv.slice(1)], - { - stdio: "inherit", - env: process.env, - }, - ); + const child = spawn(process.execPath, plan.argv, { + stdio: "inherit", + env: plan.env, + }); attachChildProcessBridge(child); @@ -150,7 +120,7 @@ if ( process.argv = normalizeWindowsArgv(process.argv); - if (!ensureExperimentalWarningSuppressed()) { + if (!ensureCliRespawnReady()) { const parsed = parseCliProfileArgs(process.argv); if (!parsed.ok) { // Keep it simple; Commander will handle rich help/errors after we strip flags.