login: add custom CA support for login flows (#14178)

## Stacked PRs

This work is split across three stacked PRs:

- #14178: add custom CA support for browser and device-code login flows,
docs, and hermetic subprocess tests
- #14239: broaden the shared custom CA path from login to other outbound
`reqwest` clients across Codex
- #14240: extend that shared custom CA handling to secure websocket TLS
so websocket connections honor the same CA env vars

Review order: #14178, then #14239, then #14240.

Supersedes #6864.

Thanks to @3axap4eHko for the original implementation and investigation
here. Although this version rearranges the code and history
significantly, the majority of the credit for this work belongs to them.

## Problem

Login flows need to work in enterprise environments where outbound TLS
is intercepted by an internal proxy or gateway. In those setups, system
root certificates alone are often insufficient to validate the OAuth and
device-code endpoints used during login. The change adds a
login-specific custom CA loading path, but the important contracts
around env precedence, PEM compatibility, test boundaries, and
probe-only workarounds need to be explicit so reviewers can understand
what behavior is intentional.

For users and operators, the behavior is simple: if login needs to trust
a custom root CA, set `CODEX_CA_CERTIFICATE` to a PEM file containing
one or more certificates. If that variable is unset, login falls back to
`SSL_CERT_FILE`. If neither is set, login uses system roots. Invalid or
empty PEM files now fail with an error that points back to those
environment variables and explains how to recover.

## What This Delivers

Users can now make Codex login work behind enterprise TLS interception
by pointing `CODEX_CA_CERTIFICATE` at a PEM bundle containing the
relevant root certificates. If that variable is unset, login falls back
to `SSL_CERT_FILE`, then to system roots.

This PR applies that behavior to both browser-based and device-code
login flows. It also makes login tolerant of the PEM shapes operators
actually have in hand: multi-certificate bundles, OpenSSL `TRUSTED
CERTIFICATE` labels, and bundles that include well-formed CRLs.

## Mental model

`codex-login` is the place where the login flows construct ad hoc
outbound HTTP clients. That makes it the right boundary for a narrow CA
policy: look for `CODEX_CA_CERTIFICATE`, fall back to `SSL_CERT_FILE`,
load every parseable certificate block in that bundle into a
`reqwest::Client`, and fail early with a clear user-facing error if the
bundle is unreadable or malformed.

The implementation is intentionally pragmatic about PEM input shape. It
accepts ordinary certificate bundles, multi-certificate bundles, OpenSSL
`TRUSTED CERTIFICATE` labels, and bundles that also contain CRLs. It
does not validate a certificate chain or prove a handshake; it only
constructs the root store used by login.

## Non-goals

This change does not introduce a general-purpose transport abstraction
for the rest of the product. It does not validate whether the provided
bundle forms a real chain, and it does not add handshake-level
integration tests against a live TLS server. It also does not change
login state management or OAuth semantics beyond ensuring the existing
flows share the same CA-loading rules.

## Tradeoffs

The main tradeoff is keeping this logic scoped to login-specific client
construction rather than lifting it into a broader shared HTTP layer.
That keeps the review surface smaller, but it also means future
login-adjacent code must continue to use `build_login_http_client()` or
it can silently bypass enterprise CA overrides.

The `TRUSTED CERTIFICATE` handling is also intentionally a local
compatibility shim. The rustls ecosystem does not currently accept that
PEM label upstream, so the code normalizes it locally and trims the
OpenSSL `X509_AUX` trailer bytes down to the certificate DER that
`reqwest` can consume.

## Architecture

`custom_ca.rs` is now the single place that owns login CA behavior. It
selects the CA file from the environment, reads it, normalizes PEM label
shape where needed, iterates mixed PEM sections with `rustls-pki-types`,
ignores CRLs, trims OpenSSL trust metadata when necessary, and returns
either a configured `reqwest::Client` or a typed error.

The browser login server and the device-code flow both call
`build_login_http_client()`, so they share the same trust-store policy.
Environment-sensitive tests run through the `login_ca_probe` helper
binary because those tests must control process-wide env vars and cannot
reliably build a real reqwest client in-process on macOS seatbelt runs.

## Observability

The custom CA path logs which environment variable selected the bundle,
which file path was loaded, how many certificates were accepted, when
`TRUSTED CERTIFICATE` labels were normalized, when CRLs were ignored,
and where client construction failed. Returned errors remain user-facing
and include the relevant path, env var, and remediation hint.

This gives enough signal for three audiences:
- users can see why login failed and which env/file caused it
- sysadmins can confirm which override actually won
- developers can tell whether the failure happened during file read, PEM
parsing, certificate registration, or final reqwest client construction

## Tests

Pure unit tests stay limited to env precedence and empty-value handling.
Real client construction lives in subprocess tests so the suite remains
hermetic with respect to process env and macOS sandbox behavior.

The subprocess tests verify:
- `CODEX_CA_CERTIFICATE` precedence over `SSL_CERT_FILE`
- fallback to `SSL_CERT_FILE`
- single-certificate and multi-certificate bundles
- malformed and empty-bundle errors
- OpenSSL `TRUSTED CERTIFICATE` handling
- CRL tolerance for well-formed CRL sections

The named PEM fixtures under `login/tests/fixtures/` are shared by the
tests so their purpose stays reviewable.

---------

Co-authored-by: Ivan Zakharchanka <3axap4eHko@gmail.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Josh McKinney
2026-03-12 17:14:54 -07:00
committed by GitHub
parent 1ea69e8d50
commit 76d8d174b1
14 changed files with 982 additions and 24 deletions

3
codex-rs/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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 }

