diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index deffcfbb9bb6..2d47976c664f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3379,6 +3379,7 @@ dependencies = [ "codex-utils-rustls-provider", "futures", "http 1.4.0", + "native-tls", "opentelemetry", "opentelemetry_sdk", "pretty_assertions", diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 65e5ed14b71f..59c2f637acbc 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -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) { let present_proxy_vars = PROXY_ENV_VARS .iter() @@ -2579,6 +2566,7 @@ fn fallback_state_check() -> DoctorCheck { struct ReachabilityPlan { description: String, endpoints: Vec, + 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 { - http_probe_url_with_timeout(url, Duration::from_secs(3)).await -} - async fn mcp_http_probe_url(url: &str) -> Result { 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())); diff --git a/codex-rs/cli/src/doctor/network.rs b/codex-rs/cli/src/doctor/network.rs new file mode 100644 index 000000000000..c3e8ec8e258c --- /dev/null +++ b/codex-rs/cli/src/doctor/network.rs @@ -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 { + 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; diff --git a/codex-rs/cli/src/doctor/network_tests.rs b/codex-rs/cli/src/doctor/network_tests.rs new file mode 100644 index 000000000000..7783c75e1397 --- /dev/null +++ b/codex-rs/cli/src/doctor/network_tests.rs @@ -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)") + ); +} diff --git a/codex-rs/cli/tests/doctor_enterprise_network.rs b/codex-rs/cli/tests/doctor_enterprise_network.rs new file mode 100644 index 000000000000..4324057c284c --- /dev/null +++ b/codex-rs/cli/tests/doctor_enterprise_network.rs @@ -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 { + 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") +} diff --git a/codex-rs/http-client/Cargo.toml b/codex-rs/http-client/Cargo.toml index d6a67d18400d..2d54ca715890 100644 --- a/codex-rs/http-client/Cargo.toml +++ b/codex-rs/http-client/Cargo.toml @@ -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 } diff --git a/codex-rs/http-client/src/lib.rs b/codex-rs/http-client/src/lib.rs index 90933efbe5fe..cb7f1209e20d 100644 --- a/codex-rs/http-client/src/lib.rs +++ b/codex-rs/http-client/src/lib.rs @@ -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; diff --git a/codex-rs/http-client/src/outbound_proxy.rs b/codex-rs/http-client/src/outbound_proxy.rs index ee2458ae31bc..27d1a80587d2 100644 --- a/codex-rs/http-client/src/outbound_proxy.rs +++ b/codex-rs/http-client/src/outbound_proxy.rs @@ -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 diff --git a/codex-rs/http-client/src/outbound_proxy/macos.rs b/codex-rs/http-client/src/outbound_proxy/macos.rs index fe07a6b498de..4f516517092e 100644 --- a/codex-rs/http-client/src/outbound_proxy/macos.rs +++ b/codex-rs/http-client/src/outbound_proxy/macos.rs @@ -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> { let store = SCDynamicStoreBuilder::new("Codex").build()?; store.get_proxies() diff --git a/codex-rs/http-client/src/outbound_proxy_tests.rs b/codex-rs/http-client/src/outbound_proxy_tests.rs index d178296a0cab..22b3716d9cf9 100644 --- a/codex-rs/http-client/src/outbound_proxy_tests.rs +++ b/codex-rs/http-client/src/outbound_proxy_tests.rs @@ -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 { self.values.get(key).cloned() diff --git a/codex-rs/http-client/src/route_aware_client_pool.rs b/codex-rs/http-client/src/route_aware_client_pool.rs index 43966cf2f629..29705e44eb66 100644 --- a/codex-rs/http-client/src/route_aware_client_pool.rs +++ b/codex-rs/http-client/src/route_aware_client_pool.rs @@ -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 { + 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::() + { + return Some(RouteFailureClass::TlsError); + } + + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(self); + while let Some(error) = source { + if error.downcast_ref::().is_some() + || error.downcast_ref::().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 { match self { Self::Request(error) => error.status(), diff --git a/codex-rs/http-client/src/route_aware_client_pool_tests.rs b/codex-rs/http-client/src/route_aware_client_pool_tests.rs index 0e3db249df12..e3848397bd5b 100644 --- a/codex-rs/http-client/src/route_aware_client_pool_tests.rs +++ b/codex-rs/http-client/src/route_aware_client_pool_tests.rs @@ -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(