diff --git a/docs/OPENAI_CHAIN_PARITY_REAUDIT.md b/docs/OPENAI_CHAIN_PARITY_REAUDIT.md index c9faff1ae..48bdc9b15 100644 --- a/docs/OPENAI_CHAIN_PARITY_REAUDIT.md +++ b/docs/OPENAI_CHAIN_PARITY_REAUDIT.md @@ -30,7 +30,7 @@ Rust 仓库:`/home/zyy/Codes/codex-proxy-rs` - `/v1/responses` 请求构造仍存在非 `/v1` alias route、`use_websocket:false` 本地扩展等差异;`/v1/responses/review` 强制 review subagent 和 `/v1/responses/compact` 已补齐。 - response 转换和错误恢复仍缺最终错误外壳对齐;tuple schema request conversion/reconvert、streaming premature close 合成、reasoning replay、implicit resume failure restore、`response.failed`/`error` 的 429/402/403 分类、账号 fallback、`previous_response_not_found`/unanswered function call strip-and-retry、5xx same-account retry、CF path-block 404、model unsupported fallback、401 token invalid fallback 已补齐。 - session affinity 的显式续链、implicit resume 主路径、implicit resume failure restore、reasoning replay cache、function call continuation 预检、variant_hash 基础算法、variant identity 和 OpenAI chat `user` conversation identity 已接入。 -- rate-limit/usage/cookie 持久化主路径已接近;dirty quota verification、失败请求持久计数和 image_generation usage 已补齐,剩余差异集中在 Cookie domain/path、quota window reset 细节和双方都未解析的 active-limit header。 +- rate-limit/usage/cookie 持久化主路径已接近;dirty quota verification、失败请求持久计数、image_generation usage 和 Cookie domain/path 语义已补齐,剩余差异集中在 quota window reset 细节和双方都未解析的 active-limit header。 明确不纳入缺口:IP 代理、VPN、本地代理探测、`HttpsProxyAgent`、账号代理池。 @@ -467,7 +467,9 @@ Rust 证据: Rust 证据: - `src/codex/accounts/cookies/repository.rs:1-111` cookie 存在 SQLite `account_cookies`,value 加密,唯一键为 `(account_id, domain, name, path)`;自动捕获白名单只有 `cf_clearance`。 -- `src/codex/accounts/cookies/repository.rs:70-102` 构造 Cookie header 时按 domain 过滤并跳过过期 cookie;`delete_account_cookies()` 支持按账号清理。 +- `src/codex/accounts/cookies/repository.rs:70-130` 构造 Cookie header 时按 domain/path 过滤并跳过过期 cookie,同名 cookie 会按更长 path 优先输出;`delete_account_cookies()` 支持按账号清理。 +- 本阶段修正后,`src/codex/gateway/transport/endpoints.rs` 集中定义 Codex upstream endpoint URL/path 规则;HTTP SSE、compact、WebSocket、dirty quota usage 和账号 usage 查询读取 Cookie 时都使用真实 upstream request path。 +- 本阶段修正后,`tests/codex_accounts/cookie_store.rs::cookie_repository_should_scope_cookie_replay_by_domain_and_path` 覆盖 repository 层 domain/path 过滤和同名 cookie 的长 path 优先;`tests/codex_serving/responses_http_sse.rs::v1_responses_should_scope_upstream_cookie_by_codex_response_path` 先以只带根路径 Cookie 失败,再修复为 `/codex/responses` 请求携带 `/codex` 与根路径 Cookie。 - 本轮修正后,`src/codex/accounts/cookies/repository.rs` 提供 `cleanup_expired(now)`,会按现有 RFC2822/RFC3339 过期解析规则持久删除过期 cookie;`src/codex/tasks/cookie_cleanup.rs` 每 5 分钟运行一次清理,并在 `src/runtime/tasks/coordinator.rs` 中随后台任务启动。 - `src/platform/storage/schema.sql:68-83` `account_usage` 已持久化 `window_request_count`、`window_input_tokens`、`window_output_tokens`、`window_cached_tokens`、`window_started_at`、`window_reset_at`、`limit_window_seconds`、`last_used_at`。 - 本轮修正后,`src/platform/storage/schema.sql` 的 `accounts.quota_verify_required` 可持久化本地 quota reset 后的 dirty verification 标记,并通过 `AccountRepository::list_pool_accounts()` 恢复到运行时 `Account`。 @@ -497,6 +499,7 @@ Rust 证据: - Cookie 自动捕获白名单与原版一致,只自动保存 `cf_clearance`,不会保存 `__cf_bm`。 - Rust 使用 SQLite + AES 加密保存 cookie,比原版 JSON 文件更适合当前数据库架构;主请求链路能注入 Cookie 并捕获 `Set-Cookie`。 - Cookie 过期清理已对齐原版长期运行语义:读取时跳过过期 cookie,同时后台调度器每 5 分钟持久删除过期行;`cookie_repository_cleanup_expired_cookies_should_delete_only_expired_rows` 与 `cookie_cleanup_scheduler_should_delete_expired_cookie_rows` 覆盖 repository 和 scheduler 两层。 +- Cookie domain/path 精细化已确认为 Rust 新架构选择:原版 per-account map 不做浏览器式 path 过滤,Rust 在无历史负担下保留 SQLite `(account_id, domain, name, path)` 唯一键,并按真实 upstream request path 选择 Cookie。这不是回退到原版简单 map 的缺口。 - primary/secondary/code_review rate-limit header 和 `codex.rate_limits` event 的解析与 quota 归一化主路径基本对齐,并能保留已有 credits。 - usage input/output/cached token 的成功路径持久化、窗口统计和重启恢复已对齐到可用水平。 - SSE usage 提取已对齐原版最终 usage 语义,不再对多个事件里重复出现的同一份 usage 做累计;`extract_sse_usage_should_use_completed_response_usage_without_merging_earlier_usage` 覆盖该行为。 @@ -508,12 +511,10 @@ Rust 证据: - image_generation usage 已对齐原版主语义:`tool_usage.image_gen` token 与 host-model token 分开累计;请求声明 `tools: [{type: "image_generation"}]` 且 `image_output_tokens > 0` 记成功,否则记失败;total/window 字段都能持久化并恢复到运行时账号。覆盖测试包括 `extract_usage_reads_image_generation_tool_usage_separately`、`extract_sse_usage_should_read_completed_image_generation_tool_usage`、`sqlite_schema_should_persist_image_generation_usage_columns`、`account_repository_should_accumulate_image_generation_usage_counters`、`account_repository_should_restore_window_usage_into_runtime_pool_accounts`、`v1_responses_should_record_image_generation_usage_when_tool_succeeds`、`v1_responses_should_record_failed_image_generation_attempt_when_tool_has_no_output`。 未完全对齐: -- Cookie domain/path 语义不同。原版 CookieJar 是 per-account 简单 map,不按 domain/path 过滤;Rust 按 domain/path 存储和匹配。对 `chatgpt.com` 主链路更严格,但不是原版逐字行为。 - quota window reset 细节不完全一致。原版 reset 后设置 `window_counters_reset_at`;Rust 设置 `window_started_at` 并触发 `quota_verify_required`,但没有 `window_counters_reset_at` 字段。 - passive header 文档提到 `x-codex-active-limit`,原版和 Rust 当前都没有实际解析该 header;如果 Codex Desktop 当前依赖 active-limit,这两边都需要重新确认。 缺口/后续动作: -- 明确 cookie domain/path 精细化是 Rust 新架构选择还是需要回到原版 per-account map;如果保留,应补 domain/path 行为测试。 - 若 OpenAI/Codex 当前返回 `x-codex-active-limit`,补解析和 quota_json 存储;否则将其记录为双方未使用字段。 ## 10. session affinity、implicit resume、reasoning replay diff --git a/src/codex/accounts/cookies/repository.rs b/src/codex/accounts/cookies/repository.rs index f73f86ae2..c17c24f8f 100644 --- a/src/codex/accounts/cookies/repository.rs +++ b/src/codex/accounts/cookies/repository.rs @@ -73,9 +73,19 @@ impl CookieRepository { &self, account_id: &str, request_domain: &str, + ) -> CookieResult> { + self.cookie_header_for_request(account_id, request_domain, "/") + .await + } + + pub async fn cookie_header_for_request( + &self, + account_id: &str, + request_domain: &str, + request_path: &str, ) -> CookieResult> { let rows = sqlx::query( - "select domain, name, value_cipher, expires_at from account_cookies where account_id = ? order by name asc", + "select domain, name, value_cipher, path, expires_at from account_cookies where account_id = ?", ) .bind(account_id) .fetch_all(&self.pool) @@ -87,18 +97,38 @@ impl CookieRepository { if !domain_matches(request_domain, &domain) { continue; } + let path = row.get::("path"); + if !path_matches(request_path, &path) { + continue; + } if cookie_is_expired(row.get::, _>("expires_at").as_deref(), now) { continue; } let name = row.get::("name"); let value_cipher = row.get::("value_cipher"); let value = self.secret_box.decrypt(&value_cipher)?; - pairs.push(format!("{name}={}", value.expose_secret())); + pairs.push(CookieHeaderPair { + path_len: path.len(), + name: name.clone(), + value: format!("{name}={}", value.expose_secret()), + }); } if pairs.is_empty() { Ok(None) } else { - Ok(Some(pairs.join("; "))) + pairs.sort_by(|left, right| { + right + .path_len + .cmp(&left.path_len) + .then_with(|| left.name.cmp(&right.name)) + }); + Ok(Some( + pairs + .into_iter() + .map(|pair| pair.value) + .collect::>() + .join("; "), + )) } } @@ -139,6 +169,12 @@ struct ParsedCookie { expires_at: Option, } +struct CookieHeaderPair { + path_len: usize, + name: String, + value: String, +} + fn parse_set_cookie(raw: &str) -> Option { let mut parts = raw.split(';').map(str::trim); let (name, value) = parts.next()?.split_once('=')?; @@ -151,7 +187,7 @@ fn parse_set_cookie(raw: &str) -> Option { }; match attribute.trim().to_ascii_lowercase().as_str() { "domain" => domain = value.trim_start_matches('.').to_string(), - "path" => path = value.to_string(), + "path" => path = normalize_cookie_path(value), "max-age" => { if let Ok(seconds) = value.parse::() { expires_at = Some(max_age_expires_at(seconds)); @@ -207,6 +243,36 @@ fn domain_matches(request_domain: &str, cookie_domain: &str) -> bool { .is_some_and(|prefix| prefix.ends_with('.')) } +fn normalize_cookie_path(path: &str) -> String { + let path = path.trim(); + if path.starts_with('/') { + path.to_string() + } else { + "/".to_string() + } +} + +fn path_matches(request_path: &str, cookie_path: &str) -> bool { + let request_path = normalize_request_path(request_path); + let cookie_path = normalize_cookie_path(cookie_path); + request_path == cookie_path + || (request_path.starts_with(&cookie_path) + && (cookie_path.ends_with('/') + || request_path + .as_bytes() + .get(cookie_path.len()) + .is_some_and(|byte| *byte == b'/'))) +} + +fn normalize_request_path(path: &str) -> String { + let path = path.trim(); + if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + } +} + fn cookie_is_expired(expires_at: Option<&str>, now: DateTime) -> bool { expires_at .and_then(parse_cookie_expires_at) diff --git a/src/codex/accounts/service/health.rs b/src/codex/accounts/service/health.rs index 2307ff6eb..8e819de79 100644 --- a/src/codex/accounts/service/health.rs +++ b/src/codex/accounts/service/health.rs @@ -12,6 +12,7 @@ use crate::{ model::AccountStatus, repository::{AccountRepository, StoredAccount}, }, + codex::gateway::transport::endpoints::primary_usage_request_path, codex::gateway::transport::http_client::{ build_reqwest_client, CodexBackendClient, CodexClientError, CodexRequestContext, }, @@ -111,10 +112,11 @@ pub(super) async fn fetch_account_usage( async fn account_cookie_header(service: &AccountService, account_id: &str) -> Option { let domain = request_domain(&service.config.api.base_url)?; + let request_path = primary_usage_request_path(&service.config.api.base_url); service .cookie_repository .as_ref()? - .cookie_header(account_id, &domain) + .cookie_header_for_request(account_id, &domain, &request_path) .await .ok() .flatten() diff --git a/src/codex/gateway/transport/endpoints.rs b/src/codex/gateway/transport/endpoints.rs new file mode 100644 index 000000000..f77c71154 --- /dev/null +++ b/src/codex/gateway/transport/endpoints.rs @@ -0,0 +1,61 @@ +use reqwest::Url; + +pub(crate) const CODEX_RESPONSES_PATH: &str = "/codex/responses"; +pub(crate) const CODEX_RESPONSES_COMPACT_PATH: &str = "/codex/responses/compact"; +pub(crate) const CODEX_USAGE_PATH: &str = "/codex/usage"; +pub(crate) const CODEX_USAGE_API_PATH: &str = "/api/codex/usage"; +pub(crate) const WHAM_USAGE_PATH: &str = "/wham/usage"; + +pub(crate) fn endpoint_url(base_url: &str, endpoint_path: &str) -> String { + format!( + "{}/{}", + base_url.trim_end_matches('/'), + endpoint_path.trim_start_matches('/') + ) +} + +pub(crate) fn endpoint_request_path(base_url: &str, endpoint_path: &str) -> String { + let endpoint_path = endpoint_path.trim_start_matches('/'); + let base_path = Url::parse(base_url) + .ok() + .map(|url| url.path().trim_end_matches('/').to_string()) + .filter(|path| !path.is_empty()) + .unwrap_or_default(); + + if base_path.is_empty() { + format!("/{endpoint_path}") + } else { + format!("{base_path}/{endpoint_path}") + } +} + +pub(crate) fn usage_endpoint_urls(base_url: &str) -> Vec { + usage_endpoint_paths(base_url) + .into_iter() + .map(|path| endpoint_url(base_url, path)) + .collect() +} + +pub(crate) fn primary_usage_request_path(base_url: &str) -> String { + let endpoint_path = usage_endpoint_paths(base_url) + .into_iter() + .next() + .unwrap_or(CODEX_USAGE_API_PATH); + endpoint_request_path(base_url, endpoint_path) +} + +fn usage_endpoint_paths(base_url: &str) -> Vec<&'static str> { + if has_backend_api_base_path(base_url) { + vec![WHAM_USAGE_PATH, CODEX_USAGE_PATH] + } else { + vec![CODEX_USAGE_API_PATH, CODEX_USAGE_PATH] + } +} + +fn has_backend_api_base_path(base_url: &str) -> bool { + Url::parse(base_url).ok().is_some_and(|url| { + url.path() + .split('/') + .any(|segment| segment == "backend-api") + }) +} diff --git a/src/codex/gateway/transport/http_client.rs b/src/codex/gateway/transport/http_client.rs index ecbf9feb9..d493636b5 100644 --- a/src/codex/gateway/transport/http_client.rs +++ b/src/codex/gateway/transport/http_client.rs @@ -20,6 +20,9 @@ use uuid::Uuid; use crate::{ codex::gateway::fingerprint::model::Fingerprint, codex::gateway::transport::{ + endpoints::{ + endpoint_url, usage_endpoint_urls, CODEX_RESPONSES_COMPACT_PATH, CODEX_RESPONSES_PATH, + }, headers::build_ordered_codex_base_headers, sse::SseError, types::{CodexCompactRequest, CodexResponsesRequest}, @@ -491,7 +494,7 @@ impl CodexBackendClient { request: &CodexResponsesRequest, headers: HeaderMap, ) -> CodexClientResult { - let url = format!("{}/codex/responses", self.base_url); + let url = endpoint_url(&self.base_url, CODEX_RESPONSES_PATH); Ok(self .client .post(url) @@ -506,7 +509,7 @@ impl CodexBackendClient { request: &CodexCompactRequest, headers: HeaderMap, ) -> CodexClientResult { - let url = format!("{}/codex/responses/compact", self.base_url); + let url = endpoint_url(&self.base_url, CODEX_RESPONSES_COMPACT_PATH); Ok(self .client .post(url) @@ -517,17 +520,7 @@ impl CodexBackendClient { } fn usage_endpoints(&self) -> Vec { - if self.base_url.contains("/backend-api") { - vec![ - format!("{}/wham/usage", self.base_url), - format!("{}/codex/usage", self.base_url), - ] - } else { - vec![ - format!("{}/api/codex/usage", self.base_url), - format!("{}/codex/usage", self.base_url), - ] - } + usage_endpoint_urls(&self.base_url) } fn request_headers(&self, context: CodexRequestContext<'_>) -> CodexClientResult { diff --git a/src/codex/gateway/transport/mod.rs b/src/codex/gateway/transport/mod.rs index ad6143042..6d5c88054 100644 --- a/src/codex/gateway/transport/mod.rs +++ b/src/codex/gateway/transport/mod.rs @@ -1,3 +1,4 @@ +pub mod endpoints; pub mod headers; pub mod http_client; pub mod rate_limits; diff --git a/src/codex/gateway/transport/websocket/mod.rs b/src/codex/gateway/transport/websocket/mod.rs index ce9f55e53..8223ad5d6 100644 --- a/src/codex/gateway/transport/websocket/mod.rs +++ b/src/codex/gateway/transport/websocket/mod.rs @@ -14,6 +14,7 @@ use futures::{channel::mpsc, SinkExt, Stream, StreamExt}; use reqwest::{header::HeaderMap, StatusCode}; use crate::codex::gateway::transport::{ + endpoints::{endpoint_url, CODEX_RESPONSES_PATH}, rate_limits::{parse_rate_limits_event_raw, rate_limits_to_header_pairs, ParsedRateLimits}, types::CodexResponsesRequest, }; @@ -471,7 +472,7 @@ fn build_ws_request( } fn websocket_url(base_url: &str) -> String { - let url = format!("{}/codex/responses", base_url.trim_end_matches('/')); + let url = endpoint_url(base_url, CODEX_RESPONSES_PATH); if let Some(rest) = url.strip_prefix("https://") { format!("wss://{rest}") } else if let Some(rest) = url.strip_prefix("http://") { diff --git a/src/codex/serving/dispatch/mod.rs b/src/codex/serving/dispatch/mod.rs index 717482d0c..0742a4d9c 100644 --- a/src/codex/serving/dispatch/mod.rs +++ b/src/codex/serving/dispatch/mod.rs @@ -48,6 +48,10 @@ use crate::{ codex::gateway::installation_id::get_installation_id, codex::gateway::protocol::codex_to_openai::openai_error, codex::gateway::transport::{ + endpoints::{ + endpoint_request_path, primary_usage_request_path, CODEX_RESPONSES_COMPACT_PATH, + CODEX_RESPONSES_PATH, + }, http_client::{ build_reqwest_client, CodexBackendClient, CodexBackendStream, CodexBackendWebSocketStream, CodexClientError, CodexCompactResponse, @@ -90,7 +94,6 @@ use self::reasoning_replay::ReasoningReplayCache; use self::stream_audit::{StreamAudit, WebSocketStreamAudit}; use self::{ limits::apply_rate_limit_headers_with_deps, - routing::request_domain, stream::{ completed_response_metadata, ensure_stream_metadata, has_terminal_sse_event, premature_close_failed_event, TupleStreamReconverter, @@ -471,6 +474,28 @@ async fn record_failed_request_attempt_with_deps( } } +async fn upstream_cookie_header_for_endpoint( + deps: &CodexUpstreamDependencies, + account_id: &str, + endpoint_path: &str, +) -> Option { + let request_path = endpoint_request_path(&deps.config.api.base_url, endpoint_path); + upstream_cookie_header_for_request_path(deps, account_id, &request_path).await +} + +async fn upstream_cookie_header_for_request_path( + deps: &CodexUpstreamDependencies, + account_id: &str, + request_path: &str, +) -> Option { + let repo = deps.cookie_repository.as_ref()?; + let domain = routing::request_domain(&deps.config.api.base_url)?; + repo.cookie_header_for_request(account_id, &domain, request_path) + .await + .ok() + .flatten() +} + struct DirtyQuotaVerification { limit_reached: bool, } @@ -495,11 +520,9 @@ async fn fetch_dirty_account_usage_with_deps( account: &Account, request_id: &str, ) -> Result { - let request_domain = request_domain(&deps.config.api.base_url); - let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) { - (Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(), - _ => None, - }; + let usage_path = primary_usage_request_path(&deps.config.api.base_url); + let cookie_header = + upstream_cookie_header_for_request_path(deps, &account.id, &usage_path).await; let client = CodexBackendClient::new( build_reqwest_client(deps.config.tls.force_http11)?, deps.config.api.base_url.clone(), @@ -1157,11 +1180,8 @@ async fn send_codex_request( account: &Account, request_id: &str, ) -> Result { - let request_domain = request_domain(&deps.config.api.base_url); - let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) { - (Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(), - _ => None, - }; + let cookie_header = + upstream_cookie_header_for_endpoint(deps, &account.id, CODEX_RESPONSES_PATH).await; let account_scope = &account.id; let identity = build_conversation_identity( @@ -1206,11 +1226,8 @@ async fn send_compact_request( account: &Account, request_id: &str, ) -> Result { - let request_domain = request_domain(&deps.config.api.base_url); - let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) { - (Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(), - _ => None, - }; + let cookie_header = + upstream_cookie_header_for_endpoint(deps, &account.id, CODEX_RESPONSES_COMPACT_PATH).await; let installation_id = get_installation_id(Some(&deps.config.database.url)); let client = CodexBackendClient::new( @@ -1261,11 +1278,8 @@ async fn send_codex_stream_request( account: &Account, request_id: &str, ) -> Result { - let request_domain = request_domain(&deps.config.api.base_url); - let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) { - (Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(), - _ => None, - }; + let cookie_header = + upstream_cookie_header_for_endpoint(deps, &account.id, CODEX_RESPONSES_PATH).await; // Build conversation identity for session affinity // Use account.id as the scope since entry_id doesn't exist @@ -1371,11 +1385,8 @@ async fn send_codex_websocket_stream_request( account: &Account, request_id: &str, ) -> Result { - let request_domain = request_domain(&deps.config.api.base_url); - let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) { - (Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(), - _ => None, - }; + let cookie_header = + upstream_cookie_header_for_endpoint(deps, &account.id, CODEX_RESPONSES_PATH).await; let account_scope = &account.id; let identity = build_conversation_identity( diff --git a/tests/codex_accounts/cookie_store.rs b/tests/codex_accounts/cookie_store.rs index f132b530c..e7584ba2b 100644 --- a/tests/codex_accounts/cookie_store.rs +++ b/tests/codex_accounts/cookie_store.rs @@ -66,6 +66,36 @@ async fn cookie_repository_encrypts_and_replays_account_scoped_cookies() { ); } +#[tokio::test] +async fn cookie_repository_should_scope_cookie_replay_by_domain_and_path() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("path-scoped-cookies.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_account(&pool, "acct_a").await; + let repo = CookieRepository::new(pool, SecretBox::new([11u8; 32])); + + repo.capture_set_cookie("acct_a", "cf_clearance=root; Domain=.chatgpt.com; Path=/") + .await + .unwrap(); + repo.capture_set_cookie("acct_a", "cf_clearance=api; Domain=.chatgpt.com; Path=/api") + .await + .unwrap(); + + assert_eq!( + repo.cookie_header_for_request("acct_a", "chatgpt.com", "/codex/responses") + .await + .unwrap(), + Some("cf_clearance=root".to_string()) + ); + assert_eq!( + repo.cookie_header_for_request("acct_a", "chatgpt.com", "/api/codex/usage") + .await + .unwrap(), + Some("cf_clearance=api; cf_clearance=root".to_string()) + ); +} + #[tokio::test] async fn cookie_repository_should_only_auto_capture_cf_clearance() { let dir = tempfile::tempdir().unwrap(); diff --git a/tests/codex_serving/responses_http_sse.rs b/tests/codex_serving/responses_http_sse.rs index b12d8e48d..d198753e3 100644 --- a/tests/codex_serving/responses_http_sse.rs +++ b/tests/codex_serving/responses_http_sse.rs @@ -429,6 +429,69 @@ async fn v1_responses_should_record_failed_image_generation_attempt_when_tool_ha assert_eq!(usage, (0, 1, 0, 1)); } +#[tokio::test] +async fn v1_responses_should_scope_upstream_cookie_by_codex_response_path() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(COMPLETED_USAGE_SSE), + ) + .mount(&server) + .await; + let imported = build_imported_app(server.uri()).await; + let cookie_domain = reqwest::Url::parse(&server.uri()) + .unwrap() + .host_str() + .unwrap() + .to_string(); + let cookie_repo = CookieRepository::new(imported.pool.clone(), imported.secret_box.clone()); + cookie_repo + .capture_set_cookie( + "acct_imported", + &format!("cf_clearance=root; Domain={cookie_domain}; Path=/"), + ) + .await + .unwrap(); + cookie_repo + .capture_set_cookie( + "acct_imported", + &format!("cf_clearance=codex; Domain={cookie_domain}; Path=/codex"), + ) + .await + .unwrap(); + + let response = imported + .app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header( + "authorization", + format!("Bearer {}", imported.client_api_key), + ) + .header("content-type", "application/json") + .body(Body::from( + r#"{"model":"gpt-5.5","input":[],"stream":false,"use_websocket":false}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let requests = server.received_requests().await.unwrap(); + let cookie_header = requests + .iter() + .find(|request| request.url.path() == "/codex/responses") + .and_then(|request| request.headers.get("cookie")) + .and_then(|value| value.to_str().ok()); + assert_eq!(cookie_header, Some("cf_clearance=codex; cf_clearance=root")); +} + #[tokio::test] async fn v1_responses_should_passively_cache_rate_limit_headers() { let server = MockServer::start().await;