Merge fb3b871e44f688ea9e17836ddf8f1f4fe5436d5d into 5bb5d7dab4b29e68b15bb7665d0736f46499a35c
This commit is contained in:
commit
40ad45dfda
@ -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)
|
||||
|
||||
@ -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, resolveLinuxSystemCaBundle } from "../../daemon/service-env.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { isNonFatalSystemdInstallProbeError } from "../../daemon/systemd.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
@ -54,6 +58,9 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
}
|
||||
if (loaded) {
|
||||
if (!opts.force) {
|
||||
// Backfill NODE_EXTRA_CA_CERTS even when service is already installed,
|
||||
// so users upgrading to this fix don't need --force to get the .env entry.
|
||||
ensureNvmCaCertsInDotEnv({ env: process.env, json, warnings });
|
||||
emit({
|
||||
ok: true,
|
||||
result: "already-installed",
|
||||
@ -119,4 +126,58 @@ 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[];
|
||||
platform?: NodeJS.Platform;
|
||||
execPath?: string;
|
||||
}): void {
|
||||
const platform = params.platform ?? process.platform;
|
||||
const execPath = params.execPath ?? process.execPath;
|
||||
if (platform !== "linux" || !isNvmNode(params.env, execPath)) {
|
||||
return;
|
||||
}
|
||||
if (params.env.NODE_EXTRA_CA_CERTS) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const caBundle = resolveLinuxSystemCaBundle();
|
||||
if (!caBundle) {
|
||||
return;
|
||||
}
|
||||
const stateDir = resolveGatewayStateDir(params.env);
|
||||
const envFile = path.join(stateDir, ".env");
|
||||
const existing = fs.existsSync(envFile) ? fs.readFileSync(envFile, "utf8") : "";
|
||||
if (/^NODE_EXTRA_CA_CERTS=/m.test(existing)) {
|
||||
return;
|
||||
}
|
||||
const line = `NODE_EXTRA_CA_CERTS=${caBundle}\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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,8 @@ import {
|
||||
buildServiceEnvironment,
|
||||
getMinimalServicePathParts,
|
||||
getMinimalServicePathPartsFromEnv,
|
||||
isNvmNode,
|
||||
resolveLinuxSystemCaBundle,
|
||||
} from "./service-env.js";
|
||||
|
||||
describe("getMinimalServicePathParts - Linux user directories", () => {
|
||||
@ -456,16 +458,18 @@ describe("buildNodeServiceEnvironment", () => {
|
||||
});
|
||||
|
||||
describe("shared Node TLS env defaults", () => {
|
||||
// Pass an explicit non-nvm execPath so tests are deterministic regardless of
|
||||
// whether the test runner itself runs under nvm.
|
||||
const builders = [
|
||||
{
|
||||
name: "gateway service env",
|
||||
build: (env: Record<string, string | undefined>, platform?: NodeJS.Platform) =>
|
||||
buildServiceEnvironment({ env, port: 18789, platform }),
|
||||
buildServiceEnvironment({ env, port: 18789, platform, execPath: "/usr/bin/node" }),
|
||||
},
|
||||
{
|
||||
name: "node service env",
|
||||
build: (env: Record<string, string | undefined>, platform?: NodeJS.Platform) =>
|
||||
buildNodeServiceEnvironment({ env, platform }),
|
||||
buildNodeServiceEnvironment({ env, platform, execPath: "/usr/bin/node" }),
|
||||
},
|
||||
] as const;
|
||||
|
||||
@ -500,6 +504,110 @@ 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("resolveLinuxSystemCaBundle", () => {
|
||||
it("returns a known CA bundle path when one exists", () => {
|
||||
const result = resolveLinuxSystemCaBundle();
|
||||
if (process.platform === "linux") {
|
||||
// On a real Linux host, at least one standard CA bundle should exist.
|
||||
expect(result).toMatch(/\.(crt|pem)$/);
|
||||
}
|
||||
// On non-Linux CI or minimal containers, result may be undefined.
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared Node TLS env — Linux nvm detection", () => {
|
||||
const nvmExecPath = "/home/user/.nvm/versions/node/v22.22.0/bin/node";
|
||||
const nonNvmExecPath = "/usr/bin/node";
|
||||
|
||||
const builders = [
|
||||
{
|
||||
name: "gateway service env",
|
||||
build: (
|
||||
env: Record<string, string | undefined>,
|
||||
platform?: NodeJS.Platform,
|
||||
execPath?: string,
|
||||
) => buildServiceEnvironment({ env, port: 18789, platform, execPath }),
|
||||
},
|
||||
{
|
||||
name: "node service env",
|
||||
build: (
|
||||
env: Record<string, string | undefined>,
|
||||
platform?: NodeJS.Platform,
|
||||
execPath?: string,
|
||||
) => buildNodeServiceEnvironment({ env, platform, execPath }),
|
||||
},
|
||||
] as const;
|
||||
|
||||
// The expected CA path depends on what the host actually has on disk.
|
||||
const expectedCaBundle = resolveLinuxSystemCaBundle();
|
||||
|
||||
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",
|
||||
nonNvmExecPath,
|
||||
);
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe(expectedCaBundle);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(builders)(
|
||||
"$name defaults NODE_EXTRA_CA_CERTS on Linux when execPath is under nvm",
|
||||
({ build }) => {
|
||||
const env = build({ HOME: "/home/user" }, "linux", nvmExecPath);
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe(expectedCaBundle);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(builders)(
|
||||
"$name does not default NODE_EXTRA_CA_CERTS on Linux without nvm",
|
||||
({ build }) => {
|
||||
const env = build({ HOME: "/home/user" }, "linux", nonNvmExecPath);
|
||||
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",
|
||||
nvmExecPath,
|
||||
);
|
||||
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" };
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { VERSION } from "../version.js";
|
||||
@ -15,6 +16,48 @@ import {
|
||||
resolveNodeWindowsTaskName,
|
||||
} from "./constants.js";
|
||||
|
||||
/** Known system CA bundle paths across common Linux distros. */
|
||||
const LINUX_CA_BUNDLE_PATHS = [
|
||||
"/etc/ssl/certs/ca-certificates.crt", // Debian, Ubuntu, Alpine
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", // RHEL, Fedora, CentOS
|
||||
"/etc/ssl/ca-bundle.pem", // openSUSE
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Find the system CA bundle on this Linux host.
|
||||
* Returns the first existing path, or `undefined` if none is found.
|
||||
*/
|
||||
export function resolveLinuxSystemCaBundle(): string | undefined {
|
||||
for (const candidate of LINUX_CA_BUNDLE_PATHS) {
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.R_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
@ -248,10 +291,17 @@ export function buildServiceEnvironment(params: {
|
||||
launchdLabel?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
extraPathDirs?: string[];
|
||||
/** Override process.execPath for nvm detection (testing). */
|
||||
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 +323,17 @@ export function buildNodeServiceEnvironment(params: {
|
||||
env: Record<string, string | undefined>;
|
||||
platform?: NodeJS.Platform;
|
||||
extraPathDirs?: string[];
|
||||
/** Override process.execPath for nvm detection (testing). */
|
||||
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 +373,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 +383,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 and the bundle exists on disk.
|
||||
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, execPath ?? process.execPath)
|
||||
? resolveLinuxSystemCaBundle()
|
||||
: undefined);
|
||||
const nodeUseSystemCa = env.NODE_USE_SYSTEM_CA ?? (platform === "darwin" ? "1" : undefined);
|
||||
return {
|
||||
stateDir,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user