From beb17da0ca556a5ef09c10587e3dd18cbb6d1f6a Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 27 May 2026 17:35:24 +0000 Subject: [PATCH 1/4] Add outbound network diagnostics scaffolding --- codex-rs/codex-client/src/lib.rs | 14 + .../codex-client/src/route_diagnostics.rs | 444 ++++++++++++++++++ codex-rs/config/src/config_toml.rs | 48 ++ codex-rs/config/src/loader/mod.rs | 1 + codex-rs/config/src/types.rs | 64 +++ codex-rs/core/config.schema.json | 39 ++ codex-rs/login/src/server.rs | 18 +- 7 files changed, 626 insertions(+), 2 deletions(-) create mode 100644 codex-rs/codex-client/src/route_diagnostics.rs diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 0f503fb3e219..b3b23b2989fb 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -5,6 +5,7 @@ mod default_client; mod error; mod request; mod retry; +mod route_diagnostics; mod sse; mod telemetry; mod transport; @@ -34,6 +35,19 @@ 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::CODEX_NETWORK_DIAGNOSTICS_ENV; +pub use crate::route_diagnostics::CODEX_SYSTEM_PROXY_ENV; +pub use crate::route_diagnostics::RedactedProxyEndpoint; +pub use crate::route_diagnostics::RouteDecision; +pub use crate::route_diagnostics::RouteDiagnostic; +pub use crate::route_diagnostics::RouteFailureClass; +pub use crate::route_diagnostics::RouteSource; +pub use crate::route_diagnostics::RouteTarget; +pub use crate::route_diagnostics::SystemProxyEnvOverride; +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::route_diagnostics::network_diagnostics_enabled; pub use crate::sse::sse_stream; pub use crate::telemetry::RequestTelemetry; pub use crate::transport::ByteStream; 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 000000000000..03a832abd058 --- /dev/null +++ b/codex-rs/codex-client/src/route_diagnostics.rs @@ -0,0 +1,444 @@ +//! Redacted route diagnostics shared by resolver-aware HTTP clients. +//! +//! This module keeps route values side-effect free; explicitly opt-in helpers emit logs. It gives the upcoming system +//! proxy resolver a common vocabulary for "what route did we choose?" without +//! changing any client routing in this phase. Values stored here must be safe to +//! emit in structured logs: proxy credentials, PAC URLs, request URLs, and token +//! material are never retained. + +use std::fmt; + +/// Environment kill switch reserved for system proxy discovery. +/// +/// Values such as `off`, `false`, `0`, `no`, or `disabled` disable system/PAC +/// discovery while still allowing explicit environment proxies to be honored by +/// future resolver-aware clients. +pub const CODEX_SYSTEM_PROXY_ENV: &str = "CODEX_SYSTEM_PROXY"; + +/// Opt-in switch for sanitized network diagnostics during auth flows. +/// +/// Set to `1`, `true`, `on`, or `yes` to emit one-shot diagnostic events from +/// call sites that explicitly opt in. Values are never logged. +pub const CODEX_NETWORK_DIAGNOSTICS_ENV: &str = "CODEX_NETWORK_DIAGNOSTICS"; + +fn env_flag_enabled(name: &str) -> bool { + std::env::var(name) + .ok() + .as_deref() + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "on" | "yes" + ) + }) + .unwrap_or(false) +} + +/// Returns whether opt-in network diagnostics are enabled for this process. +pub fn network_diagnostics_enabled() -> bool { + env_flag_enabled(/* name */ 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) +} + +/// Emit a sanitized auth-network environment snapshot when diagnostics are opt-in. +/// +/// This intentionally records only presence bits and coarse override state, never +/// proxy values, CA paths, URLs, headers, or tokens. +pub fn emit_auth_network_environment_snapshot(operation: &'static str) { + if !network_diagnostics_enabled() { + return; + } + let system_override = SystemProxyEnvOverride::from_env(); + let system_proxy_state = match system_override { + SystemProxyEnvOverride::Default => "default", + SystemProxyEnvOverride::Disabled => "disabled", + }; + tracing::info!( + target_class = "auth", + operation = operation, + http_proxy_present = + proxy_env_present(/* upper */ "HTTP_PROXY", /* lower */ "http_proxy"), + https_proxy_present = proxy_env_present( + /* upper */ "HTTPS_PROXY", + /* lower */ "https_proxy" + ), + all_proxy_present = + proxy_env_present(/* upper */ "ALL_PROXY", /* lower */ "all_proxy"), + no_proxy_present = + proxy_env_present(/* upper */ "NO_PROXY", /* lower */ "no_proxy"), + codex_system_proxy = system_proxy_state, + custom_ca_present = env_present(/* name */ "CODEX_CA_CERTIFICATE") + || env_present(/* name */ "SSL_CERT_FILE"), + "opt-in auth network diagnostic snapshot" + ); +} + +fn classify_reqwest_error(error: &reqwest::Error) -> RouteFailureClass { + if error.is_timeout() { + return RouteFailureClass::ConnectTimeout; + } + if let Some(status) = error.status() + && status.as_u16() == 407 + { + return RouteFailureClass::ProxyAuthenticationRequired; + } + let rendered = error.to_string().to_ascii_lowercase(); + if rendered.contains("tls") || rendered.contains("certificate") || rendered.contains("cert") { + return RouteFailureClass::TlsError; + } + if error.is_connect() { + return RouteFailureClass::ResolverError; + } + RouteFailureClass::Other +} + +/// Emit a sanitized auth transport failure classification when diagnostics are opt-in. +pub fn emit_auth_transport_failure(operation: &'static str, error: &reqwest::Error) { + if !network_diagnostics_enabled() { + return; + } + let failure = classify_reqwest_error(error); + 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(0), + "opt-in auth network transport diagnostic" + ); +} + +/// Emit a sanitized auth HTTP status diagnostic when diagnostics are opt-in. +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 + } else { + RouteFailureClass::Other + }; + tracing::info!( + target_class = "auth", + operation = operation, + status = status.as_u16(), + failure = %failure, + "opt-in auth network HTTP status diagnostic" + ); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SystemProxyEnvOverride { + Default, + Disabled, +} + +impl SystemProxyEnvOverride { + pub fn from_value(value: Option<&str>) -> Self { + let Some(value) = value else { + return Self::Default; + }; + match value.trim().to_ascii_lowercase().as_str() { + "off" | "false" | "0" | "no" | "disabled" => Self::Disabled, + _ => Self::Default, + } + } + + pub fn from_env() -> Self { + Self::from_value(std::env::var(CODEX_SYSTEM_PROXY_ENV).ok().as_deref()) + } + + pub const fn system_discovery_enabled(self) -> bool { + matches!(self, Self::Default) + } +} + +/// High-level client path being routed. Keep this coarse to avoid leaking URLs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteTarget { + Auth, + Api, + WebSocket, + Other, +} + +impl fmt::Display for RouteTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Auth => "auth", + Self::Api => "api", + Self::WebSocket => "wss", + Self::Other => "other", + }) + } +} + +/// Source that produced a route decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteSource { + ConfigOverride, + Env, + System, + Direct, + Disabled, + Unavailable, +} + +impl fmt::Display for RouteSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::ConfigOverride => "config_override", + Self::Env => "env", + Self::System => "system", + Self::Direct => "direct", + Self::Disabled => "disabled", + Self::Unavailable => "unavailable", + }) + } +} + +/// Coarse failure class suitable for logs and support bundles. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteFailureClass { + PacUnavailable, + ConnectTimeout, + ProxyAuthenticationRequired, + TlsError, + InvalidProxyConfig, + UnsupportedProxyScheme, + ResolverError, + Other, +} + +impl fmt::Display for RouteFailureClass { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::PacUnavailable => "pac_unavailable", + Self::ConnectTimeout => "connect_timeout", + Self::ProxyAuthenticationRequired => "proxy_407", + Self::TlsError => "tls_error", + Self::InvalidProxyConfig => "invalid_proxy_config", + Self::UnsupportedProxyScheme => "unsupported_proxy_scheme", + Self::ResolverError => "resolver_error", + Self::Other => "other", + }) + } +} + +/// A proxy endpoint rendered without credentials, hostnames, paths, or query strings. +#[derive(Clone, PartialEq, Eq)] +pub struct RedactedProxyEndpoint(String); + +impl RedactedProxyEndpoint { + pub fn parse(input: &str) -> Self { + // Avoid a URL parser dependency here: diagnostics must never echo input, + // so a conservative scheme/authority splitter is sufficient. Anything + // outside the common absolute-URL shape is rendered as invalid. + let Some((scheme, rest)) = input.split_once("://") else { + return Self("".to_string()); + }; + if scheme.is_empty() + || !scheme + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')) + { + return Self("".to_string()); + } + + let authority = rest + .split(['/', '?', '#']) + .next() + .filter(|authority| !authority.is_empty()); + let Some(authority) = authority else { + return Self("".to_string()); + }; + // Drop credentials if present. We only inspect the post-@ authority for + // a numeric port; the host itself is never copied into the output. + let hostport = authority + .rsplit_once('@') + .map_or(authority, |(_, tail)| tail); + let port = redacted_port_suffix(hostport).unwrap_or_default(); + let scheme = scheme.to_ascii_lowercase(); + Self(format!("{scheme}://{port}")) + } + + pub fn redacted(&self) -> &str { + &self.0 + } +} + +fn redacted_port_suffix(hostport: &str) -> Option { + if hostport.starts_with('[') { + let end = hostport.find(']')?; + let suffix = &hostport[end + 1..]; + if let Some(port) = suffix.strip_prefix(':') + && !port.is_empty() + && port.bytes().all(|b| b.is_ascii_digit()) + { + return Some(format!(":{port}")); + } + return None; + } + + let (host, port) = hostport.rsplit_once(':')?; + // Treat unbracketed IPv6 or empty host/port as no parseable port. + if host.is_empty() || host.contains(':') || port.is_empty() { + return None; + } + if port.bytes().all(|b| b.is_ascii_digit()) { + Some(format!(":{port}")) + } else { + None + } +} + +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) + } +} + +/// Redacted route decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub 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(reason) => write!(f, "unavailable({reason})"), + } + } +} + +/// One safe diagnostic event for a resolver/client decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RouteDiagnostic { + pub target: RouteTarget, + pub source: RouteSource, + pub decision: RouteDecision, + pub failure: Option, + pub custom_ca_configured: bool, +} + +impl RouteDiagnostic { + pub const fn direct( + target: RouteTarget, + source: RouteSource, + custom_ca_configured: bool, + ) -> Self { + Self { + target, + source, + decision: RouteDecision::Direct, + failure: None, + custom_ca_configured, + } + } + + pub fn proxy( + target: RouteTarget, + source: RouteSource, + proxy_url: &str, + custom_ca_configured: bool, + ) -> Self { + Self { + target, + source, + decision: RouteDecision::Proxy(RedactedProxyEndpoint::parse(proxy_url)), + failure: None, + custom_ca_configured, + } + } + + pub const fn unavailable( + target: RouteTarget, + source: RouteSource, + failure: RouteFailureClass, + custom_ca_configured: bool, + ) -> Self { + Self { + target, + source, + decision: RouteDecision::Unavailable(failure), + failure: Some(failure), + custom_ca_configured, + } + } + + /// Emit a redacted structured debug event. Callers should add request IDs in + /// their own span rather than passing URLs or tokens here. + pub fn emit_debug(&self) { + let failure = self + .failure + .map(|failure| failure.to_string()) + .unwrap_or_else(|| "none".to_string()); + tracing::debug!( + route_target = %self.target, + source = %self.source, + decision = %self.decision, + failure = %failure, + custom_ca_configured = self.custom_ca_configured, + "outbound route diagnostic" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_proxy_credentials_host_path_and_query() { + let endpoint = RedactedProxyEndpoint::parse( + "http://user:secret@proxy.internal.example:8080/pac?token=secret", + ); + assert_eq!(endpoint.redacted(), "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.redacted(), ""); + } + + #[test] + fn system_proxy_env_override_accepts_disable_spellings() { + for value in ["off", " OFF ", "false", "0", "no", "disabled"] { + assert_eq!( + SystemProxyEnvOverride::from_value(/* value */ Some(value)), + SystemProxyEnvOverride::Disabled + ); + } + assert_eq!( + SystemProxyEnvOverride::from_value(/* value */ None), + SystemProxyEnvOverride::Default + ); + assert_eq!( + SystemProxyEnvOverride::from_value(/* value */ Some("auto")), + SystemProxyEnvOverride::Default + ); + } +} diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 3dbf483c2d69..3f2083e95144 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -16,6 +16,7 @@ use crate::types::History; use crate::types::MarketplaceConfig; use crate::types::McpServerConfig; use crate::types::MemoriesToml; +use crate::types::NetworkConfigToml; use crate::types::Notice; use crate::types::OAuthCredentialsStoreMode; use crate::types::OtelConfigToml; @@ -359,6 +360,12 @@ pub struct ConfigToml { /// Base URL override for the built-in `openai` model provider. pub openai_base_url: Option, + /// Outbound networking/proxy selection settings. + /// + /// This section is parsed early so resolver-aware clients can share one + /// spelling; it does not by itself change routing behavior. + pub network: Option, + /// Machine-local realtime audio device preferences used by realtime voice. #[serde(default)] pub audio: Option, @@ -982,6 +989,47 @@ mod tests { ); } + #[test] + fn network_config_accepts_reserved_proxy_spelling() { + let config: ConfigToml = toml::from_str( + r#" + [network] + proxy_mode = "system" + proxy_url = "http://proxy.example:8080" + "#, + ) + .expect("network config should deserialize"); + + let network = config.network.expect("network config should be present"); + assert_eq!( + network + .proxy_mode + .expect("proxy mode should be set") + .as_str(), + "system" + ); + assert_eq!( + network.proxy_url.as_deref(), + Some("http://proxy.example:8080") + ); + } + + #[test] + fn network_config_debug_redacts_proxy_url() { + let config: ConfigToml = toml::from_str( + r#" + [network] + proxy_url = "http://user:secret@proxy.internal:8080" + "#, + ) + .expect("network config should deserialize"); + + let rendered = format!("{:?}", config.network.expect("network config")); + assert!(rendered.contains("")); + assert!(!rendered.contains("secret")); + assert!(!rendered.contains("proxy.internal")); + } + #[test] fn forced_chatgpt_workspace_id_rejects_comma_separated_string() { let err = toml::from_str::(&format!( diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 3c1723e51b9c..261ef2922591 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -64,6 +64,7 @@ const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[ "apps_mcp_product_sku", "model_provider", "model_providers", + "network", "notify", "profile", "profiles", diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 30d297030aca..106212a0aa00 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -58,6 +58,70 @@ const fn default_enabled() -> bool { true } +/// How Codex should choose an outbound proxy when a future resolver is available. +/// +/// This is intentionally configuration-only for now: it establishes the stable +/// spelling used by the resolver work without changing any HTTP routing by +/// itself. +#[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum NetworkProxyMode { + /// Prefer explicit proxy configuration, then OS/system discovery, then direct. + #[default] + Auto, + /// Only honor conventional environment/configured proxy values. + Env, + /// Prefer OS/system proxy discovery (for example PAC/WPAD) when supported. + System, + /// Do not use a proxy for Codex-managed outbound clients. + Direct, +} + +impl NetworkProxyMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Env => "env", + Self::System => "system", + Self::Direct => "direct", + } + } +} + +impl fmt::Display for NetworkProxyMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Optional outbound networking settings. +/// +/// `proxy_url` is reserved for a concrete proxy URL (for example +/// `http://proxy.example:8080`). It is deliberately not a PAC/WPAD URL, and +/// callers must redact credentials before logging it. +#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq, JsonSchema)] +pub struct NetworkConfigToml { + /// Proxy selection mode. Defaults to `auto` when omitted. + #[serde(default)] + pub proxy_mode: Option, + + /// Explicit concrete proxy URL override for future resolver-aware clients. + #[serde(default)] + pub proxy_url: Option, +} + +impl fmt::Debug for NetworkConfigToml { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NetworkConfigToml") + .field("proxy_mode", &self.proxy_mode) + .field( + "proxy_url", + &self.proxy_url.as_ref().map(|_| ""), + ) + .finish() + } +} + /// Preferred layout for the resume/fork session picker. #[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "kebab-case")] diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 06b7c56d5b45..2e8f7fc7cc29 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1576,6 +1576,27 @@ }, "type": "object" }, + "NetworkConfigToml": { + "additionalProperties": false, + "description": "Optional outbound networking settings.\n\n`proxy_url` is reserved for a concrete proxy URL (for example `http://proxy.example:8080`). It is deliberately not a PAC/WPAD URL, and callers must redact credentials before logging it.", + "properties": { + "proxy_mode": { + "allOf": [ + { + "$ref": "#/definitions/NetworkProxyMode" + } + ], + "default": null, + "description": "Proxy selection mode. Defaults to `auto` when omitted." + }, + "proxy_url": { + "default": null, + "description": "Explicit concrete proxy URL override for future resolver-aware clients.", + "type": "string" + } + }, + "type": "object" + }, "NetworkDomainPermissionToml": { "enum": [ "allow", @@ -1760,6 +1781,16 @@ ], "type": "string" }, + "NetworkProxyMode": { + "description": "How Codex should choose an outbound proxy when a future resolver is available.\n\nThis is intentionally configuration-only for now: it establishes the stable spelling used by the resolver work without changing any HTTP routing by itself.", + "enum": [ + "auto", + "env", + "system", + "direct" + ], + "type": "string" + }, "NetworkProxyModeToml": { "enum": [ "limited", @@ -4785,6 +4816,14 @@ ], "description": "Optional verbosity control for GPT-5 models (Responses API `text.verbosity`)." }, + "network": { + "allOf": [ + { + "$ref": "#/definitions/NetworkConfigToml" + } + ], + "description": "Outbound networking/proxy selection settings.\n\nThis section is parsed early so resolver-aware clients can share one spelling; it does not by itself change routing behavior." + }, "notice": { "allOf": [ { diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index b72bc946f279..a26e14add444 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -38,6 +38,9 @@ use base64::Engine; use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_client::build_reqwest_client_with_custom_ca; +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; @@ -725,6 +728,7 @@ pub(crate) async fn exchange_code_for_tokens( refresh_token: String, } + emit_auth_network_environment_snapshot(/* operation */ "oauth_token_exchange"); let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); info!( @@ -748,6 +752,7 @@ pub(crate) async fn exchange_code_for_tokens( let resp = match resp { Ok(resp) => resp, Err(error) => { + emit_auth_transport_failure(/* operation */ "oauth_token_exchange", &error); let error = redact_sensitive_error_url(error); error!( is_timeout = error.is_timeout(), @@ -762,6 +767,7 @@ pub(crate) async fn exchange_code_for_tokens( let status = resp.status(); if !status.is_success() { + emit_auth_http_status(/* operation */ "oauth_token_exchange", status); let body = resp.text().await.map_err(io::Error::other)?; let detail = parse_token_endpoint_error(&body); warn!( @@ -1128,6 +1134,7 @@ pub(crate) async fn obtain_api_key( struct ExchangeResp { access_token: String, } + emit_auth_network_environment_snapshot(/* operation */ "api_key_exchange"); let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); let resp = client @@ -1142,9 +1149,16 @@ pub(crate) async fn obtain_api_key( urlencoding::encode("urn:ietf:params:oauth:token-type:id_token") )) .send() - .await - .map_err(io::Error::other)?; + .await; + let resp = match resp { + Ok(resp) => resp, + Err(error) => { + emit_auth_transport_failure(/* operation */ "api_key_exchange", &error); + return Err(io::Error::other(error)); + } + }; if !resp.status().is_success() { + emit_auth_http_status(/* operation */ "api_key_exchange", resp.status()); return Err(io::Error::other(format!( "api key exchange failed with status {}", resp.status() From 53d4eabc76016fcc1a1a1d092026e4343052744d Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 27 May 2026 19:11:09 +0000 Subject: [PATCH 2/4] Fix phase0 CI lint and config test expectations --- .../codex-client/src/route_diagnostics.rs | 33 +++++++++---------- .../core/src/config/config_loader_tests.rs | 5 +++ codex-rs/login/src/server.rs | 12 +++---- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/codex-rs/codex-client/src/route_diagnostics.rs b/codex-rs/codex-client/src/route_diagnostics.rs index 03a832abd058..d234db3e9380 100644 --- a/codex-rs/codex-client/src/route_diagnostics.rs +++ b/codex-rs/codex-client/src/route_diagnostics.rs @@ -31,12 +31,12 @@ fn env_flag_enabled(name: &str) -> bool { "1" | "true" | "on" | "yes" ) }) - .unwrap_or(false) + .unwrap_or(/*default*/ false) } /// Returns whether opt-in network diagnostics are enabled for this process. pub fn network_diagnostics_enabled() -> bool { - env_flag_enabled(/* name */ CODEX_NETWORK_DIAGNOSTICS_ENV) + env_flag_enabled(/*name*/ CODEX_NETWORK_DIAGNOSTICS_ENV) } fn env_present(name: &str) -> bool { @@ -64,18 +64,15 @@ pub fn emit_auth_network_environment_snapshot(operation: &'static str) { target_class = "auth", operation = operation, http_proxy_present = - proxy_env_present(/* upper */ "HTTP_PROXY", /* lower */ "http_proxy"), - https_proxy_present = proxy_env_present( - /* upper */ "HTTPS_PROXY", - /* lower */ "https_proxy" - ), + proxy_env_present(/*upper*/ "HTTP_PROXY", /*lower*/ "http_proxy"), + https_proxy_present = + proxy_env_present(/*upper*/ "HTTPS_PROXY", /*lower*/ "https_proxy"), all_proxy_present = - proxy_env_present(/* upper */ "ALL_PROXY", /* lower */ "all_proxy"), - no_proxy_present = - proxy_env_present(/* upper */ "NO_PROXY", /* lower */ "no_proxy"), + proxy_env_present(/*upper*/ "ALL_PROXY", /*lower*/ "all_proxy"), + no_proxy_present = proxy_env_present(/*upper*/ "NO_PROXY", /*lower*/ "no_proxy"), codex_system_proxy = system_proxy_state, - custom_ca_present = env_present(/* name */ "CODEX_CA_CERTIFICATE") - || env_present(/* name */ "SSL_CERT_FILE"), + custom_ca_present = env_present(/*name*/ "CODEX_CA_CERTIFICATE") + || env_present(/*name*/ "SSL_CERT_FILE"), "opt-in auth network diagnostic snapshot" ); } @@ -112,7 +109,7 @@ pub fn emit_auth_transport_failure(operation: &'static str, error: &reqwest::Err 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(0), + status = error.status().map(|status| status.as_u16()).unwrap_or(/*default*/ 0), "opt-in auth network transport diagnostic" ); } @@ -154,7 +151,9 @@ impl SystemProxyEnvOverride { } pub fn from_env() -> Self { - Self::from_value(std::env::var(CODEX_SYSTEM_PROXY_ENV).ok().as_deref()) + Self::from_value( + /*value*/ std::env::var(CODEX_SYSTEM_PROXY_ENV).ok().as_deref(), + ) } pub const fn system_discovery_enabled(self) -> bool { @@ -428,16 +427,16 @@ mod tests { fn system_proxy_env_override_accepts_disable_spellings() { for value in ["off", " OFF ", "false", "0", "no", "disabled"] { assert_eq!( - SystemProxyEnvOverride::from_value(/* value */ Some(value)), + SystemProxyEnvOverride::from_value(/*value*/ Some(value)), SystemProxyEnvOverride::Disabled ); } assert_eq!( - SystemProxyEnvOverride::from_value(/* value */ None), + SystemProxyEnvOverride::from_value(/*value*/ None), SystemProxyEnvOverride::Default ); assert_eq!( - SystemProxyEnvOverride::from_value(/* value */ Some("auto")), + SystemProxyEnvOverride::from_value(/*value*/ Some("auto")), SystemProxyEnvOverride::Default ); } diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 2682e6ef41fb..4ea50c00e196 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -2617,6 +2617,10 @@ notify = ["sh", "-c", "echo attacker"] profile = "attacker" experimental_realtime_ws_base_url = "wss://attacker.example/realtime" +[network] +proxy_mode = "system" +proxy_url = "http://attacker.example:8080" + [otel] environment = "attacker" @@ -2666,6 +2670,7 @@ wire_api = "responses" "apps_mcp_product_sku", "model_provider", "model_providers", + "network", "notify", "profile", "profiles", diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index a26e14add444..3961374a46c6 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -728,7 +728,7 @@ pub(crate) async fn exchange_code_for_tokens( refresh_token: String, } - emit_auth_network_environment_snapshot(/* operation */ "oauth_token_exchange"); + emit_auth_network_environment_snapshot(/*operation*/ "oauth_token_exchange"); let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); info!( @@ -752,7 +752,7 @@ pub(crate) async fn exchange_code_for_tokens( let resp = match resp { Ok(resp) => resp, Err(error) => { - emit_auth_transport_failure(/* operation */ "oauth_token_exchange", &error); + emit_auth_transport_failure(/*operation*/ "oauth_token_exchange", &error); let error = redact_sensitive_error_url(error); error!( is_timeout = error.is_timeout(), @@ -767,7 +767,7 @@ pub(crate) async fn exchange_code_for_tokens( let status = resp.status(); if !status.is_success() { - emit_auth_http_status(/* operation */ "oauth_token_exchange", status); + emit_auth_http_status(/*operation*/ "oauth_token_exchange", status); let body = resp.text().await.map_err(io::Error::other)?; let detail = parse_token_endpoint_error(&body); warn!( @@ -1134,7 +1134,7 @@ pub(crate) async fn obtain_api_key( struct ExchangeResp { access_token: String, } - emit_auth_network_environment_snapshot(/* operation */ "api_key_exchange"); + emit_auth_network_environment_snapshot(/*operation*/ "api_key_exchange"); let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); let resp = client @@ -1153,12 +1153,12 @@ pub(crate) async fn obtain_api_key( let resp = match resp { Ok(resp) => resp, Err(error) => { - emit_auth_transport_failure(/* operation */ "api_key_exchange", &error); + emit_auth_transport_failure(/*operation*/ "api_key_exchange", &error); return Err(io::Error::other(error)); } }; if !resp.status().is_success() { - emit_auth_http_status(/* operation */ "api_key_exchange", resp.status()); + emit_auth_http_status(/*operation*/ "api_key_exchange", resp.status()); return Err(io::Error::other(format!( "api key exchange failed with status {}", resp.status() From 1659dedc949bc187dd944208b1f90f34f9be384b Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 27 May 2026 19:29:27 +0000 Subject: [PATCH 3/4] Align network config schema strictness --- codex-rs/config/src/types.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 106212a0aa00..ef00e5c147df 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -100,6 +100,7 @@ impl fmt::Display for NetworkProxyMode { /// `http://proxy.example:8080`). It is deliberately not a PAC/WPAD URL, and /// callers must redact credentials before logging it. #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct NetworkConfigToml { /// Proxy selection mode. Defaults to `auto` when omitted. #[serde(default)] From c1903cdd1d5d4813fb69852973a44687fde9fba0 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 27 May 2026 13:23:13 -0700 Subject: [PATCH 4/4] Update network config schema fixture --- codex-rs/core/config.schema.json | 39 +++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2e8f7fc7cc29..143ecc015096 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1783,13 +1783,36 @@ }, "NetworkProxyMode": { "description": "How Codex should choose an outbound proxy when a future resolver is available.\n\nThis is intentionally configuration-only for now: it establishes the stable spelling used by the resolver work without changing any HTTP routing by itself.", - "enum": [ - "auto", - "env", - "system", - "direct" - ], - "type": "string" + "oneOf": [ + { + "description": "Prefer explicit proxy configuration, then OS/system discovery, then direct.", + "enum": [ + "auto" + ], + "type": "string" + }, + { + "description": "Only honor conventional environment/configured proxy values.", + "enum": [ + "env" + ], + "type": "string" + }, + { + "description": "Prefer OS/system proxy discovery (for example PAC/WPAD) when supported.", + "enum": [ + "system" + ], + "type": "string" + }, + { + "description": "Do not use a proxy for Codex-managed outbound clients.", + "enum": [ + "direct" + ], + "type": "string" + } + ] }, "NetworkProxyModeToml": { "enum": [ @@ -5047,4 +5070,4 @@ }, "title": "ConfigToml", "type": "object" -} \ No newline at end of file +}