From f496b09bd141af0a6ec1a7ebf3e5fc557ac795dc Mon Sep 17 00:00:00 2001 From: efrazer-oai Date: Wed, 21 Oct 2026 22:17:55 +0000 Subject: [PATCH] Retry proxy CONNECT challenges with Windows authentication (#56000) ## Why HTTPS requests and WebSocket connections on Windows need to authenticate when a proxy rejects `CONNECT` with status `407`. ## What changed - Retry proxy challenges through the existing Windows authentication helper, using the signed-in user's Negotiate or NTLM credentials. Treat an origin `407` as a normal HTTP response. - Bridge authenticated tunnels to the HTTP client through destination-specific named pipes, retaining redirect checks and TLS backend fallback without caching those tunnels across destinations. - Carry explicit CA certificates and client identities into HTTPS proxy TLS configuration, and apply the configured connect timeout when opening authenticated HTTP tunnels. GitOrigin-RevId: ec7d9168972a7a640350e5348cfca40307390fbf --- codex-rs/http-client/src/client_builder.rs | 56 +++-- codex-rs/http-client/src/client_tls.rs | 84 +++++++- codex-rs/http-client/src/custom_ca.rs | 22 +- codex-rs/http-client/src/outbound_proxy.rs | 40 +--- .../src/route_aware_client_pool.rs | 32 ++- .../src/route_aware_client_pool/execution.rs | 199 ++++++++++++++---- .../src/route_aware_tls_fallback_tests.rs | 4 +- .../http-client/src/windows_proxy_auth.rs | 31 ++- .../src/windows_proxy_auth/pipe.rs | 121 +++++++++++ codex-rs/websocket-client/src/dialer.rs | 47 ++++- 10 files changed, 524 insertions(+), 112 deletions(-) create mode 100644 codex-rs/http-client/src/windows_proxy_auth/pipe.rs diff --git a/codex-rs/http-client/src/client_builder.rs b/codex-rs/http-client/src/client_builder.rs index d3b8054767..86c7cbbcda 100644 --- a/codex-rs/http-client/src/client_builder.rs +++ b/codex-rs/http-client/src/client_builder.rs @@ -38,12 +38,14 @@ pub struct HttpClientBuilder { pub(crate) default_headers: Option, follow_redirects: bool, pub(crate) redirect_observed: Option>, - connect_timeout: Option, + pub(crate) connect_timeout: Option, chatgpt_cloudflare_cookie_store: bool, chatgpt_cookie_store: Option>, pub(crate) request_logging: RequestLogging, tls_backend: TlsBackend, - tls: HttpClientTlsConfig, + pub(crate) tls: HttpClientTlsConfig, + #[cfg(windows)] + windows_proxy_pipe_path: Option, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -184,9 +186,21 @@ impl HttpClientBuilder { ) .into_client()); } + let route = http_client_factory.resolve_proxy_route(request_url); + #[cfg(windows)] + if crate::windows_proxy_auth::can_authenticate_route(request_url, &route) { + return Ok(crate::RouteAwareClientPool::with_builder_and_initial_route( + http_client_factory.clone(), + route_class, + self, + route, + )? + .into_client()); + } self.chatgpt_cookie_store = http_client_factory.chatgpt_cookie_store(); let (builder, request_logging, default_headers) = self.into_reqwest_parts(); - let inner = http_client_factory.build_reqwest_client(builder, request_url, route_class)?; + let inner = + crate::outbound_proxy::build_reqwest_client_for_route(builder, route_class, &route)?; Ok(HttpClient::from_parts( inner, request_logging, @@ -204,17 +218,16 @@ impl HttpClientBuilder { self.chatgpt_cookie_store = http_client_factory.chatgpt_cookie_store(); let explicit_roots = self.tls.root_certificate.is_some(); let (builder, request_logging, default_headers) = self.into_reqwest_parts(); - let builder = crate::outbound_proxy::configure_builder_for_resolved_route( - builder, - route_class, - route, - )?; let inner = if explicit_roots { - builder - .build() - .map_err(BuildRouteAwareHttpClientError::ExplicitTls)? + crate::outbound_proxy::configure_builder_for_resolved_route( + builder, + route_class, + route, + )? + .build() + .map_err(BuildRouteAwareHttpClientError::ExplicitTls)? } else { - build_reqwest_client_with_custom_ca(builder)? + crate::outbound_proxy::build_reqwest_client_for_route(builder, route_class, route)? }; Ok(TransportClient::new( inner, @@ -223,6 +236,15 @@ impl HttpClientBuilder { )) } + #[cfg(windows)] + pub(crate) fn with_windows_proxy_pipe( + mut self, + pipe: &crate::windows_proxy_auth::ProxyPipe, + ) -> Self { + self.windows_proxy_pipe_path = Some(pipe.path().to_owned()); + self + } + /// Builds a client using the transport's default proxy behavior. /// /// # Legacy compatibility only @@ -358,6 +380,10 @@ impl HttpClientBuilder { fn base_reqwest_builder(self) -> reqwest::ClientBuilder { let mut builder = reqwest::Client::builder(); + #[cfg(windows)] + if let Some(path) = self.windows_proxy_pipe_path { + builder = builder.windows_named_pipe(path); + } if self.http2_prior_knowledge { builder = builder.http2_prior_knowledge(); } @@ -368,10 +394,10 @@ impl HttpClientBuilder { if let Some(certificate) = self.tls.root_certificate { builder = builder .tls_built_in_root_certs(false) - .add_root_certificate(certificate); + .add_root_certificate(certificate.reqwest); } if let Some(identity) = self.tls.client_identity { - builder = builder.identity(identity).https_only(true); + builder = builder.identity(identity.reqwest).https_only(true); } if !self.follow_redirects { builder = builder.redirect(reqwest::redirect::Policy::none()); @@ -407,6 +433,8 @@ impl Default for HttpClientBuilder { request_logging: RequestLogging::Enabled, tls_backend: TlsBackend::TransportDefault, tls: HttpClientTlsConfig::default(), + #[cfg(windows)] + windows_proxy_pipe_path: None, } } } diff --git a/codex-rs/http-client/src/client_tls.rs b/codex-rs/http-client/src/client_tls.rs index 0a6ca00654..70a80eb44b 100644 --- a/codex-rs/http-client/src/client_tls.rs +++ b/codex-rs/http-client/src/client_tls.rs @@ -2,15 +2,55 @@ //! Explicit roots replace shared environment roots; PEM identities require HTTPS and rustls. use std::fmt; +#[cfg(windows)] +use std::io; +#[cfg(windows)] +use std::sync::Arc; + +#[cfg(windows)] +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +#[cfg(windows)] +use rustls::ClientConfig; +#[cfg(windows)] +use rustls::RootCertStore; +#[cfg(windows)] +use rustls_pki_types::CertificateDer; +#[cfg(windows)] +use rustls_pki_types::PrivateKeyDer; +#[cfg(windows)] +use rustls_pki_types::pem::PemObject; use crate::HttpError; +#[cfg(windows)] +use crate::WindowsProxyTls; /// TLS configuration consumed by [`crate::HttpClientBuilder::build_with_tls`]. /// Keeping this separate from the builder prevents combining it with legacy fallback APIs. #[derive(Clone, Default)] pub struct HttpClientTlsConfig { - pub(crate) root_certificate: Option, - pub(crate) client_identity: Option, + pub(crate) root_certificate: Option>, + pub(crate) client_identity: Option>, +} + +#[derive(Clone)] +pub(crate) struct TlsMaterial { + pub(crate) reqwest: T, + // Reqwest does not expose its TLS material to the Windows proxy connector. + #[cfg(windows)] + pem: Arc<[u8]>, +} + +impl TlsMaterial { + fn from_pem( + pem: &[u8], + parse: impl FnOnce(&[u8]) -> Result, + ) -> Result { + Ok(Self { + reqwest: parse(pem)?, + #[cfg(windows)] + pem: pem.into(), + }) + } } impl fmt::Debug for HttpClientTlsConfig { @@ -25,13 +65,49 @@ impl fmt::Debug for HttpClientTlsConfig { impl HttpClientTlsConfig { /// Trusts this PEM certificate instead of system or environment-provided roots. pub fn with_root_certificate_pem(mut self, pem: &[u8]) -> Result { - self.root_certificate = Some(reqwest::Certificate::from_pem(pem)?); + self.root_certificate = Some(TlsMaterial::from_pem(pem, reqwest::Certificate::from_pem)?); Ok(self) } /// Installs a PEM certificate chain and private key and requires HTTPS requests. pub fn with_client_identity_pem(mut self, pem: &[u8]) -> Result { - self.client_identity = Some(reqwest::Identity::from_pem(pem)?); + self.client_identity = Some(TlsMaterial::from_pem(pem, reqwest::Identity::from_pem)?); Ok(self) } + + #[cfg(windows)] + pub(crate) fn windows_proxy_tls(&self) -> io::Result { + if self.root_certificate.is_none() && self.client_identity.is_none() { + return Ok(WindowsProxyTls::Default); + } + let roots = if let Some(root) = &self.root_certificate { + let mut roots = RootCertStore::empty(); + for certificate in CertificateDer::pem_slice_iter(&root.pem) { + roots + .add(certificate.map_err(io::Error::other)?) + .map_err(io::Error::other)?; + } + roots + } else { + crate::custom_ca::build_rustls_root_store_with_custom_ca().map_err(io::Error::other)? + }; + ensure_rustls_crypto_provider(); + let builder = ClientConfig::builder().with_root_certificates(roots); + let config = if let Some(identity) = &self.client_identity { + let certificates = CertificateDer::pem_slice_iter(&identity.pem) + .collect::, _>>() + .map_err(io::Error::other)?; + let key = PrivateKeyDer::pem_slice_iter(&identity.pem) + .last() + .transpose() + .map_err(io::Error::other)? + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing private key"))?; + builder + .with_client_auth_cert(certificates, key) + .map_err(io::Error::other)? + } else { + builder.with_no_client_auth() + }; + Ok(WindowsProxyTls::Custom(Arc::new(config))) + } } diff --git a/codex-rs/http-client/src/custom_ca.rs b/codex-rs/http-client/src/custom_ca.rs index 4b4e3d973b..6b706b9478 100644 --- a/codex-rs/http-client/src/custom_ca.rs +++ b/codex-rs/http-client/src/custom_ca.rs @@ -51,6 +51,7 @@ use std::sync::Arc; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use rustls::ClientConfig; +use rustls::RootCertStore; use rustls_pki_types::CertificateDer; use rustls_pki_types::pem::PemObject; use rustls_pki_types::pem::SectionKind; @@ -244,7 +245,22 @@ fn build_rustls_client_config( bundle: Option<&ConfiguredCaBundle>, ) -> Result, BuildCustomCaTransportError> { ensure_rustls_crypto_provider(); + Ok(Arc::new( + ClientConfig::builder() + .with_root_certificates(build_rustls_root_store(bundle)?) + .with_no_client_auth(), + )) +} +#[cfg(windows)] +pub(crate) fn build_rustls_root_store_with_custom_ca() +-> Result { + build_rustls_root_store(ProcessEnv.configured_ca_bundle().as_ref()) +} + +fn build_rustls_root_store( + bundle: Option<&ConfiguredCaBundle>, +) -> Result { // Start from the platform roots so websocket callers keep the same baseline trust behavior // they would get from tungstenite's default rustls connector, then layer in the Codex custom // CA bundle on top when configured. @@ -271,11 +287,7 @@ fn build_rustls_client_config( } } - Ok(Arc::new( - ClientConfig::builder() - .with_root_certificates(root_store) - .with_no_client_auth(), - )) + Ok(root_store) } /// Builds a reqwest client using an injected environment source and reqwest builder. diff --git a/codex-rs/http-client/src/outbound_proxy.rs b/codex-rs/http-client/src/outbound_proxy.rs index 7668333c6a..c8831f60ea 100644 --- a/codex-rs/http-client/src/outbound_proxy.rs +++ b/codex-rs/http-client/src/outbound_proxy.rs @@ -265,7 +265,8 @@ impl HttpClientFactory { /// WebSocket schemes are resolved through their HTTP equivalents so platform PAC and system /// proxy APIs apply the same policy to `ws`/`wss` and `http`/`https` destinations. When system /// resolution is unavailable, explicit environment settings are resolved before falling back - /// to a direct route. + /// to a direct route. Ordered PAC candidates are collapsed to one route on Windows and macOS; + /// later proxy or `DIRECT` candidates are not retried after a connection failure. pub fn resolve_proxy_route(&self, request_url: &str) -> OutboundProxyRoute { resolve_proxy_route( &ProcessEnv, @@ -321,21 +322,6 @@ impl HttpClientFactory { cached_system_proxy_decision(&request_url) .map(|decision| route_from_system_decision(&ProcessEnv, env_proxy_kind, decision)) } - - /// Builds a reqwest client for a concrete outbound route. - pub(crate) fn build_reqwest_client( - &self, - builder: reqwest::ClientBuilder, - request_url: &str, - route_class: ClientRouteClass, - ) -> Result { - build_reqwest_client_for_route( - builder, - request_url, - route_class, - self.outbound_proxy_policy, - ) - } } fn resolve_proxy_route( @@ -457,29 +443,17 @@ impl From for io::Error { } } -/// Builds a reqwest client with conservative route selection and shared CA handling. -/// -/// Unavailable platform resolution falls back to environment proxies and then direct. Errors after -/// a route is selected are returned without trying another route. Ordered PAC candidates are -/// currently collapsed to one route on both Windows and macOS; later proxy or `DIRECT` candidates -/// are not retried after a connection failure. -fn build_reqwest_client_for_route( +/// Builds a reqwest client with the resolved route and shared CA handling. +pub(crate) fn build_reqwest_client_for_route( builder: reqwest::ClientBuilder, - request_url: &str, route_class: ClientRouteClass, - outbound_proxy_policy: OutboundProxyPolicy, + route: &OutboundProxyRoute, ) -> Result { - let builder = configure_proxy_for_route( - &ProcessEnv, - builder, - request_url, - route_class, - outbound_proxy_policy, - resolve_system_proxy, - )?; + let builder = configure_builder_for_resolved_route(builder, route_class, route)?; build_reqwest_client_with_custom_ca(builder).map_err(Into::into) } +#[cfg(test)] fn configure_proxy_for_route( env: &dyn EnvSource, builder: reqwest::ClientBuilder, diff --git a/codex-rs/http-client/src/route_aware_client_pool.rs b/codex-rs/http-client/src/route_aware_client_pool.rs index 905aaf8f0d..dee6fea261 100644 --- a/codex-rs/http-client/src/route_aware_client_pool.rs +++ b/codex-rs/http-client/src/route_aware_client_pool.rs @@ -295,6 +295,24 @@ impl RouteAwareClientPool { } } + /// Builds the first transport now so fixed-destination callers still receive setup errors. + #[cfg(windows)] + pub(crate) fn with_builder_and_initial_route( + http_client_factory: HttpClientFactory, + route_class: ClientRouteClass, + client_builder: HttpClientBuilder, + route: OutboundProxyRoute, + ) -> Result { + let mut pool = Self::with_builder(http_client_factory, route_class, client_builder); + let client = pool.client_builder_for_route().build_for_resolved_route( + &pool.http_client_factory, + pool.route_class, + &route, + )?; + pool.clients = Arc::new(Mutex::new(HashMap::from([(route, client)]))); + Ok(pool) + } + /// Starts a new account-scoped operation while sharing the existing connection cache. /// Capture this before resolving credentials or reading account-specific content. pub fn for_current_account(&self) -> Self { @@ -495,11 +513,7 @@ impl RouteAwareClientPool { return Ok((route, client.clone(), SelectedTlsBackend::TransportDefault)); } } - let client_builder = if self.follows_redirects_manually() { - self.client_builder.clone().without_redirects() - } else { - self.client_builder.clone() - }; + let client_builder = self.client_builder_for_route(); let pool = self.clone(); let build_route = route.clone(); let client = tokio::task::spawn_blocking(move || { @@ -553,6 +567,14 @@ impl RouteAwareClientPool { || self.rustls_clients.is_some()) } + fn client_builder_for_route(&self) -> HttpClientBuilder { + if self.follows_redirects_manually() { + self.client_builder.clone().without_redirects() + } else { + self.client_builder.clone() + } + } + fn rustls_client_for_route( &self, route: &OutboundProxyRoute, diff --git a/codex-rs/http-client/src/route_aware_client_pool/execution.rs b/codex-rs/http-client/src/route_aware_client_pool/execution.rs index 7ef0d362c4..1ddd4fe42d 100644 --- a/codex-rs/http-client/src/route_aware_client_pool/execution.rs +++ b/codex-rs/http-client/src/route_aware_client_pool/execution.rs @@ -9,10 +9,16 @@ use futures::future::BoxFuture; use http::header::PROXY_AUTHORIZATION; use super::RouteAwareClientPool; +#[cfg(windows)] +use super::RouteAwareClientPoolError; use super::RouteAwareRequestError; use super::SelectedTlsBackend; use crate::HttpResponse; use crate::OutboundProxyRoute; +#[cfg(windows)] +use crate::RouteFailureClass; +#[cfg(windows)] +use crate::client::TransportClient; use crate::client::apply_default_headers; use crate::request_draft::RequestDraft; use crate::route_aware_redirect::MAX_REDIRECTS; @@ -22,6 +28,8 @@ use crate::route_aware_redirect::redirect_request; use crate::route_aware_redirect::redirect_url; use crate::route_aware_redirect::remove_sensitive_headers; use crate::tls_backend_fallback::should_retry_with_rustls; +#[cfg(windows)] +use crate::windows_proxy_auth::ProxyPipe; impl RouteAwareClientPool { /// Executes requests through shared routing, destination, and redirect checks. @@ -108,15 +116,7 @@ impl RouteAwareClientPool { request.headers_mut().remove(PROXY_AUTHORIZATION); } previous_route = Some(current_route.clone()); - if let Some(timeout_deadline) = timeout_deadline { - let remaining = timeout_deadline - .checked_duration_since(tokio::time::Instant::now()) - .ok_or(RouteAwareRequestError::Timeout)?; - if remaining.is_zero() { - return Err(RouteAwareRequestError::Timeout); - } - *request.timeout_mut() = Some(remaining); - } + set_remaining_timeout(&mut request, timeout_deadline)?; let method = request.method().clone(); let headers = request.headers().clone(); let version = request.version(); @@ -126,28 +126,46 @@ impl RouteAwareClientPool { client.execute_without_request_logging(request).await } else { client.execute(request).await - }; - let response = match result { - Ok(response) => response, - Err(error) => { - let result = self - .retry_with_rustls( - ¤t_url, - ¤t_route, - selected_tls_backend, - replay.as_ref(), - error, - timeout_deadline, - ) - .await; - if follows_redirects_manually - && let Err(RouteAwareRequestError::Request(error)) = &result - { - client.log_error_summary(&request_method, &request_url, error); - } - result? + } + .map_err(RouteAwareRequestError::from); + let result = match result { + // A failed CONNECT identifies a proxy challenge. An origin 407 is a response. + #[cfg(windows)] + Err(error) + if error.is_connect() + && error.failure_class() + == Some(RouteFailureClass::ProxyAuthenticationRequired) => + { + self.retry_windows_proxy( + ¤t_route, + selected_tls_backend, + replay.as_ref(), + error, + timeout_deadline, + ) + .await } + Err(RouteAwareRequestError::Request(error)) => { + self.retry_with_rustls( + ¤t_route, + selected_tls_backend, + replay.as_ref(), + #[cfg(windows)] + /*proxy_pipe*/ + None, + error, + timeout_deadline, + ) + .await + } + result => result, }; + if follows_redirects_manually + && let Err(RouteAwareRequestError::Request(error)) = &result + { + client.log_error_summary(&request_method, &request_url, error); + } + let response = result?; let next_request = if follows_redirects_manually && is_redirect(response.status()) { redirect_url(&response).and_then(|next_url| { redirect_request( @@ -202,16 +220,20 @@ impl RouteAwareClientPool { pub(super) async fn retry_with_rustls( &self, - current_url: &reqwest::Url, current_route: &OutboundProxyRoute, selected_tls_backend: SelectedTlsBackend, replay: Option<&reqwest::Request>, + #[cfg(windows)] proxy_pipe: Option<&ProxyPipe>, error: reqwest::Error, timeout_deadline: Option, ) -> Result { let Some(rustls_clients) = self.rustls_clients.as_ref() else { return Err(error.into()); }; + let Some(replay) = replay else { + return Err(error.into()); + }; + let current_url = replay.url(); if current_url.scheme() != "https" || selected_tls_backend == SelectedTlsBackend::RustlsFallback @@ -220,25 +242,27 @@ impl RouteAwareClientPool { return Err(error.into()); } - let Some(mut retry_request) = replay.and_then(reqwest::Request::try_clone) else { + let Some(mut retry_request) = replay.try_clone() else { return Err(error.into()); }; - let mut fallback_client = match rustls_clients.client_for_route(current_route) { + let candidate = rustls_clients.client_for_route(current_route); + #[cfg(windows)] + let candidate = match proxy_pipe { + Some(pipe) => Some(self.windows_proxy_client( + current_route, + SelectedTlsBackend::RustlsFallback, + pipe, + )?), + None => candidate, + }; + let mut fallback_client = match candidate { Some(client) => client, None => self.rustls_client_for_route(current_route)?, }; fallback_client.request_logging = self.client_builder.request_logging; - if let Some(timeout_deadline) = timeout_deadline { - let remaining = timeout_deadline - .checked_duration_since(tokio::time::Instant::now()) - .ok_or(RouteAwareRequestError::Timeout)?; - if remaining.is_zero() { - return Err(RouteAwareRequestError::Timeout); - } - *retry_request.timeout_mut() = Some(remaining); - } + set_remaining_timeout(&mut retry_request, timeout_deadline)?; let response = if self.follows_redirects_manually() { fallback_client @@ -248,12 +272,103 @@ impl RouteAwareClientPool { fallback_client.execute(retry_request).await }?; - rustls_clients.remember(current_url, current_route, fallback_client); tracing::info!( event.name = "codex.http_client.tls_backend_fallback", "HTTP client switched to rustls after a TLS protocol negotiation failure" ); + // A Windows auth pipe serves this destination alone, so it cannot enter the route cache. + #[cfg(windows)] + if proxy_pipe.is_some() { + return Ok(response); + } + rustls_clients.remember(current_url, current_route, fallback_client); Ok(response) } + + #[cfg(windows)] + async fn retry_windows_proxy( + &self, + current_route: &OutboundProxyRoute, + selected_tls_backend: SelectedTlsBackend, + replay: Option<&reqwest::Request>, + error: RouteAwareRequestError, + timeout_deadline: Option, + ) -> Result { + let OutboundProxyRoute::Proxy { url, .. } = current_route else { + return Err(error); + }; + let Some(mut request) = replay.and_then(reqwest::Request::try_clone) else { + return Err(error); + }; + if request.url().scheme() != "https" { + return Err(error); + } + + let pipe = match crate::windows_proxy_auth::pipe_for( + url, + request.url(), + &self.client_builder.tls, + self.client_builder.connect_timeout, + ) + .await + { + Ok(pipe) => pipe, + Err(auth_error) if auth_error.kind() == io::ErrorKind::TimedOut => { + return Err(RouteAwareRequestError::Timeout); + } + Err(_) => return Err(error), + }; + let client = self.windows_proxy_client(current_route, selected_tls_backend, &pipe)?; + set_remaining_timeout(&mut request, timeout_deadline)?; + // Keep the listener alive through a possible TLS retry. + match client.execute_without_request_logging(request).await { + Ok(response) => Ok(response), + Err(error) => { + self.retry_with_rustls( + current_route, + selected_tls_backend, + replay, + Some(&pipe), + error, + timeout_deadline, + ) + .await + } + } + } + + #[cfg(windows)] + fn windows_proxy_client( + &self, + route: &OutboundProxyRoute, + backend: SelectedTlsBackend, + pipe: &ProxyPipe, + ) -> Result { + let mut builder = self + .client_builder + .clone() + .without_redirects() + .with_windows_proxy_pipe(pipe); + if backend == SelectedTlsBackend::RustlsFallback { + builder = builder.with_rustls_tls(); + } + builder + .build_for_resolved_route(&self.http_client_factory, self.route_class, route) + .map_err(Into::into) + } +} + +fn set_remaining_timeout( + request: &mut reqwest::Request, + deadline: Option, +) -> Result<(), RouteAwareRequestError> { + if let Some(deadline) = deadline { + let remaining = deadline + .checked_duration_since(tokio::time::Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or(RouteAwareRequestError::Timeout)?; + *request.timeout_mut() = Some(remaining); + } + Ok(()) } diff --git a/codex-rs/http-client/src/route_aware_tls_fallback_tests.rs b/codex-rs/http-client/src/route_aware_tls_fallback_tests.rs index dc716e3a77..8cdfae6663 100644 --- a/codex-rs/http-client/src/route_aware_tls_fallback_tests.rs +++ b/codex-rs/http-client/src/route_aware_tls_fallback_tests.rs @@ -141,10 +141,12 @@ async fn retries_a_native_tls_failure_after_another_request_caches_rustls() { let error = pool .retry_with_rustls( - &destination, &route, selected_tls_backend, Some(&replay), + #[cfg(windows)] + /*proxy_pipe*/ + None, error, /*timeout_deadline*/ None, ) diff --git a/codex-rs/http-client/src/windows_proxy_auth.rs b/codex-rs/http-client/src/windows_proxy_auth.rs index 117c1dc5cd..a41d8e3227 100644 --- a/codex-rs/http-client/src/windows_proxy_auth.rs +++ b/codex-rs/http-client/src/windows_proxy_auth.rs @@ -4,6 +4,11 @@ //! HTTPS proxies that require authentication tokens bound to their TLS connection //! are not supported because curl does not own that connection. +mod pipe; + +pub(crate) use pipe::ProxyPipe; +pub(crate) use pipe::pipe_for; + use std::io; use std::io::Cursor; use std::net::Ipv4Addr; @@ -34,6 +39,7 @@ use tokio::task::JoinSet; use tokio_rustls::TlsConnector; use url::Host; +use crate::OutboundProxyRoute; use crate::build_rustls_client_config_with_custom_ca; /// Reads and writes a TCP or TLS connection to the proxy. @@ -73,13 +79,13 @@ pub enum WindowsProxyTls { Custom(Arc), } -/// Opens a new connection to the selected proxy and authenticates a CONNECT tunnel. -/// Call only after the proxy rejects a CONNECT request with 407. -pub async fn connect_windows_proxy( - proxy_url: &str, - target: &Url, - proxy_tls: WindowsProxyTls, -) -> io::Result> { +pub(crate) fn can_authenticate_route(target: &str, route: &OutboundProxyRoute) -> bool { + matches!(route, OutboundProxyRoute::Proxy { url, .. } + if Url::parse(target).is_ok_and(|target| target.scheme() == "https") + && parse_proxy_url(url).is_ok()) +} + +fn parse_proxy_url(proxy_url: &str) -> io::Result { let proxy = Url::parse(proxy_url).map_err(|_| invalid_proxy())?; if !matches!(proxy.scheme(), "http" | "https") || !proxy.username().is_empty() @@ -87,6 +93,17 @@ pub async fn connect_windows_proxy( { return Err(invalid_proxy()); } + Ok(proxy) +} + +/// Opens a new connection to the selected proxy and authenticates a CONNECT tunnel. +/// Call only after the proxy rejects a CONNECT request with 407. +pub async fn connect_windows_proxy( + proxy_url: &str, + target: &Url, + proxy_tls: WindowsProxyTls, +) -> io::Result> { + let proxy = parse_proxy_url(proxy_url)?; let proxy_host = proxy.host_str().ok_or_else(invalid_proxy)?; let target_host = target.host_str().ok_or_else(invalid_proxy)?; let target_port = target.port_or_known_default().ok_or_else(invalid_proxy)?; diff --git a/codex-rs/http-client/src/windows_proxy_auth/pipe.rs b/codex-rs/http-client/src/windows_proxy_auth/pipe.rs new file mode 100644 index 0000000000..51ad09bd80 --- /dev/null +++ b/codex-rs/http-client/src/windows_proxy_auth/pipe.rs @@ -0,0 +1,121 @@ +//! Adapts an authenticated tunnel to reqwest's Windows named-pipe connector. +//! Each listener serves one proxy and destination; only an accepted connection can outlive it. + +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use reqwest::Url; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; +use tokio::net::windows::named_pipe::ServerOptions; +use tokio::task::JoinHandle; + +use super::WindowsProxyTls; +use super::connect_windows_proxy; +use crate::HttpClientTlsConfig; + +static NEXT_PIPE: AtomicU64 = AtomicU64::new(0); + +pub(crate) struct ProxyPipe { + path: PathBuf, + listener: JoinHandle<()>, +} + +impl ProxyPipe { + pub(crate) fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for ProxyPipe { + fn drop(&mut self) { + self.listener.abort(); + } +} + +/// Called only on a confirmed proxy CONNECT 407. A pipe cannot route an arbitrary URL: +/// reqwest sends TLS first, so the pipe must already know its proxy and destination. +pub(crate) async fn pipe_for( + proxy_url: &str, + target: &Url, + tls: &HttpClientTlsConfig, + connect_timeout: Option, +) -> io::Result { + let proxy_tls = if Url::parse(proxy_url).map_err(io::Error::other)?.scheme() == "https" { + tls.windows_proxy_tls()? + } else { + WindowsProxyTls::Default + }; + // Surface auth failure before reqwest starts TLS on the pipe and obscures the error. + let upstream = + connect_with_timeout(proxy_url, target, proxy_tls.clone(), connect_timeout).await?; + let id = NEXT_PIPE.fetch_add(1, Ordering::Relaxed); + let path = PathBuf::from(format!( + r"\\.\pipe\codex-proxy-auth-{}-{id}", + std::process::id(), + )); + let first = ServerOptions::new() + .first_pipe_instance(true) + .reject_remote_clients(true) + .create(&path)?; + let proxy_url = proxy_url.to_owned(); + let target = target.clone(); + let listen_path = path.clone(); + let listener = tokio::spawn(async move { + let mut next = first; + let mut first_upstream = Some(upstream); + while next.connect().await.is_ok() { + // Always prepare the next instance before serving this connection. + // Reqwest opens pipes without retrying ERROR_PIPE_BUSY. + let mut accepted = next; + let Ok(server) = ServerOptions::new() + .reject_remote_clients(true) + .create(&listen_path) + else { + break; + }; + next = server; + let proxy_url = proxy_url.clone(); + let target = target.clone(); + let proxy_tls = proxy_tls.clone(); + let connected = first_upstream.take(); + tokio::spawn(async move { + let result = async { + let mut upstream = match connected { + Some(upstream) => upstream, + None => { + connect_with_timeout(&proxy_url, &target, proxy_tls, connect_timeout) + .await? + } + }; + tokio::io::copy_bidirectional(&mut accepted, &mut upstream).await?; + Ok::<(), io::Error>(()) + } + .await; + if let Err(error) = result { + tracing::debug!(kind = ?error.kind(), "Windows proxy pipe ended"); + } + }); + } + }); + Ok(ProxyPipe { path, listener }) +} + +async fn connect_with_timeout( + proxy_url: &str, + target: &Url, + proxy_tls: WindowsProxyTls, + connect_timeout: Option, +) -> io::Result> { + let connect = connect_windows_proxy(proxy_url, target, proxy_tls); + match connect_timeout { + Some(timeout) => tokio::time::timeout(timeout, connect).await.map_err(|_| { + io::Error::new(io::ErrorKind::TimedOut, "proxy authentication timed out") + })?, + None => connect.await, + } +} diff --git a/codex-rs/websocket-client/src/dialer.rs b/codex-rs/websocket-client/src/dialer.rs index e705bf891d..3b84aad9f5 100644 --- a/codex-rs/websocket-client/src/dialer.rs +++ b/codex-rs/websocket-client/src/dialer.rs @@ -6,7 +6,11 @@ use std::sync::Arc; use std::time::Duration; use codex_http_client::OutboundProxyRoute; +#[cfg(windows)] +use codex_http_client::WindowsProxyTls; use codex_http_client::build_rustls_client_config_with_custom_ca; +#[cfg(windows)] +use codex_http_client::connect_windows_proxy; use futures::StreamExt; use futures::stream::FuturesUnordered; use rustls::ClientConfig; @@ -26,6 +30,8 @@ use tokio_tungstenite::tungstenite::handshake::client::Request; use tokio_tungstenite::tungstenite::handshake::client::Response; use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; use tokio_tungstenite::tungstenite::proxy::ProxyConfig; +#[cfg(windows)] +use tokio_tungstenite::tungstenite::proxy::ProxyScheme; use crate::AsyncIo; use crate::ConnectionInner; @@ -60,6 +66,21 @@ pub(crate) async fn connect( url, no_proxy: None, } => Some(url), + // Use the explicit CONNECT path so it can retry a proxy 407. + #[cfg(windows)] + OutboundProxyRoute::Proxy { + url, + no_proxy: Some(_), + } if matches!( + (ProxyConfig::from_env(request.uri()), ProxyConfig::parse(&url)), + (Ok(Some(selected)), Ok(route)) + if selected.scheme == ProxyScheme::Http + && selected.auth.is_none() + && selected == route + ) => + { + Some(url) + } OutboundProxyRoute::Proxy { url, no_proxy: Some(_), @@ -120,7 +141,31 @@ pub(crate) async fn connect( } else { Box::new(stream) }; - connect_via_proxy(stream, &proxy.config, host, port).await? + match connect_via_proxy(stream, &proxy.config, host, port).await { + Ok(stream) => stream, + #[cfg(windows)] + Err(WebSocketError::Url(UrlError::ProxyConnect(message))) + if proxy.config.scheme == ProxyScheme::Http + && proxy.config.auth.is_none() + && message == "HTTP CONNECT failed with status 407" => + { + // Reconnect so curl can authenticate the proxy tunnel. + let target = url::Url::parse(&request.uri().to_string()) + .map_err(|_| WebSocketError::Url(UrlError::UnsupportedUrlScheme))?; + Box::new( + connect_windows_proxy( + &url, + &target, + tls_config + .clone() + .map_or(WindowsProxyTls::Default, WindowsProxyTls::Custom), + ) + .await + .map_err(WebSocketError::Io)?, + ) + } + Err(error) => return Err(error), + } } };