Improve codex doctor network diagnostics (#38918)

## What changed

- Probe the configured Responses inference endpoint with Codex's route-aware HTTP client, including configured proxy and custom CA behavior.
- Classify TLS, proxy authentication, proxy configuration, resolution, and timeout failures into actionable diagnostics.
- Report privacy-safe macOS system proxy state and suggest enabling `respect_system_proxy` when a configured proxy is otherwise unused.
- Preserve system-root fallback when a custom CA is invalid, and account for provider-specific API key configuration when selecting the probe route.

## Testing

- Cover inference endpoint selection, invalid custom CA fallback, proxy authentication failures, TLS classification, and macOS proxy reporting.

GitOrigin-RevId: c30a3ca5a332119e2b2f8c65341ef9b2251a28a2
This commit is contained in:
chess
2026-08-17 00:09:44 +00:00
committed by copyberry
parent 772e88c8ae
commit c8ddb210d2
12 changed files with 677 additions and 93 deletions
+1
View File
@@ -3379,6 +3379,7 @@ dependencies = [
"codex-utils-rustls-provider",
"futures",
"http 1.4.0",
"native-tls",
"opentelemetry",
"opentelemetry_sdk",
"pretty_assertions",
+152 -93
View File
@@ -39,6 +39,10 @@ use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::find_codex_home;
use codex_features::FEATURES;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_http_client::RouteAwareClientPool;
use codex_install_context::CodexPackageLayout;
use codex_install_context::InstallContext;
use codex_install_context::InstallMethod;
@@ -63,12 +67,14 @@ use codex_tui::Cli as TuiCli;
use codex_utils_cli::CliConfigOverrides;
use http::HeaderMap;
use http::HeaderValue;
use http::Method;
use serde::Serialize;
use supports_color::Stream;
mod background;
mod disk;
mod git;
mod network;
mod output;
mod progress;
mod runtime;
@@ -376,6 +382,11 @@ async fn build_report(
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await;
let auth_manager = auth_manager_result.as_ref().ok().cloned();
let reachability_plan = provider_reachability_plan(config);
#[cfg(target_os = "macos")]
let reachability_url = reachability_plan
.endpoints
.first()
.map(|endpoint| endpoint.url.clone());
let (
config_check,
auth_check,
@@ -409,7 +420,9 @@ async fn build_report(
})
},
async { run_sync_check("updates", progress.clone(), || updates_check(config)) },
async { run_sync_check("network", progress.clone(), network_check) },
async {
run_sync_check("network", progress.clone(), || network::check(Some(config)))
},
run_async_check(
"websocket",
progress.clone(),
@@ -449,6 +462,12 @@ async fn build_report(
provider_reachability_check(reachability_plan),
),
);
#[cfg(target_os = "macos")]
let reachability_check = network::with_system_proxy_remediation(
reachability_check,
config,
reachability_url.as_deref(),
);
checks.extend([
config_check,
auth_check,
@@ -488,7 +507,11 @@ async fn build_report(
.remediation("Fix the reported config error, then rerun codex doctor.")
})
},
async { run_sync_check("network", progress.clone(), network_check) },
async {
run_sync_check("network", progress.clone(), || {
network::check(/*config*/ None)
})
},
async {
run_sync_check("terminal", progress.clone(), || {
terminal_check(command.no_color)
@@ -1472,42 +1495,6 @@ fn stored_auth_issues(
issues
}
fn network_check() -> DoctorCheck {
let mut details = Vec::new();
push_proxy_env_details(&mut details);
let mut status = CheckStatus::Ok;
let mut summary = "network-related environment looks readable".to_string();
for name in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
if let Some(raw) = env::var_os(name) {
let path = PathBuf::from(raw);
match std::fs::metadata(&path) {
Ok(metadata) if metadata.is_file() => {
if let Err(err) = read_probe_file(&path) {
status = CheckStatus::Warning;
summary = "custom CA env var points at an unreadable file".to_string();
details.push(format!("{name}: {} ({err})", path.display()));
} else {
details.push(format!("{name}: readable file {}", path.display()));
}
}
Ok(_) => {
status = CheckStatus::Warning;
summary = "custom CA env var does not point at a file".to_string();
details.push(format!("{name}: not a file {}", path.display()));
}
Err(err) => {
status = CheckStatus::Warning;
summary = "custom CA env var points at an unreadable path".to_string();
details.push(format!("{name}: {} ({err})", path.display()));
}
}
}
}
DoctorCheck::new("network.env", "network", status, summary).details(details)
}
fn push_proxy_env_details(details: &mut Vec<String>) {
let present_proxy_vars = PROXY_ENV_VARS
.iter()
@@ -2579,6 +2566,7 @@ fn fallback_state_check() -> DoctorCheck {
struct ReachabilityPlan {
description: String,
endpoints: Vec<ReachabilityEndpoint>,
http_client_factory: HttpClientFactory,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -2616,10 +2604,12 @@ fn provider_reachability_plan(config: &Config) -> ReachabilityPlan {
.flatten();
let mode = provider_auth_reachability_mode_from_auth(
config.model_provider.requires_openai_auth,
config.model_provider.env_key.as_deref(),
config.model_provider.base_url.as_deref(),
env_var_present,
stored_auth.as_ref(),
);
provider_reachability_plan_from_parts(
let mut plan = provider_reachability_plan_from_parts(
mode,
&config.model_provider_id,
&config.model_provider.name,
@@ -2627,7 +2617,9 @@ fn provider_reachability_plan(config: &Config) -> ReachabilityPlan {
config.model_provider.query_params.as_ref(),
config.model_provider.is_amazon_bedrock(),
&config.chatgpt_base_url,
)
);
plan.http_client_factory = config.http_client_factory();
plan
}
fn default_reachability_plan() -> ReachabilityPlan {
@@ -2644,13 +2636,19 @@ fn default_reachability_plan() -> ReachabilityPlan {
fn provider_auth_reachability_mode_from_auth(
requires_openai_auth: bool,
provider_env_key: Option<&str>,
provider_base_url: Option<&str>,
env_var_present: impl Fn(&str) -> bool,
stored_auth: Option<&AuthDotJson>,
) -> ProviderAuthReachabilityMode {
if !requires_openai_auth {
return ProviderAuthReachabilityMode::NotRequired;
}
if env_var_present(OPENAI_API_KEY_ENV_VAR) || env_var_present(CODEX_API_KEY_ENV_VAR) {
if provider_base_url.is_some_and(|url| !url.trim().is_empty())
&& provider_env_key
.is_some_and(|env_key| !env_key.trim().is_empty() && env_var_present(env_key))
|| env_var_present(CODEX_API_KEY_ENV_VAR)
{
return ProviderAuthReachabilityMode::ApiKey;
}
if env_var_present(CODEX_ACCESS_TOKEN_ENV_VAR) {
@@ -2686,35 +2684,29 @@ fn provider_reachability_plan_from_parts(
should_probe_models_route(provider_name, url, is_amazon_bedrock)
.then(|| provider_url_for_path(url, "models", provider_query_params))
});
let endpoints = match mode {
ProviderAuthReachabilityMode::ApiKey => vec![ReachabilityEndpoint {
let endpoints = match (mode, provider_base_url) {
(ProviderAuthReachabilityMode::ApiKey, _) | (_, Some(_)) => vec![ReachabilityEndpoint {
label: format!("{provider_id} API"),
url: provider_base_url
.unwrap_or("https://api.openai.com/v1")
.to_string(),
url: provider_url_for_path(
provider_base_url.unwrap_or("https://api.openai.com/v1"),
"responses",
provider_query_params,
),
required: true,
route_probe_url: provider_route_probe_url,
}],
ProviderAuthReachabilityMode::Chatgpt => vec![ReachabilityEndpoint {
(ProviderAuthReachabilityMode::Chatgpt, None) => vec![ReachabilityEndpoint {
label: "ChatGPT".to_string(),
url: chatgpt_base_url.to_string(),
url: provider_url_for_path(chatgpt_base_url, "codex/responses", provider_query_params),
required: true,
route_probe_url: None,
}],
ProviderAuthReachabilityMode::NotRequired => provider_base_url
.map(|url| {
vec![ReachabilityEndpoint {
label: format!("{provider_id} API"),
url: url.to_string(),
required: true,
route_probe_url: provider_route_probe_url,
}]
})
.unwrap_or_default(),
(ProviderAuthReachabilityMode::NotRequired, None) => Vec::new(),
};
ReachabilityPlan {
description: mode.description().to_string(),
endpoints,
http_client_factory: HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
}
}
@@ -2765,15 +2757,20 @@ async fn provider_reachability_check(plan: ReachabilityPlan) -> DoctorCheck {
.details(details);
}
let client = RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_request_logging(
plan.http_client_factory,
ClientRouteClass::Api,
)
.with_legacy_custom_ca_fallback();
let mut failures = Vec::new();
let mut optional_failures = Vec::new();
let mut route_failures = Vec::new();
let mut route_warnings = Vec::new();
let mut issues = Vec::new();
for endpoint in plan.endpoints {
match http_probe_url(&endpoint.url).await {
match network::probe_status(&client, &endpoint.url, Method::HEAD, default_headers()).await {
Ok(status) => details.push(format!(
"{} base URL: {} reachable ({status})",
"{} inference URL: {} reachable (HTTP {status})",
endpoint.label, endpoint.url
)),
Err(err) => {
@@ -2783,7 +2780,7 @@ async fn provider_reachability_check(plan: ReachabilityPlan) -> DoctorCheck {
"optional"
};
details.push(format!(
"{} base URL: {} {err} ({requirement})",
"{} inference URL: {} {err} ({requirement})",
endpoint.label, endpoint.url
));
if endpoint.required {
@@ -2798,7 +2795,7 @@ async fn provider_reachability_check(plan: ReachabilityPlan) -> DoctorCheck {
let Some(route_probe_url) = endpoint.route_probe_url.as_deref() else {
continue;
};
match provider_route_probe_url(route_probe_url).await {
match provider_route_probe_url(&client, route_probe_url).await {
RouteProbeOutcome::Ok(status) => {
details.push(format!(
"{} route probe: {route_probe_url} route exists ({status})",
@@ -2876,8 +2873,8 @@ enum RouteProbeOutcome {
TransportError(String),
}
async fn provider_route_probe_url(url: &str) -> RouteProbeOutcome {
match http_get_probe_status_with_timeout(url, Duration::from_secs(3)).await {
async fn provider_route_probe_url(client: &RouteAwareClientPool, url: &str) -> RouteProbeOutcome {
match network::probe_status(client, url, Method::GET, default_headers()).await {
Ok(status) if (200..300).contains(&status) || matches!(status, 401 | 403) => {
RouteProbeOutcome::Ok(format!("HTTP {status}"))
}
@@ -2907,10 +2904,6 @@ fn provider_reachability_outcome(
}
}
async fn http_probe_url(url: &str) -> Result<String, String> {
http_probe_url_with_timeout(url, Duration::from_secs(3)).await
}
async fn mcp_http_probe_url(url: &str) -> Result<String, String> {
mcp_http_probe_url_with_timeout(url, Duration::from_secs(3)).await
}
@@ -3652,6 +3645,8 @@ mod tests {
assert_eq!(
provider_auth_reachability_mode_from_auth(
/*requires_openai_auth*/ true,
/*provider_env_key*/ None,
/*provider_base_url*/ None,
|_| false,
Some(&api_key_auth),
),
@@ -3660,11 +3655,49 @@ mod tests {
assert_eq!(
provider_auth_reachability_mode_from_auth(
/*requires_openai_auth*/ true,
|name| name == OPENAI_API_KEY_ENV_VAR,
/*provider_env_key*/ None,
/*provider_base_url*/ None,
|name| name == CODEX_API_KEY_ENV_VAR,
/*stored_auth*/ None,
),
ProviderAuthReachabilityMode::ApiKey
);
let chatgpt_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
..api_key_auth
};
assert_eq!(
provider_auth_reachability_mode_from_auth(
/*requires_openai_auth*/ true,
/*provider_env_key*/ None,
Some("https://custom.example/v1"),
|name| name == OPENAI_API_KEY_ENV_VAR,
Some(&chatgpt_auth),
),
ProviderAuthReachabilityMode::Chatgpt
);
assert_eq!(
provider_auth_reachability_mode_from_auth(
/*requires_openai_auth*/ true,
Some(OPENAI_API_KEY_ENV_VAR),
Some("https://custom.example/v1"),
|name| name == OPENAI_API_KEY_ENV_VAR,
Some(&chatgpt_auth),
),
ProviderAuthReachabilityMode::ApiKey
);
assert_eq!(
provider_auth_reachability_mode_from_auth(
/*requires_openai_auth*/ true,
/*provider_env_key*/ None,
/*provider_base_url*/ None,
|name| name == CODEX_API_KEY_ENV_VAR,
Some(&chatgpt_auth),
),
ProviderAuthReachabilityMode::ApiKey
);
}
#[test]
@@ -3683,10 +3716,11 @@ mod tests {
description: "provider auth".to_string(),
endpoints: vec![ReachabilityEndpoint {
label: "azure API".to_string(),
url: "https://example.openai.azure.com/openai/v1".to_string(),
url: "https://example.openai.azure.com/openai/v1/responses".to_string(),
required: true,
route_probe_url: None,
}],
http_client_factory: HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
}
);
}
@@ -3695,28 +3729,38 @@ mod tests {
fn provider_reachability_adds_models_route_probe_for_openai_compatible_base_urls() {
let query_params = HashMap::from([("api-version".to_string(), "2026-01-01".to_string())]);
assert_eq!(
provider_reachability_plan_from_parts(
ProviderAuthReachabilityMode::NotRequired,
"custom",
"Custom",
Some("https://example.com/openai/v1/"),
Some(&query_params),
/*is_amazon_bedrock*/ false,
"https://chatgpt.com/backend-api/",
),
ReachabilityPlan {
description: "provider auth".to_string(),
endpoints: vec![ReachabilityEndpoint {
label: "custom API".to_string(),
url: "https://example.com/openai/v1/".to_string(),
required: true,
route_probe_url: Some(
"https://example.com/openai/v1/models?api-version=2026-01-01".to_string()
for (mode, description) in [
(ProviderAuthReachabilityMode::NotRequired, "provider auth"),
(ProviderAuthReachabilityMode::Chatgpt, "ChatGPT auth"),
] {
assert_eq!(
provider_reachability_plan_from_parts(
mode,
"custom",
"Custom",
Some("https://example.com/openai/v1/"),
Some(&query_params),
/*is_amazon_bedrock*/ false,
"https://chatgpt.com/backend-api/",
),
ReachabilityPlan {
description: description.to_string(),
endpoints: vec![ReachabilityEndpoint {
label: "custom API".to_string(),
url: "https://example.com/openai/v1/responses?api-version=2026-01-01"
.to_string(),
required: true,
route_probe_url: Some(
"https://example.com/openai/v1/models?api-version=2026-01-01"
.to_string()
),
}],
http_client_factory: HttpClientFactory::new(
OutboundProxyPolicy::ReqwestDefault
),
}],
}
);
}
);
}
}
#[test]
@@ -3750,13 +3794,26 @@ mod tests {
plan.endpoints,
vec![ReachabilityEndpoint {
label: "openai API".to_string(),
url: "https://api.openai.com/v1".to_string(),
url: "https://api.openai.com/v1/responses".to_string(),
required: true,
route_probe_url: Some("https://api.openai.com/v1/models".to_string()),
}]
);
}
#[test]
fn provider_reachability_chatgpt_uses_inference_endpoint() {
assert_eq!(
default_reachability_plan().endpoints,
vec![ReachabilityEndpoint {
label: "ChatGPT".to_string(),
url: "https://chatgpt.com/backend-api/codex/responses".to_string(),
required: true,
route_probe_url: None,
}]
);
}
#[test]
fn provider_reachability_outcome_reports_required_failures() {
assert_eq!(
@@ -3892,7 +3949,9 @@ mod tests {
.expect("write response");
});
let status = http_probe_url(&format!("http://{addr}/mcp")).await;
let status =
http_probe_url_with_timeout(&format!("http://{addr}/mcp"), Duration::from_secs(3))
.await;
server.join().expect("probe server thread should finish");
assert_eq!(status, Ok("HTTP 405".to_string()));
+167
View File
@@ -0,0 +1,167 @@
use std::env;
use std::path::PathBuf;
use std::time::Duration;
use codex_core::config::Config;
#[cfg(target_os = "macos")]
use codex_http_client::MacosSystemProxyConfiguration;
use codex_http_client::RouteAwareClientPool;
use codex_http_client::RouteAwareRequestError;
use codex_http_client::RouteFailureClass;
#[cfg(target_os = "macos")]
use codex_http_client::macos_system_proxy_configuration;
use codex_login::default_client::create_client_without_request_logging;
use http::HeaderMap;
use http::Method;
use super::CheckStatus;
use super::DoctorCheck;
use super::push_proxy_env_details;
use super::read_probe_file;
pub(super) fn check(config: Option<&Config>) -> DoctorCheck {
let mut details = Vec::new();
push_proxy_env_details(&mut details);
#[cfg(target_os = "macos")]
{
let request_url = config
.and_then(|config| config.model_provider.base_url.as_deref())
.or_else(|| config.map(|config| config.chatgpt_base_url.as_str()))
.unwrap_or("https://chatgpt.com/backend-api/");
let configuration = match macos_system_proxy_configuration(request_url) {
MacosSystemProxyConfiguration::Automatic => "automatic (PAC)",
MacosSystemProxyConfiguration::Manual => "manual",
MacosSystemProxyConfiguration::Direct => "direct",
MacosSystemProxyConfiguration::Unavailable => "unavailable",
};
details.push(format!("system proxy: {configuration}"));
}
if let Some(config) = config {
let enabled = |value| if value { "enabled" } else { "disabled" };
details.push(format!(
"respect system proxy: {}",
enabled(config.respect_system_proxy)
));
if config.permissions.network.is_some() {
details.push("managed proxy: configured".to_string());
} else {
details.push("managed proxy: not configured".to_string());
}
}
let mut status = CheckStatus::Ok;
let mut summary = "network-related environment looks readable".to_string();
for name in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
if let Some(raw) = env::var_os(name) {
let path = PathBuf::from(raw);
match std::fs::metadata(&path) {
Ok(metadata) if metadata.is_file() => {
if let Err(error) = read_probe_file(&path) {
status = CheckStatus::Warning;
summary = "custom CA env var points at an unreadable file".to_string();
details.push(format!("{name}: {} ({error})", path.display()));
} else {
details.push(format!("{name}: readable file {}", path.display()));
}
}
Ok(_) => {
status = CheckStatus::Warning;
summary = "custom CA env var does not point at a file".to_string();
details.push(format!("{name}: not a file {}", path.display()));
}
Err(error) => {
status = CheckStatus::Warning;
summary = "custom CA env var points at an unreadable path".to_string();
details.push(format!("{name}: {} ({error})", path.display()));
}
}
}
}
DoctorCheck::new("network.env", "network", status, summary).details(details)
}
#[cfg(target_os = "macos")]
pub(super) fn with_system_proxy_remediation(
mut check: DoctorCheck,
config: &Config,
request_url: Option<&str>,
) -> DoctorCheck {
if check.status == CheckStatus::Fail
&& !config.respect_system_proxy
&& config.permissions.network.is_none()
&& config
.config_layer_stack
.requirements()
.feature_requirements
.as_ref()
.and_then(|requirements| requirements.value.entries.get("respect_system_proxy"))
.is_none_or(|enabled| *enabled)
&& !super::PROXY_ENV_VARS
.iter()
.any(|name| !name.eq_ignore_ascii_case("NO_PROXY") && super::env_var_present(name))
&& request_url.is_some_and(|url| {
matches!(
macos_system_proxy_configuration(url),
MacosSystemProxyConfiguration::Automatic | MacosSystemProxyConfiguration::Manual
)
})
{
check.remediation = Some(
"A macOS system proxy is configured but unused. If your organization requires it, ask your administrator whether to enable the under-development feature with `codex features enable respect_system_proxy`."
.to_string(),
);
}
check
}
pub(super) async fn probe_status(
client: &RouteAwareClientPool,
url: &str,
method: Method,
headers: HeaderMap,
) -> Result<u16, String> {
let response = if env::var("CODEX_SANDBOX").as_deref() == Ok("seatbelt") {
create_client_without_request_logging()
.request(method, url)
.headers(headers)
.timeout(Duration::from_secs(8))
.send()
.await
.map_err(RouteAwareRequestError::from)
} else {
client
.request(method, url)
.headers(headers)
// Allow five seconds for PAC resolution before the three-second HTTP budget.
.timeout(Duration::from_secs(8))
.send()
.await
}
.map_err(|error| {
match error.failure_class() {
Some(RouteFailureClass::TlsError) => "TLS handshake or certificate validation failed",
Some(RouteFailureClass::ProxyAuthenticationRequired) => "proxy authentication required",
Some(RouteFailureClass::InvalidProxyConfig) => "invalid proxy configuration",
Some(RouteFailureClass::ProxyResolutionUnavailable) => {
"system proxy configuration unavailable"
}
Some(RouteFailureClass::ConnectTimeout) => "request timed out",
Some(RouteFailureClass::UnsupportedProxyScheme) => "unsupported proxy configuration",
Some(RouteFailureClass::ResolverError) => "proxy resolution failed",
None if error.is_timeout() => "request timed out",
None if error.is_connect() => "connect failed",
None => "request failed",
}
.to_string()
})?;
let status = response.status().as_u16();
if status == 407 {
return Err("proxy authentication required (HTTP 407)".to_string());
}
Ok(status)
}
#[cfg(test)]
#[path = "network_tests.rs"]
mod tests;
+37
View File
@@ -0,0 +1,37 @@
use pretty_assertions::assert_eq;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use super::super::CheckStatus;
use super::super::ProviderAuthReachabilityMode;
use super::super::provider_reachability_check;
use super::super::provider_reachability_plan_from_parts;
#[tokio::test]
async fn provider_reachability_rejects_proxy_authentication_challenges() {
let proxy = MockServer::start().await;
Mock::given(method("HEAD"))
.respond_with(ResponseTemplate::new(407))
.mount(&proxy)
.await;
let plan = provider_reachability_plan_from_parts(
ProviderAuthReachabilityMode::Chatgpt,
"openai",
"OpenAI",
/*provider_base_url*/ None,
/*provider_query_params*/ None,
/*is_amazon_bedrock*/ false,
&format!("{}/backend-api/", proxy.uri()),
);
let check = provider_reachability_check(plan).await;
assert_eq!(check.status, CheckStatus::Fail);
assert!(
check
.details
.join(" ")
.contains("proxy authentication required (HTTP 407)")
);
}
@@ -0,0 +1,112 @@
#[cfg(target_os = "macos")]
use std::path::Path;
use std::process::Command;
use std::process::Stdio;
use anyhow::Context as _;
use anyhow::Result;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn invalid_custom_ca_falls_back_to_system_roots() -> Result<()> {
let server = MockServer::start().await;
for (request_method, request_path) in [("HEAD", "/v1/responses"), ("GET", "/v1/models")] {
Mock::given(method(request_method))
.and(path(request_path))
.respond_with(ResponseTemplate::new(200))
.expect(2)
.mount(&server)
.await;
}
let codex_home = TempDir::new()?;
let certificate = codex_home.path().join("invalid-ca.pem");
std::fs::write(&certificate, "not a certificate")?;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
"model_provider = \"local\"\n[model_providers.local]\nname = \"local\"\nbase_url = \"{}/v1\"\nwire_api = \"responses\"\n",
server.uri()
),
)?;
for sandbox in [None, Some("seatbelt")] {
let mut command = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
command
.args(["doctor", "--json"])
.env("CODEX_HOME", codex_home.path())
.env("CODEX_CA_CERTIFICATE", &certificate)
.stdin(Stdio::null());
if let Some(sandbox) = sandbox {
command
.env("CODEX_SANDBOX", sandbox)
.env("HTTP_PROXY", "http://127.0.0.1:1")
.env("http_proxy", "http://127.0.0.1:1")
.env("HTTPS_PROXY", "http://127.0.0.1:1")
.env("https_proxy", "http://127.0.0.1:1")
.env("NO_PROXY", "")
.env("no_proxy", "");
} else {
command
.env("NO_PROXY", "127.0.0.1,localhost")
.env("no_proxy", "127.0.0.1,localhost");
}
let output = command
.output()
.context("failed to run the doctor with an invalid custom CA")?;
let report: Value = serde_json::from_slice(&output.stdout)?;
assert_eq!(
report["checks"]["network.provider_reachability"]["status"],
json!("ok")
);
}
server.verify().await;
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn doctor_reports_macos_system_proxy_configuration_and_policy() -> Result<()> {
let codex_home = TempDir::new()?;
let report = doctor_report(codex_home.path())?;
let details = &report["checks"]["network.env"]["details"];
assert_eq!(details["respect system proxy"], json!("disabled"));
assert!(matches!(
details["system proxy"].as_str(),
Some("automatic (PAC)" | "manual" | "direct" | "unavailable")
));
std::fs::write(
codex_home.path().join("config.toml"),
"[features]\nrespect_system_proxy = true\n",
)?;
let report = doctor_report(codex_home.path())?;
assert_eq!(
report["checks"]["network.env"]["details"]["respect system proxy"],
json!("enabled")
);
Ok(())
}
#[cfg(target_os = "macos")]
fn doctor_report(codex_home: &Path) -> Result<Value> {
let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
.args(["doctor", "--json"])
.env("CODEX_HOME", codex_home)
.stdin(Stdio::null())
.output()
.context("failed to run the doctor")?;
serde_json::from_slice(&output.stdout).context("doctor did not emit a valid json report")
}
+1
View File
@@ -9,6 +9,7 @@ bytes = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
futures = { workspace = true }
http = { workspace = true }
native-tls = "0.2"
opentelemetry = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] }
rustls = { workspace = true }
+4
View File
@@ -34,11 +34,15 @@ pub use crate::error::TransportError;
pub use crate::outbound_proxy::BuildRouteAwareHttpClientError;
pub use crate::outbound_proxy::ClientRouteClass;
pub use crate::outbound_proxy::HttpClientFactory;
#[cfg(target_os = "macos")]
pub use crate::outbound_proxy::MacosSystemProxyConfiguration;
pub use crate::outbound_proxy::OutboundProxyPolicy;
pub use crate::outbound_proxy::OutboundProxyRoute;
pub use crate::outbound_proxy::RouteFailureClass;
#[doc(hidden)]
pub use crate::outbound_proxy::cache_system_proxy_route_for_test;
#[cfg(target_os = "macos")]
pub use crate::outbound_proxy::macos_system_proxy_configuration;
pub use crate::request::EncodedJsonBody;
pub use crate::request::PreparedRequestBody;
pub use crate::request::Request;
@@ -103,6 +103,26 @@ pub enum OutboundProxyPolicy {
RespectSystemProxy,
}
/// Privacy-safe macOS system proxy configuration for one outbound destination.
#[cfg(target_os = "macos")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MacosSystemProxyConfiguration {
/// A PAC script or automatic proxy-discovery route applies to the destination.
Automatic,
/// An explicitly configured HTTP or HTTPS proxy applies.
Manual,
/// macOS selected a direct connection for the destination.
Direct,
/// The destination or system proxy settings could not be inspected.
Unavailable,
}
/// Inspects macOS proxy configuration without executing PAC scripts or exposing proxy URLs.
#[cfg(target_os = "macos")]
pub fn macos_system_proxy_configuration(request_url: &str) -> MacosSystemProxyConfiguration {
macos::configuration(request_url)
}
/// Resolved proxy route for a concrete outbound destination.
///
/// `TransportDefault` preserves the underlying transport behavior only when system-proxy support
@@ -5,6 +5,7 @@ use std::ptr;
use std::time::Duration;
use std::time::Instant;
use super::MacosSystemProxyConfiguration;
use super::RequestOrigin;
use super::RouteFailureClass;
use super::SystemProxyDecision;
@@ -102,6 +103,39 @@ pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyD
proxy_array_decision(&proxies, &target_url, origin)
}
pub(super) fn configuration(request_url: &str) -> MacosSystemProxyConfiguration {
let Some(target_url) = cf_url(request_url) else {
return MacosSystemProxyConfiguration::Unavailable;
};
let Some(settings) = system_proxy_settings() else {
return MacosSystemProxyConfiguration::Unavailable;
};
let Some(proxies) = copy_proxies_for_url(&target_url, &settings) else {
return MacosSystemProxyConfiguration::Unavailable;
};
(&proxies)
.into_iter()
.find_map(|proxy| {
let proxy_type = cf_string_value(&proxy, unsafe { kCFProxyTypeKey })?;
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeAutoConfigurationURL })
|| cf_string_equals(&proxy_type, unsafe {
kCFProxyTypeAutoConfigurationJavaScript
})
{
Some(MacosSystemProxyConfiguration::Automatic)
} else if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTP })
|| cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTPS })
{
Some(MacosSystemProxyConfiguration::Manual)
} else {
cf_string_equals(&proxy_type, unsafe { kCFProxyTypeNone })
.then_some(MacosSystemProxyConfiguration::Direct)
}
})
.unwrap_or(MacosSystemProxyConfiguration::Unavailable)
}
fn system_proxy_settings() -> Option<CFDictionary<CFString, CFType>> {
let store = SCDynamicStoreBuilder::new("Codex").build()?;
store.get_proxies()
@@ -64,6 +64,9 @@ fn spawn_http_listener(
Err(error) => panic!("HTTP listener should accept: {error}"),
}
};
stream
.set_nonblocking(false)
.expect("HTTP stream should become blocking");
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("HTTP stream should get a read timeout");
@@ -185,6 +188,15 @@ fn reqwest_default_route_preserves_transport_proxy_behavior() {
assert_eq!(route, OutboundProxyRoute::TransportDefault);
}
#[cfg(target_os = "macos")]
#[test]
fn macos_proxy_configuration_rejects_invalid_destination() {
assert_eq!(
macos_system_proxy_configuration("not a valid destination"),
MacosSystemProxyConfiguration::Unavailable
);
}
impl EnvSource for MapEnv {
fn var(&self, key: &str) -> Option<String> {
self.values.get(key).cloned()
@@ -25,6 +25,7 @@ use crate::HttpClientBuilder;
use crate::HttpClientFactory;
use crate::OutboundProxyPolicy;
use crate::OutboundProxyRoute;
use crate::RouteFailureClass;
use crate::route_aware_redirect::MAX_REDIRECTS;
use crate::route_aware_redirect::insert_referer;
use crate::route_aware_redirect::is_redirect;
@@ -101,6 +102,52 @@ pub enum RouteAwareRequestError {
}
impl RouteAwareRequestError {
/// Classifies transport, proxy, and certificate failures without exposing request details.
pub fn failure_class(&self) -> Option<RouteFailureClass> {
if self.is_timeout() {
return Some(RouteFailureClass::ConnectTimeout);
}
if self.status() == Some(StatusCode::PROXY_AUTHENTICATION_REQUIRED) {
return Some(RouteFailureClass::ProxyAuthenticationRequired);
}
if let Self::Route(RouteAwareClientPoolError::Resolve(error)) = self
&& let Some(source) = error.get_ref()
&& source.is::<rustls::Error>()
{
return Some(RouteFailureClass::TlsError);
}
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(self);
while let Some(error) = source {
if error.downcast_ref::<rustls::Error>().is_some()
|| error.downcast_ref::<native_tls::Error>().is_some()
{
return Some(RouteFailureClass::TlsError);
}
if error.to_string() == "tunnel error: proxy authorization required" {
return Some(RouteFailureClass::ProxyAuthenticationRequired);
}
source = error.source();
}
match self {
Self::Route(RouteAwareClientPoolError::Build(
BuildRouteAwareHttpClientError::CustomCa(_),
)) => Some(RouteFailureClass::TlsError),
Self::Route(RouteAwareClientPoolError::Build(
BuildRouteAwareHttpClientError::InvalidProxyConfig { .. },
)) => Some(RouteFailureClass::InvalidProxyConfig),
Self::Route(RouteAwareClientPoolError::Resolve(_)) => {
Some(RouteFailureClass::ProxyResolutionUnavailable)
}
Self::Request(_)
| Self::Build(_)
| Self::UnsupportedRedirectScheme(_)
| Self::TooManyRedirects
| Self::Timeout => None,
}
}
pub fn status(&self) -> Option<StatusCode> {
match self {
Self::Request(error) => error.status(),
@@ -19,6 +19,96 @@ use tracing_subscriber::layer::SubscriberExt;
use super::*;
use crate::OutboundProxyPolicy;
#[tokio::test]
async fn request_failures_classify_real_untrusted_certificate_handshakes() {
codex_utils_rustls_provider::ensure_rustls_crypto_provider();
let certificate = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
.expect("self-signed certificate should generate");
let private_key = rustls::pki_types::PrivateKeyDer::Pkcs8(
rustls::pki_types::PrivatePkcs8KeyDer::from(certificate.signing_key.serialize_der()),
);
let configuration = Arc::new(
rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![certificate.cert.der().clone()], private_key)
.expect("TLS server should be configured"),
);
let listener = TcpListener::bind("127.0.0.1:0").expect("TLS server should bind");
let address = listener
.local_addr()
.expect("TLS server should have an address");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("TLS server should accept");
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.expect("TLS handshake timeout");
let mut connection = rustls::ServerConnection::new(configuration)
.expect("TLS server connection should be created");
let _ = connection.complete_io(&mut stream);
});
let pool = RouteAwareClientPool::new_without_request_logging(
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
ClientRouteClass::Api,
);
let request = pool
.get(format!("https://localhost:{}/", address.port()))
.timeout(Duration::from_secs(3))
.request
.expect("TLS request should build");
let error = pool
.send_with_resolver(request, |_| async { Ok(OutboundProxyRoute::Direct) })
.await
.expect_err("self-signed server certificate must not be trusted");
drop(std::net::TcpStream::connect(address));
server.join().expect("TLS server should finish");
assert_eq!(
error.failure_class(),
Some(RouteFailureClass::TlsError),
"unexpected certificate error: {error:?}"
);
}
#[tokio::test]
async fn request_failures_classify_https_proxy_authentication_challenges() {
let (address, proxy) = spawn_response_server(vec![
"HTTP/1.1 407 Proxy Authentication Required\r\n\
Proxy-Authenticate: Basic realm=\"codex\"\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\r\n"
.to_string(),
]);
let pool = RouteAwareClientPool::new_without_request_logging(
HttpClientFactory::new(OutboundProxyPolicy::RespectSystemProxy),
ClientRouteClass::Api,
);
let mut request = reqwest::Request::new(
Method::GET,
reqwest::Url::parse("https://example.com/").expect("request URL should parse"),
);
*request.timeout_mut() = Some(Duration::from_secs(3));
let error = pool
.send_with_resolver(request, move |_| async move {
Ok(OutboundProxyRoute::Proxy {
url: format!("http://{address}"),
no_proxy: None,
})
})
.await
.expect_err("HTTPS proxy challenge should reject the CONNECT request");
let requests = proxy.join().expect("proxy fixture should finish");
assert_eq!(requests.len(), 1);
assert!(requests[0].starts_with("CONNECT example.com:443 HTTP/1.1\r\n"));
assert_eq!(
error.failure_class(),
Some(RouteFailureClass::ProxyAuthenticationRequired),
"unexpected HTTPS proxy error: {error:?}"
);
}
#[test]
fn request_builder_debug_redacts_url_secrets() {
let pool = RouteAwareClientPool::new(