Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 14 additions & 2 deletions src/lib/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
}
Expand Down
41 changes: 38 additions & 3 deletions src/lib/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,7 +66,34 @@ export interface ExecResult {
error: NodeJS.ErrnoException | null;
}

export function execAsync(file: string, args: string[]): Promise<ExecResult> {
// 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<ExecResult> {
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<ExecResult> {
// 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
Expand All @@ -72,6 +104,9 @@ export function execAsync(file: string, args: string[]): Promise<ExecResult> {
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 = (
Expand Down Expand Up @@ -119,12 +154,12 @@ export function execAsync(file: string, args: string[]): Promise<ExecResult> {
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),
);
}
Expand Down
8 changes: 8 additions & 0 deletions src/lib/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
claude: "Claude Code",
Expand Down Expand Up @@ -60,6 +61,13 @@ export function runAgent(cmd: string, args: string[]): Promise<number> {
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
Expand Down
107 changes: 107 additions & 0 deletions src/lib/tls.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading
Loading