Skip to content

Commit 9b8e0d0

Browse files
committed
feat: add mTLS support for model providers
Adds mutual TLS (mTLS) authentication support for model provider connections, allowing Codex to connect to endpoints requiring client certificates. The implementation uses rustls exclusively - native-tls is not supported due to Cargo's feature unification causing both TLS backends to be linked when any dependency enables native-tls. Configuration is done via TOML for each model provider: ```toml [model-providers.my-provider] base_url = "https://example.com" tls.ca-certificate = "/path/to/ca.pem" tls.client-certificate = "/path/to/client.pem" tls.client-private-key = "/path/to/key.pem" ``` Paths can be absolute or relative to `~/.codex/`. All three TLS fields are optional - omit CA certificate to use system roots, omit client cert/key for server-only TLS. The implementation gracefully falls back to a non-TLS client if certificate loading fails (with error logging), ensuring connectivity isn't completely broken by misconfigured certificates. This matches the previous behavior on reqwest builder failures.
1 parent 47cb2fc commit 9b8e0d0

File tree

11 files changed

+226
-7
lines changed

11 files changed

+226
-7
lines changed

codex-rs/core/src/client.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ use crate::client_common::Prompt;
4242
use crate::client_common::ResponseEvent;
4343
use crate::client_common::ResponseStream;
4444
use crate::config::Config;
45-
use crate::default_client::build_reqwest_client;
45+
use crate::default_client::build_reqwest_client_for_provider;
4646
use crate::error::CodexErr;
4747
use crate::error::Result;
4848
use crate::flags::CODEX_RS_SSE_FIXTURE;
@@ -168,7 +168,8 @@ impl ModelClient {
168168
.provider
169169
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
170170
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider).await?;
171-
let transport = ReqwestTransport::new(build_reqwest_client());
171+
let transport =
172+
ReqwestTransport::new(build_reqwest_client_for_provider(&self.provider));
172173
let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry();
173174
let client = ApiChatClient::new(transport, api_provider, api_auth)
174175
.with_telemetry(Some(request_telemetry), Some(sse_telemetry));
@@ -253,7 +254,8 @@ impl ModelClient {
253254
.provider
254255
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
255256
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider).await?;
256-
let transport = ReqwestTransport::new(build_reqwest_client());
257+
let transport =
258+
ReqwestTransport::new(build_reqwest_client_for_provider(&self.provider));
257259
let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry();
258260
let client = ApiResponsesClient::new(transport, api_provider, api_auth)
259261
.with_telemetry(Some(request_telemetry), Some(sse_telemetry));
@@ -337,7 +339,7 @@ impl ModelClient {
337339
.provider
338340
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
339341
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider).await?;
340-
let transport = ReqwestTransport::new(build_reqwest_client());
342+
let transport = ReqwestTransport::new(build_reqwest_client_for_provider(&self.provider));
341343
let request_telemetry = self.build_request_telemetry();
342344
let client = ApiCompactClient::new(transport, api_provider, api_auth)
343345
.with_telemetry(Some(request_telemetry));

