diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index b651cb0ba0..ae49e95ca5 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -6,6 +6,7 @@ mod error; mod outbound_proxy; mod request; mod retry; +mod route_diagnostics; mod sse; mod telemetry; mod transport; @@ -43,6 +44,9 @@ pub use crate::retry::RetryOn; pub use crate::retry::RetryPolicy; pub use crate::retry::backoff; pub use crate::retry::run_with_retry; +pub use crate::route_diagnostics::emit_auth_http_status; +pub use crate::route_diagnostics::emit_auth_network_environment_snapshot; +pub use crate::route_diagnostics::emit_auth_transport_failure; pub use crate::sse::sse_stream; pub use crate::telemetry::RequestTelemetry; pub use crate::transport::ByteStream; diff --git a/codex-rs/codex-client/src/outbound_proxy.rs b/codex-rs/codex-client/src/outbound_proxy.rs index d00df0f3a0..aeaf1c0f4c 100644 --- a/codex-rs/codex-client/src/outbound_proxy.rs +++ b/codex-rs/codex-client/src/outbound_proxy.rs @@ -15,6 +15,8 @@ use std::time::Instant; use crate::custom_ca::BuildCustomCaTransportError; use crate::custom_ca::build_reqwest_client_with_custom_ca; +use crate::route_diagnostics::RouteDecisionSource; +use crate::route_diagnostics::RouteDiagnostic; #[cfg(any(target_os = "windows", target_os = "macos"))] use sha2::Digest; #[cfg(any(target_os = "windows", target_os = "macos"))] @@ -209,6 +211,7 @@ fn configure_proxy_for_route( let origin = RequestOrigin::parse(request_url); if config.mode == OutboundProxyMode::Direct { + RouteDiagnostic::direct(route_class, RouteDecisionSource::ConfigDisabled).emit_opt_in(); return Ok(builder.no_proxy()); } @@ -217,6 +220,7 @@ fn configure_proxy_for_route( // `NO_PROXY` is an env-derived direct decision; reqwest remains the // authority for applying the env proxy contract to its own env proxies. if origin.as_ref().is_some_and(no_proxy_env_matches_origin) { + RouteDiagnostic::direct(route_class, RouteDecisionSource::Env).emit_opt_in(); if config.mode == OutboundProxyMode::Env { return Ok(builder.no_proxy()); } @@ -233,6 +237,12 @@ fn configure_proxy_for_route( } if !SystemProxyEnvOverride::from_env().system_discovery_enabled() { + RouteDiagnostic::unavailable( + route_class, + RouteDecisionSource::ConfigDisabled, + RouteFailureClass::ProxyResolutionUnavailable, + ) + .emit_opt_in(); if config.mode == OutboundProxyMode::System { return Err(BuildRouteAwareHttpClientError::SystemProxyUnavailable { route_class, @@ -248,6 +258,12 @@ fn configure_proxy_for_route( } if !system_proxy_supported() { + RouteDiagnostic::unavailable( + route_class, + RouteDecisionSource::UnsupportedPlatform, + RouteFailureClass::ProxyResolutionUnavailable, + ) + .emit_opt_in(); if config.mode == OutboundProxyMode::System { return Err(BuildRouteAwareHttpClientError::SystemProxyUnavailable { route_class, @@ -263,6 +279,12 @@ fn configure_proxy_for_route( } let Some(origin) = origin.as_ref() else { + RouteDiagnostic::unavailable( + route_class, + RouteDecisionSource::ResolutionError, + RouteFailureClass::InvalidProxyConfig, + ) + .emit_opt_in(); return if config.mode == OutboundProxyMode::System { Err(BuildRouteAwareHttpClientError::SystemProxyUnavailable { route_class, @@ -283,9 +305,15 @@ fn configure_proxy_for_route( OutboundProxyMode::Auto | OutboundProxyMode::System ); match resolve_system_proxy(request_url, origin, include_auto_detect) { - SystemProxyDecision::Direct => Ok(builder.no_proxy()), - SystemProxyDecision::Proxy { url } => configure_concrete_proxy(builder, route_class, &url), - SystemProxyDecision::Unavailable { failure } => { + SystemProxyDecision::Direct { source } => { + RouteDiagnostic::direct(route_class, source).emit_opt_in(); + Ok(builder.no_proxy()) + } + SystemProxyDecision::Proxy { source, url } => { + configure_concrete_proxy(builder, route_class, source, &url) + } + SystemProxyDecision::Unavailable { source, failure } => { + RouteDiagnostic::unavailable(route_class, source, failure).emit_opt_in(); if config.mode == OutboundProxyMode::System { Err(BuildRouteAwareHttpClientError::SystemProxyUnavailable { route_class, @@ -310,15 +338,23 @@ const fn system_proxy_supported() -> bool { fn configure_concrete_proxy( builder: reqwest::ClientBuilder, route_class: ClientRouteClass, + source: RouteDecisionSource, proxy_url: &str, ) -> Result { let proxy = match reqwest::Proxy::all(proxy_url) { Ok(proxy) => proxy, Err(_source) => { + RouteDiagnostic::unavailable( + route_class, + source, + RouteFailureClass::InvalidProxyConfig, + ) + .emit_opt_in(); return Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { route_class }); } }; let proxy = proxy.no_proxy(reqwest::NoProxy::from_env()); + RouteDiagnostic::proxy(route_class, source, proxy_url).emit_opt_in(); Ok(builder.proxy(proxy)) } @@ -338,18 +374,26 @@ fn configure_env_proxy_handling( && let Some(proxy_url) = env_proxy_for_origin(origin) { if handling == EnvProxyHandling::ResolveConcreteOrNoProxy { - return configure_concrete_proxy(builder, route_class, &proxy_url); + return configure_concrete_proxy( + builder, + route_class, + RouteDecisionSource::Env, + &proxy_url, + ); } + RouteDiagnostic::proxy(route_class, RouteDecisionSource::Env, &proxy_url).emit_opt_in(); return Ok(builder); } if conventional_proxy_env_present() { + RouteDiagnostic::direct(route_class, RouteDecisionSource::Env).emit_opt_in(); return match handling { EnvProxyHandling::ResolveConcreteOrNoProxy => Ok(builder.no_proxy()), EnvProxyHandling::DelegateToReqwest => Ok(builder), }; } + RouteDiagnostic::direct(route_class, RouteDecisionSource::Direct).emit_opt_in(); match handling { EnvProxyHandling::ResolveConcreteOrNoProxy => Ok(builder.no_proxy()), EnvProxyHandling::DelegateToReqwest => Ok(builder), @@ -386,17 +430,25 @@ fn default_port_for_scheme(scheme: &str) -> Option { #[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))] #[derive(Debug, Clone, PartialEq, Eq)] enum SystemProxyDecision { - Direct, - Proxy { url: String }, - Unavailable { failure: RouteFailureClass }, + Direct { + source: RouteDecisionSource, + }, + Proxy { + source: RouteDecisionSource, + url: String, + }, + Unavailable { + source: RouteDecisionSource, + failure: RouteFailureClass, + }, } impl From for SystemProxyRouteDecision { fn from(decision: SystemProxyDecision) -> Self { match decision { - SystemProxyDecision::Direct => Self::Direct, - SystemProxyDecision::Proxy { url } => Self::Proxy { url }, - SystemProxyDecision::Unavailable { failure } => Self::Unavailable { failure }, + SystemProxyDecision::Direct { .. } => Self::Direct, + SystemProxyDecision::Proxy { url, .. } => Self::Proxy { url }, + SystemProxyDecision::Unavailable { failure, .. } => Self::Unavailable { failure }, } } } @@ -406,19 +458,37 @@ pub fn resolve_system_proxy_for_url( request_url: &str, include_auto_detect: bool, ) -> SystemProxyRouteDecision { - if !SystemProxyEnvOverride::from_env().system_discovery_enabled() || !system_proxy_supported() { - return SystemProxyRouteDecision::Unavailable { + let decision = if !SystemProxyEnvOverride::from_env().system_discovery_enabled() { + SystemProxyDecision::Unavailable { + source: RouteDecisionSource::ConfigDisabled, failure: RouteFailureClass::ProxyResolutionUnavailable, - }; - } - - let Some(origin) = RequestOrigin::parse(request_url) else { - return SystemProxyRouteDecision::Unavailable { + } + } else if !system_proxy_supported() { + SystemProxyDecision::Unavailable { + source: RouteDecisionSource::UnsupportedPlatform, + failure: RouteFailureClass::ProxyResolutionUnavailable, + } + } else if let Some(origin) = RequestOrigin::parse(request_url) { + resolve_system_proxy(request_url, &origin, include_auto_detect) + } else { + SystemProxyDecision::Unavailable { + source: RouteDecisionSource::ResolutionError, failure: RouteFailureClass::InvalidProxyConfig, - }; + } }; - resolve_system_proxy(request_url, &origin, include_auto_detect).into() + match &decision { + SystemProxyDecision::Direct { source } => { + RouteDiagnostic::direct(ClientRouteClass::Other, *source).emit_opt_in(); + } + SystemProxyDecision::Proxy { source, url } => { + RouteDiagnostic::proxy(ClientRouteClass::Other, *source, url).emit_opt_in(); + } + SystemProxyDecision::Unavailable { source, failure } => { + RouteDiagnostic::unavailable(ClientRouteClass::Other, *source, *failure).emit_opt_in(); + } + } + decision.into() } fn resolve_system_proxy( @@ -460,6 +530,7 @@ fn resolve_platform_system_proxy( _include_auto_detect: bool, ) -> SystemProxyDecision { SystemProxyDecision::Unavailable { + source: RouteDecisionSource::UnsupportedPlatform, failure: RouteFailureClass::ProxyResolutionUnavailable, } } diff --git a/codex-rs/codex-client/src/outbound_proxy/macos.rs b/codex-rs/codex-client/src/outbound_proxy/macos.rs index 334d8319eb..0076f14497 100644 --- a/codex-rs/codex-client/src/outbound_proxy/macos.rs +++ b/codex-rs/codex-client/src/outbound_proxy/macos.rs @@ -6,6 +6,7 @@ use std::time::Instant; use super::RequestOrigin; use super::RouteFailureClass; use super::SystemProxyDecision; +use crate::route_diagnostics::RouteDecisionSource; use system_configuration::core_foundation::array::CFArray; use system_configuration::core_foundation::array::CFArrayRef; use system_configuration::core_foundation::base::CFEqual; @@ -87,23 +88,31 @@ pub(super) fn resolve( ) -> SystemProxyDecision { let Some(target_url) = cf_url(request_url) else { return SystemProxyDecision::Unavailable { + source: RouteDecisionSource::ResolutionError, failure: RouteFailureClass::InvalidProxyConfig, }; }; let Some(settings) = system_proxy_settings(include_auto_detect) else { return SystemProxyDecision::Unavailable { + source: RouteDecisionSource::ResolutionError, failure: RouteFailureClass::ProxyResolutionUnavailable, }; }; let Some(proxies) = copy_proxies_for_url(&target_url, &settings) else { return SystemProxyDecision::Unavailable { + source: RouteDecisionSource::ResolutionError, failure: RouteFailureClass::ProxyResolutionUnavailable, }; }; - proxy_array_decision(&proxies, &target_url, origin) + proxy_array_decision( + &proxies, + &target_url, + origin, + RouteDecisionSource::MacOsSystem, + ) } fn system_proxy_settings(include_auto_detect: bool) -> Option> { @@ -146,14 +155,19 @@ fn proxy_array_decision( proxies: &ProxyArray, target_url: &CFURL, origin: &RequestOrigin, + source: RouteDecisionSource, ) -> SystemProxyDecision { let mut saw_unsupported = false; let mut saw_unavailable = false; for proxy in proxies { - match proxy_entry_decision(&proxy, target_url, origin) { - ProxyEntryDecision::Direct => return SystemProxyDecision::Direct, - ProxyEntryDecision::Proxy { url } => return SystemProxyDecision::Proxy { url }, + match proxy_entry_decision(&proxy, target_url, origin, source) { + ProxyEntryDecision::Direct { source } => { + return SystemProxyDecision::Direct { source }; + } + ProxyEntryDecision::Proxy { source, url } => { + return SystemProxyDecision::Proxy { source, url }; + } ProxyEntryDecision::UnsupportedScheme => saw_unsupported = true, ProxyEntryDecision::Unavailable => saw_unavailable = true, } @@ -161,14 +175,16 @@ fn proxy_array_decision( if saw_unsupported { SystemProxyDecision::Unavailable { + source, failure: RouteFailureClass::UnsupportedProxyScheme, } } else if saw_unavailable { SystemProxyDecision::Unavailable { + source, failure: RouteFailureClass::ProxyResolutionUnavailable, } } else { - SystemProxyDecision::Direct + SystemProxyDecision::Direct { source } } } @@ -176,23 +192,24 @@ fn proxy_entry_decision( proxy: &ProxyDictionary, target_url: &CFURL, origin: &RequestOrigin, + source: RouteDecisionSource, ) -> ProxyEntryDecision { let Some(proxy_type) = cf_string_value(proxy, unsafe { kCFProxyTypeKey }) else { return ProxyEntryDecision::Unavailable; }; if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeNone }) { - return ProxyEntryDecision::Direct; + return ProxyEntryDecision::Direct { source }; } if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTP }) { - return concrete_proxy_entry(proxy, "http"); + return concrete_proxy_entry(proxy, "http", source); } if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTPS }) { // CFNetwork's HTTPS proxy type is a tunneling proxy for HTTPS destinations; it does not // preserve an explicit TLS-to-proxy transport. See https://developer.apple.com/documentation/cfnetwork/kcfproxytypehttps. - return concrete_proxy_entry(proxy, "http"); + return concrete_proxy_entry(proxy, "http", source); } if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeSOCKS }) { @@ -233,13 +250,19 @@ fn pac_decision( Err(_) => return ProxyEntryDecision::Unavailable, }; - match proxy_array_decision(&proxies, target_url, origin) { - SystemProxyDecision::Direct => ProxyEntryDecision::Direct, - SystemProxyDecision::Proxy { url } => ProxyEntryDecision::Proxy { url }, + match proxy_array_decision( + &proxies, + target_url, + origin, + RouteDecisionSource::MacOsCfNetworkPac, + ) { + SystemProxyDecision::Direct { source } => ProxyEntryDecision::Direct { source }, + SystemProxyDecision::Proxy { source, url } => ProxyEntryDecision::Proxy { source, url }, SystemProxyDecision::Unavailable { failure: RouteFailureClass::UnsupportedProxyScheme, + .. } => ProxyEntryDecision::UnsupportedScheme, - SystemProxyDecision::Unavailable { failure: _ } => ProxyEntryDecision::Unavailable, + SystemProxyDecision::Unavailable { .. } => ProxyEntryDecision::Unavailable, } } @@ -325,7 +348,11 @@ struct PacRunLoopState { result: Option>, } -fn concrete_proxy_entry(proxy: &ProxyDictionary, proxy_scheme: &str) -> ProxyEntryDecision { +fn concrete_proxy_entry( + proxy: &ProxyDictionary, + proxy_scheme: &str, + source: RouteDecisionSource, +) -> ProxyEntryDecision { let Some(host) = cf_string_value(proxy, unsafe { kCFProxyHostNameKey }) .map(|host| host.to_string()) .filter(|host| !host.is_empty()) @@ -338,7 +365,7 @@ fn concrete_proxy_entry(proxy: &ProxyDictionary, proxy_scheme: &str) -> ProxyEnt Some(port) if port > 0 => format!("{proxy_scheme}://{host}:{port}"), _ => format!("{proxy_scheme}://{host}"), }; - ProxyEntryDecision::Proxy { url } + ProxyEntryDecision::Proxy { source, url } } fn bracket_ipv6_host(host: &str) -> String { @@ -395,8 +422,13 @@ fn cf_url(value: &str) -> Option { } enum ProxyEntryDecision { - Direct, - Proxy { url: String }, + Direct { + source: RouteDecisionSource, + }, + Proxy { + source: RouteDecisionSource, + url: String, + }, UnsupportedScheme, Unavailable, } diff --git a/codex-rs/codex-client/src/outbound_proxy/windows.rs b/codex-rs/codex-client/src/outbound_proxy/windows.rs index aa73ee4d3c..f0a5b2f1ca 100644 --- a/codex-rs/codex-client/src/outbound_proxy/windows.rs +++ b/codex-rs/codex-client/src/outbound_proxy/windows.rs @@ -7,6 +7,7 @@ use super::RouteFailureClass; use super::SystemProxyDecision; use super::no_proxy_matches_origin; use super::parse_proxy_list; +use crate::route_diagnostics::RouteDecisionSource; use windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND; use windows_sys::Win32::Foundation::FALSE; use windows_sys::Win32::Foundation::GetLastError; @@ -56,7 +57,10 @@ pub(super) fn resolve( let ie_config = match current_user_ie_proxy_config() { Ok(config) => config, Err(failure) => { - return SystemProxyDecision::Unavailable { failure }; + return SystemProxyDecision::Unavailable { + source: RouteDecisionSource::ResolutionError, + failure, + }; } }; @@ -80,17 +84,22 @@ pub(super) fn resolve( .as_deref() .is_some_and(|bypass| proxy_bypass_matches_origin(bypass, origin)) { - return SystemProxyDecision::Direct; + return SystemProxyDecision::Direct { + source: RouteDecisionSource::WindowsStatic, + }; } - return proxy_list_decision(proxy, origin); + return proxy_list_decision(proxy, origin, RouteDecisionSource::WindowsStatic); } if ie_config.auto_config_url.is_some() || (include_auto_detect && ie_config.auto_detect) { SystemProxyDecision::Unavailable { + source: RouteDecisionSource::WindowsWinHttpPac, failure: RouteFailureClass::ProxyResolutionUnavailable, } } else { - SystemProxyDecision::Direct + SystemProxyDecision::Direct { + source: RouteDecisionSource::Direct, + } } } @@ -130,6 +139,7 @@ fn resolve_with_winhttp_options( ) -> SystemProxyDecision { let Some(session) = WinHttpSession::open() else { return SystemProxyDecision::Unavailable { + source: RouteDecisionSource::WindowsWinHttpPac, failure: classify_winhttp_error(last_error()), }; }; @@ -150,35 +160,46 @@ fn resolve_with_winhttp_options( }; if ok == FALSE { return SystemProxyDecision::Unavailable { + source: RouteDecisionSource::WindowsWinHttpPac, failure: classify_winhttp_error(last_error()), }; } let proxy_info = ProxyInfo::from_raw(proxy_info); if proxy_info.access_type == WINHTTP_ACCESS_TYPE_NO_PROXY { - return SystemProxyDecision::Direct; + return SystemProxyDecision::Direct { + source: RouteDecisionSource::WindowsWinHttpPac, + }; } if proxy_info.access_type != WINHTTP_ACCESS_TYPE_NAMED_PROXY { return SystemProxyDecision::Unavailable { + source: RouteDecisionSource::WindowsWinHttpPac, failure: RouteFailureClass::ProxyResolutionUnavailable, }; } let Some(proxy) = proxy_info.proxy.as_deref() else { return SystemProxyDecision::Unavailable { + source: RouteDecisionSource::WindowsWinHttpPac, failure: RouteFailureClass::ProxyResolutionUnavailable, }; }; - proxy_list_decision(proxy, origin) + proxy_list_decision(proxy, origin, RouteDecisionSource::WindowsWinHttpPac) } -fn proxy_list_decision(proxy_list: &str, origin: &RequestOrigin) -> SystemProxyDecision { +fn proxy_list_decision( + proxy_list: &str, + origin: &RequestOrigin, + source: RouteDecisionSource, +) -> SystemProxyDecision { match parse_proxy_list(proxy_list, &origin.scheme) { - ParsedProxyListDecision::Direct => SystemProxyDecision::Direct, - ParsedProxyListDecision::Proxy(url) => SystemProxyDecision::Proxy { url }, + ParsedProxyListDecision::Direct => SystemProxyDecision::Direct { source }, + ParsedProxyListDecision::Proxy(url) => SystemProxyDecision::Proxy { source, url }, ParsedProxyListDecision::UnsupportedScheme => SystemProxyDecision::Unavailable { + source, failure: RouteFailureClass::UnsupportedProxyScheme, }, ParsedProxyListDecision::Unavailable => SystemProxyDecision::Unavailable { + source, failure: RouteFailureClass::ProxyResolutionUnavailable, }, } diff --git a/codex-rs/codex-client/src/route_diagnostics.rs b/codex-rs/codex-client/src/route_diagnostics.rs new file mode 100644 index 0000000000..cdde863f09 --- /dev/null +++ b/codex-rs/codex-client/src/route_diagnostics.rs @@ -0,0 +1,321 @@ +//! Sanitized, opt-in diagnostics for resolver-aware HTTP clients. +//! +//! Values emitted from this module must not contain request URLs, PAC URLs, +//! proxy hostnames, credentials, headers, tokens, or certificate paths. + +use std::fmt; + +use crate::outbound_proxy::ClientRouteClass; +use crate::outbound_proxy::RouteFailureClass; + +/// Opt-in switch for sanitized network diagnostics. +/// +/// Set to `1`, `true`, `on`, or `yes` to emit diagnostic events. The configured +/// value itself is never logged. +const CODEX_NETWORK_DIAGNOSTICS_ENV: &str = "CODEX_NETWORK_DIAGNOSTICS"; + +const CODEX_SYSTEM_PROXY_ENV: &str = "CODEX_SYSTEM_PROXY"; + +fn env_flag_enabled(name: &str) -> bool { + std::env::var(name).ok().as_deref().is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "on" | "yes" + ) + }) +} + +/// Returns whether sanitized network diagnostics are enabled for this process. +fn network_diagnostics_enabled() -> bool { + env_flag_enabled(CODEX_NETWORK_DIAGNOSTICS_ENV) +} + +fn env_present(name: &str) -> bool { + std::env::var_os(name).is_some_and(|value| !value.is_empty()) +} + +fn proxy_env_present(upper: &str, lower: &str) -> bool { + env_present(upper) || env_present(lower) +} + +fn system_proxy_override_state() -> &'static str { + let disabled = std::env::var(CODEX_SYSTEM_PROXY_ENV) + .ok() + .as_deref() + .is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "off" | "false" | "0" | "no" | "disabled" + ) + }); + if disabled { "disabled" } else { "default" } +} + +/// Emits environment presence bits for an auth operation. +/// +/// Proxy values, CA paths, URLs, headers, and tokens are intentionally omitted. +pub fn emit_auth_network_environment_snapshot(operation: &'static str) { + if !network_diagnostics_enabled() { + return; + } + tracing::info!( + target_class = "auth", + operation = operation, + http_proxy_present = proxy_env_present("HTTP_PROXY", "http_proxy"), + https_proxy_present = proxy_env_present("HTTPS_PROXY", "https_proxy"), + all_proxy_present = proxy_env_present("ALL_PROXY", "all_proxy"), + no_proxy_present = proxy_env_present("NO_PROXY", "no_proxy"), + codex_system_proxy = system_proxy_override_state(), + custom_ca_present = env_present("CODEX_CA_CERTIFICATE") || env_present("SSL_CERT_FILE"), + "opt-in auth network diagnostic snapshot" + ); +} + +fn classify_reqwest_error(error: &reqwest::Error) -> Option { + if error.is_timeout() { + return Some(RouteFailureClass::ConnectTimeout); + } + if error.status().is_some_and(|status| status.as_u16() == 407) { + return Some(RouteFailureClass::ProxyAuthenticationRequired); + } + let rendered = error.to_string().to_ascii_lowercase(); + if rendered.contains("tls") || rendered.contains("certificate") || rendered.contains("cert") { + return Some(RouteFailureClass::TlsError); + } + if error.is_connect() { + return Some(RouteFailureClass::ResolverError); + } + None +} + +/// Emits a coarse auth transport failure without the error text or URL. +pub fn emit_auth_transport_failure(operation: &'static str, error: &reqwest::Error) { + if !network_diagnostics_enabled() { + return; + } + let failure = classify_reqwest_error(error) + .map(|failure| failure.to_string()) + .unwrap_or_else(|| "other".to_string()); + tracing::info!( + target_class = "auth", + operation = operation, + failure = %failure, + is_timeout = error.is_timeout(), + is_connect = error.is_connect(), + status_present = error.status().is_some(), + status = error + .status() + .map(|status| status.as_u16()) + .unwrap_or(/*default*/ 0), + "opt-in auth network transport diagnostic" + ); +} + +/// Emits an auth HTTP status without response content or endpoint details. +pub fn emit_auth_http_status(operation: &'static str, status: reqwest::StatusCode) { + if !network_diagnostics_enabled() { + return; + } + let failure = if status.as_u16() == 407 { + RouteFailureClass::ProxyAuthenticationRequired.to_string() + } else { + "other".to_string() + }; + tracing::info!( + target_class = "auth", + operation = operation, + status = status.as_u16(), + failure = %failure, + "opt-in auth network HTTP status diagnostic" + ); +} + +/// Source that produced a route decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RouteDecisionSource { + Env, + ConfigDisabled, + UnsupportedPlatform, + ResolutionError, + #[cfg(target_os = "macos")] + MacOsCfNetworkPac, + #[cfg(target_os = "macos")] + MacOsSystem, + #[cfg(target_os = "windows")] + WindowsWinHttpPac, + #[cfg(target_os = "windows")] + WindowsStatic, + Direct, +} + +impl fmt::Display for RouteDecisionSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Env => "env", + Self::ConfigDisabled => "config_disabled", + Self::UnsupportedPlatform => "unsupported_platform", + Self::ResolutionError => "resolution_error", + #[cfg(target_os = "macos")] + Self::MacOsCfNetworkPac => "macos_cfnetwork_pac", + #[cfg(target_os = "macos")] + Self::MacOsSystem => "macos_system", + #[cfg(target_os = "windows")] + Self::WindowsWinHttpPac => "windows_winhttp_pac", + #[cfg(target_os = "windows")] + Self::WindowsStatic => "windows_static", + Self::Direct => "direct", + }) + } +} + +/// A proxy endpoint rendered without credentials, hostname, path, or query. +#[derive(Clone, PartialEq, Eq)] +struct RedactedProxyEndpoint(String); + +impl RedactedProxyEndpoint { + fn parse(input: &str) -> Self { + let Some((scheme, rest)) = input.split_once("://") else { + return Self("".to_string()); + }; + if scheme.is_empty() + || !scheme + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')) + { + return Self("".to_string()); + } + + let Some(authority) = rest + .split(['/', '?', '#']) + .next() + .filter(|authority| !authority.is_empty()) + else { + return Self("".to_string()); + }; + let host_port = authority + .rsplit_once('@') + .map_or(authority, |(_, tail)| tail); + let port = redacted_port_suffix(host_port).unwrap_or_default(); + Self(format!( + "{}://{port}", + scheme.to_ascii_lowercase() + )) + } +} + +fn redacted_port_suffix(host_port: &str) -> Option { + if host_port.starts_with('[') { + let end = host_port.find(']')?; + let port = host_port[end + 1..].strip_prefix(':')?; + return (!port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())) + .then(|| format!(":{port}")); + } + + let (host, port) = host_port.rsplit_once(':')?; + if host.is_empty() || host.contains(':') || port.is_empty() { + return None; + } + port.bytes() + .all(|byte| byte.is_ascii_digit()) + .then(|| format!(":{port}")) +} + +impl fmt::Debug for RedactedProxyEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl fmt::Display for RedactedProxyEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RouteDecision { + Direct, + Proxy(RedactedProxyEndpoint), + Unavailable(RouteFailureClass), +} + +impl fmt::Display for RouteDecision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Direct => f.write_str("direct"), + Self::Proxy(endpoint) => write!(f, "proxy({endpoint})"), + Self::Unavailable(failure) => write!(f, "unavailable({failure})"), + } + } +} + +/// One safe diagnostic event for a resolver/client decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RouteDiagnostic { + route_class: ClientRouteClass, + source: RouteDecisionSource, + decision: RouteDecision, +} + +impl RouteDiagnostic { + pub(crate) fn direct(route_class: ClientRouteClass, source: RouteDecisionSource) -> Self { + Self::new(route_class, source, RouteDecision::Direct) + } + + pub(crate) fn proxy( + route_class: ClientRouteClass, + source: RouteDecisionSource, + proxy_url: &str, + ) -> Self { + Self::new( + route_class, + source, + RouteDecision::Proxy(RedactedProxyEndpoint::parse(proxy_url)), + ) + } + + pub(crate) fn unavailable( + route_class: ClientRouteClass, + source: RouteDecisionSource, + failure: RouteFailureClass, + ) -> Self { + Self::new(route_class, source, RouteDecision::Unavailable(failure)) + } + + fn new( + route_class: ClientRouteClass, + source: RouteDecisionSource, + decision: RouteDecision, + ) -> Self { + Self { + route_class, + source, + decision, + } + } + + /// Emits a sanitized structured event when diagnostics are explicitly enabled. + pub(crate) fn emit_opt_in(&self) { + if !network_diagnostics_enabled() { + return; + } + let failure = match &self.decision { + RouteDecision::Unavailable(failure) => failure.to_string(), + RouteDecision::Direct | RouteDecision::Proxy(_) => "none".to_string(), + }; + let custom_ca_configured = + env_present("CODEX_CA_CERTIFICATE") || env_present("SSL_CERT_FILE"); + tracing::info!( + route_class = %self.route_class, + source = %self.source, + decision = %self.decision, + failure = %failure, + custom_ca_configured = custom_ca_configured, + "opt-in outbound route diagnostic" + ); + } +} + +#[cfg(test)] +#[path = "route_diagnostics_tests.rs"] +mod tests; diff --git a/codex-rs/codex-client/src/route_diagnostics_tests.rs b/codex-rs/codex-client/src/route_diagnostics_tests.rs new file mode 100644 index 0000000000..d8ef8031e3 --- /dev/null +++ b/codex-rs/codex-client/src/route_diagnostics_tests.rs @@ -0,0 +1,20 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn proxy_endpoint_redacts_credentials_host_path_and_query() { + let endpoint = RedactedProxyEndpoint::parse( + "http://user:secret@proxy.internal.example:8080/pac?token=secret", + ); + + assert_eq!(endpoint.to_string(), "http://:8080"); + assert!(!format!("{endpoint:?}").contains("secret")); + assert!(!format!("{endpoint}").contains("proxy.internal")); +} + +#[test] +fn invalid_proxy_url_is_not_echoed() { + let endpoint = RedactedProxyEndpoint::parse("not a url with password=secret"); + + assert_eq!(endpoint.to_string(), ""); +} diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 1aa22a13f4..cb7079b333 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -39,6 +39,9 @@ use crate::token_data::parse_chatgpt_jwt_claims; use base64::Engine; use chrono::Utc; use codex_app_server_protocol::AuthMode; +use codex_client::emit_auth_http_status; +use codex_client::emit_auth_network_environment_snapshot; +use codex_client::emit_auth_transport_failure; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_template::Template; use rand::RngCore; @@ -748,6 +751,7 @@ pub(crate) async fn exchange_code_for_tokens( } let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); + emit_auth_network_environment_snapshot("oauth_token_exchange"); let client = build_auth_reqwest_client_with_auth_route_config(&token_endpoint, auth_route_config)?; info!( @@ -771,6 +775,7 @@ pub(crate) async fn exchange_code_for_tokens( let resp = match resp { Ok(resp) => resp, Err(error) => { + emit_auth_transport_failure("oauth_token_exchange", &error); let error = redact_sensitive_error_url(error); error!( is_timeout = error.is_timeout(), @@ -785,6 +790,7 @@ pub(crate) async fn exchange_code_for_tokens( let status = resp.status(); if !status.is_success() { + emit_auth_http_status("oauth_token_exchange", status); let body = resp.text().await.map_err(io::Error::other)?; let detail = parse_token_endpoint_error(&body); warn!( @@ -1172,6 +1178,7 @@ pub(crate) async fn obtain_api_key( access_token: String, } let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); + emit_auth_network_environment_snapshot("api_key_exchange"); let client = build_auth_reqwest_client_with_auth_route_config(&token_endpoint, auth_route_config)?; let resp = client @@ -1189,9 +1196,13 @@ pub(crate) async fn obtain_api_key( .await; let resp = match resp { Ok(resp) => resp, - Err(error) => return Err(io::Error::other(error)), + Err(error) => { + emit_auth_transport_failure("api_key_exchange", &error); + return Err(io::Error::other(error)); + } }; if !resp.status().is_success() { + emit_auth_http_status("api_key_exchange", resp.status()); return Err(io::Error::other(format!( "api key exchange failed with status {}", resp.status()