fix: handle Linux nvm CA env before startup (#51146) (thanks @GodsBoy)

This commit is contained in:
Peter Steinberger
2026-03-21 10:47:29 -07:00
parent d6367c2c55
commit 40f5305cd2
11 changed files with 544 additions and 56 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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]);
});
});

View File

@@ -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<string, string | undefined>;
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,
});
}

View File

@@ -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);
});
});

View File

@@ -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<typeof resolveGatewayService>;
env: Record<string, string | undefined>;
}): Promise<boolean> {
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;
}
}

View File

@@ -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");
});
});

View File

@@ -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<string, string | undefined> {
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<string, string | undefined>;
platform?: NodeJS.Platform;
extraPathDirs?: string[];
execPath?: string;
}): Record<string, string | undefined> {
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<string, string | undefined>,
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,

75
src/entry.respawn.test.ts Normal file
View File

@@ -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();
});
});

79
src/entry.respawn.ts Normal file
View File

@@ -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,
};
}

View File

@@ -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.