From 3aa8bf4bc335dacb1dbfa105a7cb4acc7a2db9c8 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 9 Jun 2026 14:51:32 -0700 Subject: [PATCH] Add system proxy routing to network proxy --- codex-rs/Cargo.lock | 1 + codex-rs/codex-client/src/lib.rs | 2 + codex-rs/codex-client/src/outbound_proxy.rs | 38 +++++ codex-rs/core/src/config/mod.rs | 24 +++ codex-rs/network-proxy/Cargo.toml | 1 + codex-rs/network-proxy/src/config.rs | 14 ++ codex-rs/network-proxy/src/http_proxy.rs | 39 ++++- codex-rs/network-proxy/src/lib.rs | 1 + codex-rs/network-proxy/src/mitm.rs | 7 +- codex-rs/network-proxy/src/runtime.rs | 6 + codex-rs/network-proxy/src/state.rs | 1 + codex-rs/network-proxy/src/upstream.rs | 172 +++++++++++++++++--- 12 files changed, 283 insertions(+), 23 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 01c683993a..94f885e0d0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3451,6 +3451,7 @@ dependencies = [ "base64 0.22.1", "chrono", "clap", + "codex-client", "codex-utils-absolute-path", "codex-utils-home-dir", "codex-utils-rustls-provider", diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 6bb0aac38b..b651cb0ba0 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -31,7 +31,9 @@ pub use crate::outbound_proxy::ClientRouteClass; pub use crate::outbound_proxy::OutboundProxyConfig; pub use crate::outbound_proxy::OutboundProxyMode; pub use crate::outbound_proxy::RouteFailureClass; +pub use crate::outbound_proxy::SystemProxyRouteDecision; pub use crate::outbound_proxy::build_reqwest_client_for_route; +pub use crate::outbound_proxy::resolve_system_proxy_for_url; pub use crate::request::PreparedRequestBody; pub use crate::request::Request; pub use crate::request::RequestBody; diff --git a/codex-rs/codex-client/src/outbound_proxy.rs b/codex-rs/codex-client/src/outbound_proxy.rs index 293fc4bc3b..d00df0f3a0 100644 --- a/codex-rs/codex-client/src/outbound_proxy.rs +++ b/codex-rs/codex-client/src/outbound_proxy.rs @@ -109,6 +109,14 @@ impl fmt::Display for RouteFailureClass { } } +/// URL-specific system proxy decision from the platform resolver. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SystemProxyRouteDecision { + Direct, + Proxy { url: String }, + Unavailable { failure: RouteFailureClass }, +} + /// How a resolver-aware client should choose an outbound proxy. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum OutboundProxyMode { @@ -383,6 +391,36 @@ enum SystemProxyDecision { Unavailable { 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 }, + } + } +} + +/// Resolve system proxy/PAC routing for a single outbound URL. +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 { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + } + + let Some(origin) = RequestOrigin::parse(request_url) else { + return SystemProxyRouteDecision::Unavailable { + failure: RouteFailureClass::InvalidProxyConfig, + }; + }; + + resolve_system_proxy(request_url, &origin, include_auto_detect).into() +} + fn resolve_system_proxy( request_url: &str, origin: &RequestOrigin, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 74c6c93e5b..5990087de9 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -137,6 +137,7 @@ use crate::config_lock::config_without_lock_controls; use crate::config_lock::lock_layer_from_config; use crate::config_lock::read_config_lock_from_path; use codex_network_proxy::NetworkProxyConfig; +use codex_network_proxy::UpstreamProxyMode; use toml::Value as TomlValue; use toml_edit::DocumentMut; @@ -2483,6 +2484,21 @@ fn resolved_system_proxy_config( resolved_system_proxy_config_from_features(cfg, features.get(), mode_requirement) } +fn apply_system_proxy_feature_config_to_network_proxy( + config: &mut NetworkProxyConfig, + system_proxy: Option<&SystemProxyFeatureConfigToml>, +) { + let Some(system_proxy) = system_proxy else { + return; + }; + config.network.upstream_proxy_mode = match system_proxy.mode.unwrap_or_default() { + SystemProxyFeatureModeToml::Auto => UpstreamProxyMode::Auto, + SystemProxyFeatureModeToml::Env => UpstreamProxyMode::Env, + SystemProxyFeatureModeToml::System => UpstreamProxyMode::System, + SystemProxyFeatureModeToml::Direct => UpstreamProxyMode::Direct, + }; +} + /// Resolves `[features.system_proxy]` for the initial cloud-config bootstrap. /// /// This runs before cloud-managed config can be fetched, so it can only use @@ -3081,6 +3097,10 @@ impl Config { network_proxy, ); } + apply_system_proxy_feature_config_to_network_proxy( + &mut configured_network_proxy_config, + system_proxy.as_ref(), + ); configured_network_proxy_config.network.enabled = true; } let approval_policy_was_explicit = @@ -3841,6 +3861,10 @@ impl Config { network_proxy, ); } + apply_system_proxy_feature_config_to_network_proxy( + &mut configured_network_proxy_config, + self.system_proxy.as_ref(), + ); configured_network_proxy_config.network.enabled = true; } configured_network_proxy_config diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index a400038382..910f8f8d93 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -18,6 +18,7 @@ async-trait = { workspace = true } base64 = { workspace = true } clap = { workspace = true, features = ["derive"] } chrono = { workspace = true } +codex-client = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-rustls-provider = { workspace = true } diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index d9cef46b15..54d9a0235d 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -116,6 +116,16 @@ pub struct NetworkUnixSocketPermissions { pub entries: BTreeMap, } +#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum UpstreamProxyMode { + Auto, + #[default] + Env, + System, + Direct, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct NetworkProxySettings { @@ -129,6 +139,8 @@ pub struct NetworkProxySettings { pub enable_socks5_udp: bool, pub allow_upstream_proxy: bool, #[serde(default)] + pub upstream_proxy_mode: UpstreamProxyMode, + #[serde(default)] pub dangerously_allow_non_loopback_proxy: bool, #[serde(default)] pub dangerously_allow_all_unix_sockets: bool, @@ -154,6 +166,7 @@ impl Default for NetworkProxySettings { socks_url: default_socks_url(), enable_socks5_udp: true, allow_upstream_proxy: true, + upstream_proxy_mode: UpstreamProxyMode::default(), dangerously_allow_non_loopback_proxy: false, dangerously_allow_all_unix_sockets: false, mode: NetworkMode::default(), @@ -586,6 +599,7 @@ mod tests { socks_url: "http://127.0.0.1:8081".to_string(), enable_socks5_udp: true, allow_upstream_proxy: true, + upstream_proxy_mode: UpstreamProxyMode::Env, dangerously_allow_non_loopback_proxy: false, dangerously_allow_all_unix_sockets: false, mode: NetworkMode::Full, diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index f20c01b906..e9ade1abac 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -383,7 +383,25 @@ async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> { }; let proxy = if allow_upstream_proxy { - proxy_for_connect() + let upstream_proxy_mode = match app_state.upstream_proxy_mode().await { + Ok(mode) => mode, + Err(err) => { + error!("failed to read upstream proxy mode: {err}"); + return Ok(()); + } + }; + let target_host = target.host.to_string(); + let target_url = https_url_for_host_port(&target_host, target.port); + match proxy_for_connect(&target_url, upstream_proxy_mode) { + Ok(proxy) => proxy, + Err(err) => { + warn!( + "CONNECT upstream proxy resolution failed (host={}, port={}): {err}", + target.host, target.port + ); + return Ok(()); + } + } } else { None }; @@ -786,7 +804,15 @@ async fn http_plain_proxy( Err(resp) => return Ok(resp), }; let client = if allow_upstream_proxy { - UpstreamClient::from_env_proxy(app_state.clone()) + let upstream_proxy_mode = match app_state + .upstream_proxy_mode() + .await + .map_err(|err| internal_error("failed to read upstream proxy mode", err)) + { + Ok(mode) => mode, + Err(resp) => return Ok(resp), + }; + UpstreamClient::from_proxy_mode(app_state.clone(), upstream_proxy_mode) } else { UpstreamClient::direct(app_state.clone()) }; @@ -837,6 +863,15 @@ fn client_addr(input: &T) -> Option { .map(|info| info.peer_addr().to_string()) } +fn https_url_for_host_port(host: &str, port: u16) -> String { + let host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + }; + format!("https://{host}:{port}/") +} + fn validate_absolute_form_host_header( req: &Request, request_ctx: &RequestContext, diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 9ad16f8ecc..ca0c58299e 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -25,6 +25,7 @@ pub use config::NetworkMode; pub use config::NetworkProxyConfig; pub use config::NetworkUnixSocketPermission; pub use config::NetworkUnixSocketPermissions; +pub use config::UpstreamProxyMode; pub use config::host_and_port_from_network_addr; pub use mitm_hook::InjectedHeaderConfig; pub use mitm_hook::MitmHookActionsConfig; diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index 345c5b5032..872198b705 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -1,5 +1,6 @@ use crate::certs::ManagedMitmCa; use crate::config::NetworkMode; +use crate::config::UpstreamProxyMode; use crate::mitm_hook::HookEvaluation; use crate::mitm_hook::MitmHookActions; use crate::policy::normalize_host; @@ -61,6 +62,7 @@ pub struct MitmState { pub(crate) struct MitmUpstreamConfig { pub(crate) allow_upstream_proxy: bool, + pub(crate) upstream_proxy_mode: UpstreamProxyMode, pub(crate) allow_local_binding: bool, } @@ -109,7 +111,10 @@ impl MitmState { let ca = ManagedMitmCa::load_or_create()?; let upstream = if config.allow_upstream_proxy { - UpstreamClient::from_env_proxy_with_allow_local_binding(config.allow_local_binding) + UpstreamClient::from_proxy_mode_with_allow_local_binding( + config.upstream_proxy_mode, + config.allow_local_binding, + ) } else { UpstreamClient::direct_with_allow_local_binding(config.allow_local_binding) }; diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 60894d5d5b..cb831c16d4 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -546,6 +546,12 @@ impl NetworkProxyState { Ok(guard.config.network.allow_upstream_proxy) } + pub async fn upstream_proxy_mode(&self) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.network.upstream_proxy_mode) + } + pub async fn allow_local_binding(&self) -> Result { self.reload_if_needed().await?; let guard = self.state.read().await; diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 32cdfab149..db88eff00a 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -76,6 +76,7 @@ pub fn build_config_state( let mitm = if config.network.mitm { Some(Arc::new(MitmState::new(MitmUpstreamConfig { allow_upstream_proxy: config.network.allow_upstream_proxy, + upstream_proxy_mode: config.network.upstream_proxy_mode, allow_local_binding: config.network.allow_local_binding, })?)) } else { diff --git a/codex-rs/network-proxy/src/upstream.rs b/codex-rs/network-proxy/src/upstream.rs index 3437b0d32d..1649ac259a 100644 --- a/codex-rs/network-proxy/src/upstream.rs +++ b/codex-rs/network-proxy/src/upstream.rs @@ -1,5 +1,9 @@ +use crate::config::UpstreamProxyMode; use crate::connect_policy::TargetCheckedTcpConnector; use crate::state::NetworkProxyState; +use codex_client::RouteFailureClass; +use codex_client::SystemProxyRouteDecision; +use codex_client::resolve_system_proxy_for_url; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use rama_core::Layer; use rama_core::Service; @@ -23,20 +27,33 @@ use rama_tls_rustls::client::TlsConnectorDataBuilder; use rama_tls_rustls::client::TlsConnectorLayer; use std::sync::Arc; use std::time::Instant; +use thiserror::Error; use tracing::info; use tracing::warn; #[cfg(target_os = "macos")] use rama_unix::client::UnixConnector; +#[derive(Debug, Error)] +pub(crate) enum UpstreamProxyError { + #[error("system proxy resolution failed: {0}")] + SystemProxyUnavailable(RouteFailureClass), + + #[error("system proxy returned invalid proxy URL: {url}")] + InvalidSystemProxyUrl { url: String }, + + #[error("system proxy returned unsupported proxy protocol: {url}")] + UnsupportedSystemProxyProtocol { url: String }, +} + #[derive(Clone, Default)] -struct ProxyConfig { +struct EnvProxyConfig { http: Option, https: Option, all: Option, } -impl ProxyConfig { +impl EnvProxyConfig { fn from_env() -> Self { let http = read_proxy_env(&["HTTP_PROXY", "http_proxy"]); let https = read_proxy_env(&["HTTPS_PROXY", "https_proxy"]); @@ -56,6 +73,83 @@ impl ProxyConfig { } } +#[derive(Clone)] +struct ProxyConfig { + mode: UpstreamProxyMode, + env: EnvProxyConfig, +} + +impl ProxyConfig { + fn direct() -> Self { + Self { + mode: UpstreamProxyMode::Direct, + env: EnvProxyConfig::default(), + } + } + + fn from_mode(mode: UpstreamProxyMode) -> Self { + Self { + mode, + env: EnvProxyConfig::from_env(), + } + } + + fn proxy_for_url( + &self, + request_url: &str, + is_secure: bool, + ) -> Result, UpstreamProxyError> { + match self.mode { + UpstreamProxyMode::Direct => Ok(None), + UpstreamProxyMode::Env => Ok(self.env.proxy_for_protocol(is_secure)), + UpstreamProxyMode::System => { + system_proxy_for_url(request_url, /*include_auto_detect*/ true) + } + UpstreamProxyMode::Auto => { + match system_proxy_for_url(request_url, /*include_auto_detect*/ true) { + Ok(proxy) => Ok(proxy), + Err(err) => { + warn!("system proxy unavailable; falling back to env proxy ({err})"); + Ok(self.env.proxy_for_protocol(is_secure)) + } + } + } + } + } +} + +fn system_proxy_for_url( + request_url: &str, + include_auto_detect: bool, +) -> Result, UpstreamProxyError> { + match resolve_system_proxy_for_url(request_url, include_auto_detect) { + SystemProxyRouteDecision::Direct => Ok(None), + SystemProxyRouteDecision::Proxy { url } => proxy_address_from_system_url(&url).map(Some), + SystemProxyRouteDecision::Unavailable { failure } => { + Err(UpstreamProxyError::SystemProxyUnavailable(failure)) + } + } +} + +fn proxy_address_from_system_url(proxy_url: &str) -> Result { + let proxy = ProxyAddress::try_from(proxy_url).map_err(|_| { + UpstreamProxyError::InvalidSystemProxyUrl { + url: proxy_url.to_string(), + } + })?; + if proxy + .protocol + .as_ref() + .map(rama_net::Protocol::is_http) + .unwrap_or(true) + { + return Ok(proxy); + } + Err(UpstreamProxyError::UnsupportedSystemProxyProtocol { + url: proxy_url.to_string(), + }) +} + fn read_proxy_env(keys: &[&str]) -> Option { for key in keys { let Ok(value) = std::env::var(key) else { @@ -85,8 +179,11 @@ fn read_proxy_env(keys: &[&str]) -> Option { None } -pub(crate) fn proxy_for_connect() -> Option { - ProxyConfig::from_env().proxy_for_protocol(/*is_secure*/ true) +pub(crate) fn proxy_for_connect( + request_url: &str, + mode: UpstreamProxyMode, +) -> Result, UpstreamProxyError> { + ProxyConfig::from_mode(mode).proxy_for_url(request_url, /*is_secure*/ true) } #[derive(Clone)] @@ -101,29 +198,29 @@ pub(crate) struct UpstreamClient { impl UpstreamClient { pub(crate) fn direct(state: Arc) -> Self { - Self::new( - ProxyConfig::default(), - TargetCheckedTcpConnector::new(state), - ) + Self::new(ProxyConfig::direct(), TargetCheckedTcpConnector::new(state)) } - pub(crate) fn from_env_proxy(state: Arc) -> Self { + pub(crate) fn from_proxy_mode(state: Arc, mode: UpstreamProxyMode) -> Self { Self::new( - ProxyConfig::from_env(), + ProxyConfig::from_mode(mode), TargetCheckedTcpConnector::new(state), ) } pub(crate) fn direct_with_allow_local_binding(allow_local_binding: bool) -> Self { Self::new( - ProxyConfig::default(), + ProxyConfig::direct(), TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding), ) } - pub(crate) fn from_env_proxy_with_allow_local_binding(allow_local_binding: bool) -> Self { + pub(crate) fn from_proxy_mode_with_allow_local_binding( + mode: UpstreamProxyMode, + allow_local_binding: bool, + ) -> Self { Self::new( - ProxyConfig::from_env(), + ProxyConfig::from_mode(mode), TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding), ) } @@ -133,7 +230,7 @@ impl UpstreamClient { let connector = build_unix_connector(path); Self { connector, - proxy_config: ProxyConfig::default(), + proxy_config: ProxyConfig::direct(), } } @@ -156,12 +253,28 @@ impl Service> for UpstreamClient { .as_ref() .map(|ctx| ctx.host_with_port().to_string()) .unwrap_or_else(|| "".to_string()); - let proxy = self.proxy_config.proxy_for_protocol( - request_context - .as_ref() - .map(|ctx| ctx.protocol.is_secure()) - .unwrap_or(false), - ); + let proxy = match request_context + .as_ref() + .map(|ctx| request_target_url(&req, ctx)) + .map(|request_url| { + self.proxy_config.proxy_for_url( + &request_url, + request_context + .as_ref() + .map(|ctx| ctx.protocol.is_secure()) + .unwrap_or(false), + ) + }) + .transpose() + { + Ok(proxy) => proxy.flatten(), + Err(err) => { + warn!("HTTP upstream proxy resolution failed (target={authority}): {err}"); + return Err(OpaqueError::from_display(format!( + "upstream proxy resolution failed: {err}" + ))); + } + }; match proxy.as_ref() { Some(proxy) => info!( "HTTP upstream route selected (target={authority}, route=upstream_proxy, proxy={})", @@ -219,6 +332,25 @@ impl Service> for UpstreamClient { } } +fn request_target_url(req: &Request, request_context: &RequestContext) -> String { + if req.uri().scheme_str().is_some() && req.uri().authority().is_some() { + return req.uri().to_string(); + } + + let scheme = if request_context.protocol.is_secure() { + "https" + } else { + "http" + }; + let authority = request_context.host_with_port(); + let path = req + .uri() + .path_and_query() + .map(rama_http::uri::PathAndQuery::as_str) + .unwrap_or("/"); + format!("{scheme}://{authority}{path}") +} + fn build_http_connector( transport: TargetCheckedTcpConnector, ) -> BoxService<