mirror of
https://github.com/openai/codex.git
synced 2026-11-12 22:42:36 +00:00
## Why #31335 lets HTTP callers obtain proxy-aware clients from `HttpClientFactory`, but a non-HTTP transport such as WebSockets also needs two pieces of policy owned by `codex-http-client`: a concrete route decision for its destination and the same custom-CA-aware rustls trust configuration used by HTTPS. Keeping these prerequisites in the shared abstraction means the dependent Responses WebSocket change (#31441) cannot independently reinterpret `features.respect_system_proxy`, PAC results, or enterprise CA settings. ## What changed - Add a redaction-safe `OutboundProxyRoute` with explicit transport-default, direct, and concrete-proxy outcomes. - Add `HttpClientFactory::resolve_proxy_route()` so transports can resolve a destination through the already-selected outbound proxy policy. - Resolve `ws://` and `wss://` URLs through their HTTP equivalents so system and PAC rules apply consistently. - Add an always-returned rustls config builder that starts from native roots and layers in any configured Codex custom CA bundle. The existing optional builder remains available to callers that can delegate the default configuration to their transport. - Continue redacting proxy URLs from `Debug` output because they may contain credentials. ## Review guide 1. `http-client/src/outbound_proxy.rs` defines the transport-neutral route result and WebSocket URL normalization. 2. `http-client/src/custom_ca.rs` factors the native-root/custom-CA construction so callers that perform TLS themselves can always obtain a config. 3. `http-client/src/outbound_proxy_tests.rs` verifies WebSocket normalization and legacy transport-default behavior. ## Test plan - `just test -p codex-http-client outbound_proxy` - `just test -p codex-http-client custom_ca` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/31342). * #31431 * #31363 * #31362 * #31361 * #31442 * #31441 * __->__ #31342
249 lines
7.9 KiB
Rust
249 lines
7.9 KiB
Rust
//! Shared outbound proxy policy tests.
|
|
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
use std::io::Read;
|
|
use std::io::Write;
|
|
|
|
struct MapEnv {
|
|
values: HashMap<String, String>,
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_route_uses_http_equivalent_for_system_resolution() {
|
|
let route = resolve_proxy_route(
|
|
"wss://api.openai.com/v1/responses",
|
|
OutboundProxyPolicy::RespectSystemProxy,
|
|
|request_url, origin| {
|
|
assert_eq!(request_url, "https://api.openai.com/v1/responses");
|
|
assert_eq!(origin.scheme, "https");
|
|
assert_eq!(origin.host, "api.openai.com");
|
|
assert_eq!(origin.port, 443);
|
|
SystemProxyDecision::Proxy {
|
|
url: "http://proxy.example:8080".to_string(),
|
|
}
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
route,
|
|
OutboundProxyRoute::Proxy {
|
|
url: "http://proxy.example:8080".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reqwest_default_route_preserves_transport_proxy_behavior() {
|
|
let route = resolve_proxy_route(
|
|
"wss://api.openai.com/v1/responses",
|
|
OutboundProxyPolicy::ReqwestDefault,
|
|
|_, _| panic!("default policy should not resolve system proxy settings"),
|
|
);
|
|
|
|
assert_eq!(route, OutboundProxyRoute::TransportDefault);
|
|
}
|
|
|
|
impl EnvSource for MapEnv {
|
|
fn var(&self, key: &str) -> Option<String> {
|
|
self.values.get(key).cloned()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn proxy_env_value_matches_reqwest_casing_precedence() {
|
|
let env = MapEnv {
|
|
values: HashMap::from([
|
|
("HTTPS_PROXY".to_string(), "upper".to_string()),
|
|
("https_proxy".to_string(), "lower".to_string()),
|
|
("http_proxy".to_string(), "lower-only".to_string()),
|
|
("ALL_PROXY".to_string(), String::new()),
|
|
("all_proxy".to_string(), "masked".to_string()),
|
|
]),
|
|
};
|
|
|
|
assert_eq!(
|
|
proxy_env_value(&env, "HTTPS_PROXY"),
|
|
Some("upper".to_string())
|
|
);
|
|
assert_eq!(
|
|
proxy_env_value(&env, "HTTP_PROXY"),
|
|
Some("lower-only".to_string())
|
|
);
|
|
assert_eq!(proxy_env_value(&env, "ALL_PROXY"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn environment_fallback_reads_injected_proxy_environment() {
|
|
let env = MapEnv {
|
|
values: HashMap::from([("HTTPS_PROXY".to_string(), "://invalid".to_string())]),
|
|
};
|
|
let origin = RequestOrigin::parse("https://auth.openai.com/oauth/token").expect("valid URL");
|
|
let result = configure_env_proxy_handling(
|
|
&env,
|
|
reqwest::Client::builder(),
|
|
Some(&origin),
|
|
ClientRouteClass::Auth,
|
|
);
|
|
|
|
assert!(matches!(
|
|
result,
|
|
Err(BuildRouteAwareHttpClientError::InvalidProxyConfig {
|
|
route_class: ClientRouteClass::Auth,
|
|
})
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enabled_environment_proxy_routes_request_through_proxy() {
|
|
let listener =
|
|
std::net::TcpListener::bind(("127.0.0.1", 0)).expect("local proxy listener should bind");
|
|
let proxy_addr = listener
|
|
.local_addr()
|
|
.expect("local proxy listener should have an address");
|
|
let proxy_thread = std::thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().expect("proxy should accept a request");
|
|
let mut buffer = [0_u8; 4096];
|
|
let size = stream.read(&mut buffer).expect("proxy should read request");
|
|
stream
|
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
|
|
.expect("proxy should write response");
|
|
String::from_utf8_lossy(&buffer[..size]).into_owned()
|
|
});
|
|
let env = MapEnv {
|
|
values: HashMap::from([("HTTP_PROXY".to_string(), format!("http://{proxy_addr}"))]),
|
|
};
|
|
let request_url = "http://enabled-proxy.test/proxy-check";
|
|
let builder = configure_proxy_for_route(
|
|
&env,
|
|
reqwest::Client::builder().timeout(Duration::from_secs(2)),
|
|
request_url,
|
|
ClientRouteClass::Auth,
|
|
OutboundProxyPolicy::RespectSystemProxy,
|
|
|_, _| SystemProxyDecision::Unavailable {
|
|
failure: RouteFailureClass::ProxyResolutionUnavailable,
|
|
},
|
|
)
|
|
.expect("enabled proxy route should configure");
|
|
|
|
let response = builder
|
|
.build()
|
|
.expect("proxy client should build")
|
|
.get(request_url)
|
|
.send()
|
|
.await
|
|
.expect("request should use local proxy");
|
|
let proxy_request = proxy_thread.join().expect("proxy thread should finish");
|
|
|
|
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
|
assert_eq!(
|
|
proxy_request.lines().next(),
|
|
Some("GET http://enabled-proxy.test/proxy-check HTTP/1.1")
|
|
);
|
|
}
|
|
|
|
#[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";
|
|
let decision = SystemProxyDecision::Unavailable {
|
|
failure: RouteFailureClass::ProxyResolutionUnavailable,
|
|
};
|
|
|
|
cache_system_proxy_decision(request_url, decision.clone());
|
|
|
|
assert_eq!(cached_system_proxy_decision(request_url), Some(decision));
|
|
}
|
|
|
|
#[test]
|
|
fn system_proxy_cache_is_bounded() {
|
|
let mut cache = HashMap::new();
|
|
let now = Instant::now();
|
|
|
|
for index in 0..=SYSTEM_PROXY_CACHE_MAX_ENTRIES {
|
|
insert_system_proxy_cache_entry(
|
|
&mut cache,
|
|
&format!("https://bounded-cache.test/{index}"),
|
|
SystemProxyDecision::Direct,
|
|
now,
|
|
);
|
|
}
|
|
|
|
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(any(target_os = "windows", target_os = "macos"))]
|
|
assert!(!cache_key.contains(request_url));
|
|
}
|