Repository navigation
Responses WebSocket transport ignores SSL_CERT_FILE (custom CA), fails with UnknownIssuer behind TLS-intercepting proxies while HTTP honors it #46489
Description
Summary
The Responses WebSocket transport validates the upstream TLS certificate against system roots only — it ignores SSL_CERT_FILE (and appears not to share the codex_http_client::custom_ca policy). The HTTP transport honors SSL_CERT_FILE correctly.
Behind a TLS-intercepting egress proxy (corporate MITM, or a credential-injecting proxy like Agent Vault) whose CA is provided via SSL_CERT_FILE, this splits codex's behavior in two:
- Plain HTTP requests to
chatgpt.comaccept the proxy's re-signed certificate and work fine. - The WebSocket connect to
wss://chatgpt.com/backend-api/codex/responsesrejects the same certificate:
ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket:
IO error: invalid peer certificate: UnknownIssuer,
url: wss://chatgpt.com/backend-api/codex/responses
From the proxy's side this looks like the client aborting the TLS handshake (EOF). The session then burns the WebSocket retry loop before any HTTP fallback (#19821), and in our deployment turns fail outright. Because the built-in openai provider hard-codes supports_websockets: true and cannot be overridden (#13103), there is no way to avoid the broken path without defining a whole custom provider.
PR #31441 ("core: preserve Responses WebSockets with system proxy") describes applying "the same effective outbound proxy and custom-CA policy as HTTP" to WebSocket connections — the proxy part works (the WS connect does route through HTTPS_PROXY), but the custom-CA part does not.
Reproduction
Reproduced on 0.147.0 and 0.154.0 (macOS, ChatGPT subscription auth). No special infrastructure needed — a ~60-line local MITM proxy and a throwaway CA:
1. Make a test CA and a re-signed leaf for chatgpt.com:
openssl genrsa -out ca.key 2048
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3 -subj "/CN=Test MITM CA" -out ca.pem
openssl genrsa -out leaf.key 2048
openssl req -new -key leaf.key -subj "/CN=chatgpt.com" -out leaf.csr
printf "subjectAltName=DNS:chatgpt.com\n" > leaf.ext
openssl x509 -req -in leaf.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 3 -sha256 -extfile leaf.ext -out leaf.pem2. Run a re-signing CONNECT proxy (terminates TLS with leaf.pem, then decrypts and re-forwards plaintext to the real chatgpt.com:443 — i.e., a standard MITM). Minimal Python version:
import socket, ssl, threading
srv = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
srv.load_cert_chain("leaf.pem", "leaf.key")
srv.set_alpn_protocols(["http/1.1"])
up = ssl.create_default_context()
def pipe(a, b):
try:
while (d := a.recv(65536)): b.sendall(d)
except Exception: pass
def handle(c):
req = b""
while b"\r\n\r\n" not in req: req += c.recv(4096)
host, port = req.split(b"\r\n",1)[0].split(b" ")[1].decode().rsplit(":",1)
c.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
try:
tc = srv.wrap_socket(c, server_side=True)
print(f"handshake OK host={host}")
except ssl.SSLError as e:
print(f"HANDSHAKE REJECTED host={host} err={e}"); return
u = up.wrap_socket(socket.create_connection((host, int(port))), server_hostname=host)
threading.Thread(target=pipe, args=(tc, u), daemon=True).start(); pipe(u, tc)
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 8898)); s.listen(64)
while True:
conn, _ = s.accept(); threading.Thread(target=handle, args=(conn,), daemon=True).start()3. Run codex through it, trusting the CA the documented way:
HTTPS_PROXY=http://127.0.0.1:8898 SSL_CERT_FILE=$PWD/ca.pem RUST_LOG=error \
codex --enable respect_system_proxy exec --skip-git-repo-check "reply with the single word pong"(We originally drove codex app-server over JSON-RPC through a full thread/start → turn/start; the behavior is the same.)
Observed
- The proxy logs
handshake OK host=chatgpt.comfor the HTTP connections — the custom CA inSSL_CERT_FILEis honored, requests flow, injection-style proxies work. - The WebSocket connect fails with
invalid peer certificate: UnknownIssueron every retry before HTTP fallback kicks in:
2026-09-18T22:41:53.392473Z ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: IO error: invalid peer certificate: UnknownIssuer, url: wss://chatgpt.com/backend-api/codex/responses
2026-09-18T22:41:53.615316Z ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: IO error: invalid peer certificate: UnknownIssuer, url: wss://chatgpt.com/backend-api/codex/responses
2026-09-18T22:41:53.850139Z ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: IO error: invalid peer certificate: UnknownIssuer, url: wss://chatgpt.com/backend-api/codex/responses
... (repeats)
The exec above eventually completes via HTTP fallback after burning the retry loop; in our app-server deployment (agent sandbox behind the same kind of proxy) turns fail outright.
Startup logging confirms the HTTP side has explicit custom-CA plumbing that the WS side apparently doesn't use — without the env var it prints:
INFO codex_http_client::custom_ca: using system root certificates because no CA override
environment variable was selected codex_ca_certificate_configured=false ssl_cert_file_configured=false
With SSL_CERT_FILE set, HTTP accepts the re-signed cert; WS still rejects it.
Expected
The WebSocket transport should build its TLS config from the same custom-CA policy as codex_http_client (per #31441's stated intent), so SSL_CERT_FILE / CODEX_CA_CERTIFICATE apply to wss:// connections too.
Workaround we're using
Point codex at a custom provider that opts out of WebSockets (since the built-in openai provider can't be overridden, per #13103):
model_provider = "openai_https"
[model_providers.openai_https]
name = "OpenAI (HTTPS only)"
wire_api = "responses"
requires_openai_auth = true
base_url = "https://chatgpt.com/backend-api/codex"
supports_websockets = falseThis restores fully working turns through the same MITM proxy — confirming the only broken piece is WS certificate validation.
Environment
- codex-cli 0.147.0 and 0.154.0, macOS (arm64)
- Auth: ChatGPT subscription (
auth_mode=Chatgpt) --enable respect_system_proxy, proxy viaHTTPS_PROXY, CA viaSSL_CERT_FILE
Related: #31441 (WS + system proxy / custom-CA policy), #19821 (WS connect failures burn all retries before HTTP fallback), #13103 (built-in provider supports_websockets not overridable), #28503 (feature request for a first-class WS disable).
Activity
argszero commentedon Sep 19, 2026
I verified this against main and rust-v0.154.0 and could not confirm the mechanism as stated, so here are the code facts plus one diagnosability gap that I think is worth fixing either way.
websocket-client/src/dialer_tests.rs, test http_proxy_tunnels_secure_websocket_before_handshake (helper at 584), drives wss://localhost:PORT/v1/responses through a plaintext CONNECT proxy with a ClientConfig whose root store contains only a freshly generated self-signed certificate (test_tls_configs at 682-707). That CA is in no system store, so a pass can only mean the connector config, rather than tungstenite defaults, validated the target behind the proxy.
The error string does match a target-TLS failure: the vendored tokio-tungstenite maps rustls verification failures to Error::Io in the wrap_stream rustls arm of src/tls.rs, whose Display is the IO error prefix, and connect_websocket logs the raw error. So the observation is real, but what is not established is that WS policy differs from HTTP; the open question is whether the WS config received the bundle at all.
build_reqwest_client_with_env announces its decision at custom_ca.rs:303-305 (HTTP client built with a rustls backend for the custom CA bundle) and at 350-355 (system roots used because no CA override env var was selected). Its rustls sibling build_rustls_client_config at 243-285 logs nothing on the success path, only warn on failures. So nothing in the logs says whether the WS config got the bundle, which is exactly the ambiguity this report runs into. I am happy to send a small behaviour-preserving patch adding the symmetric lines (bundle source_env plus resolved path, and the no-override case).
Separately, the two adjacent issues you cite both look right to me: the retry loop burning attempts before the HTTP fallback (#19821), and the non-overridable supports_websockets on the built-in provider (#13103).
VonC commentedon Oct 3, 2026
I can confirm the HTTPS-only provider workaround on Windows in a corporate TLS-intercepting environment, with codex-cli 0.160.0.
My environment is Windows 11 Enterprise behind a corporate network.
SSL_CERT_FILEpoints to the corporate CA bundle:With the built-in
openaiprovider,codex doctorreported:At the same time, the ChatGPT inference endpoint remained reachable over HTTP.
This was not just a diagnostic warning. In long-running conversations, automatic remote compaction could fail with:
Once that happened, the affected conversation could become stuck because the next turn needed compaction again.
I then applied the HTTPS-only provider workaround described in this issue:
With that configuration,
codex doctor --allconfirms:The corporate CA bundle is still visible to Codex through
SSL_CERT_FILE.On this machine, forcing the Responses API to HTTPS is a valid workaround. Codex can run long sessions and remote compaction without reproducing the previous failure.
This confirms the symptom and workaround described here. It does not by itself confirm the proposed implementation-level explanation that the WebSocket path ignores
SSL_CERT_FILE. As noted in the existing analysis, the code is intended to apply the custom-CA policy to WebSockets as well.What I can establish from this A/B test is narrower:
UnknownIssuer, and remote compaction can failsupports_websockets = false: HTTPS operation and remote compaction workSo disabling the Responses WebSocket path is an effective workaround for this corporate-network case on codex-cli 0.160.0.