Skip to content

fix: hand the OS CA bundle to npm and the agents behind TLS proxies#206

Open
quickbeard wants to merge 1 commit into
mainfrom
fix/child-process-ca-trust
Open

fix: hand the OS CA bundle to npm and the agents behind TLS proxies#206
quickbeard wants to merge 1 commit into
mainfrom
fix/child-process-ca-trust

Conversation

@quickbeard

Copy link
Copy Markdown
Owner

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.

The fix

ensureSystemCaBundle writes every CA we trust to ~/.codev-hub/system-ca.pem; childCaEnv points children at it with NODE_EXTRA_CA_CERTS. Injected from both execAsync (npm, code, JetBrains CLIs, codegraph) and runAgent (the agents).

Only two of the four children actually need it:

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 (docs name Zscaler)
Codex Rust/rustls no — reads the OS store via rustls-native-certs

Key decisions:

  • 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 (RFC #748) 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 (defaultsystem), never just the corporate root — default carries the Mozilla roots plus any NODE_EXTRA_CA_CERTS the user already 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 rule as fix: trust the OS certificate store so login works behind TLS proxies #205's merge: the OS-store read is a synchronous ~300ms stall on Windows (that PR's CI regression). Unaffected users pay one existsSync per spawn and nothing else.
  • execAsync retries a child once when its stderr blames the chain (outputHasCertError, the text-level twin of isCertError — a child's failure reaches us as output, not a typed error). The bundle usually exists already, since 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.

Two bugs end-to-end testing caught that unit tests could not

  1. The bundle was invalid PEM. Node returns the bundled certs without a trailing newline but the OS store's with one, so join("") produced -----END CERTIFICATE----------BEGIN CERTIFICATE-----. OpenSSL rejects the file (bad end line) and Node merely warns and ignores it — the entire feature silently did nothing while every unit test passed, because my fixtures all ended in \n. Certs are now terminated explicitly, and the fixtures deliberately preserve the newline skew. Re-running with the naive join("") now fails two tests.

  2. My first check of the PEM format sampled sys[0] ?? def[0], which picked a system cert — the one shape that happens to be newline-terminated. Sampling one store hid the skew entirely.

Verification

Drove a real child process against a server presenting an intercepted chain (leaf + untrusted self-signed root) — the shape of npm install -g behind the proxy:

child without the bundle        -> FAILED SELF_SIGNED_CERT_IN_CHAIN
child with NODE_EXTRA_CA_CERTS  -> OK 200

And the bundle is complete, not narrowing: openssl storeutl parses 146 certs = 145 public defaults + the corporate root.

Unit tests pin the bundle contents, dedup, newline handling, the user's-var-wins rule, the npm retry (including that it does not retry a 404), and that an unaffected agent launch reads neither the bundle nor the OS store. pnpm fix / typecheck / test (960 passing) / build && node dist/index.js --version all green.

Note for reviewers

Bun has two open reports that touch this exact path — #24581 (NODE_EXTRA_CA_CERTS not working in 1.3.2) and #23735 (1.3.0 stopped using the system store by default). Neither is confirmed resolved. If OpenCode/CoDev Code still fail behind a proxy with the bundle set, the fallback is NODE_USE_SYSTEM_CA=1 (Bun ≥1.2.23) — deliberately not set here, since it would make each agent launch re-read the OS store rather than use the file we already wrote.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant