mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user