From f02d3ccc6f7e42ad9761ca032caa185b70899438 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 17 Jul 2026 15:37:10 +0700 Subject: [PATCH] fix: hand the OS CA bundle to npm and the agents behind TLS proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #205, which fixed only our own process. `npm i -g` and the agents are separate processes behind the same proxy, each with its own trust store, so an affected user could get past login and still fail at install or agent launch. Write every CA we trust to ~/.codev-hub/system-ca.pem and point children at it with NODE_EXTRA_CA_CERTS, from both execAsync (npm, code, JetBrains CLIs, codegraph) and runAgent (the agents). Who actually needs it: npm Node yes — ignores the OS store opencode / codev-code Bun yes — ignores the OS store Claude Code bun-native no — reads the OS store itself Codex Rust/rustls no — reads it 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` or Codex's SSL_CERT_FILE — those REPLACE the root set, so they'd break 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. A user's own NODE_EXTRA_CA_CERTS wins — the var holds a single path, so ours would silently replace theirs. Written only once something has *seen* a cert failure, same as the merge: the OS-store read is a synchronous ~300ms stall on Windows (the #205 CI regression). Unaffected users pay one existsSync per spawn and nothing else. execAsync also retries a child once when its stderr blames the chain, covering the gap where nothing fetched first (a cached session, or `codevhub update`) and npm is the first thing to meet the proxy. Two things end-to-end testing caught that unit tests could not: - Node returns bundled certs WITHOUT a trailing newline but the OS store's WITH one, so a plain join("") produced `-----END CERTIFICATE----------BEGIN CERTIFICATE-----`. OpenSSL rejects the file ("bad end line") and Node merely warns and ignores it — the whole feature silently did nothing. Every cert is now terminated explicitly, and the fixtures preserve that newline skew, since uniform fixtures are what hid it. - Verified a real child process against a server presenting an intercepted chain: without the bundle it fails with the users' exact SELF_SIGNED_CERT_IN_CHAIN; with it, HTTP 200. The bundle parses as 146 certs (145 public + the corporate root) — nothing narrowed. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 19 +++++- src/lib/log.ts | 16 ++++- src/lib/npm.ts | 41 ++++++++++++- src/lib/run.ts | 8 +++ src/lib/tls.ts | 107 +++++++++++++++++++++++++++++++++ tests/lib/npm.test.ts | 121 ++++++++++++++++++++++++++++++++++++- tests/lib/run.test.ts | 46 ++++++++++++++ tests/lib/tls.test.ts | 135 +++++++++++++++++++++++++++++++++++++++++- 8 files changed, 485 insertions(+), 8 deletions(-) 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(