fix(install): auto-set NODE_EXTRA_CA_CERTS for nvm Node.js on Linux

This commit is contained in:
GodsBoy 2026-03-17 17:13:03 +02:00
parent fcabecc9a4
commit de0e5af942
4 changed files with 168 additions and 1 deletions

View File

@ -133,6 +133,25 @@ 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.
Since `v2026.3.17`, `openclaw gateway install` on Linux automatically detects nvm and writes the
fix to both the systemd service environment and `~/.openclaw/.env`.
**Manual fix (for older versions or manual gateway starts):**
Add to `~/.openclaw/.env`:
```
NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
```
Then restart the gateway. This appends the system CA bundle to Node's bundled store.
## Related
- [Gateway configuration](/gateway/configuration)

View File

@ -1,3 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { buildGatewayInstallPlan } from "../../commands/daemon-install-helpers.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
@ -5,6 +7,8 @@ import {
} from "../../commands/daemon-runtime.js";
import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js";
import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js";
import { resolveGatewayStateDir } from "../../daemon/paths.js";
import { isNvmNode } from "../../daemon/service-env.js";
import { resolveGatewayService } from "../../daemon/service.js";
import { isNonFatalSystemdInstallProbeError } from "../../daemon/systemd.js";
import { defaultRuntime } from "../../runtime.js";
@ -119,4 +123,50 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
});
},
});
// On Linux with nvm-installed Node, ensure NODE_EXTRA_CA_CERTS is in ~/.openclaw/.env
// so manual `openclaw gateway run` also picks up the system CA bundle via dotenv.
ensureNvmCaCertsInDotEnv({ env: process.env, json, warnings });
}
/**
* When Node.js is installed via nvm on Linux, write NODE_EXTRA_CA_CERTS to
* the global .env file so non-service runs (e.g. `openclaw gateway run`)
* also get the system CA bundle. The service environment already handles this
* via buildServiceEnvironment, but dotenv covers the manual-start path.
*/
function ensureNvmCaCertsInDotEnv(params: {
env: Record<string, string | undefined>;
json: boolean;
warnings: string[];
}): void {
if (process.platform !== "linux" || !isNvmNode(params.env, process.execPath)) {
return;
}
if (params.env.NODE_EXTRA_CA_CERTS) {
return;
}
try {
const stateDir = resolveGatewayStateDir(params.env);
const envFile = path.join(stateDir, ".env");
const existing = fs.existsSync(envFile) ? fs.readFileSync(envFile, "utf8") : "";
if (existing.includes("NODE_EXTRA_CA_CERTS")) {
return;
}
const line = "NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt\n";
const content = existing.endsWith("\n") || !existing ? existing + line : `${existing}\n${line}`;
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(envFile, content, "utf8");
const message =
"nvm detected: wrote NODE_EXTRA_CA_CERTS to ~/.openclaw/.env for TLS compatibility";
if (params.json) {
params.warnings.push(message);
} else {
defaultRuntime.log(message);
}
} catch {
// Best-effort; the service environment already has the var via buildServiceEnvironment.
}
}

View File

@ -8,6 +8,7 @@ import {
buildServiceEnvironment,
getMinimalServicePathParts,
getMinimalServicePathPartsFromEnv,
isNvmNode,
} from "./service-env.js";
describe("getMinimalServicePathParts - Linux user directories", () => {
@ -500,6 +501,74 @@ describe("shared Node TLS env defaults", () => {
});
});
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);
});
it("returns false for empty env and system execPath", () => {
expect(isNvmNode({}, "/usr/local/bin/node")).toBe(false);
});
it("returns true when NVM_DIR is set even with system execPath", () => {
expect(isNvmNode({ NVM_DIR: "/home/user/.nvm" }, "/usr/bin/node")).toBe(true);
});
});
describe("shared Node TLS env — Linux nvm detection", () => {
const builders = [
{
name: "gateway service env",
build: (env: Record<string, string | undefined>, platform?: NodeJS.Platform) =>
buildServiceEnvironment({ env, port: 18789, platform }),
},
{
name: "node service env",
build: (env: Record<string, string | undefined>, platform?: NodeJS.Platform) =>
buildNodeServiceEnvironment({ env, platform }),
},
] as const;
it.each(builders)(
"$name defaults NODE_EXTRA_CA_CERTS on Linux when NVM_DIR is set",
({ build }) => {
const env = build({ HOME: "/home/user", NVM_DIR: "/home/user/.nvm" }, "linux");
expect(env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/ca-certificates.crt");
},
);
it.each(builders)(
"$name does not default NODE_EXTRA_CA_CERTS on Linux without nvm",
({ build }) => {
const env = build({ HOME: "/home/user" }, "linux");
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
},
);
it.each(builders)(
"$name respects user-provided NODE_EXTRA_CA_CERTS on Linux with nvm",
({ build }) => {
const env = build(
{
HOME: "/home/user",
NVM_DIR: "/home/user/.nvm",
NODE_EXTRA_CA_CERTS: "/custom/ca-bundle.crt",
},
"linux",
);
expect(env.NODE_EXTRA_CA_CERTS).toBe("/custom/ca-bundle.crt");
},
);
});
describe("resolveGatewayStateDir", () => {
it("uses the default state dir when no overrides are set", () => {
const env = { HOME: "/Users/test" };

View File

@ -15,6 +15,28 @@ import {
resolveNodeWindowsTaskName,
} from "./constants.js";
/** Standard system CA bundle path on Debian/Ubuntu/Alpine Linux. */
const LINUX_SYSTEM_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
/**
* Detect if Node.js was installed via nvm.
* nvm-installed Node uses a bundled CA certificate store that may be missing modern
* root CAs (ISRG Root X1/X2, DigiCert Global Root G2, etc.), causing TLS failures
* with Node's built-in fetch (undici) for the majority of real-world HTTPS sites.
*
* Pass `execPath` explicitly to also check the binary path (e.g. `process.execPath`).
* Without it, only the `NVM_DIR` env var is checked.
*/
export function isNvmNode(env?: Record<string, string | undefined>, execPath?: string): boolean {
if (env?.NVM_DIR) {
return true;
}
if (execPath !== undefined) {
return execPath.includes("/.nvm/");
}
return false;
}
export type MinimalServicePathOptions = {
platform?: NodeJS.Platform;
extraDirs?: string[];
@ -325,8 +347,15 @@ 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 uses a bundled CA store that is missing modern root CAs.
// Default to the system CA bundle when nvm is detected so TLS works out of the box.
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"
: platform === "linux" && isNvmNode(env)
? LINUX_SYSTEM_CA_BUNDLE
: undefined);
const nodeUseSystemCa = env.NODE_USE_SYSTEM_CA ?? (platform === "darwin" ? "1" : undefined);
return {
stateDir,