Share ChatGPT cookies between HTTP and WebSocket transports (#46506)

## Why

WebSocket handshakes did not reuse the HTTP cookie store or retain response cookies, so routing cookies such as `__oailb` were unavailable to subsequent connections.

## What changed

- Reuse the HTTP factory's ChatGPT cookies for secure WebSocket handshakes, preserving explicit `Cookie` headers and marking generated headers sensitive.
- Retain allowlisted infrastructure cookies from both successful and rejected upgrades. Keep configured cookies scoped to their factory and exclude account and session cookies from the shared store.
- Apply HTTPS cookie scope to `wss` requests, preserving host and path restrictions and excluding insecure `ws` requests and non-ChatGPT hosts.

## Testing

Add HTTP/WebSocket cookie-sharing coverage and local TLS handshake tests for routing-cookie reuse across connectors, rejected-upgrade refreshes, explicit header precedence, cookie scope, session-cookie exclusion, and deletion.

GitOrigin-RevId: 6bd6afe16e97cf9758ca7ba207a4e88c969497a4
This commit is contained in:
jif
2026-09-18 23:17:17 +00:00
committed by copyberry
parent cc7591646e
commit 12acac2a66
3 changed files with 331 additions and 5 deletions
@@ -1,10 +1,17 @@
//! ChatGPT cookies shared by HTTP and WebSocket transports. Only infrastructure cookies may be
//! stored globally; configured cookies remain scoped to their factory.
use std::sync::Arc;
use std::sync::LazyLock;
use http::HeaderMap;
use http::HeaderValue;
use http::Uri;
use http::header::SET_COOKIE;
use reqwest::cookie::CookieStore;
use reqwest::cookie::Jar;
use reqwest::header::HeaderValue;
use crate::HttpClientFactory;
use crate::chatgpt_hosts::is_allowed_chatgpt_host;
// WARNING: this HTTP cookie store is process-global and may be shared across auth contexts.
@@ -96,6 +103,38 @@ impl CookieStore for ChatGptCookieStore {
}
}
impl HttpClientFactory {
/// Returns cookies for a ChatGPT HTTPS or WSS request, using the same store as HTTP clients.
/// Explicit request Cookie headers should take precedence over this value.
pub fn chatgpt_cookie_header(&self, uri: &Uri) -> Option<HeaderValue> {
let url = chatgpt_cookie_url(uri)?;
let mut cookies = match self.chatgpt_cookie_store() {
Some(store) => store.cookies(&url),
None => SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE.cookies(&url),
}?;
cookies.set_sensitive(true);
Some(cookies)
}
/// Retains only allowlisted infrastructure cookies from a ChatGPT HTTPS or WSS response.
/// Account and session cookies are never added to the shared store.
pub fn store_chatgpt_response_cookies(&self, uri: &Uri, headers: &HeaderMap) {
if let Some(url) = chatgpt_cookie_url(uri) {
SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE
.set_cookies(&mut headers.get_all(SET_COOKIE).iter(), &url);
}
}
}
fn chatgpt_cookie_url(uri: &Uri) -> Option<reqwest::Url> {
let mut url = reqwest::Url::parse(&uri.to_string()).ok()?;
// A secure WebSocket handshake has the same cookie scope as HTTPS.
if url.scheme() == "wss" {
url.set_scheme("https").ok()?;
}
is_chatgpt_cookie_url(&url).then_some(url)
}
/// Adds the process-local ChatGPT infrastructure cookie jar used by Codex HTTP clients.
///
/// WARNING: this jar is global within the process. It is only acceptable because it hardcodes a
@@ -177,9 +216,52 @@ fn is_allowed_cloudflare_cookie_name(name: &str) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::OutboundProxyPolicy;
use pretty_assertions::assert_eq;
use reqwest::cookie::CookieStore;
#[test]
fn http_and_websocket_cookies_share_the_factory_store() {
let factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault)
.with_chatgpt_cookies([HeaderValue::from_static("configured=owner")]);
let store = factory.chatgpt_cookie_store().unwrap();
let https = reqwest::Url::parse("https://http-websocket-cookies.chatgpt.com/api").unwrap();
let wss: Uri = "wss://http-websocket-cookies.chatgpt.com/api"
.parse()
.unwrap();
let cookie = HeaderValue::from_static("__oailb=from-http; Path=/; Secure");
store.set_cookies(&mut std::iter::once(&cookie), &https);
let header = factory.chatgpt_cookie_header(&wss).unwrap();
assert_eq!(header, "__oailb=from-http; configured=owner");
assert!(header.is_sensitive());
let mut response_headers = HeaderMap::new();
response_headers.insert(
SET_COOKIE,
HeaderValue::from_static("__oailb=from-wss; Path=/; Secure"),
);
factory.store_chatgpt_response_cookies(&wss, &response_headers);
assert_eq!(
store.cookies(&https),
Some(HeaderValue::from_static(
"__oailb=from-wss; configured=owner"
))
);
assert_eq!(
factory.chatgpt_cookie_header(
&"ws://http-websocket-cookies.chatgpt.com/api"
.parse()
.unwrap()
),
None
);
assert_eq!(
factory.chatgpt_cookie_header(&"wss://api.openai.com/api".parse().unwrap()),
None
);
}
#[test]
fn additional_cookies_use_current_path_scoped_cloudflare_cookies() {
let cloudflare = Arc::new(ChatGptCloudflareCookieStore::default());