mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-21 23:11:01 +00:00
fix: handle Linux nvm CA env before startup (#51146) (thanks @GodsBoy)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
86
src/bootstrap/node-extra-ca-certs.test.ts
Normal file
86
src/bootstrap/node-extra-ca-certs.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
68
src/bootstrap/node-extra-ca-certs.ts
Normal file
68
src/bootstrap/node-extra-ca-certs.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
75
src/entry.respawn.test.ts
Normal 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
79
src/entry.respawn.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
48
src/entry.ts
48
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.
|
||||
|
||||
Reference in New Issue
Block a user