diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index cd658f9a8..f0d9f135b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2404,12 +2404,14 @@ dependencies = [ "rustls-pki-types", "serde", "serde_json", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", "tracing", "tracing-opentelemetry", "tracing-subscriber", + "windows-sys 0.52.0", "zstd 0.13.3", ] diff --git a/codex-rs/codex-client/Cargo.toml b/codex-rs/codex-client/Cargo.toml index 87f95de65..65be21913 100644 --- a/codex-rs/codex-client/Cargo.toml +++ b/codex-rs/codex-client/Cargo.toml @@ -24,6 +24,13 @@ tracing-opentelemetry = { workspace = true } codex-utils-rustls-provider = { workspace = true } zstd = { workspace = true } +[target.'cfg(target_os = "windows")'.dependencies] +sha2 = { workspace = true } +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_Networking_WinHttp", +] } + [lints] workspace = true diff --git a/codex-rs/codex-client/src/outbound_proxy.rs b/codex-rs/codex-client/src/outbound_proxy.rs index a5bef21bc..ba3735ba5 100644 --- a/codex-rs/codex-client/src/outbound_proxy.rs +++ b/codex-rs/codex-client/src/outbound_proxy.rs @@ -14,12 +14,19 @@ use std::time::Instant; use crate::custom_ca::BuildCustomCaTransportError; use crate::custom_ca::build_reqwest_client_with_custom_ca; +#[cfg(target_os = "windows")] +use sha2::Digest; +#[cfg(target_os = "windows")] +use sha2::Sha256; use thiserror::Error; const SYSTEM_PROXY_SUCCESS_CACHE_TTL: Duration = Duration::from_secs(60); const SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL: Duration = Duration::from_secs(5); const SYSTEM_PROXY_CACHE_MAX_ENTRIES: usize = 256; +#[cfg(target_os = "windows")] +mod windows; + /// Coarse semantic bucket for the HTTP or WebSocket client being constructed. /// /// This is not the selected proxy route or a concrete endpoint. It labels the @@ -209,11 +216,14 @@ impl RequestOrigin { } } -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow( - dead_code, - reason = "Direct and Proxy are constructed by platform resolvers added in later PRs" +#[cfg_attr( + not(target_os = "windows"), + allow( + dead_code, + reason = "Direct and Proxy are constructed only by platform-specific resolvers" + ) )] +#[derive(Debug, Clone, PartialEq, Eq)] enum SystemProxyDecision { Direct, Proxy { url: String }, @@ -230,6 +240,12 @@ fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProx decision } +#[cfg(target_os = "windows")] +fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + windows::resolve(request_url, origin) +} + +#[cfg(not(target_os = "windows"))] fn resolve_platform_system_proxy( _request_url: &str, _origin: &RequestOrigin, @@ -251,24 +267,26 @@ static SYSTEM_PROXY_CACHE: OnceLock Option { let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); let mut cache = cache.lock().ok()?; - let cached = cache.get(request_url)?; + let key = system_proxy_cache_key(request_url); + let cached = cache.get(&key)?; if cached.expires_at > Instant::now() { return Some(cached.decision.clone()); } - cache.remove(request_url); + cache.remove(&key); None } fn cache_system_proxy_decision(request_url: &str, decision: SystemProxyDecision) { let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); if let Ok(mut cache) = cache.lock() { - insert_system_proxy_cache_entry(&mut cache, request_url, decision, Instant::now()); + let cache_key = system_proxy_cache_key(request_url); + insert_system_proxy_cache_entry(&mut cache, &cache_key, decision, Instant::now()); } } fn insert_system_proxy_cache_entry( cache: &mut HashMap, - request_url: &str, + cache_key: &str, decision: SystemProxyDecision, now: Instant, ) { @@ -281,16 +299,16 @@ fn insert_system_proxy_cache_entry( cache.retain(|_, cached| cached.expires_at > now); if cache.len() >= SYSTEM_PROXY_CACHE_MAX_ENTRIES - && !cache.contains_key(request_url) - && let Some(request_url_to_evict) = cache + && !cache.contains_key(cache_key) + && let Some(cache_key_to_evict) = cache .iter() .min_by_key(|(_, cached)| cached.expires_at) - .map(|(request_url, _)| request_url.clone()) + .map(|(cache_key, _)| cache_key.clone()) { - cache.remove(&request_url_to_evict); + cache.remove(&cache_key_to_evict); } cache.insert( - request_url.to_string(), + cache_key.to_string(), CachedSystemProxyDecision { decision, expires_at: now + ttl, @@ -298,6 +316,206 @@ fn insert_system_proxy_cache_entry( ); } +fn system_proxy_cache_key(request_url: &str) -> String { + #[cfg(target_os = "windows")] + { + // Keep URL-specific PAC decisions without retaining the raw routed URL. + let mut hasher = Sha256::new(); + hasher.update(b"system-proxy-cache-v1\0"); + hasher.update(request_url.as_bytes()); + format!("{:x}", hasher.finalize()) + } + + #[cfg(not(target_os = "windows"))] + request_url.to_string() +} + +#[cfg(any(test, target_os = "windows"))] +fn no_proxy_matches_origin(no_proxy: &str, origin: &RequestOrigin) -> bool { + no_proxy + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .any(|entry| no_proxy_entry_matches_origin(entry, origin)) +} + +#[cfg(any(test, target_os = "windows"))] +fn no_proxy_entry_matches_origin(entry: &str, origin: &RequestOrigin) -> bool { + if entry == "*" { + return true; + } + + let mut entry = entry + .strip_prefix("http://") + .or_else(|| entry.strip_prefix("https://")) + .unwrap_or(entry) + .trim_matches(['[', ']']) + .to_ascii_lowercase(); + let mut port = None; + let parsed_host_port = entry.rsplit_once(':').and_then(|(host, candidate_port)| { + if host.contains(':') { + return None; + } + candidate_port + .parse::() + .ok() + .map(|parsed_port| (host.to_string(), parsed_port)) + }); + if let Some((host, parsed_port)) = parsed_host_port { + entry = host; + port = Some(parsed_port); + } + if port.is_some_and(|port| port != origin.port) { + return false; + } + + if let Some(suffix) = entry.strip_prefix('.') { + return origin.host == suffix || origin.host.ends_with(&format!(".{suffix}")); + } + + if entry.contains('*') { + return wildcard_host_match(&entry, &origin.host); + } + + origin.host == entry +} + +#[cfg(any(test, target_os = "windows"))] +fn wildcard_host_match(pattern: &str, host: &str) -> bool { + let mut remaining = host; + let mut first = true; + for part in pattern.split('*') { + if part.is_empty() { + continue; + } + if first && !pattern.starts_with('*') { + let Some(stripped) = remaining.strip_prefix(part) else { + return false; + }; + remaining = stripped; + } else { + let Some(index) = remaining.find(part) else { + return false; + }; + remaining = &remaining[index + part.len()..]; + } + first = false; + } + pattern.ends_with('*') || remaining.is_empty() +} + +#[cfg(any(test, target_os = "windows"))] +#[derive(Debug, Clone, PartialEq, Eq)] +enum ParsedProxyListDecision { + Direct, + Proxy(String), + UnsupportedScheme, + Unavailable, +} + +#[cfg(any(test, target_os = "windows"))] +fn parse_proxy_list(input: &str, target_scheme: &str) -> ParsedProxyListDecision { + let mut saw_unsupported = false; + + { + let mut process_token = |token: &str| { + let decision = parse_proxy_token(token, target_scheme); + match decision { + ParsedProxyListDecision::Direct => Some(ParsedProxyListDecision::Direct), + ParsedProxyListDecision::Proxy(url) => Some(ParsedProxyListDecision::Proxy(url)), + ParsedProxyListDecision::UnsupportedScheme => { + saw_unsupported = true; + None + } + ParsedProxyListDecision::Unavailable => None, + } + }; + + for segment in input + .split(';') + .map(str::trim) + .filter(|segment| !segment.is_empty()) + { + let mut parts = segment.split_whitespace(); + let directive = parts.next(); + let hostport = parts.next(); + let extra = parts.next(); + let is_proxy_directive = matches!( + directive.map(str::to_ascii_lowercase).as_deref(), + Some("proxy" | "http" | "https" | "socks" | "socks4" | "socks5") + ) && hostport.is_some() + && extra.is_none(); + + if is_proxy_directive { + if let Some(decision) = process_token(segment) { + return decision; + } + } else { + for token in segment.split_whitespace() { + if let Some(decision) = process_token(token) { + return decision; + } + } + } + } + } + + if saw_unsupported { + ParsedProxyListDecision::UnsupportedScheme + } else { + ParsedProxyListDecision::Unavailable + } +} + +#[cfg(any(test, target_os = "windows"))] +fn parse_proxy_token(token: &str, target_scheme: &str) -> ParsedProxyListDecision { + if token.eq_ignore_ascii_case("DIRECT") { + return ParsedProxyListDecision::Direct; + } + + if let Some(decision) = parse_proxy_key_token(token, target_scheme) { + return decision; + } + if token.contains('=') { + return ParsedProxyListDecision::Unavailable; + } + + let mut parts = token.split_whitespace(); + let directive = parts.next(); + let hostport = parts.next(); + if let (Some(directive), Some(hostport), None) = (directive, hostport, parts.next()) { + return match directive.to_ascii_lowercase().as_str() { + "proxy" | "http" => proxy_url_from_hostport("http", hostport), + "https" => proxy_url_from_hostport("https", hostport), + "socks" | "socks4" | "socks5" => ParsedProxyListDecision::UnsupportedScheme, + _ => ParsedProxyListDecision::Unavailable, + }; + } + + proxy_url_from_hostport("http", token) +} + +#[cfg(any(test, target_os = "windows"))] +fn parse_proxy_key_token(token: &str, target_scheme: &str) -> Option { + let (key, value) = token.split_once('=')?; + if key.trim().eq_ignore_ascii_case(target_scheme) { + Some(proxy_url_from_hostport("http", value.trim())) + } else { + Some(ParsedProxyListDecision::Unavailable) + } +} + +#[cfg(any(test, target_os = "windows"))] +fn proxy_url_from_hostport(proxy_scheme: &str, hostport: &str) -> ParsedProxyListDecision { + if hostport.is_empty() { + return ParsedProxyListDecision::Unavailable; + } + if hostport.contains("://") { + return ParsedProxyListDecision::Proxy(hostport.to_string()); + } + ParsedProxyListDecision::Proxy(format!("{proxy_scheme}://{hostport}")) +} + trait EnvSource { fn var(&self, key: &str) -> Option; } diff --git a/codex-rs/codex-client/src/outbound_proxy/windows.rs b/codex-rs/codex-client/src/outbound_proxy/windows.rs new file mode 100644 index 000000000..9e6419670 --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy/windows.rs @@ -0,0 +1,362 @@ +use std::ffi::c_void; +use std::ptr; + +use super::ParsedProxyListDecision; +use super::RequestOrigin; +use super::RouteFailureClass; +use super::SystemProxyDecision; +use super::no_proxy_matches_origin; +use super::parse_proxy_list; +use windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND; +use windows_sys::Win32::Foundation::FALSE; +use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::GlobalFree; +use windows_sys::Win32::Foundation::TRUE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_AUTODETECTION_FAILED; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_BAD_AUTO_PROXY_SCRIPT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_CANNOT_CONNECT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_CONNECTION_ERROR; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_INVALID_URL; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_LOGIN_FAILURE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_NAME_NOT_RESOLVED; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SCRIPT_EXECUTION_ERROR; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_CN_INVALID; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_DATE_INVALID; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_REV_FAILED; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_REVOKED; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_WRONG_USAGE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CHANNEL_ERROR; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_FAILURE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_INVALID_CA; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_INVALID_CERT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_TIMEOUT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNHANDLED_SCRIPT_TYPE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNRECOGNIZED_SCHEME; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_ACCESS_TYPE_NAMED_PROXY; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_ACCESS_TYPE_NO_PROXY; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTO_DETECT_TYPE_DHCP; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTO_DETECT_TYPE_DNS_A; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_AUTO_DETECT; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_CONFIG_URL; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_OPTIONS; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_CURRENT_USER_IE_PROXY_CONFIG; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_PROXY_INFO; +use windows_sys::Win32::Networking::WinHttp::WinHttpCloseHandle; +use windows_sys::Win32::Networking::WinHttp::WinHttpGetIEProxyConfigForCurrentUser; +use windows_sys::Win32::Networking::WinHttp::WinHttpGetProxyForUrl; +use windows_sys::Win32::Networking::WinHttp::WinHttpOpen; +use windows_sys::core::PWSTR; + +pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + let ie_config = match current_user_ie_proxy_config() { + Ok(config) => config, + Err(failure) => { + return SystemProxyDecision::Unavailable { failure }; + } + }; + + if let Some(pac_url) = ie_config.auto_config_url.as_deref() { + let decision = resolve_with_pac_url(request_url, origin, pac_url); + if !matches!(decision, SystemProxyDecision::Unavailable { .. }) { + return decision; + } + } + + if ie_config.auto_detect { + let decision = resolve_with_auto_detect(request_url, origin); + if !matches!(decision, SystemProxyDecision::Unavailable { .. }) { + return decision; + } + } + + if let Some(proxy) = ie_config.static_proxy.as_deref() { + if ie_config + .proxy_bypass + .as_deref() + .is_some_and(|bypass| proxy_bypass_matches_origin(bypass, origin)) + { + return SystemProxyDecision::Direct; + } + return proxy_list_decision(proxy, origin); + } + + if ie_config.auto_config_url.is_some() || ie_config.auto_detect { + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + } + } else { + SystemProxyDecision::Direct + } +} + +fn resolve_with_pac_url( + request_url: &str, + origin: &RequestOrigin, + pac_url: &str, +) -> SystemProxyDecision { + let pac_url = wide_null(pac_url); + let options = WINHTTP_AUTOPROXY_OPTIONS { + dwFlags: WINHTTP_AUTOPROXY_CONFIG_URL, + dwAutoDetectFlags: 0, + lpszAutoConfigUrl: pac_url.as_ptr(), + lpvReserved: ptr::null_mut(), + dwReserved: 0, + fAutoLogonIfChallenged: TRUE, + }; + resolve_with_winhttp_options(request_url, origin, options) +} + +fn resolve_with_auto_detect(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + let options = WINHTTP_AUTOPROXY_OPTIONS { + dwFlags: WINHTTP_AUTOPROXY_AUTO_DETECT, + dwAutoDetectFlags: WINHTTP_AUTO_DETECT_TYPE_DHCP | WINHTTP_AUTO_DETECT_TYPE_DNS_A, + lpszAutoConfigUrl: ptr::null(), + lpvReserved: ptr::null_mut(), + dwReserved: 0, + fAutoLogonIfChallenged: FALSE, + }; + resolve_with_winhttp_options(request_url, origin, options) +} + +fn resolve_with_winhttp_options( + request_url: &str, + origin: &RequestOrigin, + mut options: WINHTTP_AUTOPROXY_OPTIONS, +) -> SystemProxyDecision { + let Some(session) = WinHttpSession::open() else { + return SystemProxyDecision::Unavailable { + failure: classify_winhttp_error(last_error()), + }; + }; + + let request_url = wide_null(request_url); + let mut proxy_info = WINHTTP_PROXY_INFO { + dwAccessType: WINHTTP_ACCESS_TYPE_NO_PROXY, + lpszProxy: ptr::null_mut(), + lpszProxyBypass: ptr::null_mut(), + }; + let ok = unsafe { + WinHttpGetProxyForUrl( + session.0, + request_url.as_ptr(), + &mut options, + &mut proxy_info, + ) + }; + if ok == FALSE { + return SystemProxyDecision::Unavailable { + 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; + } + if proxy_info.access_type != WINHTTP_ACCESS_TYPE_NAMED_PROXY { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + } + let Some(proxy) = proxy_info.proxy.as_deref() else { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + }; + proxy_list_decision(proxy, origin) +} + +fn proxy_list_decision(proxy_list: &str, origin: &RequestOrigin) -> SystemProxyDecision { + match parse_proxy_list(proxy_list, &origin.scheme) { + ParsedProxyListDecision::Direct => SystemProxyDecision::Direct, + ParsedProxyListDecision::Proxy(url) => SystemProxyDecision::Proxy { url }, + ParsedProxyListDecision::UnsupportedScheme => SystemProxyDecision::Unavailable { + failure: RouteFailureClass::UnsupportedProxyScheme, + }, + ParsedProxyListDecision::Unavailable => SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }, + } +} + +fn current_user_ie_proxy_config() -> Result { + let mut raw = WINHTTP_CURRENT_USER_IE_PROXY_CONFIG { + fAutoDetect: FALSE, + lpszAutoConfigUrl: ptr::null_mut(), + lpszProxy: ptr::null_mut(), + lpszProxyBypass: ptr::null_mut(), + }; + let ok = unsafe { WinHttpGetIEProxyConfigForCurrentUser(&mut raw) }; + if ok == FALSE { + let error = last_error(); + if error == ERROR_FILE_NOT_FOUND { + // Match WinHTTP's fallback by attempting WPAD when no IE proxy settings exist. + return Ok(IeProxyConfig { + auto_detect: true, + ..Default::default() + }); + } + return Err(classify_winhttp_error(error)); + } + + let auto_config_url = GlobalWideString::from_raw(raw.lpszAutoConfigUrl).into_string(); + let static_proxy = GlobalWideString::from_raw(raw.lpszProxy).into_string(); + let proxy_bypass = GlobalWideString::from_raw(raw.lpszProxyBypass).into_string(); + + Ok(IeProxyConfig { + auto_detect: raw.fAutoDetect != FALSE, + auto_config_url, + static_proxy, + proxy_bypass, + }) +} + +#[derive(Debug, Default)] +struct IeProxyConfig { + auto_detect: bool, + auto_config_url: Option, + static_proxy: Option, + proxy_bypass: Option, +} + +struct ProxyInfo { + access_type: u32, + proxy: Option, + _proxy_bypass: Option, +} + +impl ProxyInfo { + fn from_raw(raw: WINHTTP_PROXY_INFO) -> Self { + Self { + access_type: raw.dwAccessType, + proxy: GlobalWideString::from_raw(raw.lpszProxy).into_string(), + _proxy_bypass: GlobalWideString::from_raw(raw.lpszProxyBypass).into_string(), + } + } +} + +struct GlobalWideString(PWSTR); + +impl GlobalWideString { + fn from_raw(ptr: PWSTR) -> Self { + Self(ptr) + } + + fn into_string(self) -> Option { + if self.0.is_null() { + return None; + } + let string = unsafe { wide_ptr_to_string(self.0) }; + if string.is_empty() { + None + } else { + Some(string) + } + } +} + +impl Drop for GlobalWideString { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + GlobalFree(self.0.cast::()); + } + } + } +} + +struct WinHttpSession(*mut c_void); + +impl WinHttpSession { + fn open() -> Option { + let agent = wide_null("Codex"); + let handle = unsafe { + WinHttpOpen( + agent.as_ptr(), + WINHTTP_ACCESS_TYPE_NO_PROXY, + ptr::null(), + ptr::null(), + 0, + ) + }; + if handle.is_null() { + None + } else { + Some(Self(handle)) + } + } +} + +impl Drop for WinHttpSession { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + WinHttpCloseHandle(self.0); + } + } + } +} + +fn proxy_bypass_matches_origin(proxy_bypass: &str, origin: &RequestOrigin) -> bool { + proxy_bypass + .split(|ch: char| ch == ';' || ch == ',' || ch.is_whitespace()) + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .any(|entry| { + if entry.eq_ignore_ascii_case("") { + !origin.host.contains('.') + } else { + no_proxy_matches_origin(entry, origin) + } + }) +} + +#[cfg(test)] +#[path = "windows_tests.rs"] +mod tests; + +fn wide_null(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +unsafe fn wide_ptr_to_string(ptr: PWSTR) -> String { + let mut len = 0; + while unsafe { *ptr.add(len) } != 0 { + len += 1; + } + let slice = unsafe { std::slice::from_raw_parts(ptr, len) }; + String::from_utf16_lossy(slice) +} + +fn last_error() -> u32 { + unsafe { GetLastError() } +} + +fn classify_winhttp_error(code: u32) -> RouteFailureClass { + match code { + ERROR_WINHTTP_TIMEOUT => RouteFailureClass::ConnectTimeout, + ERROR_WINHTTP_LOGIN_FAILURE => RouteFailureClass::ProxyAuthenticationRequired, + ERROR_WINHTTP_AUTODETECTION_FAILED + | ERROR_WINHTTP_BAD_AUTO_PROXY_SCRIPT + | ERROR_WINHTTP_SCRIPT_EXECUTION_ERROR + | ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT + | ERROR_WINHTTP_UNHANDLED_SCRIPT_TYPE => RouteFailureClass::ProxyResolutionUnavailable, + ERROR_WINHTTP_SECURE_CERT_CN_INVALID + | ERROR_WINHTTP_SECURE_CERT_DATE_INVALID + | ERROR_WINHTTP_SECURE_CERT_REVOKED + | ERROR_WINHTTP_SECURE_CERT_REV_FAILED + | ERROR_WINHTTP_SECURE_CERT_WRONG_USAGE + | ERROR_WINHTTP_SECURE_CHANNEL_ERROR + | ERROR_WINHTTP_SECURE_FAILURE + | ERROR_WINHTTP_SECURE_INVALID_CA + | ERROR_WINHTTP_SECURE_INVALID_CERT => RouteFailureClass::TlsError, + ERROR_WINHTTP_INVALID_URL | ERROR_WINHTTP_UNRECOGNIZED_SCHEME => { + RouteFailureClass::InvalidProxyConfig + } + ERROR_WINHTTP_CANNOT_CONNECT + | ERROR_WINHTTP_CONNECTION_ERROR + | ERROR_WINHTTP_NAME_NOT_RESOLVED => RouteFailureClass::ResolverError, + _ => RouteFailureClass::ResolverError, + } +} diff --git a/codex-rs/codex-client/src/outbound_proxy/windows_tests.rs b/codex-rs/codex-client/src/outbound_proxy/windows_tests.rs new file mode 100644 index 000000000..3772c057d --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy/windows_tests.rs @@ -0,0 +1,18 @@ +use super::*; + +#[test] +fn proxy_bypass_matches_whitespace_separated_winhttp_entries() { + let local_origin = RequestOrigin { + scheme: "https".to_string(), + host: "intranet".to_string(), + port: 443, + }; + assert!(proxy_bypass_matches_origin(" *.corp", &local_origin)); + + let corp_origin = RequestOrigin { + scheme: "https".to_string(), + host: "service.corp".to_string(), + port: 443, + }; + assert!(proxy_bypass_matches_origin(" *.corp", &corp_origin)); +} diff --git a/codex-rs/codex-client/src/outbound_proxy_tests.rs b/codex-rs/codex-client/src/outbound_proxy_tests.rs index 9c713d28f..27e303a5d 100644 --- a/codex-rs/codex-client/src/outbound_proxy_tests.rs +++ b/codex-rs/codex-client/src/outbound_proxy_tests.rs @@ -103,6 +103,18 @@ async fn enabled_environment_proxy_routes_request_through_proxy() { ); } +#[test] +fn parses_pac_proxy_tokens() { + assert_eq!( + parse_proxy_list("PROXY proxy.internal:8080; DIRECT", "https"), + ParsedProxyListDecision::Proxy("http://proxy.internal:8080".to_string()) + ); + assert_eq!( + parse_proxy_list("HTTPS proxy.internal:8443", "https"), + ParsedProxyListDecision::Proxy("https://proxy.internal:8443".to_string()) + ); +} + #[test] fn unavailable_system_proxy_decision_is_cached() { let request_url = "https://unavailable-cache.test/oauth/token"; @@ -131,3 +143,67 @@ fn system_proxy_cache_is_bounded() { assert_eq!(cache.len(), SYSTEM_PROXY_CACHE_MAX_ENTRIES); } + +#[test] +fn parses_static_winhttp_proxy_entries_for_target_scheme() { + assert_eq!( + parse_proxy_list("http=web-proxy:8080;https=secure-proxy:8443", "https"), + ParsedProxyListDecision::Proxy("http://secure-proxy:8443".to_string()) + ); + assert_eq!( + parse_proxy_list("http=web-proxy:8080 https=secure-proxy:8443", "https"), + ParsedProxyListDecision::Proxy("http://secure-proxy:8443".to_string()) + ); + assert_eq!( + parse_proxy_list("http=web-proxy:8080", "https"), + ParsedProxyListDecision::Unavailable + ); + assert_eq!( + parse_proxy_list("proxy.internal:8080", "https"), + ParsedProxyListDecision::Proxy("http://proxy.internal:8080".to_string()) + ); +} + +#[test] +fn reports_direct_and_unsupported_proxy_tokens() { + assert_eq!( + parse_proxy_list("DIRECT; PROXY proxy.internal:8080", "https"), + ParsedProxyListDecision::Direct + ); + assert_eq!( + parse_proxy_list("DIRECT", "https"), + ParsedProxyListDecision::Direct + ); + assert_eq!( + parse_proxy_list("SOCKS proxy.internal:1080", "https"), + ParsedProxyListDecision::UnsupportedScheme + ); +} + +#[test] +fn no_proxy_matches_exact_suffix_wildcard_and_port() { + let origin = RequestOrigin { + scheme: "https".to_string(), + host: "auth.openai.com".to_string(), + port: 443, + }; + assert!(no_proxy_matches_origin("auth.openai.com", &origin)); + assert!(!no_proxy_matches_origin("openai.com", &origin)); + assert!(no_proxy_matches_origin(".openai.com", &origin)); + assert!(no_proxy_matches_origin("*.openai.com", &origin)); + assert!(no_proxy_matches_origin("auth.openai.com:443", &origin)); + assert!(!no_proxy_matches_origin("auth.openai.com:8443", &origin)); +} + +#[test] +fn system_proxy_cache_key_preserves_url_specific_pac_decisions() { + let request_url = "https://auth.openai.com/oauth/token?access_token=secret"; + let cache_key = system_proxy_cache_key(request_url); + + assert_ne!( + cache_key, + system_proxy_cache_key("https://auth.openai.com/oauth/revoke") + ); + #[cfg(target_os = "windows")] + assert!(!cache_key.contains(request_url)); +}