View File

@@ -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);
}
}
}

View File

@@ -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<u8>);
/// 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<BuildLoginHttpClientError> 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<BuildLoginHttpClientError> 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<reqwest::Client, BuildLoginHttpClientError> {
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<reqwest::Client, BuildLoginHttpClientError> {
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<reqwest::Client, BuildLoginHttpClientError> {
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<String>;
/// 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<PathBuf> {
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<ConfiguredCaBundle> {
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<String> {
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<Vec<CertificateDer<'static>>, 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<Vec<CertificateDer<'static>>, 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<Vec<u8>, 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:
/// <https://github.com/rustls/pemfile/issues/52>
/// - OpenSSL `x509 -trustout`, which emits `TRUSTED CERTIFICATE` PEM blocks:
/// <https://docs.openssl.org/master/man1/openssl-x509/>
/// - OpenSSL PEM readers, which document that plain `PEM_read_bio_X509()` discards auxiliary
/// trust settings:
/// <https://docs.openssl.org/master/man3/PEM_read_bio_PrivateKey/>
/// - `openssl s_server`, a real OpenSSL-based server/test tool that operates in this
/// ecosystem:
/// <https://docs.openssl.org/master/man1/openssl-s_server/>
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<u8>)` tuple. Keeping that type-directed API here lets callers iterate in
/// terms of normalized sections rather than trait plumbing.
fn sections(&self) -> impl Iterator<Item = Result<PemSection, pem::Error>> + '_ {
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<usize> {
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<String, String>,
}
impl EnvSource for MapEnv {
fn var(&self, key: &str) -> Option<String> {
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"))
);
}
}

View File

@@ -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::<u64>()
.map_err(|e| de::Error::custom(format!("invalid u64 string: {e}")))
s.trim().parse::<u64>().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<DeviceCode> {
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);

View File

@@ -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;

View File

@@ -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<reqwest::Client, BuildLoginHttpClientError> {
crate::custom_ca::build_login_http_client_for_subprocess_tests()
}

View File

@@ -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<LoginServer> {
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(_) => "<invalid-url>".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;

View File

@@ -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());
}

View File

@@ -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-----

View File

@@ -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-----

View File

@@ -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-----

View File

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