Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions codex-rs/codex-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Comment thread
canvrno-oai marked this conversation as resolved.
"Win32_Foundation",
"Win32_Networking_WinHttp",
] }

[lints]
workspace = true

Expand Down
244 changes: 231 additions & 13 deletions codex-rs/codex-client/src/outbound_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 },
Expand All @@ -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,
Expand All @@ -251,24 +267,26 @@ static SYSTEM_PROXY_CACHE: OnceLock<Mutex<HashMap<String, CachedSystemProxyDecis
fn cached_system_proxy_decision(request_url: &str) -> Option<SystemProxyDecision> {
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<String, CachedSystemProxyDecision>,
request_url: &str,
cache_key: &str,
decision: SystemProxyDecision,
now: Instant,
) {
Expand All @@ -281,23 +299,223 @@ 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,
},
);
}

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::<u16>()
.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;
Comment thread
canvrno-oai marked this conversation as resolved.
}
} else {
for token in segment.split_whitespace() {
if let Some(decision) = process_token(token) {
return decision;
Comment thread
canvrno-oai marked this conversation as resolved.
}
}
}
}
}

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<ParsedProxyListDecision> {
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)
Comment thread
canvrno-oai marked this conversation as resolved.
}
}

#[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<String>;
}
Expand Down
Loading
Loading