diff --git a/AGENTS.md b/AGENTS.md index 4ca1949..988b368 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,24 @@ Node verifies TLS against its **own bundled Mozilla CA snapshot and never consul `describeNetworkError` unwraps Node's bare `fetch failed` (the real reason hides on `err.cause`) and appends a remedy for cert codes. It exists because **Node's own `--use-system-ca` hint pointedly excludes `SELF_SIGNED_CERT_IN_CHAIN`** — `crypto_common.cc` gates it on `DEPTH_ZERO_SELF_SIGNED_CERT` / `UNABLE_TO_VERIFY_LEAF_SIGNATURE` / `UNABLE_TO_GET_ISSUER_CERT` only, so the corporate-proxy case, the one the hint most exists for, is the one that prints nothing. If a cert error survives the merge, the root isn't in the OS store either, so the hint names `NODE_EXTRA_CA_CERTS` rather than `--use-system-ca`, which would be a dead end. Keep `certHint` **pure** — reading the OS store to word a sentence would put that same Windows stall on the error path, and the retry has already read it. -Note the blast radius: this only covers **our own** process. `npm i -g` during install and the agents themselves are separate processes behind the same proxy and need `NODE_EXTRA_CA_CERTS` / npm's `cafile` of their own. +### Child processes + +Merging CAs fixes *this* process only. `npm i -g` and the agents are separate processes behind the same proxy, each with its own trust store, so `ensureSystemCaBundle` writes every CA we trust to `~/.codev-hub/system-ca.pem` and `childCaEnv` points children at it via `NODE_EXTRA_CA_CERTS`. Both `execAsync` (npm, `code`, JetBrains CLIs, codegraph) and `runAgent` (the agents) inject it. + +Which children actually need it, and why the env var is the one we chose: + +| child | runtime | needs it? | +|---|---|---| +| npm | Node | **yes** — ignores the OS store, fails exactly like we did | +| opencode / codev-code | Bun | **yes** — ignores the OS store; honors `NODE_EXTRA_CA_CERTS` since Bun 1.1.22 | +| Claude Code | bun-native | no — reads the OS store itself ([its docs name Zscaler](https://code.claude.com/docs/en/network-config)) | +| Codex | Rust/rustls | no — reads the OS store via rustls-native-certs | + +- **`NODE_EXTRA_CA_CERTS` appends**, which is what makes it safe to set for all four: the two that don't need it are unharmed. Deliberately **not** npm's `cafile`/`ca` or Codex's `SSL_CERT_FILE` — those **replace** the root set, so pointing them at a corporate root breaks every other endpoint, and aiming them at our bundle could narrow trust for a tool that already works. +- **The bundle is the complete set** (`default` ∪ `system`), never just the corporate root — `default` carries the Mozilla roots plus any `NODE_EXTRA_CA_CERTS` the user set, so a child gains the proxy without losing anything. +- **Terminate every cert when concatenating.** Node returns the bundled certs *without* a trailing newline and the OS store's *with* one; a plain `join("")` produces `-----END CERTIFICATE----------BEGIN CERTIFICATE-----`, OpenSSL rejects the file ("bad end line"), and **Node only warns and ignores it** — so the whole feature silently does nothing. `tests/lib/tls.test.ts` fixtures deliberately preserve that newline skew; making them uniform is what let this ship once already. +- **A user's own `NODE_EXTRA_CA_CERTS` wins** — the var holds a single path, so ours would silently replace theirs. +- `execAsync` retries a child once when its *stderr* blames the chain (`outputHasCertError` — the text-level twin of `isCertError`, since a child's failure reaches us as output, not a typed error). Usually the bundle already exists, because `codevhub install` logs in before it installs; this covers the gap where nothing fetched first (a cached session, or `codevhub update`) and npm is the first thing on the machine to meet the proxy. ## Diagnostic logging diff --git a/src/lib/log.ts b/src/lib/log.ts index 0f10b8c..09ab109 100644 --- a/src/lib/log.ts +++ b/src/lib/log.ts @@ -9,7 +9,11 @@ import { import { join } from "node:path"; import { VERSION } from "@/lib/const.js"; import { cliLogsDir } from "@/lib/paths.js"; -import { applySystemCaCertsOnce, isCertError } from "@/lib/tls.js"; +import { + applySystemCaCertsOnce, + ensureSystemCaBundle, + isCertError, +} from "@/lib/tls.js"; // Runs a request, and if it fails because the certificate chain isn't trusted, // merges the OS trust store into Node's defaults and tries once more. @@ -36,9 +40,17 @@ async function fetchTrustingSystemCa( if (!isCertError(err)) throw err; const ca = applySystemCaCertsOnce(); if (ca?.status !== "merged") throw err; + // We now know this machine is behind an intercepting proxy. Persist the + // bundle so the children we spawn later — `npm install -g`, the agents — + // inherit the same trust instead of each rediscovering this the hard way. + const bundlePath = ensureSystemCaBundle(); logInfo("certificate chain untrusted; retrying with the OS CA store", { action: "http.request", - extra: { endpoint, ca_system_count: ca.systemCount }, + extra: { + endpoint, + ca_system_count: ca.systemCount, + ca_bundle: bundlePath, + }, }); return await fetch(input, init); } diff --git a/src/lib/npm.ts b/src/lib/npm.ts index 2ff91a5..557894f 100644 --- a/src/lib/npm.ts +++ b/src/lib/npm.ts @@ -3,6 +3,11 @@ import { existsSync, statSync } from "node:fs"; import { join } from "node:path"; import type { Tool } from "@/lib/configure.js"; import { logDebug, logWarn } from "@/lib/log.js"; +import { + childCaEnv, + ensureSystemCaBundle, + outputHasCertError, +} from "@/lib/tls.js"; // Tools installed via npm-global. Extension/plugin variants (Claude Code + // Continue) are not npm packages — VS Code installs them via @@ -61,7 +66,34 @@ export interface ExecResult { error: NodeJS.ErrnoException | null; } -export function execAsync(file: string, args: string[]): Promise { +// Runs a child, and if it failed because *it* didn't trust the certificate +// chain, writes the CA bundle and runs it once more with NODE_EXTRA_CA_CERTS. +// +// The bundle usually already exists by now — `codevhub install` logs in before +// it installs, so loggedFetch's own recovery has run. This covers the gap where +// nothing fetched first (a cached session, or `codevhub update`), leaving npm to +// be the first thing on the machine to meet the proxy. +// +// Only retries when the bundle is newly written: if the child still fails with +// the bundle in hand, its CA isn't in the OS store either and a second attempt +// would just be slower. +export async function execAsync( + file: string, + args: string[], +): Promise { + const first = await execOnce(file, args); + if (!first.error || !outputHasCertError(first.stderr)) return first; + if (childCaEnv().NODE_EXTRA_CA_CERTS) return first; + const bundlePath = ensureSystemCaBundle(); + if (!bundlePath) return first; + logDebug(`retrying ${file} with the OS CA bundle`, { + action: "process.spawn", + extra: { command: file, ca_bundle: bundlePath }, + }); + return execOnce(file, args); +} + +function execOnce(file: string, args: string[]): Promise { // Every child process codev shells out to funnels through here (npm, the // agent --version probes, `code --install-extension`, JetBrains CLIs, // codegraph), so this one seam gives the diagnostic log full child-process @@ -72,6 +104,9 @@ export function execAsync(file: string, args: string[]): Promise { eventType: "start", extra: { command: file, args }, }); + // npm and the Bun-based agents ignore the OS trust store, so hand them our + // bundle when one exists. Costs a single existsSync on the happy path. + const env: NodeJS.ProcessEnv = { ...process.env, ...childCaEnv() }; const startedAt = Date.now(); return new Promise((resolve) => { const done = ( @@ -119,12 +154,12 @@ export function execAsync(file: string, args: string[]): Promise { if (USE_SHELL) { execFile( `${file} ${args.join(" ")}`, - { shell: true, encoding: "utf-8" }, + { shell: true, encoding: "utf-8", env }, (err, stdout, stderr) => done(err as NodeJS.ErrnoException | null, stdout, stderr), ); } else { - execFile(file, args, { encoding: "utf-8" }, (err, stdout, stderr) => + execFile(file, args, { encoding: "utf-8", env }, (err, stdout, stderr) => done(err as NodeJS.ErrnoException | null, stdout, stderr), ); } diff --git a/src/lib/run.ts b/src/lib/run.ts index 9b43b54..6f96b29 100644 --- a/src/lib/run.ts +++ b/src/lib/run.ts @@ -5,6 +5,7 @@ import { delimiter, join } from "node:path"; import { logError, logInfo, logWarn } from "@/lib/log.js"; import { claudeNativeBinaryMissing } from "@/lib/npm.js"; import { stripShimDirFromPath } from "@/lib/shims.js"; +import { childCaEnv } from "@/lib/tls.js"; const AGENT_LABEL: Record = { claude: "Claude Code", @@ -60,6 +61,13 @@ export function runAgent(cmd: string, args: string[]): Promise { const env: NodeJS.ProcessEnv = { ...process.env, PATH: stripShimDirFromPath(process.env.PATH), + // OpenCode and CoDev Code are Bun binaries, which ignore the OS trust + // store — behind an intercepting proxy they fail like we did until + // handed our bundle. Claude Code and Codex read the OS store natively + // and don't need this; it's harmless to them because + // NODE_EXTRA_CA_CERTS appends rather than replaces. Only set once + // something has detected interception, so this is one existsSync. + ...childCaEnv(), }; // CoDev Code (the codev-code package) has its own self-updater, but the // hub owns updates (`codevhub update`) — disable the agent's updater at diff --git a/src/lib/tls.ts b/src/lib/tls.ts index 9a05387..b7a5480 100644 --- a/src/lib/tls.ts +++ b/src/lib/tls.ts @@ -1,3 +1,6 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import tls from "node:tls"; // Node verifies TLS against its own bundled Mozilla CA snapshot and never @@ -94,6 +97,89 @@ function mergeSystemCaCerts(): CaMergeResult { // Test-only: forget that the merge ran so each case starts clean. export function resetSystemCaCertsCache(): void { attempted = false; + bundle = undefined; +} + +// A PEM bundle of every CA we trust, for handing to child processes. +// +// applySystemCaCertsOnce only fixes *this* process. `npm install -g` and the +// agents are separate processes behind the same proxy, and each has its own +// trust store: +// - npm (Node) ignores the OS store, so it fails exactly like we did. It +// honors NODE_EXTRA_CA_CERTS, which *appends* to the defaults. (Its own +// `cafile`/`ca` config REPLACES the root set, so pointing that at a +// corporate root would break every other registry — don't.) +// - opencode / codev-code (Bun) also ignore the OS store by default, and +// honor NODE_EXTRA_CA_CERTS since Bun 1.1.22. +// - Claude Code reads the OS store itself (its docs name Zscaler), and Codex +// reads it via rustls-native-certs. Neither needs us; NODE_EXTRA_CA_CERTS +// is merely harmless to them because it appends. +// Deliberately NOT SSL_CERT_FILE (Codex's knob): it *replaces* the trust store +// rather than appending, so aiming it at this bundle could narrow trust for a +// tool that already works. Leave the natively-fine tools alone. +export function systemCaBundlePath(): string { + return join(homedir(), ".codev-hub", "system-ca.pem"); +} + +let bundle: string | null | undefined; + +// Writes the bundle, once per process. Returns its path, or null when there's +// nothing useful to write. +// +// Only ever called once we've *seen* a certificate failure, for the same reason +// the merge is: reading the OS store is a synchronous ~300ms stall on Windows. +// Unaffected users never reach this. +export function ensureSystemCaBundle(): string | null { + if (bundle !== undefined) return bundle; + bundle = writeSystemCaBundle(); + return bundle; +} + +function writeSystemCaBundle(): string | null { + if (!tlsApi.supported()) return null; + try { + const system = tlsApi.getCACertificates("system"); + if (system.length === 0) return null; + // Write the *complete* set, not just the corporate root: "default" carries + // the bundled Mozilla roots plus any NODE_EXTRA_CA_CERTS the user already + // configured, so a child pointed at this file trusts everything it used to + // plus the proxy. + const certs = [ + ...new Set([...tlsApi.getCACertificates("default"), ...system]), + ]; + // Terminate every cert ourselves. Node's bundled certs come back WITHOUT a + // trailing newline while the OS store's carry one, so a plain join glues + // `-----END CERTIFICATE----------BEGIN CERTIFICATE-----` together and + // OpenSSL rejects the whole file ("bad end line"). Node then only warns and + // ignores the file, so getting this wrong silently does nothing. + const pem = certs + .map((cert) => (cert.endsWith("\n") ? cert : `${cert}\n`)) + .join(""); + const path = systemCaBundlePath(); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, pem); + return path; + } catch { + // Best-effort: a child that can't be helped fails with its own error, + // which is no worse than before. + return null; + } +} + +// Env additions that make a spawned child trust what we trust. +// +// Cheap by design — one existsSync, no OS-store read — because every spawn pays +// it. The bundle only exists once something has detected interception, so +// unaffected users get an empty object forever. +export function childCaEnv(env: NodeJS.ProcessEnv = process.env): { + NODE_EXTRA_CA_CERTS?: string; +} { + // A user who set this themselves has made a deliberate choice; ours would + // silently replace it (the var takes a single path, not a list). + if (env.NODE_EXTRA_CA_CERTS) return {}; + const path = systemCaBundlePath(); + if (!existsSync(path)) return {}; + return { NODE_EXTRA_CA_CERTS: path }; } // OpenSSL verify failures that mean "I don't trust this chain", as opposed to @@ -118,6 +204,27 @@ export function isCertError(err: unknown): boolean { return code !== undefined && CERT_ERROR_CODES.has(code); } +// True when a child process's output blames the certificate chain. +// +// A child's failure reaches us as text, not a typed error, so this is the +// stderr equivalent of isCertError. Matches the OpenSSL codes (npm prints +// `code SELF_SIGNED_CERT_IN_CHAIN`) and the message text other runtimes use — +// both spellings, since OpenSSL 3.2 hyphenated "self-signed". +const CERT_ERROR_TEXT = [ + ...CERT_ERROR_CODES, + "self-signed certificate", + "self signed certificate", + "unable to get local issuer certificate", + "unable to verify the first certificate", +]; + +export function outputHasCertError(text: string): boolean { + const haystack = text.toLowerCase(); + return CERT_ERROR_TEXT.some((needle) => + haystack.includes(needle.toLowerCase()), + ); +} + // Pure: reading the OS store here would put a ~300ms Windows stall on the error // path just to word a sentence. By the time this runs, loggedFetch has already // tried the merge and retried, so "not in your store either" is accurate. diff --git a/tests/lib/npm.test.ts b/tests/lib/npm.test.ts index acd9e0b..557a93d 100644 --- a/tests/lib/npm.test.ts +++ b/tests/lib/npm.test.ts @@ -1,7 +1,9 @@ import * as child_process from "node:child_process"; import * as fs from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { claudeNativeBinaryMissing, detectInstalledViaNpm, @@ -11,6 +13,12 @@ import { npmGlobalRoot, verifyInstall, } from "@/lib/npm.js"; +import { + ensureSystemCaBundle, + resetSystemCaCertsCache, + systemCaBundlePath, + tlsApi, +} from "@/lib/tls.js"; // ESM module namespaces are frozen — vi.spyOn can't redefine `execFile` / // `existsSync` directly. We replace them up-front with vi.fn() via vi.mock() @@ -87,6 +95,117 @@ afterEach(() => { vi.mocked(fs.statSync).mockReset(); }); +// `npm install -g` is a separate process with its own trust store: Node ignores +// the OS store, so behind an intercepting proxy npm fails exactly like our own +// fetch did. These pin the recovery. +describe("execAsync CA recovery", () => { + const PEM_A = "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n"; + const PEM_B = "-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n"; + let tempDir: string; + + // Captures the env each child was spawned with, which the shared stub drops. + function stubExecFileCapturingEnv( + handler: (n: number) => { + error?: Error | null; + stdout?: string; + stderr?: string; + }, + ): NodeJS.ProcessEnv[] { + const envs: NodeJS.ProcessEnv[] = []; + vi.mocked(child_process.execFile).mockImplementation((( + ...callArgs: unknown[] + ) => { + const cb = callArgs[callArgs.length - 1] as ExecCb; + const opts = callArgs.find( + (a): a is { env?: NodeJS.ProcessEnv } => + typeof a === "object" && a !== null && !Array.isArray(a), + ); + envs.push(opts?.env ?? {}); + const r = handler(envs.length); + setImmediate(() => cb(r.error ?? null, r.stdout ?? "", r.stderr ?? "")); + return {} as unknown as child_process.ChildProcess; + }) as unknown as typeof child_process.execFile); + return envs; + } + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), "codev-npm-ca-")); + vi.stubEnv("HOME", tempDir); + vi.stubEnv("USERPROFILE", tempDir); + vi.stubEnv("NODE_EXTRA_CA_CERTS", undefined); + // node:fs is module-mocked here, and the shared afterEach resets the impl. + // childCaEnv must see real disk: a stub that claims the bundle exists + // before it's written makes execAsync think the child was already helped + // and skip the retry. + const actualFs = await vi.importActual("node:fs"); + vi.mocked(fs.existsSync).mockImplementation(actualFs.existsSync); + vi.spyOn(tlsApi, "getCACertificates").mockImplementation((type) => + type === "system" ? [PEM_B] : [PEM_A], + ); + }); + + afterEach(() => { + resetSystemCaCertsCache(); + vi.unstubAllEnvs(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("retries a cert-failed install with the CA bundle", async () => { + const envs = stubExecFileCapturingEnv((n) => + n === 1 + ? { + error: new Error("Command failed"), + stderr: "npm error code SELF_SIGNED_CERT_IN_CHAIN", + } + : { stdout: "ok" }, + ); + + const err = await installPackage("some-pkg"); + + expect(err).toBeNull(); + expect(envs.length).toBe(2); + // First attempt is unaided — nothing had detected interception yet. + expect(envs[0]?.NODE_EXTRA_CA_CERTS).toBeUndefined(); + expect(envs[1]?.NODE_EXTRA_CA_CERTS).toBe(systemCaBundlePath()); + }); + + test("hands the bundle to the first attempt once it exists", async () => { + ensureSystemCaBundle(); + const envs = stubExecFileCapturingEnv(() => ({ stdout: "ok" })); + + await installPackage("some-pkg"); + + expect(envs.length).toBe(1); + expect(envs[0]?.NODE_EXTRA_CA_CERTS).toBe(systemCaBundlePath()); + }); + + test("does not retry an ordinary npm failure", async () => { + const envs = stubExecFileCapturingEnv(() => ({ + error: new Error("Command failed"), + stderr: "npm error 404 Not Found - GET https://registry/some-pkg", + })); + + const err = await installPackage("some-pkg"); + + expect(err).toContain("404"); + expect(envs.length).toBe(1); + }); + + // Retrying with the same env would just be slower — the CA isn't in the OS + // store either, so nothing changed between attempts. + test("gives up when there is no bundle to write", async () => { + vi.spyOn(tlsApi, "getCACertificates").mockReturnValue([]); + const envs = stubExecFileCapturingEnv(() => ({ + error: new Error("Command failed"), + stderr: "npm error code SELF_SIGNED_CERT_IN_CHAIN", + })); + + await installPackage("some-pkg"); + + expect(envs.length).toBe(1); + }); +}); + describe("npm.ts", () => { describe("installPackage", () => { test("runs npm i -g with hardening flags", async () => { diff --git a/tests/lib/run.test.ts b/tests/lib/run.test.ts index e9489d5..fabb3d3 100644 --- a/tests/lib/run.test.ts +++ b/tests/lib/run.test.ts @@ -7,6 +7,12 @@ import type { MockInstance } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { claudeNativeBinaryMissing } from "@/lib/npm.js"; import { agentOnPath, runAgent, spawner } from "@/lib/run.js"; +import { + ensureSystemCaBundle, + resetSystemCaCertsCache, + systemCaBundlePath, + tlsApi, +} from "@/lib/tls.js"; // Stub the native-binary probe so the runtime repair hint can be exercised // without a real npm-global Claude Code install. Defaults to "present" so @@ -255,6 +261,46 @@ describe("runAgent", () => { vi.unstubAllEnvs(); } }); + + // OpenCode and CoDev Code are Bun binaries and ignore the OS trust store, so + // behind an intercepting proxy they need our bundle handed to them. + describe("system CA bundle", () => { + beforeEach(() => { + vi.stubEnv("HOME", tempDir); + vi.stubEnv("USERPROFILE", tempDir); + vi.stubEnv("NODE_EXTRA_CA_CERTS", undefined); + }); + + afterEach(() => { + resetSystemCaCertsCache(); + vi.unstubAllEnvs(); + // This file's top-level afterEach only restores the console spy, so the + // tlsApi spy would otherwise carry its call history into the next case. + vi.restoreAllMocks(); + }); + + test("passes NODE_EXTRA_CA_CERTS once the bundle exists", async () => { + vi.spyOn(tlsApi, "getCACertificates").mockReturnValue([ + "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n", + ]); + ensureSystemCaBundle(); + + const env = await runCapturingEnv("codev"); + + expect(env?.NODE_EXTRA_CA_CERTS).toBe(systemCaBundlePath()); + }); + + // Nothing detected interception for this user, so the agent must launch + // exactly as it did before — no bundle, no env var, no OS-store read. + test("passes nothing when no bundle has been written", async () => { + const get = vi.spyOn(tlsApi, "getCACertificates"); + + const env = await runCapturingEnv("codev"); + + expect(env?.NODE_EXTRA_CA_CERTS).toBeUndefined(); + expect(get).not.toHaveBeenCalled(); + }); + }); }); describe("agentOnPath", () => { diff --git a/tests/lib/tls.test.ts b/tests/lib/tls.test.ts index 51778ec..c66a1b3 100644 --- a/tests/lib/tls.test.ts +++ b/tests/lib/tls.test.ts @@ -1,17 +1,43 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { applySystemCaCertsOnce, + childCaEnv, describeNetworkError, + ensureSystemCaBundle, isCertError, + outputHasCertError, resetSystemCaCertsCache, + systemCaBundlePath, tlsApi, } from "@/lib/tls.js"; +let tempDir: string; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-tls-test-")); + vi.stubEnv("HOME", tempDir); + vi.stubEnv("USERPROFILE", tempDir); + // A stray real NODE_EXTRA_CA_CERTS in the dev's shell would make childCaEnv + // bow out and quietly neuter these cases. + vi.stubEnv("NODE_EXTRA_CA_CERTS", undefined); +}); + afterEach(() => { resetSystemCaCertsCache(); + vi.unstubAllEnvs(); vi.restoreAllMocks(); + rmSync(tempDir, { recursive: true, force: true }); }); +// Node hands back the two stores in different shapes: the bundled Mozilla certs +// have NO trailing newline, the OS store's do. Fixtures must keep that skew — +// making them uniform is what let a bundle ship that OpenSSL rejected outright. +const PEM_A = "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----"; +const PEM_B = "-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n"; + // Builds the error Node's fetch actually throws: a bare `fetch failed` with the // real reason hidden on `cause`. function fetchError(code: string, message: string): Error { @@ -85,6 +111,113 @@ describe("applySystemCaCertsOnce", () => { }); }); +describe("ensureSystemCaBundle", () => { + test("writes the complete trust set, not just the corporate root", () => { + // SSL_CERT_FILE-style consumers replace their store with this file, and a + // child handed only the proxy root would lose every public CA. + vi.spyOn(tlsApi, "getCACertificates").mockImplementation((type) => + type === "system" ? [PEM_B] : [PEM_A], + ); + + const path = ensureSystemCaBundle(); + + expect(path).toBe(systemCaBundlePath()); + const written = readFileSync(path as string, "utf-8"); + // Every cert newline-terminated exactly once: PEM_A arrives without one + // and must gain it, PEM_B already has one and must not gain a second. + expect(written).toBe(`${PEM_A}\n${PEM_B}`); + expect(written).not.toContain("-----END CERTIFICATE----------BEGIN"); + }); + + test("dedupes certs present in both stores", () => { + vi.spyOn(tlsApi, "getCACertificates").mockReturnValue([PEM_A]); + + const path = ensureSystemCaBundle(); + + expect(readFileSync(path as string, "utf-8")).toBe(`${PEM_A}\n`); + }); + + test("writes once per process", () => { + const get = vi + .spyOn(tlsApi, "getCACertificates") + .mockImplementation((type) => (type === "system" ? [PEM_B] : [PEM_A])); + + expect(ensureSystemCaBundle()).toBe(systemCaBundlePath()); + expect(ensureSystemCaBundle()).toBe(systemCaBundlePath()); + expect(get).toHaveBeenCalledTimes(2); // one "system" + one "default" + }); + + test("writes nothing when the OS store is empty or unreadable", () => { + vi.spyOn(tlsApi, "getCACertificates").mockReturnValue([]); + + expect(ensureSystemCaBundle()).toBeNull(); + expect(existsSync(systemCaBundlePath())).toBe(false); + }); + + test("writes nothing on a Node without the CA APIs", () => { + vi.spyOn(tlsApi, "supported").mockReturnValue(false); + + expect(ensureSystemCaBundle()).toBeNull(); + expect(existsSync(systemCaBundlePath())).toBe(false); + }); +}); + +describe("childCaEnv", () => { + // The whole design rests on this: unaffected users must never get the var, + // since nothing ever detected interception for them. + test("is empty until something has written the bundle", () => { + expect(childCaEnv()).toEqual({}); + }); + + test("points children at the bundle once it exists", () => { + vi.spyOn(tlsApi, "getCACertificates").mockImplementation((type) => + type === "system" ? [PEM_B] : [PEM_A], + ); + ensureSystemCaBundle(); + + expect(childCaEnv()).toEqual({ NODE_EXTRA_CA_CERTS: systemCaBundlePath() }); + }); + + test("defers to a NODE_EXTRA_CA_CERTS the user set themselves", () => { + vi.spyOn(tlsApi, "getCACertificates").mockImplementation((type) => + type === "system" ? [PEM_B] : [PEM_A], + ); + ensureSystemCaBundle(); + + // The var holds a single path, so ours would silently replace theirs. + expect(childCaEnv({ NODE_EXTRA_CA_CERTS: "/corp/root.pem" })).toEqual({}); + }); +}); + +describe("outputHasCertError", () => { + test("matches what npm and other runtimes actually print", () => { + expect(outputHasCertError("npm error code SELF_SIGNED_CERT_IN_CHAIN")).toBe( + true, + ); + expect( + outputHasCertError( + "request to https://registry.npmjs.org failed, " + + "reason: self-signed certificate in certificate chain", + ), + ).toBe(true); + // OpenSSL only hyphenated "self-signed" in 3.2; older builds print this. + expect( + outputHasCertError("self signed certificate in certificate chain"), + ).toBe(true); + expect(outputHasCertError("unable to get local issuer certificate")).toBe( + true, + ); + }); + + test("does not fire for ordinary npm failures", () => { + expect( + outputHasCertError("npm error 404 Not Found - GET https://x/y"), + ).toBe(false); + expect(outputHasCertError("npm error network ETIMEDOUT")).toBe(false); + expect(outputHasCertError("")).toBe(false); + }); +}); + describe("isCertError", () => { test("recognizes the corporate-proxy chain error", () => { expect(