Skip to content

Instantly share code, notes, and snippets.

@yurukusa
Created June 26, 2026 23:08
Show Gist options
  • Select an option

  • Save yurukusa/94f3d13621f6def56abaf7c12953e41c to your computer and use it in GitHub Desktop.

Select an option

Save yurukusa/94f3d13621f6def56abaf7c12953e41c to your computer and use it in GitHub Desktop.
curl works but 'claude' won't connect — the 5 distinct TLS/DNS causes behind Claude Code CLI failures in corporate/VPN environments (CERT_HAS_EXPIRED / EREFUSED / 503 / EPERM), why curl succeeding doesn't mean the CLI will (Bun's own bundled stack), and how to tell which cause you have. From cc-safe-setup.

curl works but claude won't connect — the 5 distinct TLS/DNS causes behind Claude Code CLI failures in corporate/VPN environments

In locked-down environments, claude /login (or claude update) fails with CERT_HAS_EXPIRED, getaddrinfo EREFUSED, a 503, or EPERM — while curl to the same host returns a clean 200. The generic error collapses five distinct causes into one line and usually points you at "your proxy," which is wrong most of the time. Here's how to tell which one you actually have.

These mechanisms are relayed from incidents reported on the tracker in late June 2026 (numbers at the end); I helped triage each. I'm stating the mechanism, not claiming I reproduced every environment.

Why "curl works" doesn't mean the CLI will

The native CLI runs on Bun, which validates TLS against its own bundled CA/trust store (BoringSSL) and, on some paths, resolves DNS through its own path rather than the OS getaddrinfo. So a clean curl -vI only proves the OS path is healthy — it says nothing about the path Bun builds. "curl works but the CLI doesn't" in a corp/VPN environment is almost always this mismatch, and the one-line error doesn't distinguish the causes below.

The 5 causes

1. DNS — split-DNS VPN, internal resolver refuses public names. Symptom: getaddrinfo EREFUSED. EREFUSED is a c-ares code — the tell that the CLI (or its updater) read /etc/resolv.conf directly, hit the first listed (internal authoritative) server, got REFUSED, and stopped before reaching the systemd-resolved stub. Fix: update through the OS resolver path (curl -fsSL https://claude.ai/install.sh | bash, or temporarily front-load nameserver 127.0.0.53). /etc/hosts won't help — c-ares bypasses NSS, so the failing code ignores it. (#71699)

2. TLS — stale bundled CA store, or an expired cross-signed root in the chain. Symptom: CERT_HAS_EXPIRED with no proxy and a correct clock. EXPIRED (not UNABLE_TO_GET_ISSUER) means a cert in the built chain has a notAfter in the past — classically an expired cross-signed root that OpenSSL/curl routes around but the stricter bundled store stops at. Confirm: openssl s_client -connect platform.claude.com:443 -servername platform.claude.com -showcerts </dev/null | openssl x509 -noout -dates. Fix: update the native build (newer Bun → newer CA store), or pass a fresh cacert.pem via an absolute NODE_EXTRA_CA_CERTS. (#71708)

3. NODE_EXTRA_CA_CERTS set to a relative path. A value like cert.pem is resolved from the CWD, so launching from ~/.claude silently reads nothing and falls back to the default store. Fix: absolute path, and export/set it in the shell before launch (settings.json env can apply too late for the first handshake). (#71663)

4. Parent-process env injects a proxy, changing only the transport. Symptom: fails from one terminal (e.g. VS Code's integrated terminal) with a 503/hang, but the same binary works from the native terminal. The integrated terminal injects env (proxy vars, NODE_OPTIONS) that reroutes the CLI's HTTP path even though curl in the same terminal is fine. Confirm: diff env | sort between the two terminals; the delta is the suspect. Fix: unset HTTP(S)_PROXY/ALL_PROXY/NODE_OPTIONS and retry. (#71683)

5. Post-update EPERM (won't even start). Symptom: An internal error occurred (EPERM) on every run after an update, even though the directories are writable. Usually a root-owned file inside ~/.claude left by a past sudo (a writable dir can still hold root-owned files), or a dual npm-global + native install with a PATH conflict. Confirm: claude --debug "test" 2>&1 | tail -60 — Node/Bun EPERM carries the offending path:. (#71655)

The habit that saves the hour

The five share one trait: the CLI uses a different stack than curl/the OS, and the one-line error doesn't say which. So don't act on the message's "it's your proxy" guess — run claude --debug first and read which host and which of name-resolution / cert-chain / transport / permissions actually failed. EREFUSED → DNS; CERT_HAS_EXPIRED → chain/bundled store; 503/terminal-specific → injected env; EPERM → permissions.

If you run agents in a locked-down environment, the same operator discipline that untangles "won't connect" also prevents the running failures — data loss, runaway cost, settings lockout. Free MIT pre-execution guards are in cc-safe-setup; the monthly Agent Safety Brief (free by email; $5/mo done-for-you) covers each month's new failure modes for people running this for real.


Sources (reported incidents I helped triage; mechanisms relayed, not reproduced by me in every environment): DNS #71699, bundled CA / expired chain #71708, relative CA path #71663, injected-env transport #71683, post-update EPERM #71655. That the native CLI runs on Bun with its own bundled TLS stack is standard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment