From 411f3f068060a191fc8f6f677718fd1694ab2d3d Mon Sep 17 00:00:00 2001 From: stevenlee-oai Date: Tue, 4 Aug 2026 22:10:39 +0000 Subject: [PATCH] Support configured ChatGPT cookies in HTTP clients (#36984) ## What changed - Let `HttpClientFactory` carry additional ChatGPT cookies and share their store across cloned factories. - Attach configured cookies when route-aware clients or explicitly opted-in clients enable the ChatGPT cookie store. - Combine configured cookies with the current path-scoped Cloudflare cookies only for HTTPS ChatGPT hosts, while preserving sensitive-header metadata. ## Testing - Cover cookie merging, path-scoped Cloudflare updates, host and scheme restrictions, sensitive values, and factory clone/equality behavior. GitOrigin-RevId: 5baf745859e14db739fd62ab931720dbe90c04e0 --- .../src/chatgpt_cloudflare_cookies.rs | 109 ++++++++++++++++++ codex-rs/http-client/src/client_builder.rs | 22 +++- codex-rs/http-client/src/outbound_proxy.rs | 49 +++++++- .../http-client/src/outbound_proxy_tests.rs | 22 ++++ 4 files changed, 198 insertions(+), 4 deletions(-) diff --git a/codex-rs/http-client/src/chatgpt_cloudflare_cookies.rs b/codex-rs/http-client/src/chatgpt_cloudflare_cookies.rs index f63763dad4..a2abde31a9 100644 --- a/codex-rs/http-client/src/chatgpt_cloudflare_cookies.rs +++ b/codex-rs/http-client/src/chatgpt_cloudflare_cookies.rs @@ -19,6 +19,24 @@ struct ChatGptCloudflareCookieStore { jar: Jar, } +pub(crate) struct ChatGptCookieStore { + cloudflare: Arc, + cookies: Vec, +} + +impl ChatGptCookieStore { + pub(crate) fn new(cookies: Vec) -> Self { + Self { + cloudflare: Arc::clone(&SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE), + cookies, + } + } + + pub(crate) fn configured_cookies(&self) -> &[HeaderValue] { + &self.cookies + } +} + impl CookieStore for ChatGptCloudflareCookieStore { fn set_cookies( &self, @@ -43,6 +61,41 @@ impl CookieStore for ChatGptCloudflareCookieStore { } } +impl CookieStore for ChatGptCookieStore { + fn set_cookies( + &self, + cookie_headers: &mut dyn Iterator, + url: &reqwest::Url, + ) { + self.cloudflare.set_cookies(cookie_headers, url); + } + + fn cookies(&self, url: &reqwest::Url) -> Option { + if !is_chatgpt_cookie_url(url) { + return self.cloudflare.cookies(url); + } + let cloudflare = self.cloudflare.cookies(url); + let mut sensitive = cloudflare.as_ref().is_some_and(HeaderValue::is_sensitive); + let mut cookies = cloudflare + .map(|cloudflare| cloudflare.as_bytes().to_vec()) + .unwrap_or_default(); + for cookie in &self.cookies { + sensitive |= cookie.is_sensitive(); + if !cookies.is_empty() { + cookies.extend_from_slice(b"; "); + } + cookies.extend_from_slice(cookie.as_bytes()); + } + if cookies.is_empty() { + None + } else { + let mut header = HeaderValue::from_bytes(&cookies).ok()?; + header.set_sensitive(sensitive); + Some(header) + } + } +} + /// Adds the process-local ChatGPT Cloudflare cookie jar used by Codex HTTP clients. /// /// WARNING: this jar is global within the process. It is only acceptable because it hardcodes a @@ -124,6 +177,62 @@ mod tests { use pretty_assertions::assert_eq; use reqwest::cookie::CookieStore; + #[test] + fn additional_cookies_use_current_path_scoped_cloudflare_cookies() { + let cloudflare = Arc::new(ChatGptCloudflareCookieStore::default()); + let mut sensitive_cookie = HeaderValue::from_static("additional=true"); + sensitive_cookie.set_sensitive(true); + let store = ChatGptCookieStore { + cloudflare: Arc::clone(&cloudflare), + cookies: vec![ + sensitive_cookie, + HeaderValue::from_static("another=enabled"), + ], + }; + let first_url = reqwest::Url::parse("https://cookies.chatgpt.com/a").unwrap(); + let second_url = reqwest::Url::parse("https://cookies.chatgpt.com/b").unwrap(); + let affinity = HeaderValue::from_static("__cflb=current; Path=/a; Secure"); + cloudflare.set_cookies(&mut std::iter::once(&affinity), &first_url); + + assert_eq!( + store.cookies(&first_url), + Some(HeaderValue::from_static( + "__cflb=current; additional=true; another=enabled" + )) + ); + assert!(store.cookies(&first_url).unwrap().is_sensitive()); + assert_eq!( + store.cookies(&second_url), + Some(HeaderValue::from_static("additional=true; another=enabled")) + ); + + let expired = HeaderValue::from_static("__cflb=; Max-Age=0; Path=/a; Secure"); + store.set_cookies(&mut std::iter::once(&expired), &first_url); + assert_eq!( + store.cookies(&first_url), + Some(HeaderValue::from_static("additional=true; another=enabled")) + ); + } + + #[test] + fn additional_cookies_are_restricted_to_https_chatgpt_hosts() { + let store = ChatGptCookieStore { + cloudflare: Arc::new(ChatGptCloudflareCookieStore::default()), + cookies: vec![HeaderValue::from_static("additional=true")], + }; + + for url in ["http://chatgpt.com/a", "https://api.openai.com/a"] { + let url = reqwest::Url::parse(url).unwrap(); + assert_eq!(store.cookies(&url), None); + } + let url = reqwest::Url::parse("https://chatgpt.com/a").unwrap(); + assert_eq!( + store.cookies(&url), + Some(HeaderValue::from_static("additional=true")) + ); + assert!(!store.cookies(&url).unwrap().is_sensitive()); + } + #[test] fn stores_and_returns_cloudflare_cookies_for_chatgpt_hosts() { let store = ChatGptCloudflareCookieStore::default(); diff --git a/codex-rs/http-client/src/client_builder.rs b/codex-rs/http-client/src/client_builder.rs index 79f58a7cf2..7cd0b4db2d 100644 --- a/codex-rs/http-client/src/client_builder.rs +++ b/codex-rs/http-client/src/client_builder.rs @@ -6,6 +6,7 @@ //! paths. use http::HeaderMap; +use std::sync::Arc; use std::time::Duration; use crate::BuildCustomCaTransportError; @@ -14,6 +15,7 @@ use crate::ClientRouteClass; use crate::HttpClient; use crate::HttpClientFactory; use crate::OutboundProxyRoute; +use crate::chatgpt_cloudflare_cookies::ChatGptCookieStore; use crate::client::RequestLogging; use crate::custom_ca::build_reqwest_client_with_custom_ca; use crate::with_chatgpt_cloudflare_cookie_store; @@ -29,6 +31,7 @@ pub struct HttpClientBuilder { follow_redirects: bool, connect_timeout: Option, chatgpt_cloudflare_cookie_store: bool, + chatgpt_cookie_store: Option>, request_logging: RequestLogging, } @@ -93,6 +96,13 @@ impl HttpClientBuilder { self } + /// Uses the factory's configured ChatGPT cookies without changing proxy behavior. + pub fn with_chatgpt_cookies(mut self, http_client_factory: &HttpClientFactory) -> Self { + self.chatgpt_cloudflare_cookie_store = true; + self.chatgpt_cookie_store = http_client_factory.chatgpt_cookie_store(); + self + } + /// Suppresses request URL and response-header diagnostics. pub fn without_request_logging(mut self) -> Self { self.request_logging = RequestLogging::Disabled; @@ -105,11 +115,12 @@ impl HttpClientBuilder { /// resolve a concrete direct or proxy route when the factory is configured with /// [`crate::OutboundProxyPolicy::RespectSystemProxy`]. pub fn build_respecting_outbound_proxy_policy( - self, + mut self, http_client_factory: &HttpClientFactory, request_url: &str, route_class: ClientRouteClass, ) -> Result { + self.chatgpt_cookie_store = http_client_factory.chatgpt_cookie_store(); let (builder, request_logging) = self.into_reqwest_parts(); let inner = http_client_factory.build_reqwest_client(builder, request_url, route_class)?; Ok(HttpClient::from_parts(inner, request_logging)) @@ -117,11 +128,12 @@ impl HttpClientBuilder { /// Builds a client for a route that was already resolved by a route-aware caller. pub(crate) fn build_for_resolved_route( - self, + mut self, http_client_factory: &HttpClientFactory, route_class: ClientRouteClass, route: &OutboundProxyRoute, ) -> Result { + self.chatgpt_cookie_store = http_client_factory.chatgpt_cookie_store(); let (builder, request_logging) = self.into_reqwest_parts(); let inner = http_client_factory.build_reqwest_client_for_resolved_route( builder, @@ -258,7 +270,10 @@ impl HttpClientBuilder { builder = builder.connect_timeout(connect_timeout); } if self.chatgpt_cloudflare_cookie_store { - builder = with_chatgpt_cloudflare_cookie_store(builder); + builder = match self.chatgpt_cookie_store { + Some(store) => builder.cookie_provider(store), + None => with_chatgpt_cloudflare_cookie_store(builder), + }; } builder } @@ -271,6 +286,7 @@ impl Default for HttpClientBuilder { follow_redirects: true, connect_timeout: None, chatgpt_cloudflare_cookie_store: false, + chatgpt_cookie_store: None, request_logging: RequestLogging::Enabled, } } diff --git a/codex-rs/http-client/src/outbound_proxy.rs b/codex-rs/http-client/src/outbound_proxy.rs index 93c85bb12f..ee2458ae31 100644 --- a/codex-rs/http-client/src/outbound_proxy.rs +++ b/codex-rs/http-client/src/outbound_proxy.rs @@ -8,6 +8,7 @@ use std::borrow::Cow; use std::collections::HashMap; use std::fmt; use std::io; +use std::sync::Arc; use std::sync::Mutex; use std::sync::OnceLock; use std::time::Duration; @@ -15,6 +16,9 @@ use std::time::Instant; #[cfg(any(target_os = "windows", target_os = "macos"))] use tokio::sync::Semaphore; +use http::HeaderValue; + +use crate::chatgpt_cloudflare_cookies::ChatGptCookieStore; use crate::custom_ca::BuildCustomCaTransportError; use crate::custom_ca::build_reqwest_client_with_custom_ca; use sha2::Digest; @@ -138,9 +142,34 @@ impl fmt::Debug for OutboundProxyRoute { /// Construct this once from the effective application configuration and carry it with the /// session or component that owns outbound requests. Individual request paths should supply only /// their destination and route class rather than resolving feature state themselves. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone)] pub struct HttpClientFactory { outbound_proxy_policy: OutboundProxyPolicy, + chatgpt_cookie_store: Option>, +} + +impl PartialEq for HttpClientFactory { + fn eq(&self, other: &Self) -> bool { + self.outbound_proxy_policy == other.outbound_proxy_policy + && self + .chatgpt_cookie_store + .as_ref() + .map(|store| store.configured_cookies()) + == other + .chatgpt_cookie_store + .as_ref() + .map(|store| store.configured_cookies()) + } +} + +impl Eq for HttpClientFactory {} + +impl fmt::Debug for HttpClientFactory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HttpClientFactory") + .field("outbound_proxy_policy", &self.outbound_proxy_policy) + .finish() + } } impl HttpClientFactory { @@ -148,9 +177,27 @@ impl HttpClientFactory { pub const fn new(outbound_proxy_policy: OutboundProxyPolicy) -> Self { Self { outbound_proxy_policy, + chatgpt_cookie_store: None, } } + /// Adds process-scoped cookies to requests made by ChatGPT cookie-store clients. + pub fn with_chatgpt_cookies(mut self, cookies: impl IntoIterator) -> Self { + let cookies = cookies.into_iter().collect::>(); + self.chatgpt_cookie_store = + (!cookies.is_empty()).then(|| Arc::new(ChatGptCookieStore::new(cookies))); + self + } + + /// Returns whether ChatGPT cookie-store clients have additional configured cookies. + pub fn has_chatgpt_cookies(&self) -> bool { + self.chatgpt_cookie_store.is_some() + } + + pub(crate) fn chatgpt_cookie_store(&self) -> Option> { + self.chatgpt_cookie_store.clone() + } + /// Returns the outbound proxy policy used for clients built by this factory. pub const fn outbound_proxy_policy(&self) -> OutboundProxyPolicy { self.outbound_proxy_policy diff --git a/codex-rs/http-client/src/outbound_proxy_tests.rs b/codex-rs/http-client/src/outbound_proxy_tests.rs index 2f4b7fbf0b..d178296a0c 100644 --- a/codex-rs/http-client/src/outbound_proxy_tests.rs +++ b/codex-rs/http-client/src/outbound_proxy_tests.rs @@ -119,6 +119,28 @@ fn read_http_message(stream: &mut impl Read) -> String { String::from_utf8_lossy(&buffer).into_owned() } +#[test] +fn cloned_factories_share_chatgpt_cookie_stores_without_changing_value_equality() { + let factory = + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault).with_chatgpt_cookies([ + HeaderValue::from_static("first=true"), + HeaderValue::from_static("second=true"), + ]); + let cloned = factory.clone(); + + assert!(Arc::ptr_eq( + &factory.chatgpt_cookie_store().expect("configured store"), + &cloned.chatgpt_cookie_store().expect("shared store"), + )); + assert_eq!( + factory, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault).with_chatgpt_cookies([ + HeaderValue::from_static("first=true"), + HeaderValue::from_static("second=true"), + ]) + ); +} + #[test] fn websocket_route_uses_http_equivalent_for_system_resolution() { let env = MapEnv {