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
This commit is contained in:
stevenlee-oai
2026-08-04 22:18:09 +00:00
committed by copyberry
parent bf05737da4
commit 411f3f0680
4 changed files with 198 additions and 4 deletions
@@ -19,6 +19,24 @@ struct ChatGptCloudflareCookieStore {
jar: Jar,
}
pub(crate) struct ChatGptCookieStore {
cloudflare: Arc<ChatGptCloudflareCookieStore>,
cookies: Vec<HeaderValue>,
}
impl ChatGptCookieStore {
pub(crate) fn new(cookies: Vec<HeaderValue>) -> 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<Item = &HeaderValue>,
url: &reqwest::Url,
) {
self.cloudflare.set_cookies(cookie_headers, url);
}
fn cookies(&self, url: &reqwest::Url) -> Option<HeaderValue> {
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();
+19 -3
View File
@@ -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<Duration>,
chatgpt_cloudflare_cookie_store: bool,
chatgpt_cookie_store: Option<Arc<ChatGptCookieStore>>,
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<HttpClient, BuildRouteAwareHttpClientError> {
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<HttpClient, BuildRouteAwareHttpClientError> {
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,
}
}
+48 -1
View File
@@ -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<Arc<ChatGptCookieStore>>,
}
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<Item = HeaderValue>) -> Self {
let cookies = cookies.into_iter().collect::<Vec<_>>();
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<Arc<ChatGptCookieStore>> {
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
@@ -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 {