diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2969e7522..fbb3450e9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2134,14 +2134,17 @@ dependencies = [ "chrono", "codex-app-server-protocol", "codex-core", + "codex-utils-cargo-bin", "core_test_support", "pretty_assertions", "rand 0.9.2", "reqwest", + "rustls-pki-types", "serde", "serde_json", "sha2", "tempfile", + "thiserror 2.0.18", "tiny_http", "tokio", "tracing", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 77ffb6120..1b8303d6a 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -240,6 +240,7 @@ rustls = { version = "0.23", default-features = false, features = [ "ring", "std", ] } +rustls-pki-types = "1.14.0" schemars = "0.8.22" seccompiler = "0.5.0" semver = "1.0" diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index ac745e87c..794b2be28 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -14,11 +14,12 @@ codex-core = { workspace = true } codex-app-server-protocol = { workspace = true } rand = { workspace = true } reqwest = { workspace = true, features = ["json", "blocking"] } +rustls-pki-types = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } -tempfile = { workspace = true } tiny_http = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", @@ -33,6 +34,7 @@ webbrowser = { workspace = true } [dev-dependencies] anyhow = { workspace = true } +codex-utils-cargo-bin = { workspace = true } core_test_support = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/login/src/bin/login_ca_probe.rs b/codex-rs/login/src/bin/login_ca_probe.rs new file mode 100644 index 000000000..1e9073601 --- /dev/null +++ b/codex-rs/login/src/bin/login_ca_probe.rs @@ -0,0 +1,31 @@ +//! Helper binary for exercising custom CA environment handling in tests. +//! +//! The login flows honor `CODEX_CA_CERTIFICATE` and `SSL_CERT_FILE`, but those environment +//! variables are process-global and unsafe to mutate in parallel test execution. This probe keeps +//! the behavior under test while letting integration tests (`tests/ca_env.rs`) set env vars +//! per-process, proving: +//! +//! - env precedence is respected, +//! - multi-cert PEM bundles load, +//! - error messages guide users when CA files are invalid. +//! +//! The probe intentionally disables reqwest proxy autodetection while building the client. That +//! keeps the subprocess tests hermetic in macOS seatbelt runs, where +//! `reqwest::Client::builder().build()` can panic inside the `system-configuration` crate while +//! probing macOS proxy settings. Without that workaround, the subprocess exits before the custom +//! CA code reports either success or a structured `BuildLoginHttpClientError`, so tests that are +//! supposed to validate CA parsing instead fail on unrelated platform proxy discovery. + +use std::process; + +fn main() { + match codex_login::probe_support::build_login_http_client() { + Ok(_) => { + println!("ok"); + } + Err(error) => { + eprintln!("{error}"); + process::exit(1); + } + } +} diff --git a/codex-rs/login/src/custom_ca.rs b/codex-rs/login/src/custom_ca.rs new file mode 100644 index 000000000..7d401b619 --- /dev/null +++ b/codex-rs/login/src/custom_ca.rs @@ -0,0 +1,658 @@ +//! Custom CA handling for login HTTP clients. +//! +//! Login flows are the only place this crate constructs ad hoc outbound HTTP clients, so this +//! module centralizes the trust-store behavior that those clients must share. Enterprise networks +//! often terminate TLS with an internal root CA, which means system roots alone cannot validate +//! the OAuth and device-code endpoints that the login flows call. +//! +//! The module intentionally has a narrow responsibility: +//! +//! - read CA material from `CODEX_CA_CERTIFICATE`, falling back to `SSL_CERT_FILE` +//! - normalize PEM variants that show up in real deployments, including OpenSSL-style +//! `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 `reqwest::Client` whose root store contains every parseable certificate +//! block from the configured PEM bundle, or fail early with a precise error before the caller +//! starts a login flow. +//! +//! The tests in this module therefore split on that boundary: +//! +//! - unit tests cover pure env-selection logic without constructing a real client +//! - subprocess tests in `tests/ca_env.rs` cover real client construction, because that path is +//! not hermetic in macOS sandboxed runs and must also scrub inherited CA environment variables +//! - the spawned `login_ca_probe` binary reaches the probe-only builder through the hidden +//! `probe_support` module so that workaround does not become part of the normal crate API + +use std::env; +use std::fs; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use rustls_pki_types::CertificateDer; +use rustls_pki_types::pem::PemObject; +use rustls_pki_types::pem::SectionKind; +use rustls_pki_types::pem::{self}; +use thiserror::Error; +use tracing::info; +use tracing::warn; + +const CODEX_CA_CERT_ENV: &str = "CODEX_CA_CERTIFICATE"; +const SSL_CERT_FILE_ENV: &str = "SSL_CERT_FILE"; +const CA_CERT_HINT: &str = "If you set CODEX_CA_CERTIFICATE or SSL_CERT_FILE, ensure it points to a PEM file containing one or more CERTIFICATE blocks, or unset it to use system roots."; +type PemSection = (SectionKind, Vec); + +/// Describes why the login HTTP client could not be constructed. +/// +/// This boundary is more specific than `io::Error`: login can fail because the configured CA file +/// could not be read, could not be parsed as certificates, contained certs that `reqwest` refused +/// to register, or because the final client builder failed. The rest of the login crate still +/// speaks `io::Error`, so callers that do not care about the distinction can rely on the +/// `From for io::Error` conversion. +#[derive(Debug, Error)] +pub enum BuildLoginHttpClientError { + /// Reading the selected CA file from disk failed before any PEM parsing could happen. + #[error( + "Failed to read CA certificate file {} selected by {}: {source}. {hint}", + path.display(), + source_env, + hint = CA_CERT_HINT + )] + ReadCaFile { + source_env: &'static str, + path: PathBuf, + source: io::Error, + }, + + /// The selected CA file was readable, but did not produce usable certificate material. + #[error( + "Failed to load CA certificates from {} selected by {}: {detail}. {hint}", + path.display(), + source_env, + hint = CA_CERT_HINT + )] + InvalidCaFile { + source_env: &'static str, + path: PathBuf, + detail: String, + }, + + /// One parsed certificate block could not be registered with the reqwest client builder. + #[error( + "Failed to parse certificate #{certificate_index} from {} selected by {}: {source}. {hint}", + path.display(), + source_env, + hint = CA_CERT_HINT + )] + RegisterCertificate { + source_env: &'static str, + path: PathBuf, + certificate_index: usize, + source: reqwest::Error, + }, + + /// Reqwest rejected the final client configuration after a custom CA bundle was loaded. + #[error( + "Failed to build login HTTP client while using CA bundle from {} ({}): {source}", + source_env, + path.display() + )] + BuildClientWithCustomCa { + source_env: &'static str, + path: PathBuf, + #[source] + source: reqwest::Error, + }, + + /// Reqwest rejected the final client configuration while using only system roots. + #[error("Failed to build login HTTP client while using system root certificates: {0}")] + BuildClientWithSystemRoots(#[source] reqwest::Error), +} + +impl From for io::Error { + fn from(error: BuildLoginHttpClientError) -> Self { + match error { + BuildLoginHttpClientError::ReadCaFile { ref source, .. } => { + io::Error::new(source.kind(), error) + } + BuildLoginHttpClientError::InvalidCaFile { .. } + | BuildLoginHttpClientError::RegisterCertificate { .. } => { + io::Error::new(io::ErrorKind::InvalidData, error) + } + BuildLoginHttpClientError::BuildClientWithCustomCa { .. } + | BuildLoginHttpClientError::BuildClientWithSystemRoots(_) => io::Error::other(error), + } + } +} + +/// Builds the HTTP client used by login and device-code flows. +/// +/// Callers should use this instead of constructing a raw `reqwest::Client` so every login entry +/// point honors the same CA override behavior. A caller that bypasses this helper can silently +/// regress enterprise login setups that rely on `CODEX_CA_CERTIFICATE` or `SSL_CERT_FILE`. +/// `CODEX_CA_CERTIFICATE` takes precedence over `SSL_CERT_FILE`, and empty values for either are +/// treated as unset so callers do not accidentally turn `VAR=""` into a bogus path lookup. +/// +/// # Errors +/// +/// Returns a [`BuildLoginHttpClientError`] when the configured CA file is unreadable, malformed, +/// or contains a certificate block that `reqwest` cannot register as a root. Calling raw +/// `reqwest::Client::builder()` instead would skip those user-facing errors and can make login +/// failures in enterprise environments much harder to diagnose. +pub fn build_login_http_client() -> Result { + build_login_http_client_with_env(&ProcessEnv, reqwest::Client::builder()) +} + +/// Builds the login HTTP client used behind the spawned CA probe binary. +/// +/// This stays crate-private because normal callers should continue to go through +/// [`build_login_http_client`]. The hidden `probe_support` module exposes this behavior only to +/// `login_ca_probe`, which disables proxy autodetection so the subprocess tests can reach the +/// custom-CA code path in sandboxed macOS test runs without crashing first in reqwest's platform +/// proxy setup. Using this path for normal login would make the tests and production behavior +/// diverge on proxy handling, which is exactly what the hidden module arrangement is trying to +/// avoid. +pub(crate) fn build_login_http_client_for_subprocess_tests() +-> Result { + build_login_http_client_with_env( + &ProcessEnv, + // The probe disables proxy autodetection so the subprocess tests can reach the custom-CA + // code path even in macOS seatbelt runs, where platform proxy discovery can panic first. + reqwest::Client::builder().no_proxy(), + ) +} + +/// Builds a login HTTP client using an injected environment source and reqwest builder. +/// +/// This exists so unit tests can exercise precedence and PEM-handling behavior deterministically. +/// Production code should call [`build_login_http_client`] instead of supplying its own +/// environment adapter, otherwise the tests and the real process environment can drift apart. +/// This function is also the place where module responsibilities come together: 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. +fn build_login_http_client_with_env( + env_source: &dyn EnvSource, + mut builder: reqwest::ClientBuilder, +) -> Result { + if let Some(bundle) = env_source.configured_ca_bundle() { + let certificates = bundle.load_certificates()?; + + for (idx, cert) in certificates.iter().enumerate() { + let certificate = match reqwest::Certificate::from_der(cert.as_ref()) { + Ok(certificate) => certificate, + Err(source) => { + warn!( + source_env = bundle.source_env, + ca_path = %bundle.path.display(), + certificate_index = idx + 1, + error = %source, + "failed to register login CA certificate" + ); + return Err(BuildLoginHttpClientError::RegisterCertificate { + source_env: bundle.source_env, + path: bundle.path.clone(), + certificate_index: idx + 1, + source, + }); + } + }; + builder = builder.add_root_certificate(certificate); + } + return match builder.build() { + Ok(client) => Ok(client), + Err(source) => { + warn!( + source_env = bundle.source_env, + ca_path = %bundle.path.display(), + error = %source, + "failed to build client after loading custom CA bundle" + ); + Err(BuildLoginHttpClientError::BuildClientWithCustomCa { + source_env: bundle.source_env, + path: bundle.path.clone(), + source, + }) + } + }; + } + + info!( + codex_ca_certificate_configured = false, + ssl_cert_file_configured = false, + "using system root certificates because no CA override environment variable was selected" + ); + + match builder.build() { + Ok(client) => Ok(client), + Err(source) => { + warn!( + error = %source, + "failed to build client while using system root certificates" + ); + Err(BuildLoginHttpClientError::BuildClientWithSystemRoots( + source, + )) + } + } +} + +/// Abstracts environment access so tests can cover precedence rules without mutating process-wide +/// variables. +trait EnvSource { + /// Returns the environment variable value for `key`, if this source considers it set. + /// + /// Implementations should return `None` for absent values and may also collapse unreadable + /// process-environment states into `None`, because the login CA logic treats both cases as + /// "no override configured". Callers build precedence and empty-string handling on top of this + /// method, so implementations should not trim or normalize the returned string. + fn var(&self, key: &str) -> Option; + + /// Returns a non-empty environment variable value interpreted as a filesystem path. + /// + /// Empty strings are treated as unset because login uses presence here as a boolean "custom CA + /// override requested" signal. This keeps the precedence logic from treating `VAR=""` as an + /// attempt to open the current working directory or some other platform-specific oddity once it + /// is converted into a path. + fn non_empty_path(&self, key: &str) -> Option { + self.var(key) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + } + + /// Returns the configured CA bundle and which environment variable selected it. + /// + /// `CODEX_CA_CERTIFICATE` wins over `SSL_CERT_FILE` because it is the login-specific override. + /// Keeping the winning variable name with the path lets later logging explain not only which + /// file was used but also why that file was chosen. + fn configured_ca_bundle(&self) -> Option { + self.non_empty_path(CODEX_CA_CERT_ENV) + .map(|path| ConfiguredCaBundle { + source_env: CODEX_CA_CERT_ENV, + path, + }) + .or_else(|| { + self.non_empty_path(SSL_CERT_FILE_ENV) + .map(|path| ConfiguredCaBundle { + source_env: SSL_CERT_FILE_ENV, + path, + }) + }) + } +} + +/// Reads login CA configuration from the real process environment. +/// +/// This is the production `EnvSource` implementation used by +/// [`build_login_http_client`]. Tests substitute in-memory env maps so they can +/// exercise precedence and empty-value behavior without mutating process-global +/// variables. +struct ProcessEnv; + +impl EnvSource for ProcessEnv { + fn var(&self, key: &str) -> Option { + env::var(key).ok() + } +} + +/// Identifies the CA bundle selected for login and the policy decision that selected it. +/// +/// This is the concrete output of the environment-precedence logic. Callers use `source_env` for +/// logging and diagnostics, while `path` is the bundle that will actually be loaded. +struct ConfiguredCaBundle { + /// The environment variable that won the precedence check for this bundle. + source_env: &'static str, + /// The filesystem path that should be read as PEM certificate input. + path: PathBuf, +} + +impl ConfiguredCaBundle { + /// Loads certificates from this selected CA bundle. + /// + /// The bundle already represents the output of environment-precedence selection, so this is + /// the natural point where the file-loading phase begins. The method owns the high-level + /// success/failure logs for that phase and keeps the source env and path together for lower- + /// level parsing and error shaping. + fn load_certificates(&self) -> Result>, BuildLoginHttpClientError> { + match self.parse_certificates() { + Ok(certificates) => { + info!( + source_env = self.source_env, + ca_path = %self.path.display(), + certificate_count = certificates.len(), + "loaded certificates from custom CA bundle" + ); + Ok(certificates) + } + Err(error) => { + warn!( + source_env = self.source_env, + ca_path = %self.path.display(), + error = %error, + "failed to load custom CA bundle" + ); + Err(error) + } + } + } + + /// Loads every certificate block from a PEM file intended for login CA overrides. + /// + /// This accepts a few common real-world variants so login behaves like other CA-aware tooling: + /// leading comments are preserved, `TRUSTED CERTIFICATE` labels are normalized to standard + /// certificate labels, and embedded CRLs are ignored. + /// + /// # Errors + /// + /// Returns `InvalidData` when the file cannot be interpreted as one or more certificates, and + /// preserves the filesystem error kind when the file itself cannot be read. + fn parse_certificates( + &self, + ) -> Result>, BuildLoginHttpClientError> { + let pem_data = self.read_pem_data()?; + let normalized_pem = NormalizedPem::from_pem_data(self.source_env, &self.path, &pem_data); + + let mut certificates = Vec::new(); + let mut logged_crl_presence = false; + // Use the mixed-section parser from `rustls-pki-types` so CRLs can be identified and + // skipped explicitly instead of being removed with ad hoc text rewriting. + for section_result in normalized_pem.sections() { + // Known limitation: if `rustls-pki-types` fails while parsing a malformed CRL section, + // that error is reported here before we can classify the block as ignorable. A bundle + // containing valid certificates plus a malformed `X509 CRL` therefore still fails to + // load today, even though well-formed CRLs are ignored. + let (section_kind, der) = match section_result { + Ok(section) => section, + Err(error) => return Err(self.pem_parse_error(&error)), + }; + match section_kind { + SectionKind::Certificate => { + let cert_der = normalized_pem.certificate_der(&der).ok_or_else(|| { + self.invalid_ca_file( + "failed to extract certificate data from TRUSTED CERTIFICATE: invalid DER length", + ) + })?; + certificates.push(CertificateDer::from(cert_der.to_vec())); + } + SectionKind::Crl => { + if !logged_crl_presence { + info!( + source_env = self.source_env, + ca_path = %self.path.display(), + "ignoring X509 CRL entries found in custom CA bundle" + ); + logged_crl_presence = true; + } + } + _ => {} + } + } + + if certificates.is_empty() { + return Err(self.pem_parse_error(&pem::Error::NoItemsFound)); + } + + Ok(certificates) + } + /// Reads the CA bundle bytes while preserving the original filesystem error kind. + /// + /// The caller wants a user-facing error that includes the bundle path and remediation hint, but + /// the higher-level login surfaces still benefit from distinguishing "not found" from other I/O + /// failures. This helper keeps both pieces together. + fn read_pem_data(&self) -> Result, BuildLoginHttpClientError> { + fs::read(&self.path).map_err(|source| BuildLoginHttpClientError::ReadCaFile { + source_env: self.source_env, + path: self.path.clone(), + source, + }) + } + + /// Rewrites PEM parsing failures into user-facing configuration errors. + /// + /// The underlying parser knows whether the file was empty, malformed, or contained unsupported + /// PEM content, but callers need a message that also points them back to the relevant + /// environment variables and the expected remediation. + fn pem_parse_error(&self, error: &pem::Error) -> BuildLoginHttpClientError { + let detail = match error { + pem::Error::NoItemsFound => "no certificates found in PEM file".to_string(), + _ => format!("failed to parse PEM file: {error}"), + }; + + self.invalid_ca_file(detail) + } + + /// Creates an invalid-CA error tied to this file path. + /// + /// Most parse-time failures in this module eventually collapse to "the configured CA bundle is + /// not usable", but the detailed reason still matters for operator debugging. Centralizing that + /// formatting keeps the path and hint text consistent across the different parser branches. + fn invalid_ca_file(&self, detail: impl std::fmt::Display) -> BuildLoginHttpClientError { + BuildLoginHttpClientError::InvalidCaFile { + source_env: self.source_env, + path: self.path.clone(), + detail: detail.to_string(), + } + } +} + +/// The PEM text shape after OpenSSL compatibility normalization. +/// +/// `Standard` means the input already used ordinary PEM certificate labels. `TrustedCertificate` +/// means the input used OpenSSL's `TRUSTED CERTIFICATE` labels, so callers must also be prepared +/// to trim trailing `X509_AUX` bytes from decoded certificate sections. +enum NormalizedPem { + /// PEM contents that already used ordinary `CERTIFICATE` labels. + Standard(String), + /// PEM contents rewritten from OpenSSL `TRUSTED CERTIFICATE` labels to `CERTIFICATE`. + TrustedCertificate(String), +} + +impl NormalizedPem { + /// Normalizes PEM text from a CA bundle into the label shape this module expects. + /// + /// Login only needs certificate DER bytes to seed `reqwest`'s root store, but operators may + /// point it at CA files that came from OpenSSL tooling rather than from a minimal certificate + /// bundle. OpenSSL's `TRUSTED CERTIFICATE` form is one such variant: it is still certificate + /// material, but it uses a different PEM label and may carry auxiliary trust metadata that + /// this crate does not consume. This constructor rewrites only the PEM labels so the mixed- + /// section parser can keep treating the file as certificate input. The rustls ecosystem does + /// not currently accept `TRUSTED CERTIFICATE` as a standard certificate label upstream, so + /// this remains a local compatibility shim rather than behavior delegated to + /// `rustls-pki-types`. + /// + /// See also: + /// - rustls/pemfile issue #52, closed as not planned, documenting that + /// `BEGIN TRUSTED CERTIFICATE` blocks are ignored upstream: + /// + /// - OpenSSL `x509 -trustout`, which emits `TRUSTED CERTIFICATE` PEM blocks: + /// + /// - OpenSSL PEM readers, which document that plain `PEM_read_bio_X509()` discards auxiliary + /// trust settings: + /// + /// - `openssl s_server`, a real OpenSSL-based server/test tool that operates in this + /// ecosystem: + /// + fn from_pem_data(source_env: &'static str, path: &Path, pem_data: &[u8]) -> Self { + let pem = String::from_utf8_lossy(pem_data); + if pem.contains("TRUSTED CERTIFICATE") { + info!( + source_env, + ca_path = %path.display(), + "normalizing OpenSSL TRUSTED CERTIFICATE labels in custom CA bundle" + ); + Self::TrustedCertificate( + pem.replace("BEGIN TRUSTED CERTIFICATE", "BEGIN CERTIFICATE") + .replace("END TRUSTED CERTIFICATE", "END CERTIFICATE"), + ) + } else { + Self::Standard(pem.into_owned()) + } + } + + /// Returns the normalized PEM contents regardless of the label shape that produced them. + fn contents(&self) -> &str { + match self { + Self::Standard(contents) | Self::TrustedCertificate(contents) => contents, + } + } + + /// Iterates over every recognized PEM section in this normalized PEM text. + /// + /// `rustls-pki-types` exposes mixed-section parsing through a `PemObject` implementation on the + /// `(SectionKind, Vec)` tuple. Keeping that type-directed API here lets callers iterate in + /// terms of normalized sections rather than trait plumbing. + fn sections(&self) -> impl Iterator> + '_ { + PemSection::pem_slice_iter(self.contents().as_bytes()) + } + + /// Returns the certificate DER bytes for one parsed PEM certificate section. + /// + /// Standard PEM certificates already decode to the exact DER bytes `reqwest` wants. OpenSSL + /// `TRUSTED CERTIFICATE` sections may append `X509_AUX` bytes after the certificate, so those + /// sections need to be trimmed down to their first DER object before registration. + fn certificate_der<'a>(&self, der: &'a [u8]) -> Option<&'a [u8]> { + match self { + Self::Standard(_) => Some(der), + Self::TrustedCertificate(_) => first_der_item(der), + } + } +} + +/// Returns the first DER-encoded ASN.1 object in `der`, ignoring any trailing OpenSSL metadata. +/// +/// A PEM `CERTIFICATE` block usually decodes to exactly one DER blob: the certificate itself. +/// OpenSSL's `TRUSTED CERTIFICATE` variant is different. It starts with that same certificate +/// blob, but may append extra `X509_AUX` bytes after it to describe OpenSSL-specific trust +/// settings. `reqwest::Certificate::from_der` only understands the certificate object, not those +/// trailing OpenSSL extensions. +/// +/// This helper therefore asks a narrower question than "is this a valid certificate?": where does +/// the first top-level DER object end? If that boundary can be found, the caller keeps only that +/// prefix and discards the trailing trust metadata. If it cannot be found, the input is treated as +/// malformed CA data. +fn first_der_item(der: &[u8]) -> Option<&[u8]> { + der_item_length(der).map(|length| &der[..length]) +} + +/// Returns the byte length of the first DER item in `der`. +/// +/// DER is a binary encoding for ASN.1 objects. Each object begins with: +/// +/// - a tag byte describing what kind of object follows +/// - one or more length bytes describing how many content bytes belong to that object +/// - the content bytes themselves +/// +/// For this module, the important fact is that a certificate is stored as one complete top-level +/// DER object. Once we know that object's declared length, we know exactly where the certificate +/// ends and where any trailing OpenSSL `X509_AUX` data begins. +/// +/// This helper intentionally parses only that outer length field. It does not validate the inner +/// certificate structure, the meaning of the tag, or every nested ASN.1 value. That narrower scope +/// is deliberate: the caller only needs a safe slice boundary for the leading certificate object +/// before handing those bytes to `reqwest`, which performs the real certificate parsing. +/// +/// The implementation supports the DER length forms needed here: +/// +/// - short form, where the length is stored directly in the second byte +/// - long form, where the second byte says how many following bytes make up the length value +/// +/// Indefinite lengths are rejected because DER does not permit them, and any declared length that +/// would run past the end of the input is treated as malformed. +fn der_item_length(der: &[u8]) -> Option { + let &length_octet = der.get(1)?; + if length_octet & 0x80 == 0 { + return Some(2 + usize::from(length_octet)).filter(|length| *length <= der.len()); + } + + let length_octets = usize::from(length_octet & 0x7f); + if length_octets == 0 { + return None; + } + + let length_start = 2usize; + let length_end = length_start.checked_add(length_octets)?; + let length_bytes = der.get(length_start..length_end)?; + let mut content_length = 0usize; + for &byte in length_bytes { + content_length = content_length + .checked_mul(256)? + .checked_add(usize::from(byte))?; + } + + length_end + .checked_add(content_length) + .filter(|length| *length <= der.len()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::path::PathBuf; + + use pretty_assertions::assert_eq; + + use super::CODEX_CA_CERT_ENV; + use super::EnvSource; + use super::SSL_CERT_FILE_ENV; + + // Keep this module limited to pure precedence logic. Building a real reqwest client here is + // not hermetic on macOS sandboxed test runs because client construction can consult platform + // networking configuration and panic before the test asserts anything. The real client-building + // cases live in `tests/ca_env.rs`, which exercises them in a subprocess with explicit env. + struct MapEnv { + values: HashMap, + } + + impl EnvSource for MapEnv { + fn var(&self, key: &str) -> Option { + self.values.get(key).cloned() + } + } + + fn map_env(pairs: &[(&str, &str)]) -> MapEnv { + MapEnv { + values: pairs + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect(), + } + } + + #[test] + fn ca_path_prefers_codex_env() { + let env = map_env(&[ + (CODEX_CA_CERT_ENV, "/tmp/codex.pem"), + (SSL_CERT_FILE_ENV, "/tmp/fallback.pem"), + ]); + + assert_eq!( + env.configured_ca_bundle().map(|bundle| bundle.path), + Some(PathBuf::from("/tmp/codex.pem")) + ); + } + + #[test] + fn ca_path_falls_back_to_ssl_cert_file() { + let env = map_env(&[(SSL_CERT_FILE_ENV, "/tmp/fallback.pem")]); + + assert_eq!( + env.configured_ca_bundle().map(|bundle| bundle.path), + Some(PathBuf::from("/tmp/fallback.pem")) + ); + } + + #[test] + fn ca_path_ignores_empty_values() { + let env = map_env(&[ + (CODEX_CA_CERT_ENV, ""), + (SSL_CERT_FILE_ENV, "/tmp/fallback.pem"), + ]); + + assert_eq!( + env.configured_ca_bundle().map(|bundle| bundle.path), + Some(PathBuf::from("/tmp/fallback.pem")) + ); + } +} diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 9bf477181..c283ad117 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -6,6 +6,7 @@ use serde::de::{self}; use std::time::Duration; use std::time::Instant; +use crate::build_login_http_client; use crate::pkce::PkceCodes; use crate::server::ServerOptions; use std::io; @@ -47,9 +48,7 @@ where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; - s.trim() - .parse::() - .map_err(|e| de::Error::custom(format!("invalid u64 string: {e}"))) + s.trim().parse::().map_err(de::Error::custom) } #[derive(Deserialize)] @@ -158,7 +157,7 @@ fn print_device_code_prompt(verification_url: &str, code: &str) { } pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result { - let client = reqwest::Client::new(); + let client = build_login_http_client()?; let base_url = opts.issuer.trim_end_matches('/'); let api_base_url = format!("{base_url}/api/accounts"); let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?; @@ -175,7 +174,7 @@ pub async fn complete_device_code_login( opts: ServerOptions, device_code: DeviceCode, ) -> std::io::Result<()> { - let client = reqwest::Client::new(); + let client = build_login_http_client()?; let base_url = opts.issuer.trim_end_matches('/'); let api_base_url = format!("{base_url}/api/accounts"); @@ -222,7 +221,6 @@ pub async fn complete_device_code_login( .await } -/// Full device code login flow. pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { let device_code = request_device_code(&opts).await?; print_device_code_prompt(&device_code.verification_url, &device_code.user_code); diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 256e60eed..ea2e059fb 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -1,7 +1,16 @@ +mod custom_ca; mod device_code_auth; mod pkce; +// Hidden because this exists only to let the spawned `login_ca_probe` binary call the +// probe-specific client builder without exposing that workaround as part of the normal API. +// `login_ca_probe` is a separate binary target, not a `#[cfg(test)]` module inside this crate, so +// it cannot call crate-private helpers and would not see test-only modules. +#[doc(hidden)] +pub mod probe_support; mod server; +pub use custom_ca::BuildLoginHttpClientError; +pub use custom_ca::build_login_http_client; pub use device_code_auth::DeviceCode; pub use device_code_auth::complete_device_code_login; pub use device_code_auth::request_device_code; diff --git a/codex-rs/login/src/probe_support.rs b/codex-rs/login/src/probe_support.rs new file mode 100644 index 000000000..bf050a347 --- /dev/null +++ b/codex-rs/login/src/probe_support.rs @@ -0,0 +1,22 @@ +//! Test-only support for spawned login probe binaries. +//! +//! This module exists because `login_ca_probe` is compiled as a separate binary target, so it +//! cannot call crate-private helpers directly. Keeping the probe entry point under a hidden module +//! avoids surfacing it as part of the normal `codex-login` public API while still letting the +//! subprocess tests share the real custom-CA client-construction code. It is intentionally not a +//! general-purpose login API: the functions here exist only so the subprocess tests can exercise +//! CA loading in a separate process without duplicating logic in the probe binary. + +use crate::BuildLoginHttpClientError; + +/// Builds the login HTTP client for the subprocess CA probe tests. +/// +/// The probe disables reqwest proxy autodetection so it can exercise custom-CA success and +/// failure in macOS seatbelt runs without tripping the known `system-configuration` panic during +/// platform proxy discovery. This is intentionally not the main public login entry point: normal +/// login callers should continue to use [`crate::build_login_http_client`]. A non-test caller that +/// reached for this helper would mask real proxy behavior and risk debugging a code path that does +/// not match production login. +pub fn build_login_http_client() -> Result { + crate::custom_ca::build_login_http_client_for_subprocess_tests() +} diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 929633aa1..08f99bb97 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -23,6 +23,7 @@ use std::sync::Arc; use std::thread; use std::time::Duration; +use crate::custom_ca::build_login_http_client; use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; use base64::Engine; @@ -159,10 +160,13 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { let server = server.clone(); thread::spawn(move || -> io::Result<()> { while let Ok(request) = server.recv() { - tx.blocking_send(request).map_err(|e| { - eprintln!("Failed to send request to channel: {e}"); - io::Error::other("Failed to send request to channel") - })?; + match tx.blocking_send(request) { + Ok(()) => {} + Err(error) => { + eprintln!("Failed to send request to channel: {error}"); + return Err(io::Error::other("Failed to send request to channel")); + } + } } Ok(()) }) @@ -668,7 +672,6 @@ fn sanitize_url_for_logging(url: &str) -> String { Err(_) => "".to_string(), } } - /// Exchanges an authorization code for tokens. /// /// The returned error remains suitable for user-facing CLI/browser surfaces, so backend-provided @@ -689,7 +692,7 @@ pub(crate) async fn exchange_code_for_tokens( refresh_token: String, } - let client = reqwest::Client::new(); + let client = build_login_http_client()?; info!( issuer = %sanitize_url_for_logging(issuer), redirect_uri = %redirect_uri, @@ -706,18 +709,21 @@ pub(crate) async fn exchange_code_for_tokens( urlencoding::encode(&pkce.code_verifier) )) .send() - .await - .map_err(|err| { - let err = redact_sensitive_error_url(err); + .await; + let resp = match resp { + Ok(resp) => resp, + Err(error) => { + let error = redact_sensitive_error_url(error); error!( - is_timeout = err.is_timeout(), - is_connect = err.is_connect(), - is_request = err.is_request(), - error = %err, + is_timeout = error.is_timeout(), + is_connect = error.is_connect(), + is_request = error.is_request(), + error = %error, "oauth token exchange transport failure" ); - io::Error::other(err) - })?; + return Err(io::Error::other(error)); + } + }; let status = resp.status(); if !status.is_success() { @@ -1055,7 +1061,7 @@ pub(crate) async fn obtain_api_key( struct ExchangeResp { access_token: String, } - let client = reqwest::Client::new(); + let client = build_login_http_client()?; let resp = client .post(format!("{issuer}/oauth/token")) .header("Content-Type", "application/x-www-form-urlencoded") @@ -1079,7 +1085,6 @@ pub(crate) async fn obtain_api_key( let body: ExchangeResp = resp.json().await.map_err(io::Error::other)?; Ok(body.access_token) } - #[cfg(test)] mod tests { use pretty_assertions::assert_eq; diff --git a/codex-rs/login/tests/ca_env.rs b/codex-rs/login/tests/ca_env.rs new file mode 100644 index 000000000..d4fd1fa27 --- /dev/null +++ b/codex-rs/login/tests/ca_env.rs @@ -0,0 +1,146 @@ +//! Subprocess coverage for custom CA behavior that must build a real reqwest client. +//! +//! These tests intentionally run through `login_ca_probe` instead of calling the helper in-process: +//! reqwest client construction is not hermetic on macOS sandboxed runs, and these cases also need +//! exact control over inherited CA environment variables. The probe disables reqwest proxy +//! autodetection because `reqwest::Client::builder().build()` can panic inside +//! `system-configuration` while probing macOS proxy settings under seatbelt. The probe-level +//! workaround keeps these tests focused on custom-CA success and failure instead of failing first +//! on unrelated platform proxy discovery. These tests still stop at client construction: they +//! verify CA file selection, PEM parsing, and user-facing errors, not a full TLS handshake. + +use codex_utils_cargo_bin::cargo_bin; +use std::fs; +use std::path::Path; +use std::process::Command; +use tempfile::TempDir; + +const CODEX_CA_CERT_ENV: &str = "CODEX_CA_CERTIFICATE"; +const SSL_CERT_FILE_ENV: &str = "SSL_CERT_FILE"; + +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 { + let path = temp_dir.path().join(name); + fs::write(&path, contents).unwrap_or_else(|error| { + panic!("write cert fixture failed for {}: {error}", path.display()) + }); + path +} + +fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output { + let mut cmd = Command::new( + cargo_bin("login_ca_probe") + .unwrap_or_else(|error| panic!("failed to locate login_ca_probe: {error}")), + ); + // `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(SSL_CERT_FILE_ENV); + for (key, value) in envs { + cmd.env(key, value); + } + cmd.output() + .unwrap_or_else(|error| panic!("failed to run login_ca_probe: {error}")) +} + +#[test] +fn uses_codex_ca_cert_env() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "ca.pem", TEST_CERT_1); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} + +#[test] +fn falls_back_to_ssl_cert_file() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "ssl.pem", TEST_CERT_1); + + let output = run_probe(&[(SSL_CERT_FILE_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} + +#[test] +fn prefers_codex_ca_cert_over_ssl_cert_file() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "ca.pem", TEST_CERT_1); + let bad_path = write_cert_file(&temp_dir, "bad.pem", ""); + + let output = run_probe(&[ + (CODEX_CA_CERT_ENV, cert_path.as_path()), + (SSL_CERT_FILE_ENV, bad_path.as_path()), + ]); + + assert!(output.status.success()); +} + +#[test] +fn handles_multi_certificate_bundle() { + let temp_dir = TempDir::new().expect("tempdir"); + let bundle = format!("{TEST_CERT_1}\n{TEST_CERT_2}"); + let cert_path = write_cert_file(&temp_dir, "bundle.pem", &bundle); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} + +#[test] +fn rejects_empty_pem_file_with_hint() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "empty.pem", ""); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("no certificates found in PEM file")); + assert!(stderr.contains("CODEX_CA_CERTIFICATE")); + assert!(stderr.contains("SSL_CERT_FILE")); +} + +#[test] +fn rejects_malformed_pem_with_hint() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file( + &temp_dir, + "malformed.pem", + "-----BEGIN CERTIFICATE-----\nMIIBroken", + ); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("failed to parse PEM file")); + assert!(stderr.contains("CODEX_CA_CERTIFICATE")); + assert!(stderr.contains("SSL_CERT_FILE")); +} + +#[test] +fn accepts_openssl_trusted_certificate() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "trusted.pem", TRUSTED_TEST_CERT); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} + +#[test] +fn accepts_bundle_with_crl() { + let temp_dir = TempDir::new().expect("tempdir"); + let crl = "-----BEGIN X509 CRL-----\nMIIC\n-----END X509 CRL-----"; + let bundle = format!("{TEST_CERT_1}\n{crl}"); + let cert_path = write_cert_file(&temp_dir, "bundle_crl.pem", &bundle); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} diff --git a/codex-rs/login/tests/fixtures/test-ca-trusted.pem b/codex-rs/login/tests/fixtures/test-ca-trusted.pem new file mode 100644 index 000000000..76716033c --- /dev/null +++ b/codex-rs/login/tests/fixtures/test-ca-trusted.pem @@ -0,0 +1,23 @@ +# Test-only OpenSSL trusted-certificate fixture generated from test-ca.pem with +# `openssl x509 -addtrust serverAuth -trustout`. +# The extra trailing bytes model the OpenSSL X509_AUX data that follows the +# certificate DER in real TRUSTED CERTIFICATE bundles. +-----BEGIN TRUSTED CERTIFICATE----- +MIIDBTCCAe2gAwIBAgIUZYhGvBUG7SucNzYh9VIeZ7b9zHowDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHdGVzdC1jYTAeFw0yNTEyMTEyMzEyNTFaFw0zNTEyMDky +MzEyNTFaMBIxEDAOBgNVBAMMB3Rlc3QtY2EwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQC+NJRZAdn15FFBN8eR1HTAe+LMVpO19kKtiCsQjyqHONfhfHcF +7zQfwmH6MqeNpC/5k5m8V1uSIhyHBskQm83Jv8/vHlffNxE/hl0Na/Yd1bc+2kxH +twIAsF32GKnSKnFva/iGczV81+/ETgG6RXfTfy/Xs6fXL8On8SRRmTcMw0bEfwko +ziid87VOHg2JfdRKN5QpS9lvQ8q4q2M3jMftolpUTpwlR0u8j9OXnZfn+ja33X0l +kjkoCbXE2fVbAzO/jhUHQX1H5RbTGGUnrrCWAj84Rq/E80KK1nrRF91K+vgZmilM +gOZosLMMI1PeqTakwg1yIRngpTyk0eJP+haxAgMBAAGjUzBRMB0GA1UdDgQWBBT6 +sqvfjMIl0DFZkeu8LU577YqMVDAfBgNVHSMEGDAWgBT6sqvfjMIl0DFZkeu8LU57 +7YqMVDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBQ1sYs2RvB +TZ+xSBglLwH/S7zXVJIDwQ23Rlj11dgnVvcilSJCX24Rr+pfIVLpYNDdZzc/DIJd +S1dt2JuLnvXnle29rU7cxuzYUkUkRtaeY2Sj210vsE3lqUFyIy8XCc/lteb+FiJ7 +zo/gPk7P+y4ihK9Mm6SBqkDVEYSFSn9bgoemK+0e93jGe2182PyuTwfTmZgENSBO +2f9dSuay4C7e5UO8bhVccQJg6f4d70zUNG0oPHrnVxJLjwCd++jx25Gh4U7+ek13 +CW57pxJrpPMDWb2YK64rT2juHMKF73YuplW92SInd+QLpI2ekTLc+bRw8JvqzXg+ +SprtRUBjlWzjMAwwCgYIKwYBBQUHAwE= +-----END TRUSTED CERTIFICATE----- diff --git a/codex-rs/login/tests/fixtures/test-ca.pem b/codex-rs/login/tests/fixtures/test-ca.pem new file mode 100644 index 000000000..7c9a9883c --- /dev/null +++ b/codex-rs/login/tests/fixtures/test-ca.pem @@ -0,0 +1,21 @@ +# Test-only self-signed CA fixture used for single-certificate loading. +# These tests only verify PEM parsing and root-certificate registration, not a TLS handshake. +-----BEGIN CERTIFICATE----- +MIIDBTCCAe2gAwIBAgIUZYhGvBUG7SucNzYh9VIeZ7b9zHowDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHdGVzdC1jYTAeFw0yNTEyMTEyMzEyNTFaFw0zNTEyMDky +MzEyNTFaMBIxEDAOBgNVBAMMB3Rlc3QtY2EwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQC+NJRZAdn15FFBN8eR1HTAe+LMVpO19kKtiCsQjyqHONfhfHcF +7zQfwmH6MqeNpC/5k5m8V1uSIhyHBskQm83Jv8/vHlffNxE/hl0Na/Yd1bc+2kxH +twIAsF32GKnSKnFva/iGczV81+/ETgG6RXfTfy/Xs6fXL8On8SRRmTcMw0bEfwko +ziid87VOHg2JfdRKN5QpS9lvQ8q4q2M3jMftolpUTpwlR0u8j9OXnZfn+ja33X0l +kjkoCbXE2fVbAzO/jhUHQX1H5RbTGGUnrrCWAj84Rq/E80KK1nrRF91K+vgZmilM +gOZosLMMI1PeqTakwg1yIRngpTyk0eJP+haxAgMBAAGjUzBRMB0GA1UdDgQWBBT6 +sqvfjMIl0DFZkeu8LU577YqMVDAfBgNVHSMEGDAWgBT6sqvfjMIl0DFZkeu8LU57 +7YqMVDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBQ1sYs2RvB +TZ+xSBglLwH/S7zXVJIDwQ23Rlj11dgnVvcilSJCX24Rr+pfIVLpYNDdZzc/DIJd +S1dt2JuLnvXnle29rU7cxuzYUkUkRtaeY2Sj210vsE3lqUFyIy8XCc/lteb+FiJ7 +zo/gPk7P+y4ihK9Mm6SBqkDVEYSFSn9bgoemK+0e93jGe2182PyuTwfTmZgENSBO +2f9dSuay4C7e5UO8bhVccQJg6f4d70zUNG0oPHrnVxJLjwCd++jx25Gh4U7+ek13 +CW57pxJrpPMDWb2YK64rT2juHMKF73YuplW92SInd+QLpI2ekTLc+bRw8JvqzXg+ +SprtRUBjlWzj +-----END CERTIFICATE----- diff --git a/codex-rs/login/tests/fixtures/test-intermediate.pem b/codex-rs/login/tests/fixtures/test-intermediate.pem new file mode 100644 index 000000000..f29e69d63 --- /dev/null +++ b/codex-rs/login/tests/fixtures/test-intermediate.pem @@ -0,0 +1,21 @@ +# Second valid test-only certificate used for multi-certificate bundle coverage. +# It is intentionally distinct from test-ca.pem; chain validation is not part of these tests. +-----BEGIN CERTIFICATE----- +MIIDGTCCAgGgAwIBAgIUWxlcvHzwITWAHWHbKMFUTgeDmjwwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRdGVzdC1pbnRlcm1lZGlhdGUwHhcNMjUxMTE5MTU1MDIz +WhcNMjYxMTE5MTU1MDIzWjAcMRowGAYDVQQDDBF0ZXN0LWludGVybWVkaWF0ZTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANq7xbeYpC2GaXANqD1nLk0t +j9j2sOk6e7DqTapxnIUijS7z4DF0Vo1xHM07wK1m+wsB/t9CubNYRvtn6hrIzx7K +jjlmvxo4/YluwO1EDMQWZAXkaY2O28ESKVx7QLfBPYAc4bf/5B4Nmt6KX5sQyyyH +2qTfzVBUCAl3sI+Ydd3mx7NOye1yNNkCNqyK3Hj45F1JuH8NZxcb4OlKssZhMlD+ +EQx4G46AzKE9Ho8AqlQvg/tiWrMHRluw7zolMJ/AXzedAXedNIrX4fCOmZwcTkA1 +a8eLPP8oM9VFrr67a7on6p4zPqugUEQ4fawp7A5KqSjUAVCt1FXmn2V8N8V6W/sC +AwEAAaNTMFEwHQYDVR0OBBYEFBEwRwW0gm3IjhLw1U3eOAvR0r6SMB8GA1UdIwQY +MBaAFBEwRwW0gm3IjhLw1U3eOAvR0r6SMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAB2fjAlpevK42Odv8XUEgV6VWlEP9HAmkRvugW9hjhzx1Iz9 +Vh/l9VcxL7PcqdpyGH+BIRvQIMokcYF5TXzf/KV1T2y56U8AWaSd2/xSjYNWwkgE +TLE5V+H/YDKzvTe58UrOaxa5N3URscQL9f+ZKworODmfMlkJ1mlREK130ZMlBexB +p9w5wo1M1fjx76Rqzq9MkpwBSbIO2zx/8+qy4BAH23MPGW+9OOnnq2DiIX3qUu1v +hnjYOxYpCB28MZEJmqsjFJQQ9RF+Te4U2/oknVcf8lZIMJ2ZBOwt2zg8RqCtM52/ +IbATwYj77wg3CFLFKcDYs3tdUqpiniabKcf6zAs= +-----END CERTIFICATE----- diff --git a/docs/config.md b/docs/config.md index a810262a4..8adc3c660 100644 --- a/docs/config.md +++ b/docs/config.md @@ -36,6 +36,24 @@ Codex stores the SQLite-backed state DB under `sqlite_home` (config key) or the `CODEX_SQLITE_HOME` environment variable. When unset, WorkspaceWrite sandbox sessions default to a temp directory; other modes default to `CODEX_HOME`. +## Login Custom CA Certificates + +Browser login and device-code login can trust a custom root CA bundle when +enterprise proxies or gateways intercept TLS. + +Set `CODEX_CA_CERTIFICATE` to the path of a PEM file containing one or more +certificate blocks to use a login-specific CA bundle. If `CODEX_CA_CERTIFICATE` +is unset, login falls back to `SSL_CERT_FILE`. If neither variable is set, +login uses the system root certificates. + +`CODEX_CA_CERTIFICATE` takes precedence over `SSL_CERT_FILE`. Empty values are +treated as unset. + +The PEM file may contain multiple certificates. Codex also tolerates OpenSSL +`TRUSTED CERTIFICATE` labels and ignores well-formed `X509 CRL` sections in the +same bundle. If the file is empty, unreadable, or malformed, login fails with a +user-facing error that points back to these environment variables. + ## Notices Codex stores "do not show again" flags for some UI prompts under the `[notice]` table.