Summary
abctl configure claude-code status reports only which env keys are set. It never stats the CA file, parses the certificate, checks expiry, or compares the CA against what the running proxy actually signs with. So the most common real-world TLS-bridge failure is invisible to the one command users are told to run.
The gap is self-evident from the code: claudeCodeStatus (authbridge/cmd/abctl/cmd_claudecode.go:567-592) iterates managedKeys, prints each as KEY=value or KEY (unset), and concludes enabled when all are present:
if set == len(managedKeys) {
fmt.Fprintf(stdout, "enabled in %s\n", settingsPath)
A config pointing NODE_EXTRA_CA_CERTS at a deleted, unreadable, or wrong CA reports enabled.
The irony worth fixing: enable already advises users to check with this exact command for a condition it cannot detect — cmd_claudecode.go:427 tells the user "Start Cortex, then check with: abctl configure claude-code status".
Why it matters — the failure this hid
Debugged on a laptop (see #1104 for the installer-side fix). Two proxies from different installs were fighting over :47600. The one holding the port signed with ca_dir=<checkout>/.cortex/ca, while every client trusted ~/.cortex/ca — different CA fingerprints. Intercepted requests failed certificate verification and surfaced to the user as:
API Error: Unable to connect to API: Self-signed certificate detected.
Throughout, status would have reported enabled: all four keys were set, and the path existed and was readable. The reinstall had left four independent CAs on disk, all with CN=authbridge-tls-bridge-ca and all with different fingerprints. Nothing in the tooling reconciles or reports on them.
Proposed checks
Extend claudeCodeStatus to report, per CA-bearing key:
-
Exists / readable — os.Stat plus a read attempt. Note NODE_EXTRA_CA_CERTS extends Node's roots, so an unreadable file does not break TLS: Node prints Warning: Ignoring extra certs from ..., load failed and continues. Verified — a request still returned 200. That silence is exactly why it needs surfacing here.
-
Parses as a certificate, and whether it is the bridge CA — reuse parseBridgeCA / parseBridgeCAPEM (cmd/authbridge-proxy/local.go:144-160), which return nil unless Subject.CommonName == bridgeCACommonName.
-
Not expired / not near expiry — the CA has a 365-day lifetime and the proxy renews within 30 days of NotAfter (caRenewBefore, authlib/tlsbridge/ca.go:196; caNeedsRenewal, :239-253). Renewal is evaluated at startup only, so a long-running proxy sails past the window — worth warning about.
-
Matches the CA the proxy is actually configured with — compare against tls_bridge.ca_dir from the loaded config. This is the check that catches the bug above.
-
(Optional, highest value) Matches the CA the live listener presents. A config-level comparison still misses a foreign proxy holding the port. Note the retrieval detail: a forward proxy presents no certificate on a bare TCP connect — the CA must be read through a CONNECT tunnel, where the bridge sends it as the second cert in the chain. Verified equivalent to this shell:
echo | openssl s_client -proxy 127.0.0.1:47600 -connect example.com:443 \
-servername example.com -showcerts 2>/dev/null \
| awk '/BEGIN CERT/{n++} n==2{print} /END CERT/{if(n==2) exit}' \
| openssl x509 -noout -fingerprint -sha256
Because this reaches out to a third-party host, it should probably be opt-in behind a flag.
Reusable pieces
| What |
Where |
Note |
FingerprintSHA256(crt *x509.Certificate) string |
authlib/tlsbridge/ca.go:267 |
Exported. Uppercase colon-separated hex, deliberately matching openssl x509 -noout -fingerprint -sha256 so a human can compare output directly. |
staleClientCAWarning(...) |
cmd/authbridge-proxy/local.go:106-137 |
Closest existing analogue. Its doc comment (:85-105) explains why it compares certificates, not paths — the configured file is often a system root bundle behind a corporate proxy, and path comparison both false-fired and gave wrong advice. Unexported in package main: lift the pattern, not the symbol. |
parseBridgeCA / parseBridgeCAPEM |
cmd/authbridge-proxy/local.go:144-160 |
The CN filter is what keeps diagnostics silent on foreign certs. Also unexported. |
caNeedsRenewal / caRenewBefore |
authlib/tlsbridge/ca.go:239-253, :196 |
Expiry-window logic. |
Gotchas for whoever picks this up
-
Compare certificates, not paths. Already learned once in staleClientCAWarning — read its doc comment before designing the comparison.
-
Fingerprints, not issuer names. All four leftover CAs on the reproducing machine shared CN=authbridge-tls-bridge-ca. The name proves nothing; only the fingerprint (or serial) discriminates. Each regeneration mints a new random 128-bit serial (ca.go:53), so serial works too.
-
bundle.crt mtime is not a staleness signal. EnsureTrustBundle recomputes it on every start but writes only on content change (authlib/tlsbridge/bundle.go:151-153), and concatCertDir sorts filenames to keep the bytes stable across boots. Compare content, not timestamps.
-
ca.crt always lands before bundle.crt — EnsureFileSource (cmd/authbridge-proxy/main.go:716) then EnsureTrustBundle (:769). enable already relies on this ordering to warn about the two files separately (cmd_claudecode.go:436-444).
-
The two kinds of CA var are not interchangeable. NODE_EXTRA_CA_CERTS extends the trust store, so ca.crt is correct for it. SSL_CERT_FILE / REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE replace it, so they need bundle.crt (CA + system roots) or ordinary HTTPS stops verifying. Any "here's the fix" output must respect that distinction.
-
When no system root store is found, EnsureTrustBundle deliberately leaves an old bundle in place (bundle.go:136-139), which on a renewal boot pins a stale bridge CA — called out at main.go:760-768. A status check should be able to say this happened.
-
Exit code. claudeCodeStatus currently always returns 0. Decide deliberately whether a detected mismatch should make it non-zero — useful for scripting, but a behavior change for anything parsing it today.
Repro
- Start a proxy with
--local from some checkout, so it mints its own CA under <checkout>/.cortex/ca.
- Run
abctl configure claude-code enable against ~/.cortex, so clients are pointed at ~/.cortex/ca/ca.crt.
- Confirm the two
ca.crt fingerprints differ (openssl x509 -noout -fingerprint -sha256 -in ...).
abctl configure claude-code status → reports enabled, with no indication anything is wrong.
- Any intercepted request fails certificate verification.
Scope
Diagnostics only. Deliberately excluded:
Assisted-By: Claude (Anthropic AI) noreply@anthropic.com
Summary
abctl configure claude-code statusreports only which env keys are set. It never stats the CA file, parses the certificate, checks expiry, or compares the CA against what the running proxy actually signs with. So the most common real-world TLS-bridge failure is invisible to the one command users are told to run.The gap is self-evident from the code:
claudeCodeStatus(authbridge/cmd/abctl/cmd_claudecode.go:567-592) iteratesmanagedKeys, prints each asKEY=valueorKEY (unset), and concludesenabledwhen all are present:A config pointing
NODE_EXTRA_CA_CERTSat a deleted, unreadable, or wrong CA reportsenabled.The irony worth fixing:
enablealready advises users to check with this exact command for a condition it cannot detect —cmd_claudecode.go:427tells the user "Start Cortex, then check with: abctl configure claude-code status".Why it matters — the failure this hid
Debugged on a laptop (see #1104 for the installer-side fix). Two proxies from different installs were fighting over
:47600. The one holding the port signed withca_dir=<checkout>/.cortex/ca, while every client trusted~/.cortex/ca— different CA fingerprints. Intercepted requests failed certificate verification and surfaced to the user as:Throughout,
statuswould have reportedenabled: all four keys were set, and the path existed and was readable. The reinstall had left four independent CAs on disk, all withCN=authbridge-tls-bridge-caand all with different fingerprints. Nothing in the tooling reconciles or reports on them.Proposed checks
Extend
claudeCodeStatusto report, per CA-bearing key:Exists / readable —
os.Statplus a read attempt. NoteNODE_EXTRA_CA_CERTSextends Node's roots, so an unreadable file does not break TLS: Node printsWarning: Ignoring extra certs from ..., load failedand continues. Verified — a request still returned200. That silence is exactly why it needs surfacing here.Parses as a certificate, and whether it is the bridge CA — reuse
parseBridgeCA/parseBridgeCAPEM(cmd/authbridge-proxy/local.go:144-160), which return nil unlessSubject.CommonName == bridgeCACommonName.Not expired / not near expiry — the CA has a 365-day lifetime and the proxy renews within 30 days of
NotAfter(caRenewBefore,authlib/tlsbridge/ca.go:196;caNeedsRenewal,:239-253). Renewal is evaluated at startup only, so a long-running proxy sails past the window — worth warning about.Matches the CA the proxy is actually configured with — compare against
tls_bridge.ca_dirfrom the loaded config. This is the check that catches the bug above.(Optional, highest value) Matches the CA the live listener presents. A config-level comparison still misses a foreign proxy holding the port. Note the retrieval detail: a forward proxy presents no certificate on a bare TCP connect — the CA must be read through a CONNECT tunnel, where the bridge sends it as the second cert in the chain. Verified equivalent to this shell:
Because this reaches out to a third-party host, it should probably be opt-in behind a flag.
Reusable pieces
FingerprintSHA256(crt *x509.Certificate) stringauthlib/tlsbridge/ca.go:267openssl x509 -noout -fingerprint -sha256so a human can compare output directly.staleClientCAWarning(...)cmd/authbridge-proxy/local.go:106-137:85-105) explains why it compares certificates, not paths — the configured file is often a system root bundle behind a corporate proxy, and path comparison both false-fired and gave wrong advice. Unexported inpackage main: lift the pattern, not the symbol.parseBridgeCA/parseBridgeCAPEMcmd/authbridge-proxy/local.go:144-160caNeedsRenewal/caRenewBeforeauthlib/tlsbridge/ca.go:239-253,:196Gotchas for whoever picks this up
Compare certificates, not paths. Already learned once in
staleClientCAWarning— read its doc comment before designing the comparison.Fingerprints, not issuer names. All four leftover CAs on the reproducing machine shared
CN=authbridge-tls-bridge-ca. The name proves nothing; only the fingerprint (or serial) discriminates. Each regeneration mints a new random 128-bit serial (ca.go:53), so serial works too.bundle.crtmtime is not a staleness signal.EnsureTrustBundlerecomputes it on every start but writes only on content change (authlib/tlsbridge/bundle.go:151-153), andconcatCertDirsorts filenames to keep the bytes stable across boots. Compare content, not timestamps.ca.crtalways lands beforebundle.crt—EnsureFileSource(cmd/authbridge-proxy/main.go:716) thenEnsureTrustBundle(:769).enablealready relies on this ordering to warn about the two files separately (cmd_claudecode.go:436-444).The two kinds of CA var are not interchangeable.
NODE_EXTRA_CA_CERTSextends the trust store, soca.crtis correct for it.SSL_CERT_FILE/REQUESTS_CA_BUNDLE/CURL_CA_BUNDLEreplace it, so they needbundle.crt(CA + system roots) or ordinary HTTPS stops verifying. Any "here's the fix" output must respect that distinction.When no system root store is found,
EnsureTrustBundledeliberately leaves an old bundle in place (bundle.go:136-139), which on a renewal boot pins a stale bridge CA — called out atmain.go:760-768. A status check should be able to say this happened.Exit code.
claudeCodeStatuscurrently always returns 0. Decide deliberately whether a detected mismatch should make it non-zero — useful for scripting, but a behavior change for anything parsing it today.Repro
--localfrom some checkout, so it mints its own CA under<checkout>/.cortex/ca.abctl configure claude-code enableagainst~/.cortex, so clients are pointed at~/.cortex/ca/ca.crt.ca.crtfingerprints differ (openssl x509 -noout -fingerprint -sha256 -in ...).abctl configure claude-code status→ reportsenabled, with no indication anything is wrong.Scope
Diagnostics only. Deliberately excluded:
--local's per-directory CA minting. There is no registry, which is how four CAs accumulated on one machine; that likely wants its own design discussion.Assisted-By: Claude (Anthropic AI) noreply@anthropic.com