From c91497ebef6601682805e21c60a44dd0f709a4f1 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Fri, 5 Jun 2026 16:31:52 -0700 Subject: [PATCH] Add macOS system proxy resolver --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 + codex-rs/codex-client/Cargo.toml | 7 +- codex-rs/codex-client/src/outbound_proxy.rs | 27 +- .../codex-client/src/outbound_proxy/macos.rs | 402 ++++++++++++++++++ 5 files changed, 429 insertions(+), 9 deletions(-) create mode 100644 codex-rs/codex-client/src/outbound_proxy/macos.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d1538d2bd9..01c683993a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2391,6 +2391,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "system-configuration", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index b87b0cf39f..340f20a11b 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -394,6 +394,7 @@ strum_macros = "0.28.0" supports-color = "3.0.2" syntect = "5" sys-locale = "0.3.2" +system-configuration = "0.7" tar = { version = "=0.4.45", default-features = false } tempfile = "3.23.0" test-log = "0.2.19" diff --git a/codex-rs/codex-client/Cargo.toml b/codex-rs/codex-client/Cargo.toml index 9e477ba5b4..28a44ca4e1 100644 --- a/codex-rs/codex-client/Cargo.toml +++ b/codex-rs/codex-client/Cargo.toml @@ -25,8 +25,13 @@ tracing-opentelemetry = { workspace = true } codex-utils-rustls-provider = { workspace = true } zstd = { workspace = true } -[target.'cfg(target_os = "windows")'.dependencies] +[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] sha2 = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +system-configuration = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.52", features = [ "Win32_Foundation", "Win32_Networking_WinHttp", diff --git a/codex-rs/codex-client/src/outbound_proxy.rs b/codex-rs/codex-client/src/outbound_proxy.rs index 3f97aa6f7a..293fc4bc3b 100644 --- a/codex-rs/codex-client/src/outbound_proxy.rs +++ b/codex-rs/codex-client/src/outbound_proxy.rs @@ -15,14 +15,16 @@ use std::time::Instant; use crate::custom_ca::BuildCustomCaTransportError; use crate::custom_ca::build_reqwest_client_with_custom_ca; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "macos"))] use sha2::Digest; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "macos"))] use sha2::Sha256; use thiserror::Error; const SYSTEM_PROXY_SUCCESS_CACHE_TTL: Duration = Duration::from_secs(60); +#[cfg(target_os = "macos")] +mod macos; #[cfg(target_os = "windows")] mod windows; @@ -294,7 +296,7 @@ fn configure_proxy_for_route( } const fn system_proxy_supported() -> bool { - cfg!(target_os = "windows") + cfg!(any(target_os = "windows", target_os = "macos")) } fn configure_concrete_proxy( @@ -373,7 +375,7 @@ fn default_port_for_scheme(scheme: &str) -> Option { } } -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))] #[derive(Debug, Clone, PartialEq, Eq)] enum SystemProxyDecision { Direct, @@ -395,6 +397,15 @@ fn resolve_system_proxy( decision } +#[cfg(target_os = "macos")] +fn resolve_platform_system_proxy( + request_url: &str, + origin: &RequestOrigin, + include_auto_detect: bool, +) -> SystemProxyDecision { + macos::resolve(request_url, origin, include_auto_detect) +} + #[cfg(target_os = "windows")] fn resolve_platform_system_proxy( request_url: &str, @@ -404,7 +415,7 @@ fn resolve_platform_system_proxy( windows::resolve(request_url, origin, include_auto_detect) } -#[cfg(not(target_os = "windows"))] +#[cfg(not(any(target_os = "windows", target_os = "macos")))] fn resolve_platform_system_proxy( _request_url: &str, _origin: &RequestOrigin, @@ -463,7 +474,7 @@ fn cache_system_proxy_decision( } fn system_proxy_cache_key(request_url: &str, include_auto_detect: bool) -> String { - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] { // Keep URL-specific PAC decisions without retaining the raw routed URL. let mut hasher = Sha256::new(); @@ -474,7 +485,7 @@ fn system_proxy_cache_key(request_url: &str, include_auto_detect: bool) -> Strin format!("{:x}", hasher.finalize()) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(any(target_os = "windows", target_os = "macos")))] format!("{request_url}:auto_detect={include_auto_detect}") } @@ -776,7 +787,7 @@ mod tests { /*include_auto_detect*/ false, ) ); - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] assert!(!cache_key.contains(request_url)); } } diff --git a/codex-rs/codex-client/src/outbound_proxy/macos.rs b/codex-rs/codex-client/src/outbound_proxy/macos.rs new file mode 100644 index 0000000000..334d8319eb --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy/macos.rs @@ -0,0 +1,402 @@ +use std::ffi::c_void; +use std::ptr; +use std::time::Duration; +use std::time::Instant; + +use super::RequestOrigin; +use super::RouteFailureClass; +use super::SystemProxyDecision; +use system_configuration::core_foundation::array::CFArray; +use system_configuration::core_foundation::array::CFArrayRef; +use system_configuration::core_foundation::base::CFEqual; +use system_configuration::core_foundation::base::CFGetTypeID; +use system_configuration::core_foundation::base::CFIndex; +use system_configuration::core_foundation::base::CFType; +use system_configuration::core_foundation::base::CFTypeRef; +use system_configuration::core_foundation::base::TCFType; +use system_configuration::core_foundation::base::kCFAllocatorDefault; +use system_configuration::core_foundation::dictionary::CFDictionary; +use system_configuration::core_foundation::dictionary::CFDictionaryRef; +use system_configuration::core_foundation::dictionary::CFMutableDictionary; +use system_configuration::core_foundation::error::CFErrorRef; +use system_configuration::core_foundation::number::CFNumber; +use system_configuration::core_foundation::runloop::CFRunLoop; +use system_configuration::core_foundation::runloop::CFRunLoopSource; +use system_configuration::core_foundation::runloop::CFRunLoopSourceInvalidate; +use system_configuration::core_foundation::runloop::CFRunLoopSourceRef; +use system_configuration::core_foundation::runloop::kCFRunLoopDefaultMode; +use system_configuration::core_foundation::string::CFString; +use system_configuration::core_foundation::string::CFStringRef; +use system_configuration::core_foundation::url::CFURL; +use system_configuration::core_foundation::url::CFURLCreateWithString; +use system_configuration::core_foundation::url::CFURLGetTypeID; +use system_configuration::core_foundation::url::CFURLRef; +use system_configuration::dynamic_store::SCDynamicStoreBuilder; +use system_configuration::sys::schema_definitions::kSCPropNetProxiesProxyAutoDiscoveryEnable; + +const PAC_EXECUTION_TIMEOUT: Duration = Duration::from_secs(5); + +type ProxyDictionary = CFDictionary; +type ProxyArray = CFArray; + +#[repr(C)] +struct CFStreamClientContext { + version: CFIndex, + info: *mut c_void, + retain: Option *mut c_void>, + release: Option, + copy_description: Option CFStringRef>, +} + +type CFProxyAutoConfigurationResultCallback = + unsafe extern "C" fn(*mut c_void, CFArrayRef, CFErrorRef); + +#[link(name = "CFNetwork", kind = "framework")] +unsafe extern "C" { + static kCFProxyTypeKey: CFStringRef; + static kCFProxyHostNameKey: CFStringRef; + static kCFProxyPortNumberKey: CFStringRef; + static kCFProxyAutoConfigurationURLKey: CFStringRef; + static kCFProxyAutoConfigurationJavaScriptKey: CFStringRef; + static kCFProxyTypeNone: CFStringRef; + static kCFProxyTypeHTTP: CFStringRef; + static kCFProxyTypeHTTPS: CFStringRef; + static kCFProxyTypeSOCKS: CFStringRef; + static kCFProxyTypeAutoConfigurationURL: CFStringRef; + static kCFProxyTypeAutoConfigurationJavaScript: CFStringRef; + + fn CFNetworkCopyProxiesForURL(url: CFURLRef, proxy_settings: CFDictionaryRef) -> CFArrayRef; + fn CFNetworkExecuteProxyAutoConfigurationURL( + proxy_auto_config_url: CFURLRef, + target_url: CFURLRef, + callback: CFProxyAutoConfigurationResultCallback, + client_context: *mut CFStreamClientContext, + ) -> CFRunLoopSourceRef; + fn CFNetworkExecuteProxyAutoConfigurationScript( + proxy_auto_config_script: CFStringRef, + target_url: CFURLRef, + callback: CFProxyAutoConfigurationResultCallback, + client_context: *mut CFStreamClientContext, + ) -> CFRunLoopSourceRef; +} + +pub(super) fn resolve( + request_url: &str, + origin: &RequestOrigin, + include_auto_detect: bool, +) -> SystemProxyDecision { + let Some(target_url) = cf_url(request_url) else { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::InvalidProxyConfig, + }; + }; + + let Some(settings) = system_proxy_settings(include_auto_detect) else { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + }; + + let Some(proxies) = copy_proxies_for_url(&target_url, &settings) else { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + }; + + proxy_array_decision(&proxies, &target_url, origin) +} + +fn system_proxy_settings(include_auto_detect: bool) -> Option> { + let store = SCDynamicStoreBuilder::new("Codex").build()?; + let settings = store.get_proxies()?; + if include_auto_detect { + Some(settings) + } else { + Some(settings_without_auto_discovery(&settings)) + } +} + +fn settings_without_auto_discovery( + settings: &CFDictionary, +) -> CFDictionary { + let mut settings = CFMutableDictionary::from(settings); + let key = unsafe { CFString::wrap_under_get_rule(kSCPropNetProxiesProxyAutoDiscoveryEnable) }; + settings.set(key, CFNumber::from(0).into_CFType()); + settings.to_immutable() +} + +fn copy_proxies_for_url( + target_url: &CFURL, + settings: &CFDictionary, +) -> Option { + let proxies = unsafe { + CFNetworkCopyProxiesForURL( + target_url.as_concrete_TypeRef(), + settings.as_concrete_TypeRef(), + ) + }; + if proxies.is_null() { + None + } else { + Some(unsafe { ProxyArray::wrap_under_create_rule(proxies) }) + } +} + +fn proxy_array_decision( + proxies: &ProxyArray, + target_url: &CFURL, + origin: &RequestOrigin, +) -> 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 }, + ProxyEntryDecision::UnsupportedScheme => saw_unsupported = true, + ProxyEntryDecision::Unavailable => saw_unavailable = true, + } + } + + if saw_unsupported { + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::UnsupportedProxyScheme, + } + } else if saw_unavailable { + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + } + } else { + SystemProxyDecision::Direct + } +} + +fn proxy_entry_decision( + proxy: &ProxyDictionary, + target_url: &CFURL, + origin: &RequestOrigin, +) -> 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; + } + + if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTP }) { + return concrete_proxy_entry(proxy, "http"); + } + + 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"); + } + + if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeSOCKS }) { + return ProxyEntryDecision::UnsupportedScheme; + } + + if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeAutoConfigurationURL }) { + let Some(pac_url) = cf_url_value(proxy, unsafe { kCFProxyAutoConfigurationURLKey }) else { + return ProxyEntryDecision::Unavailable; + }; + return pac_decision(execute_pac_url(&pac_url, target_url), target_url, origin); + } + + if cf_string_equals(&proxy_type, unsafe { + kCFProxyTypeAutoConfigurationJavaScript + }) { + let Some(script) = + cf_string_value(proxy, unsafe { kCFProxyAutoConfigurationJavaScriptKey }) + else { + return ProxyEntryDecision::Unavailable; + }; + return pac_decision(execute_pac_script(&script, target_url), target_url, origin); + } + + ProxyEntryDecision::Unavailable +} + +fn pac_decision( + result: Result, + target_url: &CFURL, + origin: &RequestOrigin, +) -> ProxyEntryDecision { + let proxies = match result { + Ok(proxies) => proxies, + Err(RouteFailureClass::UnsupportedProxyScheme) => { + return ProxyEntryDecision::UnsupportedScheme; + } + Err(_) => return ProxyEntryDecision::Unavailable, + }; + + match proxy_array_decision(&proxies, target_url, origin) { + SystemProxyDecision::Direct => ProxyEntryDecision::Direct, + SystemProxyDecision::Proxy { url } => ProxyEntryDecision::Proxy { url }, + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::UnsupportedProxyScheme, + } => ProxyEntryDecision::UnsupportedScheme, + SystemProxyDecision::Unavailable { failure: _ } => ProxyEntryDecision::Unavailable, + } +} + +fn execute_pac_url(pac_url: &CFURL, target_url: &CFURL) -> Result { + execute_pac(|callback, context| unsafe { + CFNetworkExecuteProxyAutoConfigurationURL( + pac_url.as_concrete_TypeRef(), + target_url.as_concrete_TypeRef(), + callback, + context, + ) + }) +} + +fn execute_pac_script( + script: &CFString, + target_url: &CFURL, +) -> Result { + execute_pac(|callback, context| unsafe { + CFNetworkExecuteProxyAutoConfigurationScript( + script.as_concrete_TypeRef(), + target_url.as_concrete_TypeRef(), + callback, + context, + ) + }) +} + +fn execute_pac( + create_source: impl FnOnce( + CFProxyAutoConfigurationResultCallback, + *mut CFStreamClientContext, + ) -> CFRunLoopSourceRef, +) -> Result { + let mut state = PacRunLoopState { result: None }; + let mut context = CFStreamClientContext { + version: 0, + info: (&mut state as *mut PacRunLoopState).cast::(), + retain: None, + release: None, + copy_description: None, + }; + + let source = create_source(pac_result_callback, &mut context); + if source.is_null() { + return Err(RouteFailureClass::ProxyResolutionUnavailable); + } + + let source = unsafe { CFRunLoopSource::wrap_under_create_rule(source) }; + let run_loop = CFRunLoop::get_current(); + let mode = unsafe { kCFRunLoopDefaultMode }; + run_loop.add_source(&source, mode); + + let started_at = Instant::now(); + while state.result.is_none() && started_at.elapsed() < PAC_EXECUTION_TIMEOUT { + CFRunLoop::run_in_mode(mode, Duration::from_millis(50), true); + } + + if state.result.is_none() { + unsafe { CFRunLoopSourceInvalidate(source.as_concrete_TypeRef()) }; + } + run_loop.remove_source(&source, mode); + state + .result + .unwrap_or(Err(RouteFailureClass::ConnectTimeout)) +} + +unsafe extern "C" fn pac_result_callback( + client: *mut c_void, + proxies: CFArrayRef, + error: CFErrorRef, +) { + let state = unsafe { &mut *client.cast::() }; + state.result = if !error.is_null() || proxies.is_null() { + Some(Err(RouteFailureClass::ProxyResolutionUnavailable)) + } else { + Some(Ok(unsafe { ProxyArray::wrap_under_get_rule(proxies) })) + }; + CFRunLoop::get_current().stop(); +} + +struct PacRunLoopState { + result: Option>, +} + +fn concrete_proxy_entry(proxy: &ProxyDictionary, proxy_scheme: &str) -> ProxyEntryDecision { + let Some(host) = cf_string_value(proxy, unsafe { kCFProxyHostNameKey }) + .map(|host| host.to_string()) + .filter(|host| !host.is_empty()) + else { + return ProxyEntryDecision::Unavailable; + }; + + let host = bracket_ipv6_host(&host); + let url = match cf_i32_value(proxy, unsafe { kCFProxyPortNumberKey }) { + Some(port) if port > 0 => format!("{proxy_scheme}://{host}:{port}"), + _ => format!("{proxy_scheme}://{host}"), + }; + ProxyEntryDecision::Proxy { url } +} + +fn bracket_ipv6_host(host: &str) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + } +} + +fn cf_string_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option { + proxy + .find(key) + .and_then(|value| value.downcast::()) +} + +fn cf_i32_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option { + proxy + .find(key) + .and_then(|value| value.downcast::()) + .and_then(|value| value.to_i32()) +} + +fn cf_url_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option { + proxy.find(key).and_then(|value| { + if unsafe { CFGetTypeID(value.as_CFTypeRef()) == CFURLGetTypeID() } { + Some(unsafe { CFURL::wrap_under_get_rule(value.as_CFTypeRef() as CFURLRef) }) + } else { + value + .downcast::() + .and_then(|value| cf_url(value.to_string().as_str())) + } + }) +} + +fn cf_string_equals(value: &CFString, expected: CFStringRef) -> bool { + unsafe { CFEqual(value.as_CFTypeRef(), expected as CFTypeRef) != 0 } +} + +fn cf_url(value: &str) -> Option { + let value = CFString::new(value); + let url = unsafe { + CFURLCreateWithString( + kCFAllocatorDefault, + value.as_concrete_TypeRef(), + ptr::null(), + ) + }; + if url.is_null() { + None + } else { + Some(unsafe { CFURL::wrap_under_create_rule(url) }) + } +} + +enum ProxyEntryDecision { + Direct, + Proxy { url: String }, + UnsupportedScheme, + Unavailable, +}