codex-rs/core/src/config/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2895,6 +2895,7 @@ model_verbosity = "high"
28952895
stream_max_retries: Some(10),
28962896
stream_idle_timeout_ms: Some(300_000),
28972897
requires_openai_auth: false,
2898+
tls: None,
28982899
};
28992900
let model_provider_map = {
29002901
let mut model_provider_map = built_in_model_providers();

codex-rs/core/src/default_client.rs

Lines changed: 165 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use crate::model_provider_info::ModelProviderInfo;
2+
use crate::model_provider_info::ModelProviderTlsConfig;
13
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
24
use http::Error as HttpError;
35
use reqwest::IntoUrl;
@@ -8,10 +10,22 @@ use reqwest::header::HeaderValue;
810
use serde::Serialize;
911
use std::collections::HashMap;
1012
use std::fmt::Display;
13+
use std::path::PathBuf;
1114
use std::sync::LazyLock;
1215
use std::sync::Mutex;
1316
use std::sync::OnceLock;
1417

18+
/// TLS configuration for HTTP clients
19+
#[derive(Debug, Clone)]
20+
pub struct TlsConfig {
21+
/// Path to a custom CA certificate (PEM format)
22+
pub ca_certificate: Option<PathBuf>,
23+
/// Path to the client certificate (PEM format) for mutual TLS
24+
pub client_certificate: Option<PathBuf>,
25+
/// Path to the client private key (PEM format) for mutual TLS
26+
pub client_private_key: Option<PathBuf>,
27+
}
28+
1529
/// Set this to add a suffix to the User-Agent string.
1630
///
1731
/// It is not ideal that we're using a global singleton for this.
@@ -130,7 +144,7 @@ impl CodexRequestBuilder {
130144
}
131145
Err(error) => {
132146
let status = error.status();
133-
tracing::debug!(
147+
tracing::error!(
134148
method = %self.method,
135149
url = %self.url,
136150
status = status.map(|s| s.as_u16()),
@@ -262,22 +276,170 @@ pub fn create_client() -> CodexHttpClient {
262276
CodexHttpClient::new(inner)
263277
}
264278

279+
/// Create an HTTP client with optional TLS configuration.
280+
/// Optionally configure TLS/mTLS settings via the `tls_config` parameter.
281+
pub fn create_configured_client(tls_config: Option<&TlsConfig>) -> CodexHttpClient {
282+
let inner = build_configured_reqwest_client(tls_config);
283+
CodexHttpClient::new(inner)
284+
}
285+
265286
pub fn build_reqwest_client() -> reqwest::Client {
287+
build_configured_reqwest_client(None)
288+
}
289+
290+
/// Build a reqwest client configured for a specific model provider.
291+
/// This extracts TLS configuration from the provider if present.
292+
pub fn build_reqwest_client_for_provider(provider: &ModelProviderInfo) -> reqwest::Client {
293+
let tls_config = provider
294+
.tls
295+
.as_ref()
296+
.map(ModelProviderTlsConfig::to_tls_config);
297+
build_configured_reqwest_client(tls_config.as_ref())
298+
}
299+
300+
pub fn build_configured_reqwest_client(tls_config: Option<&TlsConfig>) -> reqwest::Client {
301+
let builder = create_base_client_builder();
302+
303+
// Apply TLS configuration if provided
304+
let builder = if let Some(tls) = tls_config {
305+
match apply_tls_config(builder, tls) {
306+
Ok(configured_builder) => configured_builder,
307+
Err(e) => {
308+
tracing::error!("Failed to apply TLS configuration: {}", e);
309+
// Fall back to base builder without TLS
310+
create_base_client_builder()
311+
}
312+
}
313+
} else {
314+
builder
315+
};
316+
317+
builder.build().unwrap_or_else(|_| reqwest::Client::new())
318+
}
319+
320+
/// Create the base HTTP client builder with standard configuration.
321+
fn create_base_client_builder() -> reqwest::ClientBuilder {
266322
use reqwest::header::HeaderMap;
267323

268324
let mut headers = HeaderMap::new();
269325
headers.insert("originator", originator().header_value.clone());
270326
let ua = get_codex_user_agent();
271327

272328
let mut builder = reqwest::Client::builder()
273-
// Set UA via dedicated helper to avoid header validation pitfalls
274329
.user_agent(ua)
275330
.default_headers(headers);
331+
276332
if is_sandboxed() {
277333
builder = builder.no_proxy();
278334
}
279335

280-
builder.build().unwrap_or_else(|_| reqwest::Client::new())
336+
builder
337+
}
338+
339+
/// Apply TLS configuration to a reqwest ClientBuilder.
340+
/// Paths are resolved relative to ~/.codex/ if they're not absolute.
341+
fn apply_tls_config(
342+
mut builder: reqwest::ClientBuilder,
343+
tls: &TlsConfig,
344+
) -> Result<reqwest::ClientBuilder, String> {
345+
use reqwest::Certificate;
346+
use reqwest::Identity;
347+
348+
// Add custom CA certificate if provided
349+
if let Some(ca_path) = &tls.ca_certificate {
350+
let resolved_path = resolve_cert_path(ca_path);
351+
let cert_pem = std::fs::read(&resolved_path).map_err(|e| {
352+
format!(
353+
"Failed to read CA certificate from {}: {}",
354+
resolved_path.display(),
355+
e
356+
)
357+
})?;
358+
359+
let certificate = Certificate::from_pem(&cert_pem).map_err(|e| {
360+
format!(
361+
"Failed to parse CA certificate from {}: {}",
362+
resolved_path.display(),
363+
e
364+
)
365+
})?;
366+
367+
// Disable built-in root certificates and use only our custom CA
368+
builder = builder
369+
.tls_built_in_root_certs(false)
370+
.add_root_certificate(certificate);
371+
}
372+
373+
// Configure client certificate and private key for mTLS
374+
match (&tls.client_certificate, &tls.client_private_key) {
375+
(Some(cert_path), Some(key_path)) => {
376+
let cert_resolved = resolve_cert_path(cert_path);
377+
let key_resolved = resolve_cert_path(key_path);
378+
379+
// Read cert and key files
380+
let cert_pem = std::fs::read(&cert_resolved).map_err(|e| {
381+
format!(
382+
"Failed to read client certificate from {}: {}",
383+
cert_resolved.display(),
384+
e
385+
)
386+
})?;
387+
let key_pem = std::fs::read(&key_resolved).map_err(|e| {
388+
format!(
389+
"Failed to read client private key from {}: {}",
390+
key_resolved.display(),
391+
e
392+
)
393+
})?;
394+
395+
// For rustls, Identity::from_pem() accepts combined cert+key PEM data
396+
let mut combined_pem = cert_pem;
397+
combined_pem.extend_from_slice(&key_pem);
398+
399+
let identity = Identity::from_pem(&combined_pem).map_err(|e| {
400+
format!(
401+
"Failed to create client identity from {} and {}: {}",
402+
cert_resolved.display(),
403+
key_resolved.display(),
404+
e
405+
)
406+
})?;
407+
408+
builder = builder.identity(identity).https_only(true);
409+
}
410+
(Some(_), None) | (None, Some(_)) => {
411+
return Err(
412+
"client_certificate and client_private_key must both be provided for mTLS"
413+
.to_string(),
414+
);
415+
}
416+
(None, None) => {
417+
// No client certificate configured
418+
}
419+
}
420+
421+
Ok(builder)
422+
}
423+
424+
/// Resolve certificate paths relative to ~/.codex/ if they're not absolute.
425+
fn resolve_cert_path(path: &PathBuf) -> PathBuf {
426+
if path.is_absolute() {
427+
path.clone()
428+
} else {
429+
// Resolve relative to $CODEX_HOME (defaults to ~/.codex)
430+
let codex_home = std::env::var("CODEX_HOME")
431+
.ok()
432+
.and_then(|s| {
433+
if s.is_empty() {
434+
None
435+
} else {
436+
Some(PathBuf::from(s))
437+
}
438+
})
439+
.or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
440+
.unwrap_or_else(|| PathBuf::from(".codex"));
441+
codex_home.join(path)
442+
}
281443
}
282444

283445
fn is_sandboxed() -> bool {

codex-rs/core/src/model_provider_info.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use serde::Deserialize;
1616
use serde::Serialize;
1717
use std::collections::HashMap;
1818
use std::env::VarError;
19+
use std::path::PathBuf;
1920
use std::time::Duration;
2021

2122
use crate::error::EnvVarError;
@@ -44,6 +45,36 @@ pub enum WireApi {
4445
Chat,
4546
}
4647

48+
/// TLS configuration for mutual TLS (mTLS) authentication with model providers.
49+
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
50+
#[serde(rename_all = "kebab-case")]
51+
pub struct ModelProviderTlsConfig {
52+
/// Path to a custom CA certificate (PEM format) to trust when connecting to this provider.
53+
/// Relative paths are resolved against `~/.codex/`.
54+
pub ca_certificate: Option<PathBuf>,
55+
56+
/// Path to the client certificate (PEM format) for mutual TLS authentication.
57+
/// Must be provided together with `client_private_key`.
58+
/// Relative paths are resolved against `~/.codex/`.
59+
pub client_certificate: Option<PathBuf>,
60+
61+
/// Path to the client private key (PEM format) for mutual TLS authentication.
62+
/// Must be provided together with `client_certificate`.
63+
/// Relative paths are resolved against `~/.codex/`.
64+
pub client_private_key: Option<PathBuf>,
65+
}
66+
67+
impl ModelProviderTlsConfig {
68+
/// Convert to the default_client TlsConfig type
69+
pub fn to_tls_config(&self) -> crate::default_client::TlsConfig {
70+
crate::default_client::TlsConfig {
71+
ca_certificate: self.ca_certificate.clone(),
72+
client_certificate: self.client_certificate.clone(),
73+
client_private_key: self.client_private_key.clone(),
74+
}
75+
}
76+
}
77+
4778
/// Serializable representation of a provider definition.
4879
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
4980
pub struct ModelProviderInfo {
@@ -96,6 +127,10 @@ pub struct ModelProviderInfo {
96127
/// and API key (if needed) comes from the "env_key" environment variable.
97128
#[serde(default)]
98129
pub requires_openai_auth: bool,
130+
131+
/// TLS configuration for mutual TLS (mTLS) authentication and custom CA certificates.
132+
#[serde(default)]
133+
pub tls: Option<ModelProviderTlsConfig>,
99134
}
100135

101136
impl ModelProviderInfo {
@@ -263,6 +298,7 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
263298
stream_max_retries: None,
264299
stream_idle_timeout_ms: None,
265300
requires_openai_auth: true,
301+
tls: None,
266302
},
267303
),
268304
(
@@ -314,6 +350,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M
314350
stream_max_retries: None,
315351
stream_idle_timeout_ms: None,
316352
requires_openai_auth: false,
353+
tls: None,
317354
}
318355
}
319356

@@ -342,6 +379,7 @@ base_url = "http://localhost:11434/v1"
342379
stream_max_retries: None,
343380
stream_idle_timeout_ms: None,
344381
requires_openai_auth: false,
382+
tls: None,
345383
};
346384

347385
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
@@ -372,6 +410,7 @@ query_params = { api-version = "2025-04-01-preview" }
372410
stream_max_retries: None,
373411
stream_idle_timeout_ms: None,
374412
requires_openai_auth: false,
413+
tls: None,
375414
};
376415

377416
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
@@ -405,6 +444,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
405444
stream_max_retries: None,
406445
stream_idle_timeout_ms: None,
407446
requires_openai_auth: false,
447+
tls: None,
408448
};
409449

410450
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
@@ -436,6 +476,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
436476
stream_max_retries: None,
437477
stream_idle_timeout_ms: None,
438478
requires_openai_auth: false,
479+
tls: None,
439480
};
440481
let api = provider.to_api_provider(None).expect("api provider");
441482
assert!(
@@ -458,6 +499,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
458499
stream_max_retries: None,
459500
stream_idle_timeout_ms: None,
460501
requires_openai_auth: false,
502+
tls: None,
461503
};
462504
let named_api = named_provider.to_api_provider(None).expect("api provider");
463505
assert!(named_api.is_azure_responses_endpoint());
@@ -482,6 +524,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
482524
stream_max_retries: None,
483525
stream_idle_timeout_ms: None,
484526
requires_openai_auth: false,
527+
tls: None,
485528
};
486529
let api = provider.to_api_provider(None).expect("api provider");
487530
assert!(

codex-rs/core/src/openai_models/models_manager.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ mod tests {
128128
stream_max_retries: Some(0),
129129
stream_idle_timeout_ms: Some(5_000),
130130
requires_openai_auth: false,
131+
tls: None,
131132
}
132133
}
133134

codex-rs/core/tests/chat_completions_payload.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ async fn run_request(input: Vec<ResponseItem>) -> Value {
5656
stream_max_retries: Some(0),
5757
stream_idle_timeout_ms: Some(5_000),
5858
requires_openai_auth: false,
59+
tls: None,
5960
};
6061

6162
let codex_home = match TempDir::new() {

0 commit comments

Comments
 (0)