Skip to content

Responses WebSocket transport ignores SSL_CERT_FILE (custom CA), fails with UnknownIssuer behind TLS-intercepting proxies while HTTP honors it #46489

Open

Description

@jbchief-dev

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.com accept the proxy's re-signed certificate and work fine.
  • The WebSocket connect to wss://chatgpt.com/backend-api/codex/responses rejects 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.pem

2. 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.com for the HTTP connections — the custom CA in SSL_CERT_FILE is honored, requests flow, injection-style proxies work.
  • The WebSocket connect fails with invalid peer certificate: UnknownIssuer on 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 = false

This 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 via HTTPS_PROXY, CA via SSL_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

  1. added
    bugSomething isn't working
    CLIIssues related to the Codex CLI
    connectivityIssues involving networking or endpoint connectivity problems (disconnections)
    on Sep 18, 2026
  2. argszero commented on Sep 19, 2026

    @argszero

    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.

    1. The Responses WS path already resolves the custom-CA policy
    • codex-api/src/endpoint/responses_websocket.rs:502 builds WebSocketConnector::new(http_client_factory).
    • websocket-client/src/lib.rs:63-82: new() selects WebSocketTlsMode::ExplicitCodexTls, which sets tls_config to Some(build_rustls_client_config_with_custom_ca()).
    • http-client/src/custom_ca.rs:206-215 and 243-285: that builds a rustls ClientConfig from native roots plus the configured bundle, and configured_ca_bundle() at 373-410 selects CODEX_CA_CERTIFICATE, falling back to SSL_CERT_FILE. That is the same policy function the HTTP path resolves.
    • websocket-client/src/dialer.rs: every route passes it as the rustls connector, namely TransportDefault at 49-54, the env-proxy route with NO_PROXY at 70-75, and the explicit proxy tunnel at 131. For a plaintext CONNECT proxy the target TLS is performed by that connector, so the custom CA applies.
    1. The topology in the report is already covered by an in-tree test

    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.

    1. Why the symptom is still consistent with that

    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.

    1. Gap: the WS and rustls CA decision is invisible in logs

    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).

    1. What would discriminate
    • codex doctor, network section (cli/src/doctor/network.rs:54), run from the process that actually performs the WS connect. In the app-server deployment the connect happens in a separately spawned backend, and app-server-daemon/src/backend/pid_start.rs:148-198 re-asserts the CA env vars only inside its Windows branch, so env propagation is worth confirming there.
    • openssl verify with the bundle as CA file and your leaf, to confirm the bundle actually verifies the leaf you re-sign with.
    • whether CODEX_CA_CERTIFICATE is set anywhere in that environment, since it wins over SSL_CERT_FILE (custom_ca.rs:373-410).
    • debug logging for the codex_websocket_client target shows the route and CONNECT decision per attempt. Note dialer.rs:115 is a second place that can produce a rustls message wrapped as Error::Io, but only for an https proxy.

    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).

  3. VonC commented on Oct 3, 2026

    @VonC

    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_FILE points to the corporate CA bundle:

    SSL_CERT_FILE            readable file C:\Users\<user>\<path>\<corporate-ca-bundle>.pem
    

    With the built-in openai provider, codex doctor reported:

    Responses WebSocket failed; HTTPS fallback may still work
    ...
    invalid peer certificate: UnknownIssuer
    

    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:

    Error running remote compact task: stream disconnected before completion: Transport error: network error: error decoding response body
    

    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:

    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 = false

    With that configuration, codex doctor --all confirms:

    default model provider   openai_https
    model provider           openai_https
    provider name            OpenAI (HTTPS only)
    wire API                 responses
    supports websockets      false
    

    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:

    • built-in provider with Responses WebSockets enabled: WebSocket TLS fails with UnknownIssuer, and remote compaction can fail
    • same machine, account, network, CA bundle, and model with supports_websockets = false: HTTPS operation and remote compaction work

    So disabling the Responses WebSocket path is an effective workaround for this corporate-network case on codex-cli 0.160.0.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    CLIIssues related to the Codex CLIbugSomething isn't workingconnectivityIssues involving networking or endpoint connectivity problems (disconnections)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions