Fix custom CA login behind TLS-inspecting proxies (#20676)

Refs:
https://linear.app/openai/issue/SE-6311/login-fails-for-experian-users-behind-tls-inspecting-proxy

## Summary
- When a custom CA bundle is configured, force the shared `codex-client`
reqwest builder onto rustls before registering custom roots.
- Add the `rustls-tls-native-roots` reqwest feature so the rustls client
preserves native roots plus the enterprise CA bundle.
- Add subprocess TLS coverage for both a direct local TLS 1.3 server and
a hermetic local CONNECT TLS-intercepting proxy that forwards a
token-exchange-shaped POST to a local origin.

## Plain-language explanation
Experian users are behind a TLS-inspecting proxy, so the login token
exchange needs to trust the enterprise CA bundle from
`CODEX_CA_CERTIFICATE` or `SSL_CERT_FILE`. Before this change, that
custom-CA branch still used reqwest default TLS selection, which could
fail in the proxy environment. Now, only when a custom CA is configured,
Codex selects rustls first and then adds the custom CA roots, matching
the validated behavior from the Experian test build while leaving normal
system-root clients unchanged.

The new regression test recreates the enterprise-proxy shape locally:
the probe client sends an HTTPS `POST /oauth/token` through an explicit
HTTP CONNECT proxy, the proxy presents a leaf certificate signed by a
runtime-generated test CA, decrypts the request, forwards it to a local
origin, and relays the `ok` response back.

## Scope note
- The actual production fix is the first commit: `8368119282 Fix custom
CA reqwest clients to use rustls`.
- The second commit is integration-test coverage only. It generates all
test CA and localhost certificate material at runtime.

## Validation
- `cd codex-rs && cargo test -p codex-client --test ca_env
posts_to_token_origin_through_tls_intercepting_proxy_with_custom_ca_bundle
-- --nocapture`
- `cd codex-rs && cargo test -p codex-client`
- `cd codex-rs && cargo test -p codex-login`
- `cd codex-rs && just fmt`
- `cd codex-rs && just bazel-lock-update`
- `cd codex-rs && just bazel-lock-check`
- `cd codex-rs && just fix -p codex-client`
This commit is contained in:
jgershen-oai
2026-05-01 17:51:49 -07:00
committed by GitHub
Unverified
parent cd2760fc08
commit 9e905528bb
6 changed files with 504 additions and 15 deletions
+1
View File
@@ -2220,6 +2220,7 @@ dependencies = [
"opentelemetry_sdk",
"pretty_assertions",
"rand 0.9.3",
"rcgen",
"reqwest",
"rustls",
"rustls-native-certs",
+4
View File
@@ -322,6 +322,10 @@ quick-xml = "0.38.4"
rand = "0.9"
ratatui = "0.29.0"
ratatui-macros = "0.6.0"
rcgen = { version = "0.14.7", default-features = false, features = [
"aws_lc_rs",
"pem",
] }
regex = "1.12.3"
regex-lite = "0.1.8"
reqwest = { version = "0.12", features = ["cookies"] }
+2 -1
View File
@@ -12,7 +12,7 @@ futures = { workspace = true }
http = { workspace = true }
opentelemetry = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream"] }
reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] }
rustls = { workspace = true }
rustls-native-certs = { workspace = true }
rustls-pki-types = { workspace = true }
@@ -32,5 +32,6 @@ workspace = true
codex-utils-cargo-bin = { workspace = true }
opentelemetry_sdk = { workspace = true }
pretty_assertions = { workspace = true }
rcgen = { workspace = true }
tempfile = { workspace = true }
tracing-subscriber = { workspace = true }
@@ -8,22 +8,93 @@
//! - env precedence is respected,
//! - multi-cert PEM bundles load,
//! - error messages guide users when CA files are invalid.
//! - optional HTTPS probes can complete a request through the constructed client.
//!
//! The detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`.
//! This binary exists so the tests can exercise
//! [`codex_client::build_reqwest_client_for_subprocess_tests`] in a separate process without
//! duplicating client-construction logic.
use std::env;
use std::process;
use std::time::Duration;
const PROBE_TLS13_ENV: &str = "CODEX_CUSTOM_CA_PROBE_TLS13";
const PROBE_PROXY_ENV: &str = "CODEX_CUSTOM_CA_PROBE_PROXY";
const PROBE_URL_ENV: &str = "CODEX_CUSTOM_CA_PROBE_URL";
fn main() {
match codex_client::build_reqwest_client_for_subprocess_tests(reqwest::Client::builder()) {
Ok(_) => {
println!("ok");
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
eprintln!("failed to create probe runtime: {error}");
process::exit(1);
}
};
match runtime.block_on(run_probe()) {
Ok(()) => println!("ok"),
Err(error) => {
eprintln!("{error}");
process::exit(1);
}
}
}
async fn run_probe() -> Result<(), String> {
let proxy_url = env::var(PROBE_PROXY_ENV).ok();
let target_url = env::var(PROBE_URL_ENV).ok();
let mut builder = reqwest::Client::builder();
if target_url.is_some() {
builder = builder.timeout(Duration::from_secs(5));
}
if env::var_os(PROBE_TLS13_ENV).is_some() {
builder = builder.min_tls_version(reqwest::tls::Version::TLS_1_3);
}
let client = build_probe_client(builder, proxy_url.as_deref())?;
if let Some(url) = target_url {
post_probe_request(&client, &url).await?;
}
Ok(())
}
fn build_probe_client(
builder: reqwest::ClientBuilder,
proxy_url: Option<&str>,
) -> Result<reqwest::Client, String> {
if let Some(proxy_url) = proxy_url {
let proxy = reqwest::Proxy::https(proxy_url)
.map_err(|error| format!("failed to configure probe proxy {proxy_url}: {error}"))?;
return codex_client::build_reqwest_client_with_custom_ca(builder.proxy(proxy))
.map_err(|error| error.to_string());
}
codex_client::build_reqwest_client_for_subprocess_tests(builder)
.map_err(|error| error.to_string())
}
async fn post_probe_request(client: &reqwest::Client, url: &str) -> Result<(), String> {
let response = client
.post(url)
.header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=authorization_code&code=test")
.send()
.await
.map_err(|error| format!("probe request failed: {error:?}"))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|error| format!("failed to read probe response body: {error}"))?;
if !status.is_success() {
return Err(format!("probe request returned {status}: {body}"));
}
if body != "ok" {
return Err(format!("probe response body mismatch: {body}"));
}
Ok(())
}
+15 -6
View File
@@ -14,10 +14,9 @@
//! `TRUSTED CERTIFICATE` labels and bundles that also contain CRLs
//! - return user-facing errors that explain how to fix misconfigured CA files
//!
//! It does not validate certificate chains or perform a handshake in tests. Its contract is
//! narrower: produce a transport configuration whose root store contains every parseable
//! certificate block from the configured PEM bundle, or fail early with a precise error before
//! the caller starts network traffic.
//! Its production contract is narrow: produce a transport configuration whose root store contains
//! every parseable certificate block from the configured PEM bundle, or fail early with a precise
//! error before the caller starts network traffic.
//!
//! In this module's test setup, a hermetic test is one whose result depends only on the CA file
//! and environment variables that the test chose for itself. That matters here because the normal
@@ -36,7 +35,8 @@
//! - unit tests in this module cover env-selection logic without constructing a real client
//! - subprocess integration tests under `tests/` cover real client construction through
//! [`build_reqwest_client_for_subprocess_tests`], which disables reqwest proxy autodetection so
//! the tests can observe custom-CA success and failure directly
//! the tests can observe custom-CA success and failure directly, including one TLS handshake
//! through a local HTTPS server
//! - those subprocess tests also scrub inherited CA environment variables before launch so their
//! result depends only on the test fixtures and env vars set by the test itself
@@ -266,12 +266,21 @@ fn maybe_build_rustls_client_config_with_env(
/// This exists so tests can exercise precedence behavior deterministically without mutating the
/// real process environment. It selects the CA bundle, delegates file parsing to
/// [`ConfiguredCaBundle::load_certificates`], preserves the caller's chosen `reqwest` builder
/// configuration, and finally registers each parsed certificate with that builder.
/// configuration, forces rustls when a custom CA is configured, and finally registers each parsed
/// certificate with that builder.
fn build_reqwest_client_with_env(
env_source: &dyn EnvSource,
mut builder: reqwest::ClientBuilder,
) -> Result<reqwest::Client, BuildCustomCaTransportError> {
if let Some(bundle) = env_source.configured_ca_bundle() {
ensure_rustls_crypto_provider();
info!(
source_env = bundle.source_env,
ca_path = %bundle.path.display(),
"building HTTP client with rustls backend for custom CA bundle"
);
builder = builder.use_rustls_tls();
let certificates = bundle.load_certificates()?;
for (idx, cert) in certificates.iter().enumerate() {
+408 -5
View File
@@ -4,24 +4,83 @@
//! `build_reqwest_client_for_subprocess_tests` instead of calling the helper in-process. The
//! detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`; these
//! tests add the process-level half of that contract by scrubbing inherited CA environment
//! variables before each subprocess launch. They still stop at client construction: the
//! assertions here cover CA file selection, PEM parsing, and user-facing errors, not a full TLS
//! handshake.
//! variables before each subprocess launch. Most assertions here cover CA file selection, PEM
//! parsing, and user-facing errors. The HTTPS probes go further and perform real POSTs against
//! locally generated certificates, including through a TLS-intercepting CONNECT proxy.
use codex_utils_cargo_bin::cargo_bin;
use rcgen::BasicConstraints;
use rcgen::CertificateParams;
use rcgen::CertifiedIssuer;
use rcgen::DistinguishedName;
use rcgen::DnType;
use rcgen::ExtendedKeyUsagePurpose;
use rcgen::IsCa;
use rcgen::KeyPair;
use rcgen::KeyUsagePurpose;
use rcgen::PKCS_ECDSA_P256_SHA256;
use rustls_pki_types::CertificateDer;
use rustls_pki_types::PrivateKeyDer;
use std::fs;
use std::io;
use std::io::Read;
use std::io::Write;
use std::net::TcpListener;
use std::net::TcpStream;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use tempfile::TempDir;
const CODEX_CA_CERT_ENV: &str = "CODEX_CA_CERTIFICATE";
const PROBE_PROXY_ENV: &str = "CODEX_CUSTOM_CA_PROBE_PROXY";
const PROBE_TLS13_ENV: &str = "CODEX_CUSTOM_CA_PROBE_TLS13";
const PROBE_URL_ENV: &str = "CODEX_CUSTOM_CA_PROBE_URL";
const SSL_CERT_FILE_ENV: &str = "SSL_CERT_FILE";
const PROXY_ENV_VARS: &[&str] = &[
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
"NO_PROXY",
"no_proxy",
];
const TEST_CERT_1: &str = include_str!("fixtures/test-ca.pem");
const TEST_CERT_2: &str = include_str!("fixtures/test-intermediate.pem");
const TRUSTED_TEST_CERT: &str = include_str!("fixtures/test-ca-trusted.pem");
fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> std::path::PathBuf {
struct Tls13Material {
ca_cert_pem: String,
server_cert: CertificateDer<'static>,
server_key: PrivateKeyDer<'static>,
}
struct Tls13TestServer {
ca_cert_pem: String,
request_rx: mpsc::Receiver<Result<String, String>>,
url: String,
}
struct PlainHttpOrigin {
request_rx: mpsc::Receiver<Result<String, String>>,
url: String,
}
struct TlsInterceptingProxy {
ca_cert_pem: String,
request_rx: mpsc::Receiver<Result<String, String>>,
url: String,
}
fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> PathBuf {
let path = temp_dir.path().join(name);
fs::write(&path, contents).unwrap_or_else(|error| {
panic!("write cert fixture failed for {}: {error}", path.display())
@@ -29,7 +88,7 @@ fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> std::path:
path
}
fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output {
fn probe_command() -> Command {
let mut cmd = Command::new(
cargo_bin("custom_ca_probe")
.unwrap_or_else(|error| panic!("failed to locate custom_ca_probe: {error}")),
@@ -37,7 +96,18 @@ fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output {
// `Command` inherits the parent environment by default, so scrub CA-related variables first or
// these tests can accidentally pass/fail based on the developer shell or CI runner.
cmd.env_remove(CODEX_CA_CERT_ENV);
cmd.env_remove(PROBE_PROXY_ENV);
cmd.env_remove(PROBE_TLS13_ENV);
cmd.env_remove(PROBE_URL_ENV);
cmd.env_remove(SSL_CERT_FILE_ENV);
for env_var in PROXY_ENV_VARS {
cmd.env_remove(env_var);
}
cmd
}
fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output {
let mut cmd = probe_command();
for (key, value) in envs {
cmd.env(key, value);
}
@@ -45,6 +115,286 @@ fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output {
.unwrap_or_else(|error| panic!("failed to run custom_ca_probe: {error}"))
}
fn run_probe_posting_to_tls13_server(envs: &[(&str, &Path)], url: &str) -> std::process::Output {
let mut cmd = probe_command();
for (key, value) in envs {
cmd.env(key, value);
}
cmd.env(PROBE_TLS13_ENV, "1");
cmd.env(PROBE_URL_ENV, url);
cmd.output()
.unwrap_or_else(|error| panic!("failed to run custom_ca_probe: {error}"))
}
fn run_probe_posting_through_tls_intercepting_proxy(
envs: &[(&str, &Path)],
url: &str,
proxy_url: &str,
) -> std::process::Output {
let mut cmd = probe_command();
for (key, value) in envs {
cmd.env(key, value);
}
cmd.env(PROBE_PROXY_ENV, proxy_url);
cmd.env(PROBE_TLS13_ENV, "1");
cmd.env(PROBE_URL_ENV, url);
cmd.output()
.unwrap_or_else(|error| panic!("failed to run custom_ca_probe: {error}"))
}
fn spawn_tls13_test_server() -> Tls13TestServer {
codex_utils_rustls_provider::ensure_rustls_crypto_provider();
let material = generate_tls13_material();
let listener = TcpListener::bind(("127.0.0.1", 0))
.unwrap_or_else(|error| panic!("bind TLS test server: {error}"));
listener
.set_nonblocking(true)
.unwrap_or_else(|error| panic!("set TLS test server nonblocking: {error}"));
let port = listener
.local_addr()
.unwrap_or_else(|error| panic!("TLS test server addr: {error}"))
.port();
let config = Arc::new(
rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
.with_no_client_auth()
.with_single_cert(vec![material.server_cert], material.server_key)
.unwrap_or_else(|error| panic!("TLS 1.3 server config: {error}")),
);
let (request_tx, request_rx) = mpsc::channel();
thread::spawn(move || {
let result = accept_tls13_request(listener, config);
let _ = request_tx.send(result.map_err(|error| error.to_string()));
});
Tls13TestServer {
ca_cert_pem: material.ca_cert_pem,
request_rx,
url: format!("https://127.0.0.1:{port}/oauth/token"),
}
}
fn spawn_plain_http_origin() -> PlainHttpOrigin {
let listener = TcpListener::bind(("127.0.0.1", 0))
.unwrap_or_else(|error| panic!("bind plain HTTP origin: {error}"));
listener
.set_nonblocking(true)
.unwrap_or_else(|error| panic!("set plain HTTP origin nonblocking: {error}"));
let port = listener
.local_addr()
.unwrap_or_else(|error| panic!("plain HTTP origin addr: {error}"))
.port();
let (request_tx, request_rx) = mpsc::channel();
thread::spawn(move || {
let result = accept_plain_http_origin_request(listener);
let _ = request_tx.send(result.map_err(|error| error.to_string()));
});
PlainHttpOrigin {
request_rx,
url: format!("https://127.0.0.1:{port}/oauth/token"),
}
}
fn spawn_tls_intercepting_proxy() -> TlsInterceptingProxy {
codex_utils_rustls_provider::ensure_rustls_crypto_provider();
let material = generate_tls13_material();
let listener = TcpListener::bind(("127.0.0.1", 0))
.unwrap_or_else(|error| panic!("bind TLS intercepting proxy: {error}"));
listener
.set_nonblocking(true)
.unwrap_or_else(|error| panic!("set TLS intercepting proxy nonblocking: {error}"));
let port = listener
.local_addr()
.unwrap_or_else(|error| panic!("TLS intercepting proxy addr: {error}"))
.port();
let config = Arc::new(
rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
.with_no_client_auth()
.with_single_cert(vec![material.server_cert], material.server_key)
.unwrap_or_else(|error| panic!("TLS intercepting proxy config: {error}")),
);
let (request_tx, request_rx) = mpsc::channel();
thread::spawn(move || {
let result = accept_tls_intercepting_proxy_request(listener, config);
let _ = request_tx.send(result.map_err(|error| error.to_string()));
});
TlsInterceptingProxy {
ca_cert_pem: material.ca_cert_pem,
request_rx,
url: format!("http://127.0.0.1:{port}"),
}
}
fn generate_tls13_material() -> Tls13Material {
let mut ca_params = CertificateParams::default();
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let mut ca_distinguished_name = DistinguishedName::new();
ca_distinguished_name.push(DnType::CommonName, "codex test CA");
ca_params.distinguished_name = ca_distinguished_name;
let ca_key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)
.unwrap_or_else(|error| panic!("generate test CA key pair: {error}"));
let ca = CertifiedIssuer::self_signed(ca_params, ca_key_pair)
.unwrap_or_else(|error| panic!("generate test CA certificate: {error}"));
let mut server_params =
CertificateParams::new(vec!["localhost".to_string(), "127.0.0.1".to_string()])
.unwrap_or_else(|error| panic!("create test server certificate params: {error}"));
server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
server_params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
let server_key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)
.unwrap_or_else(|error| panic!("generate test server key pair: {error}"));
let server_cert = server_params
.signed_by(&server_key_pair, &ca)
.unwrap_or_else(|error| panic!("generate test server certificate: {error}"));
Tls13Material {
ca_cert_pem: ca.pem(),
server_cert: server_cert.der().clone(),
server_key: PrivateKeyDer::from(server_key_pair),
}
}
fn accept_plain_http_origin_request(listener: TcpListener) -> io::Result<String> {
let mut stream = accept_with_timeout(listener, Duration::from_secs(5))?;
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let request = read_http_message(&mut stream)?;
stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?;
stream.flush()?;
Ok(request)
}
fn accept_tls13_request(
listener: TcpListener,
config: Arc<rustls::ServerConfig>,
) -> io::Result<String> {
let stream = accept_with_timeout(listener, Duration::from_secs(5))?;
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let connection = rustls::ServerConnection::new(config).map_err(io::Error::other)?;
let mut tls = rustls::StreamOwned::new(connection, stream);
let request = read_http_message(&mut tls)?;
tls.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?;
tls.flush()?;
Ok(request)
}
fn accept_tls_intercepting_proxy_request(
listener: TcpListener,
config: Arc<rustls::ServerConfig>,
) -> io::Result<String> {
let mut stream = accept_with_timeout(listener, Duration::from_secs(5))?;
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let connect_request = read_http_message(&mut stream)?;
let origin_authority = connect_authority_from_request(&connect_request)?;
stream.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?;
stream.flush()?;
let connection = rustls::ServerConnection::new(config).map_err(io::Error::other)?;
let mut tls = rustls::StreamOwned::new(connection, stream);
let request = read_http_message(&mut tls)?;
let mut origin = TcpStream::connect(origin_authority.as_str())?;
origin.set_read_timeout(Some(Duration::from_secs(5)))?;
origin.set_write_timeout(Some(Duration::from_secs(5)))?;
origin.write_all(request.as_bytes())?;
origin.flush()?;
let response = read_http_message(&mut origin)?;
tls.write_all(response.as_bytes())?;
tls.flush()?;
Ok(request)
}
fn connect_authority_from_request(request: &str) -> io::Result<String> {
let request_line = request
.lines()
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty CONNECT request"))?;
let mut parts = request_line.split_whitespace();
match (parts.next(), parts.next(), parts.next()) {
(Some("CONNECT"), Some(authority), Some(_version)) => Ok(authority.to_string()),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid CONNECT request line: {request_line}"),
)),
}
}
fn accept_with_timeout(listener: TcpListener, timeout: Duration) -> io::Result<TcpStream> {
let deadline = Instant::now() + timeout;
loop {
match listener.accept() {
Ok((stream, _)) => return Ok(stream),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"timed out waiting for TLS test client",
));
}
thread::sleep(Duration::from_millis(10));
}
Err(error) => return Err(error),
}
}
}
fn read_http_message(stream: &mut impl Read) -> io::Result<String> {
let mut buffer = Vec::new();
let mut chunk = [0; 1024];
loop {
let bytes_read = stream.read(&mut chunk)?;
if bytes_read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..bytes_read]);
if let Some(header_end) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
let body_start = header_end + 4;
let headers = String::from_utf8_lossy(&buffer[..body_start]);
let content_length = headers
.lines()
.filter_map(|line| line.split_once(':'))
.find_map(|(name, value)| {
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
if buffer.len() >= body_start + content_length {
break;
}
}
}
Ok(String::from_utf8_lossy(&buffer).into_owned())
}
fn assert_token_exchange_request(request: &str) {
assert!(
request.starts_with("POST /oauth/token HTTP/1.1"),
"unexpected request:\n{request}"
);
assert!(
request.contains("grant_type=authorization_code&code=test"),
"unexpected request body:\n{request}"
);
}
#[test]
fn uses_codex_ca_cert_env() {
let temp_dir = TempDir::new().expect("tempdir");
@@ -90,6 +440,59 @@ fn handles_multi_certificate_bundle() {
assert!(output.status.success());
}
#[test]
fn posts_to_tls13_server_using_custom_ca_bundle() {
let temp_dir = TempDir::new().expect("tempdir");
let server = spawn_tls13_test_server();
let cert_path = write_cert_file(&temp_dir, "tls-ca.pem", &server.ca_cert_pem);
let output =
run_probe_posting_to_tls13_server(&[(CODEX_CA_CERT_ENV, cert_path.as_path())], &server.url);
let server_result = server.request_rx.recv_timeout(Duration::from_secs(5));
assert!(
output.status.success(),
"custom_ca_probe failed\nstdout:\n{}\nstderr:\n{}\nserver:\n{server_result:?}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let request = server_result
.expect("TLS test server should report a request")
.expect("TLS test server should accept the probe request");
assert_token_exchange_request(&request);
}
#[test]
fn posts_to_token_origin_through_tls_intercepting_proxy_with_custom_ca_bundle() {
let temp_dir = TempDir::new().expect("tempdir");
let origin = spawn_plain_http_origin();
let proxy = spawn_tls_intercepting_proxy();
let cert_path = write_cert_file(&temp_dir, "proxy-ca.pem", &proxy.ca_cert_pem);
let output = run_probe_posting_through_tls_intercepting_proxy(
&[(CODEX_CA_CERT_ENV, cert_path.as_path())],
&origin.url,
&proxy.url,
);
let proxy_result = proxy.request_rx.recv_timeout(Duration::from_secs(5));
let origin_result = origin.request_rx.recv_timeout(Duration::from_secs(5));
assert!(
output.status.success(),
"custom_ca_probe failed\nstdout:\n{}\nstderr:\n{}\nproxy:\n{proxy_result:?}\norigin:\n{origin_result:?}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let proxy_request = proxy_result
.expect("TLS intercepting proxy should report a request")
.expect("TLS intercepting proxy should accept the probe request");
let origin_request = origin_result
.expect("plain HTTP origin should report a request")
.expect("plain HTTP origin should accept the forwarded request");
assert_token_exchange_request(&proxy_request);
assert_token_exchange_request(&origin_request);
}
#[test]
fn rejects_empty_pem_file_with_hint() {
let temp_dir = TempDir::new().expect("tempdir");