diff --git a/docs-site/src/content/docs/fr/reference/proxy-formats.md b/docs-site/src/content/docs/fr/reference/proxy-formats.md index d4f3dc266c..9f1cbc3757 100644 --- a/docs-site/src/content/docs/fr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md @@ -317,16 +317,18 @@ utilisez la matrice ci-dessous. « Dédié » signifie `X-OpenCodex-API-Key` ; l | Surfaces | Dédié | Porteur | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP et WebSocket | Obligatoire | Rejeté pour l’admission au proxy | Rejeté | -| `/v1/responses/compact` | Obligatoire | Rejeté pour l’admission au proxy | Rejeté | -| `/v1/chat/completions` | Obligatoire | Rejeté pour l’admission au proxy | Rejeté | +| `/v1/responses` HTTP et WebSocket | Accepté | Accepté | Rejeté | +| `/v1/responses/compact` | Accepté | Accepté | Rejeté | +| `/v1/chat/completions` | Accepté | Accepté | Rejeté | | `/v1/messages` et `/v1/messages/count_tokens` | Accepté | Accepté | Accepté | | `/v1/models` | Accepté | Accepté | Accepté | | `/v1/live`, `/v1/realtime/calls` et jointures de bande latérale | Accepté | Accepté | Accepté | -Réponses-famille et demandes de chat réservées `Authorization` au fournisseur ou Codex Direct -passthrough, donc une clé proxy distante doit utiliser l'en-tête dédié. Messages et surfaces en temps réel -ont besoin d’une compatibilité client plus large et acceptent donc les trois formes. +Les requêtes Responses et Chat acceptent une clé du proxy dans l’en-tête dédié ou dans Bearer. Sur une route native, l’identifiant Codex stocké sélectionné remplace le bearer d’admission ; sur les autres routes, ce bearer est supprimé. Il ne sert jamais d’identifiant upstream. Utilisez l’en-tête dédié si vous fournissez aussi un bearer distinct pour le fournisseur. + +Une route Cursor sans clé et sans OAuth peut utiliser ce bearer distinct de l’appelant, mais jamais un secret du proxy ni l’authentification ChatGPT main ajoutée automatiquement. La sélection Combo/policy et les réécritures effectives shadow/thread-spawn ne transmettent pas les identifiants bruts de l’appelant aux nouvelles cibles. Le routage OpenAI canonique peut restaurer l’unique bearer de l’appelant qui n’est pas une clé du proxy après un changement de route interne uniquement si son JWT contient un claim de compte ChatGPT et si tout en-tête de compte explicite correspond à ce claim. La transmission de l’authentification de l’appelant aux sidecars OpenAI facultatifs exige un unique JWT et un `chatgpt-account-id` explicite et correspondant. Les bearers opaques ne sont pas restaurés lors des changements de route, même avec un en-tête de compte explicite. Dans les autres cas, la cible finale doit disposer de son propre identifiant configuré, OAuth ou stocké ; sinon, la requête échoue localement. Un simple marqueur thread-spawn sans changement de route ne supprime pas les identifiants. + +Le replay Claude ne conserve l’authentification main que dans un snapshot en mémoire dont le turn a acquis la propriété, et ne la reconstruit que pour une route ChatGPT canonique finale. :::caution Les clés du plan de données ne sont pas des informations d’identification de gestion. La gestion API utilise un secret d'administration distinct ; diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index 8f0a03bc09..6c25afed6e 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -234,14 +234,18 @@ API ではありません。Desktop のキー移行・復旧・切断は既存 |表面 |専用 |ベアラー | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP と WebSocket |必須 |代理入場を拒否されました |拒否されました | -| `/v1/responses/compact` |必須 |代理入場を拒否されました |拒否されました | -| `/v1/chat/completions` |必須 |代理入場を拒否されました |拒否されました | +| `/v1/responses` HTTP と WebSocket | 承認済み | 承認済み |拒否されました | +| `/v1/responses/compact` | 承認済み | 承認済み |拒否されました | +| `/v1/chat/completions` | 承認済み | 承認済み |拒否されました | | `/v1/messages` および `/v1/messages/count_tokens` |承認済み |承認済み |承認済み | | `/v1/models` |承認済み |承認済み |承認済み | | `/v1/live`、`/v1/realtime/calls`、および側波帯結合 |承認済み |承認済み |承認済み | -Responses-family および Chat リクエストは、プロバイダーまたは Codex Direct パススルー用に `Authorization` を予約するため、リモート プロキシ キーは専用ヘッダーを使用する必要があります。メッセージとリアルタイム サーフェスは、より広範なクライアント互換性を必要とするため、3 つの形式すべてを受け入れます。 +Responses 系列と Chat のリクエストは、専用ヘッダーまたは Bearer フィールドのプロキシキーを受け付けます。ネイティブルートでは選択された保存済み Codex 認証情報が admission bearer を置き換え、他のルートではその bearer を削除します。プロキシキーを upstream の認証情報として使うことはありません。別の provider bearer も渡す場合は、プロキシキーを専用ヘッダーに設定してください。 + +キーがなく OAuth を使用しない Cursor ルートは、別途指定された呼び出し元 bearer を使用できますが、プロキシ secret や自動補完された ChatGPT main 認証は使用しません。Combo/policy の選択と実際の shadow/thread-spawn ルート変更では、呼び出し元の生の認証情報を新しい対象へ渡しません。正規の OpenAI ルーティングでは、JWT に ChatGPT アカウントの claim が含まれ、明示的なアカウントヘッダーがある場合はその claim と一致するときに限り、内部ルート変更後にプロキシキーではない呼び出し元の単一 bearer を復元できます。 オプションの OpenAI sidecar に呼び出し元の認証を転送するには、単一の JWT とそれに一致する明示的な `chatgpt-account-id` が必要です。Opaque bearer は、明示的なアカウントヘッダーがあっても、ルート変更をまたいで復元されません。 それ以外の最終対象には自身の設定済み・OAuth・保存済み認証情報が必要で、なければローカルで失敗します。ルート変更のない thread-spawn マーカーだけでは認証情報を削除しません。 + +Claude replay は、その turn が所有権を確保した main 認証だけをメモリ内 snapshot に保持し、最終対象が正規の ChatGPT ルートである場合にのみ復元します。 :::caution データプレーン キーは管理資格情報ではありません。管理 API は別の管理シークレットを使用します。 [管理 API](/reference/management-api/)を参照してください。 1 つのシークレットを両方のプレーンに再利用しないでください。 diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 7837ae4d22..394d988b14 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -299,16 +299,18 @@ loopback 전용 bind에서는 data-plane admission에 설정된 key가 필요하 | 표면 | Dedicated | Bearer | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP and WebSocket | 필요함 | proxy admission에서는 거부됨 | 거부됨 | -| `/v1/responses/compact` | 필요함 | proxy admission에서는 거부됨 | 거부됨 | -| `/v1/chat/completions` | 필요함 | proxy admission에서는 거부됨 | 거부됨 | +| `/v1/responses` HTTP and WebSocket | 허용됨 | 허용됨 | 거부됨 | +| `/v1/responses/compact` | 허용됨 | 허용됨 | 거부됨 | +| `/v1/chat/completions` | 허용됨 | 허용됨 | 거부됨 | | `/v1/messages`와 `/v1/messages/count_tokens` | 허용됨 | 허용됨 | 허용됨 | | `/v1/models` | 허용됨 | 허용됨 | 허용됨 | | `/v1/live`, `/v1/realtime/calls`, 및 sideband joins | 허용됨 | 허용됨 | 허용됨 | -Responses 계열과 Chat 요청은 `Authorization`을 provider 또는 Codex Direct passthrough용으로 예약하므로, remote -proxy key는 전용 헤더를 사용해야 합니다. Messages와 Realtime 표면은 더 넓은 클라이언트 호환성이 필요하므로 -세 가지 형식을 모두 허용합니다. +Responses 계열과 Chat 요청은 전용 헤더 또는 Bearer 필드의 프록시 키를 허용합니다. 네이티브 경로에서는 선택한 저장 Codex 자격 증명이 admission bearer를 대체하고, 다른 경로에서는 해당 bearer를 제거합니다. 프록시 키를 upstream 자격 증명으로 사용하지 않습니다. 별도의 provider bearer도 전달하려면 프록시 키는 전용 헤더에 넣으십시오. + +키가 없고 OAuth를 쓰지 않는 Cursor 경로는 별도의 호출자 bearer를 사용할 수 있지만, 프록시 secret이나 자동으로 보충한 ChatGPT main 인증은 사용할 수 없습니다. Combo/policy 선택과 실제 shadow/thread-spawn 경로 변경은 호출자의 원본 자격 증명을 새 대상으로 넘기지 않습니다. 정규 OpenAI 라우팅은 JWT에 ChatGPT 계정 claim이 포함되어 있고 명시적 계정 헤더가 있으면 그 claim과 일치하는 경우에만, 내부 경로 변경 후 프록시 키가 아닌 호출자의 단일 bearer를 복원할 수 있습니다. 선택적 OpenAI sidecar에 호출자 인증을 전달하려면 단일 JWT와 이에 일치하는 명시적 `chatgpt-account-id`가 필요합니다. Opaque bearer는 명시적 계정 헤더가 있어도 경로 변경을 거쳐 복원되지 않습니다. 그 외의 최종 대상에는 자체 설정·OAuth·저장 자격 증명이 필요하며, 없으면 로컬에서 실패합니다. thread-spawn 표지만 있고 경로가 바뀌지 않으면 자격 증명을 제거하지 않습니다. + +Claude replay는 해당 turn이 소유권을 확보한 main 인증만 메모리 snapshot으로 유지하며, 최종 대상이 정규 ChatGPT 경로일 때만 복원합니다. :::caution data-plane key는 management credential이 아닙니다. management API는 별도의 admin secret을 사용합니다. diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 1f2e589252..1bc4bf1cc9 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -465,16 +465,18 @@ use the matrix below. “Dedicated” means `X-OpenCodex-API-Key`; the other col | Surface | Dedicated | Bearer | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP and WebSocket | Required | Rejected for proxy admission | Rejected | -| `/v1/responses/compact` | Required | Rejected for proxy admission | Rejected | -| `/v1/chat/completions` | Required | Rejected for proxy admission | Rejected | +| `/v1/responses` HTTP and WebSocket | Accepted | Accepted | Rejected | +| `/v1/responses/compact` | Accepted | Accepted | Rejected | +| `/v1/chat/completions` | Accepted | Accepted | Rejected | | `/v1/messages` and `/v1/messages/count_tokens` | Accepted | Accepted | Accepted | | `/v1/models` | Accepted | Accepted | Accepted | | `/v1/live`, `/v1/realtime/calls`, and sideband joins | Accepted | Accepted | Accepted | -Responses-family and Chat requests reserve `Authorization` for provider or Codex Direct -passthrough, so a remote proxy key must use the dedicated header. Messages and Realtime surfaces -need broader client compatibility and therefore accept all three forms. +Responses-family and Chat requests accept a proxy key in the dedicated header or Bearer field. On native routes, the selected stored Codex credential replaces the admission bearer; on other routes it is removed. It is never an upstream credential. Use the dedicated header when also supplying a separate provider bearer. + +A keyless, non-OAuth Cursor route may use that separate caller bearer, but never a proxy secret or automatic ChatGPT-main enrichment. Combo/policy selection and actual shadow/thread-spawn rewrites do not transfer raw caller credentials to new targets. Canonical OpenAI routing can restore the caller’s single non-proxy bearer after an internal route change only when its JWT carries a ChatGPT account claim and any explicit account header matches that claim. Forwarding caller authentication to optional OpenAI sidecars requires a single JWT and a matching explicit `chatgpt-account-id`. Opaque bearers are not restored across route changes, even with an explicit account header. Otherwise, the final target needs its own configured, OAuth, or stored credential; otherwise it fails locally. A thread-spawn marker alone does not strip credentials. + +Claude replay retains main auth only as a turn-claimed in-memory snapshot and reconstructs it only for a final canonical ChatGPT route. :::caution Data-plane keys are not management credentials. The management API uses a separate admin secret; diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index a3ef007784..707449ff12 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -301,16 +301,18 @@ conversation. | Поверхность | Выделенный | Bearer | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP и WebSocket | Обязателен | Отклоняется для proxy-admission | Отклоняется | -| `/v1/responses/compact` | Обязателен | Отклоняется для proxy-admission | Отклоняется | -| `/v1/chat/completions` | Обязателен | Отклоняется для proxy-admission | Отклоняется | +| `/v1/responses` HTTP и WebSocket | Принимается | Принимается | Отклоняется | +| `/v1/responses/compact` | Принимается | Принимается | Отклоняется | +| `/v1/chat/completions` | Принимается | Принимается | Отклоняется | | `/v1/messages` и `/v1/messages/count_tokens` | Принимается | Принимается | Принимается | | `/v1/models` | Принимается | Принимается | Принимается | | `/v1/live`, `/v1/realtime/calls` и sideband-join'ы | Принимается | Принимается | Принимается | -Responses-family и Chat-запросы резервируют `Authorization` под passthrough провайдера или Codex -Direct, поэтому remote proxy key здесь обязан идти через dedicated-заголовок. Surface'ам Messages -и Realtime нужна более широкая совместимость с клиентами, поэтому там принимаются все три формы. +Запросы Responses и Chat принимают ключ прокси в выделенном заголовке или поле Bearer. На нативных маршрутах bearer допуска заменяется выбранными сохранёнными учётными данными Codex, а на остальных удаляется. Он никогда не используется для аутентификации upstream. Если передаётся отдельный bearer провайдера, ключ прокси следует указать в выделенном заголовке. + +Маршрут Cursor без ключа и без OAuth может использовать отдельный bearer вызывающей стороны, но не секрет прокси и не автоматически добавленную аутентификацию ChatGPT main. Выбор Combo/policy и фактические изменения маршрута shadow/thread-spawn не передают исходные учётные данные вызывающей стороны новым целям. Каноническая маршрутизация OpenAI может восстановить единственный bearer вызывающей стороны, не являющийся ключом прокси, после внутреннего изменения маршрута только если его JWT содержит claim учётной записи ChatGPT и любой явно указанный заголовок учётной записи соответствует этому claim. Для передачи аутентификации вызывающей стороны в необязательные sidecar OpenAI необходимы один JWT и явно указанный соответствующий `chatgpt-account-id`. Непрозрачные bearer не восстанавливаются после изменения маршрута даже при наличии явно указанного заголовка учётной записи. В остальных случаях конечной цели нужны собственные настроенные, OAuth или сохранённые учётные данные; иначе запрос завершается локальной ошибкой. Один маркер thread-spawn без изменения маршрута не удаляет учётные данные. + +Claude replay сохраняет аутентификацию main только в снимке в памяти, владение которым обеспечено текущим turn, и восстанавливает её только для конечного канонического маршрута ChatGPT. :::caution Ключи data plane — это не management credentials. У management API свой отдельный admin-secret; diff --git a/docs-site/src/content/docs/tr/reference/proxy-formats.md b/docs-site/src/content/docs/tr/reference/proxy-formats.md index 6989e86a22..1799008bb3 100644 --- a/docs-site/src/content/docs/tr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/tr/reference/proxy-formats.md @@ -326,17 +326,18 @@ ve `x-api-key` anlamına gelir. | Yüzey | Özel | Bearer | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP ve WebSocket | Gerekli | Proxy kabulü için reddedilir | Reddedilir | -| `/v1/responses/compact` | Gerekli | Proxy kabulü için reddedilir | Reddedilir | -| `/v1/chat/completions` | Gerekli | Proxy kabulü için reddedilir | Reddedilir | +| `/v1/responses` HTTP ve WebSocket | Kabul Edilir | Kabul Edilir | Reddedilir | +| `/v1/responses/compact` | Kabul Edilir | Kabul Edilir | Reddedilir | +| `/v1/chat/completions` | Kabul Edilir | Kabul Edilir | Reddedilir | | `/v1/messages` ve `/v1/messages/count_tokens` | Kabul Edilir | Kabul Edilir | Kabul Edilir | | `/v1/models` | Kabul Edilir | Kabul Edilir | Kabul Edilir | | `/v1/live`, `/v1/realtime/calls` ve yan bant katılımları | Kabul Edilir | Kabul Edilir | Kabul Edilir | -Responses ailesi ve Sohbet istekleri `Authorization`'ı sağlayıcı veya Codex -Direct doğrudan geçişi için ayırır, bu nedenle uzak bir proxy anahtarı özel -başlığı kullanmalıdır. Messages ve Realtime yüzeyleri daha geniş istemci -uyumluluğuna ihtiyaç duyar ve bu nedenle üç formu da kabul eder. +Responses ailesi ve Chat istekleri, özel başlıkta veya Bearer alanında bir proxy anahtarını kabul eder. Yerel Codex rotalarında seçilen kayıtlı Codex kimlik bilgisi kabul bearer’ının yerini alır; diğer rotalarda bu bearer kaldırılır. Proxy anahtarı hiçbir zaman upstream kimlik bilgisi olarak kullanılmaz. Ayrı bir sağlayıcı bearer’ı da gönderiyorsanız proxy anahtarını özel başlığa koyun. + +Anahtarı olmayan ve OAuth kullanmayan bir Cursor rotası, çağıranın ayrı bearer’ını kullanabilir; proxy sırrını veya otomatik eklenen ChatGPT main kimlik bilgisini kullanamaz. Combo/policy seçimi ve gerçekleşen shadow/thread-spawn rota değişiklikleri, çağıranın ham kimlik bilgilerini yeni hedeflere aktarmaz. Kanonik OpenAI yönlendirmesi, dahili rota değişikliğinden sonra çağıranın proxy anahtarı olmayan tek bearer’ını yalnızca JWT’si bir ChatGPT hesap claim’i içeriyorsa ve açıkça belirtilmiş herhangi bir hesap başlığı bu claim ile eşleşiyorsa geri yükleyebilir. Çağıranın kimlik doğrulamasını isteğe bağlı OpenAI sidecar’larına iletmek için tek bir JWT ve onunla eşleşen, açıkça belirtilmiş bir `chatgpt-account-id` gerekir. Opaque bearer’lar, açıkça belirtilmiş bir hesap başlığı olsa bile rota değişikliklerinden sonra geri yüklenmez. Diğer durumlarda son hedefin kendi yapılandırılmış, OAuth veya kayıtlı kimlik bilgisi bulunmalıdır; aksi hâlde istek yerel olarak başarısız olur. Rota değişmeden yalnızca thread-spawn işaretinin bulunması kimlik bilgilerini kaldırmaz. + +Claude replay, main kimlik bilgisini yalnızca ilgili turn tarafından sahipliği alınmış bir bellek snapshot’ında tutar ve yalnızca son hedef kanonik bir ChatGPT rotasıysa geri yükler. :::caution Veri düzlemi anahtarları yönetim kimlik bilgileri değildir. Yönetim API'si ayrı diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index 9736aeaff9..0e294b6118 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -247,15 +247,18 @@ Compaction 会为需要缩短长 Responses 会话的客户端返回替换历史 | 表面 | Dedicated | Bearer | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP 和 WebSocket | 必需 | 被代理准入拒绝 | 被拒绝 | -| `/v1/responses/compact` | 必需 | 被代理准入拒绝 | 被拒绝 | -| `/v1/chat/completions` | 必需 | 被代理准入拒绝 | 被拒绝 | +| `/v1/responses` HTTP 和 WebSocket | 接受 | 接受 | 被拒绝 | +| `/v1/responses/compact` | 接受 | 接受 | 被拒绝 | +| `/v1/chat/completions` | 接受 | 接受 | 被拒绝 | | `/v1/messages` 和 `/v1/messages/count_tokens` | 接受 | 接受 | 接受 | | `/v1/models` | 接受 | 接受 | 接受 | | `/v1/live`、`/v1/realtime/calls` 和 sideband join | 接受 | 接受 | 接受 | -Responses 家族和 Chat 请求会把 `Authorization` 留给提供方或 Codex Direct -透传,因此远程代理密钥必须使用专用头。Messages 和 Realtime 表面需要更广泛的客户端兼容性,因此接受这三种形式。 +Responses 系列和 Chat 请求接受专用标头或 Bearer 字段中的代理密钥。在原生路由上,所选的已保存 Codex 凭据会替换 admission bearer;其他路由会移除该 bearer。代理密钥绝不会用作 upstream 凭据。如果还要提供独立的 provider bearer,请将代理密钥放在专用标头中。 + +没有密钥且不使用 OAuth 的 Cursor 路由可以使用调用方单独提供的 bearer,但不能使用代理 secret 或自动补充的 ChatGPT main 凭据。Combo/policy 选择以及实际发生的 shadow/thread-spawn 路由改写不会将调用方的原始凭据传递给新目标。规范 OpenAI 路由仅在 JWT 包含 ChatGPT 账户声明,且任何显式账户标头都与该声明匹配时,才可在内部路由变更后恢复调用方的单个非代理密钥 bearer。 向可选的 OpenAI sidecar 转发调用方认证时,需要单个 JWT 以及显式提供且匹配的 `chatgpt-account-id`。即使提供了显式账户标头,opaque bearer 也不会跨路由变更恢复。 除此之外,最终目标必须拥有自己的配置、OAuth 或已保存凭据,否则请求会在本地失败。只有 thread-spawn 标记而没有路由变化时,不会移除凭据。 + +Claude replay 只会以当前 turn 已取得所有权的内存 snapshot 保留 main 凭据,并且仅在最终目标为规范 ChatGPT 路由时恢复它。 :::caution 数据平面密钥不是管理凭证。管理 API 使用单独的 admin secret; diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index a4baa921c9..d282224da0 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -224,14 +224,18 @@ Compaction 為需要縮短長 Responses 對話的客戶端回傳取代歷史。 | 介面 | 專屬 | Bearer | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP 與 WebSocket | 必填 | 代理許可被拒 | 被拒 | -| `/v1/responses/compact` | 必填 | 代理許可被拒 | 被拒 | -| `/v1/chat/completions` | 必填 | 代理許可被拒 | 被拒 | +| `/v1/responses` HTTP 與 WebSocket | 接受 | 接受 | 被拒 | +| `/v1/responses/compact` | 接受 | 接受 | 被拒 | +| `/v1/chat/completions` | 接受 | 接受 | 被拒 | | `/v1/messages` 與 `/v1/messages/count_tokens` | 接受 | 接受 | 接受 | | `/v1/models` | 接受 | 接受 | 接受 | | `/v1/live`、`/v1/realtime/calls` 與 sideband join | 接受 | 接受 | 接受 | -Responses 家族與 Chat 請求為供應商或 Codex Direct passthrough 保留 `Authorization`,因此遠端代理金鑰必須使用專屬標頭。Messages 與 Realtime 介面需要更廣的客戶端相容性,因此接受所有三種形式。 +Responses 系列和 Chat 請求接受專用標頭或 Bearer 欄位中的代理金鑰。在原生路由上,所選的已儲存 Codex 憑證會取代 admission bearer;其他路由會移除該 bearer。代理金鑰絕不會用作 upstream 憑證。如果還要提供獨立的 provider bearer,請將代理金鑰放在專用標頭中。 + +沒有金鑰且不使用 OAuth 的 Cursor 路由可以使用呼叫端另外提供的 bearer,但不能使用代理 secret 或自動補入的 ChatGPT main 憑證。Combo/policy 選擇及實際發生的 shadow/thread-spawn 路由改寫不會將呼叫端的原始憑證傳遞給新目標。正規 OpenAI 路由僅在 JWT 包含 ChatGPT 帳戶宣告,且任何明確提供的帳戶標頭都與該宣告相符時,才可在內部路由變更後還原呼叫端的單一非代理金鑰 bearer。 將呼叫端驗證轉送至選用的 OpenAI sidecar 時,需要單一 JWT,以及明確提供且相符的 `chatgpt-account-id`。即使明確提供了帳戶標頭,opaque bearer 也不會跨路由變更還原。 除此之外,最終目標必須擁有自己的設定、OAuth 或已儲存憑證,否則請求會在本機失敗。只有 thread-spawn 標記而沒有路由變更時,不會移除憑證。 + +Claude replay 只會以目前 turn 已取得所有權的記憶體 snapshot 保留 main 憑證,並且僅在最終目標為正規 ChatGPT 路由時還原它。 :::caution Data-plane 金鑰不是管理憑證。管理 API 使用獨立的管理秘密;請見[管理 API](/zh-tw/reference/management-api/)。絕不為兩個平面重用同一個秘密。 diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 2f319b3144..030eb3882c 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -609,8 +609,10 @@ export async function resolveCodexAuthContext( throw new Error("Codex auth context cannot select and exclude an account simultaneously"); } const resolveCallerOwnedMainContext = async (): Promise => { - if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); const substituteStoredMain = options.substituteMainCredentialForDirect === true; + // An internal route change can strip the admission bearer before this point. + // Trusted substitution still has to claim and validate stored main below. + if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); if (!substituteStoredMain) { if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy); if (reserve) { diff --git a/src/oauth/chatgpt.ts b/src/oauth/chatgpt.ts index bb4d1c8497..62b90ff596 100644 --- a/src/oauth/chatgpt.ts +++ b/src/oauth/chatgpt.ts @@ -40,6 +40,62 @@ export function extractAccountId(idToken?: string, accessToken?: string): string return undefined; } +/** + * Three-way answer to "is this token marked as belonging to the ChatGPT account domain". + * Only ChatGPT-specific claims count as markers: a top-level chatgpt_account_id or the + * https://api.openai.com/auth namespace claim. A generic organizations claim is NOT domain + * evidence. JWT claims are decoded locally as routing markers, never as authenticity proof. + * + * absent — no JWT, a payload that is not a JSON object, or an object carrying neither + * marker key: the token may be a foreign credential and legacy foreign handling + * applies. This function is total; it never throws on an attacker-shaped token. + * invalid — a marker key is present but yields no usable account id (non-string, blank, + * namespace that is not an object, namespace without the claim) or the two + * markers disagree. Presence is decided by the KEY, not by its shape, so a token + * that claims this domain can never fall through to foreign handling just + * because its marker is malformed. + * valid — one consistent, non-blank ChatGPT account id. + */ +export type ChatGptDomainClaim = + | { kind: "absent" } + | { kind: "invalid" } + | { kind: "valid"; accountId: string }; + +const CHATGPT_AUTH_NAMESPACE = "https://api.openai.com/auth"; + +/** A usable account id is a non-blank string; blank or non-string values are malformed. */ +function usableAccountId(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function inspectChatGptDomainClaim(token: string): ChatGptDomainClaim { + const payload: unknown = decodeJwtPayload(token); + // decodeJwtPayload returns whatever the payload segment parses to, which may be a + // primitive or an array. Those carry no marker and must not reach the key lookups, + // where `in`/hasOwn would throw and take the whole request down. + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return { kind: "absent" }; + const claims = payload as Record; + // Presence is the KEY being there, as an own key. A reserved namespace that is null, a + // primitive, an array, or an object without the claim is a present-but-broken marker, so + // it stays invalid instead of being treated as a foreign token. + const topPresent = Object.hasOwn(claims, "chatgpt_account_id"); + const nsPresent = Object.hasOwn(claims, CHATGPT_AUTH_NAMESPACE); + if (!topPresent && !nsPresent) return { kind: "absent" }; + const topId = topPresent ? usableAccountId(claims.chatgpt_account_id) : undefined; + if (topPresent && !topId) return { kind: "invalid" }; + let nsId: string | undefined; + if (nsPresent) { + const ns = claims[CHATGPT_AUTH_NAMESPACE]; + const nsObj = ns !== null && typeof ns === "object" && !Array.isArray(ns) + ? ns as Record : undefined; + nsId = nsObj ? usableAccountId(nsObj.chatgpt_account_id) : undefined; + if (!nsId) return { kind: "invalid" }; + } + if (topId && nsId && topId !== nsId) return { kind: "invalid" }; + const accountId = topId ?? nsId; + return accountId ? { kind: "valid", accountId } : { kind: "invalid" }; +} + export function extractEmail(idToken?: string, accessToken?: string): string | undefined { for (const token of [idToken, accessToken]) { if (!token) continue; diff --git a/src/providers/caller-authorization.ts b/src/providers/caller-authorization.ts new file mode 100644 index 0000000000..9ba8b193a5 --- /dev/null +++ b/src/providers/caller-authorization.ts @@ -0,0 +1,36 @@ +import type { OcxConfig, OcxProviderConfig } from "../types"; +import { inspectChatGptDomainClaim } from "../oauth/chatgpt"; +import { isProxyAdmissionSecret } from "../server/auth-cors"; +import { isCanonicalOpenAiForwardProvider } from "./openai-tiers"; + +/** The caller's own ChatGPT-domain credential as plain Direct forwarding would use it. */ +export type CallerDirectAuth = Readonly<{ authorization: string; chatgptAccountId?: string }>; + +/** Whether this transport can consume the request's Authorization as its upstream credential. */ +export function providerConsumesCallerAuthorization(provider: OcxProviderConfig): boolean { + return isCanonicalOpenAiForwardProvider(provider) + || (provider.adapter === "cursor" && provider.authMode !== "oauth" && !provider.apiKey?.trim()); +} + +/** + * Capture the caller's Direct credential for a canonical-route restore after an internal + * rewrite. This restore is intentionally STRICTER than plain unchanged-route Direct + * forwarding: only a bearer with a VALID ChatGPT-domain claim qualifies (a clean single + * non-proxy JWT whose ChatGPT-specific account marker is well-formed and unambiguous, with + * any explicit account header matching it). An opaque bearer, a foreign JWT carrying only a + * generic organizations claim, and a ChatGPT-marked but malformed/conflicting token are all + * rejected: after a shadow/thread rewrite a self-asserted header cannot distinguish a + * caller-owned main credential from a foreign source-route token, so those cases stay + * fail-closed. Claims are decoded locally as routing markers, not authenticity proof, and + * unchanged-route Direct forwarding is governed by its own legacy rules. + */ +export function captureCallerDirectAuth(incomingHeaders: Headers, config: OcxConfig): CallerDirectAuth | null { + const raw = incomingHeaders.get("authorization")?.trim(); + const bearer = /^Bearer[\t ]+([^\s,]+)$/i.exec(raw ?? "")?.[1]; + if (!bearer || isProxyAdmissionSecret(bearer, config)) return null; + const claim = inspectChatGptDomainClaim(bearer); + if (claim.kind !== "valid") return null; + const headerAccount = incomingHeaders.get("chatgpt-account-id")?.trim(); + if (headerAccount && headerAccount !== claim.accountId) return null; + return { authorization: `Bearer ${bearer}`, chatgptAccountId: claim.accountId }; +} diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index 8551aa6f97..3b98a2b521 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -10,7 +10,7 @@ import { type CodexAuthPolicyConfig, } from "../codex/auth-context"; import { recordCodexUpstreamOutcome, type CodexUpstreamOutcome } from "../codex/routing"; -import { extractAccountId } from "../oauth/chatgpt"; +import { inspectChatGptDomainClaim } from "../oauth/chatgpt"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "../server/auth-cors"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { @@ -77,21 +77,45 @@ export function listOpenAiForwardSidecarCandidates(config: OcxConfig): OpenAiFor }]; } -function directSidecarHeaders( - incomingHeaders: Headers, - config: CodexAuthPolicyConfig, - admission?: Pick, -): Headers | undefined { - const bearer = incomingHeaders.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); - if (!bearer) return undefined; - const derivedAccountId = extractAccountId(undefined, bearer); - if (!derivedAccountId) return undefined; +/** An explicit caller bearer/account pair for canonical OpenAI destinations; never persist. */ +export type ExplicitOpenAiCallerAuth = Readonly<{ authorization: string; chatgptAccountId: string }>; + +function explicitSidecarAuth(incomingHeaders: Headers): ExplicitOpenAiCallerAuth | null { + // Combined Authorization values must not smuggle a second credential into a snapshot, + // and only a well-formed ChatGPT-specific account marker is domain evidence — a generic + // organizations claim is not. + const bearer = /^Bearer[\t ]+([^\s,]+)$/i.exec(incomingHeaders.get("authorization")?.trim() ?? "")?.[1]; + if (!bearer) return null; + const claim = inspectChatGptDomainClaim(bearer); + if (claim.kind !== "valid") return null; + const derivedAccountId = claim.accountId; const requestedAccountId = incomingHeaders.get("chatgpt-account-id")?.trim(); // JWT payloads are decoded locally but not signature-verified. Requiring the caller's // explicit account header, and checking it against the token claim, makes forwarding an // intentional ChatGPT-auth operation instead of silently reclassifying any JWT-shaped // provider credential as a Codex bearer. - if (!requestedAccountId || requestedAccountId !== derivedAccountId) return undefined; + if (!requestedAccountId || requestedAccountId !== derivedAccountId) return null; + return { authorization: incomingHeaders.get("authorization")!, chatgptAccountId: requestedAccountId }; +} + +export function captureExplicitOpenAiCallerAuth(incomingHeaders: Headers, config: OcxConfig): ExplicitOpenAiCallerAuth | null { + const auth = explicitSidecarAuth(incomingHeaders); + if (!auth) return null; + try { + validateForwardAdmissionCredential(incomingHeaders, config); + } catch (error) { + if (error instanceof ForwardAdmissionCredentialError) return null; + throw error; + } + return auth; +} + +function directSidecarHeaders( + incomingHeaders: Headers, + config: CodexAuthPolicyConfig, + admission?: Pick, +): Headers | undefined { + if (!explicitSidecarAuth(incomingHeaders)) return undefined; const selected = headersForCodexAuthContext(incomingHeaders, { kind: "main", accountId: null }, config, undefined, admission); return selected; } diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 7e69010636..8ee5f52a49 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -38,6 +38,9 @@ import { } from "./request-log"; import { responseWithDeferredRequestLog } from "./relay"; import { handleResponses } from "./responses"; +import { providerConsumesCallerAuthorization } from "../providers/caller-authorization"; +import { captureExplicitOpenAiCallerAuth } from "../providers/openai-sidecar"; +import { captureCallerDirectAuth } from "../providers/caller-authorization"; import type { AdmissionLease } from "../lib/admission"; import type { DataPlaneAdmission } from "./auth-cors"; import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission"; @@ -133,7 +136,8 @@ async function handleChatCompletionsWithBudget( // it registers (extra_headers, sent verbatim by upstream Grok). Dashboard usage // bucketing only — never an auth or billing signal. if (req.headers.get("x-opencodex-grok") === "1") logCtx.surface = "grok"; - let directRoute = false; + let callerAuthorizationRoute = false; + let routeMayChangeCredentialDomain = false; let settledRoute: ReturnType | null = null; let chatNativeRoute: ReturnType | null = null; try { @@ -150,9 +154,9 @@ async function handleChatCompletionsWithBudget( logCtx.provider = route.providerName; logCtx.routeDecision = route.routeDecision; settledRoute = route; - if (route.provider.adapter === "openai-responses") { - directRoute = route.codexAccountMode === "direct"; - } + routeMayChangeCredentialDomain = route.combo !== undefined || route.routeKind === "policy"; + callerAuthorizationRoute = !routeMayChangeCredentialDomain + && providerConsumesCallerAuthorization(route.provider); if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") { const parts: string[] = []; if (chatBody.messages !== undefined) parts.push(JSON.stringify(chatBody.messages)); @@ -240,17 +244,23 @@ async function handleChatCompletionsWithBudget( && isCodexReserveHelperUnsupported(config, settledRoute.modelId, logIds?.admission, visionDescribeTerminal)) { return chatCompletionsErrorResponse(400, CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, "invalid_request_error"); } + const nativeCallerAuth = captureExplicitOpenAiCallerAuth(req.headers, config); + // Caller-owned only: stored-main enrichment below is sidecar authority, never Direct authority. + const callerDirectAuth = captureCallerDirectAuth(req.headers, config); + let openAiSidecarAuth = nativeCallerAuth; const headers = new Headers({ "content-type": "application/json" }); // Internal bridge metadata; the Go resolver scopes and hashes it before upstream use. const openCodeSession = req.headers.get("x-opencode-session"); if (openCodeSession) headers.set("x-opencode-session", openCodeSession); for (const name of FORWARD_HEADERS) { - if (name === "authorization" && !directRoute) continue; + if (routeMayChangeCredentialDomain && (name === "authorization" || name === "chatgpt-account-id")) continue; + if (name === "authorization" && !callerAuthorizationRoute) continue; const value = req.headers.get(name); if (value) headers.set(name, value); } - // Prefer main ChatGPT auth so OpenAI-backed sidecars remain reachable on routed turns. - if (!directRoute) { + // A noncanonical caller-auth route can use stored main auth only through a sidecar snapshot. + // Later shadow/thread rewrites strip primary credentials at the actual Responses boundary. + if (!callerAuthorizationRoute || (settledRoute && !isCanonicalOpenAiForwardProvider(settledRoute.provider))) { // This enrichment is optional for routed/non-main providers. If native main // is fenced, omit it and let auth-context reject only a final physical-main // selection while healthy pool/provider routes continue. @@ -259,8 +269,12 @@ async function handleChatCompletionsWithBudget( const { getMainAccountToken } = await import("../codex/main-account"); const token = getMainAccountToken(); if (token) { - headers.set("authorization", `Bearer ${token.accessToken}`); - headers.set("chatgpt-account-id", token.chatgptAccountId); + const mainHeaders = new Headers({ authorization: `Bearer ${token.accessToken}`, "chatgpt-account-id": token.chatgptAccountId }); + openAiSidecarAuth ??= captureExplicitOpenAiCallerAuth(mainHeaders, config); + if (!callerAuthorizationRoute && !routeMayChangeCredentialDomain) { + headers.set("authorization", `Bearer ${token.accessToken}`); + headers.set("chatgpt-account-id", token.chatgptAccountId); + } } } catch { /* optional */ @@ -299,6 +313,9 @@ async function handleChatCompletionsWithBudget( addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta); }; const upstream = await handleResponses(internalReq, config, logCtx, { + openAiSidecarAuth, + nativeCallerAuth, + callerDirectAuth, ...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}), // #1686: the Chat surface translates its body and replays here, so the admission fact has // to ride along or a bearer-admitted Chat caller would still be refused by Direct. diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 8c3e37eea8..8ef750ec4e 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -837,6 +837,7 @@ async function handleClaudeMessagesWithBudget( } const headers = new Headers({ "content-type": "application/json" }); + let trustedClaudeMainAuth: { authorization: string; chatgptAccountId?: string } | undefined; for (const name of FORWARD_HEADERS) { // The caller's bearer is the proxy admission token (ocx claude placeholder), never a // ChatGPT credential — forwarding it upstream turns into {"detail":"Unauthorized"}. @@ -852,8 +853,13 @@ async function handleClaudeMessagesWithBudget( const { getMainAccountToken } = await import("../codex/main-account"); const token = getMainAccountToken(); if (token) { - headers.set("authorization", `Bearer ${token.accessToken}`); + const authorization = `Bearer ${token.accessToken}`; + headers.set("authorization", authorization); headers.set("chatgpt-account-id", token.chatgptAccountId); + trustedClaudeMainAuth = { + authorization, + ...(token.chatgptAccountId ? { chatgptAccountId: token.chatgptAccountId } : {}), + }; } } if (opencodeGoRoute) { @@ -923,6 +929,10 @@ async function handleClaudeMessagesWithBudget( // would fire, disagreeing with the pre-flight decision above. inboundWire: "anthropic", stripClaudeMainAuthForNoncanonicalForward: true, + ...(trustedClaudeMainAuth ? { trustedClaudeMainAuth } : {}), + // Claude's internal stored-main enrichment is not an original caller credential. + nativeCallerAuth: null, + callerDirectAuth: null, translatorBudget, ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index b961e7cef9..5e93721aaf 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -210,11 +210,14 @@ import { import { ForwardAdmissionCredentialError, hasForwardableCodexBearer, + isProxyAdmissionSecret, validateForwardAdmissionCredential, } from "../auth-cors"; import type { DataPlaneAdmission } from "../auth-cors"; import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; -import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; +import { captureExplicitOpenAiCallerAuth, listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ExplicitOpenAiCallerAuth, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; +import { inspectChatGptDomainClaim } from "../../oauth/chatgpt"; +import { captureCallerDirectAuth, providerConsumesCallerAuthorization, type CallerDirectAuth } from "../../providers/caller-authorization"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported } from "../../codex/loopback-target"; import { providerContextCap } from "../../providers/context-cap"; @@ -1121,7 +1124,8 @@ export async function shouldRetryCodexPoolAccountQuota( } interface CodexPoolAccountRetryArgs { - req: Request; + /** Sanitized caller input, before any selected Pool credential was materialized. */ + callerAuthHeaders: Headers; config: OcxConfig; route: { providerName: string; modelId: string; provider: OcxProviderConfig }; parsed: OcxParsedRequest; @@ -1287,7 +1291,7 @@ async function retryCodexPoolOnAlternateAccount( args: CodexPoolAccountRetryArgs, ): Promise { const { - req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse, + callerAuthHeaders, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse, outcomeStatus, upstream, connectMs, passthroughEstimate, stream, } = args; const inboundWire = options.inboundWire ?? "responses"; @@ -1321,7 +1325,7 @@ async function retryCodexPoolOnAlternateAccount( } try { retryAuthCtx ??= await resolveCodexAuthContext( - req.headers, + callerAuthHeaders, config, "pool", { @@ -1329,7 +1333,7 @@ async function retryCodexPoolOnAlternateAccount( admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, modelId: route.modelId, - requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config), + requestScopedMainCredential: hasForwardableCodexBearer(callerAuthHeaders, config), beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), resolveCodexModelEntitlements: entitlementResolver, }, @@ -1399,7 +1403,7 @@ async function retryCodexPoolOnAlternateAccount( // Only a combo reset-derived outcome is deferred. Retry-After, defaults, and // ordinary requests must block the first account before the alternate send. if (!deferFirstOutcome) recordFirstOutcome(); - const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission); + const retryHeaders = headersForCodexAuthContext(callerAuthHeaders, retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission); const retryProvider = applyCodexAuthContextToProvider( stripCodexRuntimeProviderFields(route.provider), retryAuthCtx, @@ -1703,9 +1707,17 @@ export interface HandleResponsesOptions { /** * Claude replay may add native-main auth so OpenAI sidecars remain available. * Strip only that internal credential when the final route is a noncanonical - * forward destination; final routing can differ from Claude's preflight route. + * forward/caller-auth destination; final routing can differ from Claude's preflight route. */ stripClaudeMainAuthForNoncanonicalForward?: boolean; + /** In-memory credential proven by Claude's native-main turn claim; never persist or log. */ + trustedClaudeMainAuth?: { authorization: string; chatgptAccountId?: string }; + /** Sidecar-only auth captured before route changes; null means no usable original pair. */ + openAiSidecarAuth?: ExplicitOpenAiCallerAuth | null; + /** Original caller-owned native pair; separate from any claimed sidecar enrichment. */ + nativeCallerAuth?: ExplicitOpenAiCallerAuth | null; + /** Caller Direct credential under Direct\'s own predicate; restored only for the canonical OpenAI final route. */ + callerDirectAuth?: CallerDirectAuth | null; /** Internal recursion guard; callers outside this module must not set it. */ comboAttempt?: boolean; /** Internal combo handoff for one parent-validated continuation snapshot. */ @@ -1925,6 +1937,9 @@ export function createChildPassthroughCallbackGate(options: HandleResponsesOptio export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers { const childHeaders = new Headers(parentHeaders); + // A provisional caller credential is not authoritative for a Combo child. + childHeaders.delete("authorization"); + childHeaders.delete("chatgpt-account-id"); // Combo children re-serialize already-decoded JSON. Keeping transport metadata from // the parent would make the child decoder treat plain JSON as compressed bytes. childHeaders.delete("content-length"); @@ -2009,7 +2024,7 @@ function canPassThroughEncryptedV2AgentTask( } type ResponsesAuthResolution = - | { ok: true; authCtx: CodexAuthContext; headers: Headers; substituteMainCredential: boolean } + | { ok: true; authCtx: CodexAuthContext; headers: Headers; callerAuthHeaders: Headers; substituteMainCredential: boolean } | { ok: false; response: Response }; /** @@ -2021,8 +2036,75 @@ async function resolveResponsesCodexAuth( config: OcxConfig, route: RouteResult, options: HandleResponsesOptions, + credentialDomainWasRewritten = false, ): Promise { try { + const routeMayChangeCredentialDomain = options.comboAttempt === true + || route.routeKind === "policy" + || credentialDomainWasRewritten; + const trustedClaudeMainForFinalRoute = options.stripClaudeMainAuthForNoncanonicalForward === true + && isCanonicalOpenAiForwardProvider(route.provider) + ? options.trustedClaudeMainAuth : undefined; + let authInputHeaders = req.headers; + // Route-changing recursion retains typed admission, never an unscoped raw + // caller credential. Bearer admission is substituted or stripped below. + if (routeMayChangeCredentialDomain && options.admission?.source !== "bearer" + && !trustedClaudeMainForFinalRoute) { + authInputHeaders = new Headers(req.headers); + authInputHeaders.delete("authorization"); + authInputHeaders.delete("chatgpt-account-id"); + } + if (trustedClaudeMainForFinalRoute) { + authInputHeaders = new Headers(authInputHeaders); + authInputHeaders.set("authorization", trustedClaudeMainForFinalRoute.authorization); + if (trustedClaudeMainForFinalRoute.chatgptAccountId) { + authInputHeaders.set("chatgpt-account-id", trustedClaudeMainForFinalRoute.chatgptAccountId); + } else { + authInputHeaders.delete("chatgpt-account-id"); + } + } + // A caller-auth transport that is not canonical OpenAI (keyless Cursor) consumes the + // caller's Authorization as its own upstream token. Keep that contract only for a clean + // single bearer with NO ChatGPT-domain marker. A bearer marked for the ChatGPT domain — + // whether its marker is valid or malformed/conflicting — a combined/malformed value, or + // the captured explicit OpenAI pair is never a Cursor token; a foreign JWT carrying only + // a generic organizations claim is not ChatGPT-marked and keeps the legacy contract. + // chatgpt-account-id has no meaning outside the ChatGPT domain. + if (!isCanonicalOpenAiForwardProvider(route.provider) + && providerConsumesCallerAuthorization(route.provider)) { + const rawAuth = authInputHeaders.get("authorization")?.trim(); + const singleBearer = /^Bearer[\t ]+([^\s,]+)$/i.exec(rawAuth ?? "")?.[1]; + const domainClaim = singleBearer ? inspectChatGptDomainClaim(singleBearer) : { kind: "absent" as const }; + const dropBearer = options.nativeCallerAuth != null || domainClaim.kind !== "absent" + || (rawAuth !== undefined && singleBearer === undefined); + if (dropBearer || authInputHeaders.has("chatgpt-account-id")) { + const scoped = new Headers(authInputHeaders); + if (dropBearer) scoped.delete("authorization"); + scoped.delete("chatgpt-account-id"); + authInputHeaders = scoped; + } + } + // The caller's own Direct credential may cross an internal route change only to the + // canonical OpenAI transport, under a predicate deliberately STRICTER than plain + // unchanged-route Direct forwarding: a clean non-proxy bearer whose ChatGPT-domain + // marker is valid, with any explicit account header matching that marker. Unchanged + // routes keep their legacy rules; sidecar enrichment grants no primary authority. + if (options.callerDirectAuth && isCanonicalOpenAiForwardProvider(route.provider)) { + const directHeaders = new Headers({ + authorization: options.callerDirectAuth.authorization, + ...(options.callerDirectAuth.chatgptAccountId + ? { "chatgpt-account-id": options.callerDirectAuth.chatgptAccountId } : {}), + }); + if (captureCallerDirectAuth(directHeaders, config)) { + authInputHeaders = new Headers(authInputHeaders); + authInputHeaders.set("authorization", options.callerDirectAuth.authorization); + if (options.callerDirectAuth.chatgptAccountId) { + authInputHeaders.set("chatgpt-account-id", options.callerDirectAuth.chatgptAccountId); + } else { + authInputHeaders.delete("chatgpt-account-id"); + } + } + } // #1686: a caller that proved admission with a BEARER presented one of our own secrets. // Refusing it here is what made the codex-cli `env_key` contract unusable against Direct. // Admitting it is only safe because the stored main credential is substituted below, so @@ -2046,15 +2128,16 @@ async function resolveResponsesCodexAuth( // no-ChatGPT-login install keeps working. const substituteMainCredential = options.admission?.source === "bearer" && (route.codexAccountMode !== undefined || isCanonicalOpenAiForwardProvider(route.provider)); + const stripAuthorization = options.admission?.source === "bearer" && !substituteMainCredential; const requestScopedMainCredential = route.codexAccountMode !== undefined && !substituteMainCredential - && hasForwardableCodexBearer(req.headers, config); + && hasForwardableCodexBearer(authInputHeaders, config); if (route.codexAccountMode === "direct" && !substituteMainCredential) { - validateForwardAdmissionCredential(req.headers, config); + validateForwardAdmissionCredential(authInputHeaders, config); } let authCtx: CodexAuthContext; if (route.codexAccountMode) { - authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { + authCtx = await resolveCodexAuthContext(authInputHeaders, config, route.codexAccountMode, { admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, accountId: route.codexAccountId, @@ -2090,7 +2173,7 @@ async function resolveResponsesCodexAuth( // (custom-named canonical-forward providers must retain the same protection). const mainPolicyConfig = isCanonicalOpenAiForwardProvider(route.provider) ? options.codexAuthPolicy ?? config : undefined; - const headers = await materializeCodexUpstreamAuthAsync(req.headers, authCtx, { + const headers = await materializeCodexUpstreamAuthAsync(authInputHeaders, authCtx, { admission: options.admission, config: mainPolicyConfig, modelId: route.modelId, @@ -2109,10 +2192,27 @@ async function resolveResponsesCodexAuth( response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"), }; } + if (stripAuthorization) { + headers.delete("authorization"); + headers.delete("chatgpt-account-id"); + } + if (providerConsumesCallerAuthorization(route.provider) && options.admission?.source !== undefined + && options.admission.source !== "loopback") { + validateForwardAdmissionCredential(headers, config); + } else { + // Even adapters that ignore caller auth must not retain a proxy secret for + // a later internal hop or a future transport change. + const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + if (bearer && isProxyAdmissionSecret(bearer, config)) { + headers.delete("authorization"); + headers.delete("chatgpt-account-id"); + } + } return { ok: true, authCtx, headers, + callerAuthHeaders: new Headers(authInputHeaders), substituteMainCredential, }; } catch (err) { @@ -3084,6 +3184,12 @@ export async function handleResponses( try { const response = await handleResponsesInner(req, config, logCtx, { ...options, + openAiSidecarAuth: options.openAiSidecarAuth === undefined + ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth, + nativeCallerAuth: options.nativeCallerAuth === undefined + ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.nativeCallerAuth, + callerDirectAuth: options.callerDirectAuth === undefined + ? captureCallerDirectAuth(req.headers, config) : options.callerDirectAuth, // Capture before combo replay rebuilds the Request headers; children carry options. visionDescribeTerminal: options.visionDescribeTerminal === true || req.headers.get("x-opencodex-vision-describe") === "1", @@ -3332,6 +3438,7 @@ async function handleResponsesInner( logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier); let route: RouteResult; + let credentialDomainWasRewritten = false; try { // A `compaction_trigger` turn may name a bare native model the operator has // no canonical OpenAI route for (#2901). Only the initial compaction route @@ -3353,6 +3460,7 @@ async function handleResponsesInner( } catch { /* Native Codex helper calls remain OpenAI-owned without an enabled OpenAI route. */ } const targetRoute = resolveRoute(_sci.model); if (shouldInterceptShadowCall(parsed.modelId, _sci.sourceModels, sourceIdentity, targetRoute)) { + credentialDomainWasRewritten = true; const _sciOriginal = parsed.modelId; parsed.modelId = _sci.model; if (parsed._rawBody && typeof parsed._rawBody === "object") { @@ -3484,6 +3592,7 @@ async function handleResponsesInner( if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) { try { route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody)); + credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; } catch (err) { if (err instanceof NoAvailableComboTargetsError) { @@ -3620,6 +3729,7 @@ async function handleResponsesInner( if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) { try { route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody)); + credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; } catch (err) { if (err instanceof NoAvailableComboTargetsError) { @@ -3742,11 +3852,13 @@ async function handleResponsesInner( } let substituteMainCredential = false; + let callerAuthHeaders: Headers; { - const finalAuth = await resolveResponsesCodexAuth(req, config, route, options); + const finalAuth = await resolveResponsesCodexAuth(req, config, route, options, credentialDomainWasRewritten); if (!finalAuth.ok) return finalAuth.response; authCtx = finalAuth.authCtx; selectedForwardHeaders = finalAuth.headers; + callerAuthHeaders = finalAuth.callerAuthHeaders; substituteMainCredential = finalAuth.substituteMainCredential; } @@ -4223,9 +4335,9 @@ async function handleResponsesInner( ); let adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); const stripClaudeMainAuth = options.stripClaudeMainAuthForNoncanonicalForward === true - && adapterProvider.adapter === "openai-responses" - && adapterProvider.authMode === "forward" - && !isCanonicalOpenAiForwardProvider(adapterProvider); + && !isCanonicalOpenAiForwardProvider(adapterProvider) + && ((adapterProvider.adapter === "openai-responses" && adapterProvider.authMode === "forward") + || providerConsumesCallerAuthorization(adapterProvider)); if (stripClaudeMainAuth) { releaseCodexAuthContextProbeLease(authCtx); authCtx = { kind: "main", accountId: null }; @@ -4313,9 +4425,18 @@ async function handleResponsesInner( const needsOpenAiSearch = shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough); if (needsOpenAiVision || needsOpenAiSearch) { try { + // Preserve explicit OpenAI helper auth across route changes without returning it to + // primary-provider headers or alternate-main retry. The resolver revalidates scope. + const sidecarHeaders = new Headers(req.headers); + sidecarHeaders.delete("authorization"); + sidecarHeaders.delete("chatgpt-account-id"); + if (options.openAiSidecarAuth) { + sidecarHeaders.set("authorization", options.openAiSidecarAuth.authorization); + sidecarHeaders.set("chatgpt-account-id", options.openAiSidecarAuth.chatgptAccountId); + } openAiSidecar = await resolveFirstUsableOpenAiSidecar( listOpenAiForwardSidecarCandidates(config), - req.headers, + sidecarHeaders, config, { admission: options.admission, @@ -5400,7 +5521,7 @@ async function handleResponsesInner( // justify because this flag already produced the identical result. const storedReplaySpent = codex401ReplayKind === "stored"; const retry = await retryCodexPoolOnAlternateAccount({ - req, + callerAuthHeaders, config, route, parsed, diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index a4f06d0fa6..a6f1d425d8 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -6,6 +6,8 @@ import type { OcxConfig } from "../../types"; import type { RouteCandidateTrace, RouteDecisionTraceV1 } from "../../routing/trace"; import { handleResponses as handleResponsesCore } from "./core"; import { requestPacingOverloadResponse } from "./pacing-overload"; +import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar"; +import { captureCallerDirectAuth } from "../../providers/caller-authorization"; type CoreHandler = typeof handleResponsesCore; type CoreOptions = Parameters[3]; @@ -47,6 +49,10 @@ function requestWithCandidate( candidate: Pick, ): Request { const headers = new Headers(req.headers); + // The next candidate owns a different physical credential domain. Typed + // admission and any claimed Claude snapshot stay in caller-owned CoreOptions. + headers.delete("authorization"); + headers.delete("chatgpt-account-id"); headers.delete("content-encoding"); headers.delete("content-length"); headers.set("content-type", "application/json"); @@ -119,6 +125,12 @@ export async function handleResponsesWithPolicyFallback( let storedPool401ReplayDispatched = false; const coreOptions: CoreOptions = { ...options, + openAiSidecarAuth: options.openAiSidecarAuth === undefined + ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth, + nativeCallerAuth: options.nativeCallerAuth === undefined + ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.nativeCallerAuth, + callerDirectAuth: options.callerDirectAuth === undefined + ? captureCallerDirectAuth(req.headers, config) : options.callerDirectAuth, ...(options.onRequestBodyRead ? { onRequestBodyRead: () => { if (requestBodyReadNotified) return; diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 91627acaf0..2942135d51 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -18,6 +18,39 @@ engine. Direct short-circuits that engine before pool state is read or mutated a current caller/main-login bearer. Neither mode may fall through to `openai-apikey`, and the API provider may not fall through to Codex-login credentials. +Caller credentials stay scoped to the selected physical route. Typed proxy admission survives +Combo/policy recursion, but raw Authorization and ChatGPT account headers are removed from +rebuilt requests at those selections or actual shadow/thread-spawn rewrites. An original caller's +Direct credential — a clean non-proxy bearer carrying a locally decoded ChatGPT account claim +(routing evidence, not signature verification), with any explicit account header matching that +claim — is captured separately and may be +restored only for the final canonical OpenAI route, under the existing Direct/Pool, native-main +claim, and entitlement rules. This restore is deliberately stricter than unchanged-route Direct +forwarding, which keeps its legacy rules. The stricter explicit-pair snapshot (JWT with matching account +header) additionally feeds optional OpenAI sidecars and is also +withheld from an unchanged keyless Cursor route; an independently supplied Cursor bearer +remains supported. A noncanonical caller-auth transport keeps only a clean single bearer with +no ChatGPT account claim: a bearer carrying a ChatGPT account claim, a combined or malformed +Authorization value, and the chatgpt-account-id header are withheld from it. Key-auth and noncanonical routes use +their own configured key or provider-owned OAuth credential. Canonical unqualified `openai` +forwarding preserves the sanitized caller/main-login bearer in Direct mode and may select a +stored native credential in Pool mode. An explicit account-qualified sidecar may select its +stored account even when the provider default is Direct. A thread-spawn marker without a rewrite +preserves the caller credential. Bearer admission can still select stored native credentials under +the existing turn claim. Claude replay may reconstruct its claimed main snapshot only for a final canonical +ChatGPT target. Alternate-account retry retains the sanitized caller input separately from the +selected Pool headers, so neither a discarded source bearer nor a Pool token becomes caller-main +authority during retry. + +Explicit OpenAI sidecar authentication is retained separately in request-local memory before +Combo or policy headers are rewritten. Only the canonical sidecar resolver can restore that +single bearer and matching explicit account pair; it revalidates the existing credential and +destination rules. A recorded absence is not recaptured from a later provider request, and +combined Authorization values are rejected. This snapshot never becomes primary-provider or +alternate-main retry authentication; the original caller's native snapshot is separate. +Optional Chat/Claude stored-main enrichment still requires +the native-main turn claim. + The two routes also keep separate request-compatibility contracts. The canonical ChatGPT Codex forward destination removes public `prompt_cache_options` because that backend rejects the field before inference; `prompt_cache_key` remains supported. `openai-apikey` and noncanonical/custom diff --git a/tests/codex-integration/bearer-admission-routed-provider.test.ts b/tests/codex-integration/bearer-admission-routed-provider.test.ts index e74cd0ce21..5b87318626 100644 --- a/tests/codex-integration/bearer-admission-routed-provider.test.ts +++ b/tests/codex-integration/bearer-admission-routed-provider.test.ts @@ -1,9 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; +import http2 from "node:http2"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; +import { clearComboTargetCooldowns } from "../../src/combos/failover"; import { startServer } from "../../src/server"; +import { noteSubagentModelFailure, resetSubagentModelFallbackStateForTests } from "../../src/codex/subagent-model-fallback"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; import { acquireNativeMainProfileDrain, getNativeMainProfileRequestCount, @@ -14,6 +18,8 @@ import type { NativeProfileManager } from "../../src/codex/native-profile-manage import type { OcxConfig } from "../../src/types"; import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { resetVisionDescriptionCache } from "../../src/vision"; /** * Issue #2132: bearer admission must not require a stored ChatGPT credential. @@ -34,11 +40,13 @@ const originalFetch = globalThis.fetch; const previousOcxHome = process.env.OPENCODEX_HOME; const previousCodexHome = process.env.CODEX_HOME; const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const previousCursorTestToken = process.env.OPENCODEX_CURSOR_TEST_TOKEN; let ocxHome = ""; let codexHome = ""; let routedAuth: Array = []; let nativeAuth: Array = []; +let nativeAccountIds: Array = []; const ADMISSION_SECRET = "ocx_data_2132secret"; const ROUTED_KEY = "sk-routed-provider-key"; @@ -83,7 +91,71 @@ function mixedConfig(): OcxConfig { } as OcxConfig; } +function cursorForwardConfig(baseUrl: string, apiKey?: string): OcxConfig { + return { + port: 0, + hostname: "0.0.0.0", + defaultProvider: "cursorcustom", + providers: { + cursorcustom: { + adapter: "cursor", + baseUrl, + allowPrivateNetwork: true, + authMode: "forward", + ...(apiKey ? { apiKey } : {}), + liveModels: false, + models: ["auto"], + defaultModel: "auto", + }, + }, + apiKeys: [ + { id: "env-key", name: "env_key", key: ADMISSION_SECRET, createdAt: "2026-08-20T00:00:00.000Z" }, + ], + } as OcxConfig; +} + +async function withCursorCaptureServer( + run: (baseUrl: string, capturedAuth: Array) => Promise, +): Promise { + const capturedAuth: Array = []; + const sessions = new Set(); + const server = http2.createServer(); + server.on("session", session => { + sessions.add(session); + session.once("close", () => sessions.delete(session)); + }); + server.on("stream", (stream, headers) => { + const auth = headers.authorization; + capturedAuth.push(typeof auth === "string" ? auth : null); + stream.respond({ + ":status": typeof auth === "string" ? 200 : 401, + "content-type": "application/connect+proto", + }); + stream.end(); + }); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Cursor capture fixture did not bind"); + try { + return await run(`http://127.0.0.1:${address.port}`, capturedAuth); + } finally { + for (const session of sessions) session.destroy(); + await new Promise(resolve => server.close(() => resolve())); + } +} + beforeEach(() => { + resetVisionDescriptionCache(); + clearComboTargetCooldowns(); + resetSubagentModelFallbackStateForTests(); + delete process.env.OPENCODEX_CURSOR_TEST_TOKEN; ocxHome = mkdtempSync(join(tmpdir(), "ocx-2132-home-")); codexHome = mkdtempSync(join(tmpdir(), "ocx-2132-codex-")); process.env.OPENCODEX_HOME = ocxHome; @@ -91,6 +163,7 @@ beforeEach(() => { delete process.env.OPENCODEX_API_AUTH_TOKEN; routedAuth = []; nativeAuth = []; + nativeAccountIds = []; globalThis.fetch = (async (input, init) => { const raw = input instanceof Request ? input.url : String(input); const url = new URL(raw); @@ -107,6 +180,7 @@ beforeEach(() => { } if (url.hostname === "chatgpt.com" || url.hostname === "api.openai.com") { nativeAuth.push(headers.get("authorization")); + nativeAccountIds.push(headers.get("chatgpt-account-id")); return Response.json({ id: "resp_2132", object: "response", status: "completed", output: [] }); } return originalFetch(input, init); @@ -114,6 +188,12 @@ beforeEach(() => { }); afterEach(() => { + resetVisionDescriptionCache(); + closeRequestHistoryIndex(); + clearComboTargetCooldowns(); + resetSubagentModelFallbackStateForTests(); + if (previousCursorTestToken === undefined) delete process.env.OPENCODEX_CURSOR_TEST_TOKEN; + else process.env.OPENCODEX_CURSOR_TEST_TOKEN = previousCursorTestToken; globalThis.fetch = originalFetch; if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOcxHome; @@ -127,14 +207,60 @@ afterEach(() => { codexHome = ""; }); -async function postResponses(url: string | URL, model: string): Promise { +async function postResponses( + url: string | URL, + model: string, + authHeaders: HeadersInit = { authorization: `Bearer ${ADMISSION_SECRET}` }, +): Promise { + const headers = new Headers(authHeaders); + headers.set("content-type", "application/json"); return originalFetch(new URL("/v1/responses", url), { method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${ADMISSION_SECRET}` }, + headers, body: JSON.stringify({ model, input: "hi", stream: false }), }); } +async function postChatCompletions( + url: string | URL, + model: string, + authHeaders: HeadersInit, +): Promise { + const headers = new Headers(authHeaders); + headers.set("content-type", "application/json"); + return originalFetch(new URL("/v1/chat/completions", url), { + method: "POST", + headers, + body: JSON.stringify({ model, messages: [{ role: "user", content: "hi" }], stream: false }), + }); +} + +async function postClaudeMessages( + url: string | URL, + model: string, +): Promise { + return originalFetch(new URL("/v1/messages", url), { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": ADMISSION_SECRET, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model, + max_tokens: 16, + messages: [{ role: "user", content: "hi" }], + stream: false, + }), + }); +} + +async function startOwnedServer(): Promise> { + const server = startServer(0, { inspectNativeCodexOwnership }); + await waitForNativeMainStartupGate(); + return server; +} + describe("#2132 bearer admission does not require a ChatGPT credential for routed providers", () => { test("a key-authenticated route is served with no stored main credential", async () => { saveConfig(mixedConfig()); @@ -198,6 +324,698 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route }); }); +describe("bearer admission is not reused as a Cursor upstream credential", () => { + test("a bearer admission secret is stripped before Cursor token fallback", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + const response = await postResponses(server.url, "cursorcustom/auto"); + // The runTurn adapter reports its pre-dispatch failure in a Responses terminal. + expect(await response.json()).toMatchObject({ status: "failed" }); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + test("dedicated admission preserves a separate Cursor bearer", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + await postResponses(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer cursor-upstream-token", + }); + expect(capturedAuth).toEqual(["Bearer cursor-upstream-token"]); + } finally { + await server.stop(true); + } + }); + }); + + test("bearer admission still uses a configured Cursor credential", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl, "cursor-configured-token")); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + await postResponses(server.url, "cursorcustom/auto"); + expect(capturedAuth).toEqual(["Bearer cursor-configured-token"]); + } finally { + await server.stop(true); + } + }); + }); + + test("dedicated admission refuses another proxy secret as Cursor auth", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + const response = await postResponses(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: `Bearer ${ADMISSION_SECRET}`, + }); + expect(response.status).toBe(401); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + test("configured Cursor auth still wins when dedicated admission carries a proxy bearer", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl, "cursor-configured-token")); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + await postResponses(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: `Bearer ${ADMISSION_SECRET}`, + }); + expect(capturedAuth).toEqual(["Bearer cursor-configured-token"]); + } finally { + await server.stop(true); + } + }); + }); + + test("Chat dedicated admission preserves its separate Cursor bearer over stored main auth", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + })); + + const server = await startOwnedServer(); + try { + await postChatCompletions(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer cursor-upstream-token", + }); + expect(capturedAuth).toEqual(["Bearer cursor-upstream-token"]); + } finally { + await server.stop(true); + } + }); + }); + + test.each(["owned", "fenced"])("Chat Cursor keeps stored vision auth off its primary wire (%s)", async ownership => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.providers.cursorcustom!.noVisionModels = ["auto"]; + config.providers.openai = { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", codexAccountMode: "direct", + }; + // Keep this auth fixture independent of the legacy sidecar model migration. + config.visionSidecar = { enabled: true, backend: "openai", model: "gpt-5.6-luna" }; + saveConfig(config); + const stored = fakeChatGptJwt({ chatgpt_account_id: "stored_main_acc", exp: Math.floor(Date.now() / 1000) + 3600 }); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: stored, account_id: "stored_main_acc" }, + })); + const sidecar: Array<{ authorization: string | null; account: string | null; claimed: boolean }> = []; + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "chatgpt.com") { + const headers = new Headers(input instanceof Request ? input.headers : init?.headers); + sidecar.push({ authorization: headers.get("authorization"), account: headers.get("chatgpt-account-id"), + claimed: getNativeMainProfileRequestCount() > 0 }); + return new Response(`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "A red square." })}\n\ndata: [DONE]\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + } + return originalFetch(input, init); + }) as typeof fetch; + const server = ownership === "owned" ? await startOwnedServer() : startServer(0, { + inspectNativeCodexOwnership: () => ({ ownership: "foreign", reason: "fixture owned by another service" }), + }); + try { + if (ownership === "fenced") expect(await waitForNativeMainStartupGate()).toMatchObject({ status: "blocked" }); + const response = await originalFetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json", "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer cursor-upstream-token" }, + body: JSON.stringify({ model: "cursorcustom/auto", stream: false, messages: [{ role: "user", content: [ + { type: "text", text: "Describe this image" }, + { type: "image_url", image_url: { url: "data:image/png;base64,aGVsbG8taW1hZ2UtYnl0ZXM=" } }, + ] }] }), + }); + await response.text(); + // The capture-only Cursor fixture ends without a completion frame. + expect(response.status).toBe(502); + expect(sidecar).toEqual(ownership === "owned" + ? [{ authorization: `Bearer ${stored}`, account: "stored_main_acc", claimed: true }] : []); + expect(capturedAuth).toEqual(["Bearer cursor-upstream-token"]); + } finally { + await server.stop(true); + } + expect(getNativeMainProfileRequestCount()).toBe(0); + }); + }); + + test("Chat never falls back from missing Cursor auth to stored main auth", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + })); + + const server = await startOwnedServer(); + try { + const response = await postChatCompletions(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + }); + expect(response.status).not.toBe(200); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + test("Chat bearer admission is stripped before Cursor token fallback", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + })); + + const server = await startOwnedServer(); + try { + const response = await postChatCompletions(server.url, "cursorcustom/auto", { + authorization: `Bearer ${ADMISSION_SECRET}`, + }); + expect(response.status).not.toBe(200); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + test.each(["Responses", "Chat"])("%s keeps an explicit OpenAI pair off an unchanged Cursor route", async surface => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + const server = await startOwnedServer(); + try { + const headers = { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "caller-openai" })}`, + "chatgpt-account-id": "caller-openai", + }; + const response = surface === "Chat" + ? await postChatCompletions(server.url, "cursorcustom/auto", headers) + : await postResponses(server.url, "cursorcustom/auto", headers); + if (surface === "Responses") { + expect(await response.json()).toMatchObject({ status: "failed" }); + } else { + expect(response.status).not.toBe(200); + await response.text(); + } + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + test.each(["Responses", "Chat"])("%s never treats a ChatGPT-claimed or combined bearer as a Cursor token", async surface => { + const chatGptJwt = fakeChatGptJwt({ chatgpt_account_id: "caller-openai" }); + const cases: Array> = [ + // A ChatGPT JWT without the matching account header is still the ChatGPT domain. + { authorization: `Bearer ${chatGptJwt}` }, + // A mismatched explicit account does not reclassify the token. + { authorization: `Bearer ${chatGptJwt}`, "chatgpt-account-id": "other-account" }, + // A combined value is not a single Cursor token. + { authorization: `Bearer ${chatGptJwt}, Bearer other` }, + // Conflicting ChatGPT markers are ChatGPT-marked but untrustworthy, not foreign-allowed. + { authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "caller-openai", "https://api.openai.com/auth": { chatgpt_account_id: "other-claim" } })}` }, + // A malformed ChatGPT marker is still ChatGPT-marked. + { authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: 123 })}` }, + // A blank account id is not a usable id. + { authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: " " })}` }, + // The reserved namespace is a marker by its presence, whatever shape it carries: + // a primitive, null, an array, or an object without the claim all stay ChatGPT-marked. + { authorization: `Bearer ${fakeChatGptJwt({ "https://api.openai.com/auth": "not-an-object" })}` }, + { authorization: `Bearer ${fakeChatGptJwt({ "https://api.openai.com/auth": null })}` }, + { authorization: `Bearer ${fakeChatGptJwt({ "https://api.openai.com/auth": [] })}` }, + { authorization: `Bearer ${fakeChatGptJwt({ "https://api.openai.com/auth": {} })}` }, + { authorization: `Bearer ${fakeChatGptJwt({ "https://api.openai.com/auth": { user_id: "u_1" } })}` }, + ]; + for (const extra of cases) { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + const server = await startOwnedServer(); + try { + const headers = { "x-opencodex-api-key": ADMISSION_SECRET, ...extra }; + const response = surface === "Chat" + ? await postChatCompletions(server.url, "cursorcustom/auto", headers) + : await postResponses(server.url, "cursorcustom/auto", headers); + if (surface === "Responses") { + expect(await response.json()).toMatchObject({ status: "failed" }); + } else { + expect(response.status).not.toBe(200); + await response.text(); + } + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + } + }); + + test.each(["Responses", "Chat"])("%s keeps an unmarked JWT as the Cursor credential", async surface => { + // Neither a generic organizations claim nor a payload that is not a JSON object is + // ChatGPT-domain evidence: the legacy keyless Cursor contract keeps forwarding such a + // bearer (the account header, a ChatGPT-only header, is still dropped). The primitive + // payload also proves the domain inspector stays total instead of throwing. + const orgJwt = fakeChatGptJwt({ organizations: [{ id: "org-foreign" }] }); + const primitivePayloadJwt = `eyJhbGciOiJub25lIn0.${Buffer.from("true").toString("base64url")}.fakesig`; + for (const [bearer, extra] of [ + [orgJwt, {}], + [orgJwt, { "chatgpt-account-id": "org-foreign" }], + [primitivePayloadJwt, {}], + ] as Array<[string, Record]>) { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + const server = await startOwnedServer(); + try { + const headers = { "x-opencodex-api-key": ADMISSION_SECRET, authorization: `Bearer ${bearer}`, ...extra }; + const response = surface === "Chat" + ? await postChatCompletions(server.url, "cursorcustom/auto", headers) + : await postResponses(server.url, "cursorcustom/auto", headers); + await response.text(); + expect(capturedAuth).toEqual([`Bearer ${bearer}`]); + } finally { + await server.stop(true); + } + }); + } + }); + + test.each([false, true])("Chat combos never assign caller auth to a Cursor target (OpenAI pair: %s)", async openAiPair => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.combos = { + free: { strategy: "failover", targets: [{ provider: "cursorcustom", model: "auto" }] }, + }; + saveConfig(config); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + })); + + const server = await startOwnedServer(); + try { + const response = await postChatCompletions(server.url, "combo/free", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: `Bearer ${openAiPair ? fakeChatGptJwt({ chatgpt_account_id: "caller-openai" }) : "cursor-upstream-token"}`, + ...(openAiPair ? { "chatgpt-account-id": "caller-openai" } : {}), + }); + expect(response.status).not.toBe(200); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + test.each([false, true])("Responses combos never assign caller auth to a Cursor target (OpenAI pair: %s)", async openAiPair => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.combos = { + free: { strategy: "failover", targets: [{ provider: "cursorcustom", model: "auto" }] }, + }; + saveConfig(config); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + const response = await postResponses(server.url, "combo/free", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: `Bearer ${openAiPair ? fakeChatGptJwt({ chatgpt_account_id: "caller-openai" }) : "cursor-upstream-token"}`, + ...(openAiPair ? { "chatgpt-account-id": "caller-openai" } : {}), + }); + expect(response.status).not.toBe(200); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + // A present reserved namespace carries the marker whatever its shape, so every broken + // shape is untrustworthy rather than foreign, and is denied restore. + const brokenNamespaces: Record = { + "namespace-empty-object": {}, + "namespace-primitive": "not-an-object", + "namespace-null": null, + "namespace-array": [], + "namespace-without-claim": { user_id: "u_1" }, + }; + + const callerBearers: Record = { + "opaque-with-account": "opaque-caller-direct-token", + // The namespaced marker alone is a valid ChatGPT-domain claim. + "ns-claim-only": fakeChatGptJwt({ "https://api.openai.com/auth": { chatgpt_account_id: "caller-openai" } }), + // A generic organizations claim is not ChatGPT-domain evidence. + "org-only-jwt": fakeChatGptJwt({ organizations: [{ id: "org-foreign" }] }), + "conflicting-claims": fakeChatGptJwt({ chatgpt_account_id: "caller-openai", "https://api.openai.com/auth": { chatgpt_account_id: "other-claim" } }), + "blank-account-id": fakeChatGptJwt({ chatgpt_account_id: " " }), + "numeric-account-id": fakeChatGptJwt({ chatgpt_account_id: 123 }), + ...Object.fromEntries(Object.entries(brokenNamespaces) + .map(([name, shape]) => [name, fakeChatGptJwt({ "https://api.openai.com/auth": shape })])), + }; + + test("a bearer-admitted Responses combo still substitutes stored main on its final Direct target", async () => { + const config = mixedConfig(); + config.combos = { + native: { strategy: "failover", targets: [{ provider: "openai", model: "gpt-5.6-luna" }] }, + }; + const stored = liveJwt(); + saveConfig(config); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = await startOwnedServer(); + try { + const response = await postResponses(server.url, "combo/native"); + expect(response.status).toBe(200); + expect(nativeAuth).toEqual([`Bearer ${stored}`]); + expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); + } finally { + await server.stop(true); + } + }); + + test.each(["jwt-only", "jwt-with-account", "ns-claim-only", "opaque-with-account", "jwt-mismatched-account", "org-only-jwt", "conflicting-claims", "namespace-empty-object", "namespace-primitive", "namespace-null", "namespace-array", "namespace-without-claim", "blank-account-id", "numeric-account-id"])( + "a dedicated-admission Responses combo scopes caller auth (%s) to its final Direct target", + async form => { + const config = mixedConfig(); + config.combos = { + native: { strategy: "failover", targets: [{ provider: "openai", model: "gpt-5.6-luna" }] }, + }; + saveConfig(config); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + const callerBearer = callerBearers[form] ?? fakeChatGptJwt({ chatgpt_account_id: "caller-openai" }); + const accountHeader = form === "jwt-only" || form === "ns-claim-only" ? undefined + : form === "jwt-mismatched-account" ? "other-account" + : form === "org-only-jwt" ? "org-foreign" + : "caller-openai"; + + const server = await startOwnedServer(); + try { + const response = await postResponses(server.url, "combo/native", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: `Bearer ${callerBearer}`, + ...(accountHeader ? { "chatgpt-account-id": accountHeader } : {}), + }); + const body = await response.json() as { status?: string }; + if (form === "jwt-only" || form === "jwt-with-account" || form === "ns-claim-only") { + expect(response.status).toBe(200); + expect(body).toMatchObject({ status: "completed" }); + expect(nativeAuth).toEqual([`Bearer ${callerBearer}`]); + expect(nativeAccountIds).toEqual(["caller-openai"]); + } else { + expect(response.status >= 400 || body.status === "failed").toBe(true); + expect(nativeAuth).toEqual([]); + expect(nativeAccountIds).toEqual([]); + } + } finally { + await server.stop(true); + } + }, + ); + + test("Chat thread-spawn fallback never carries the provisional Cursor bearer into Direct", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + defaultModel: "gpt-5.6-luna", + }; + config.subagentModelFallback = ["gpt-5.6-luna"]; + const stored = liveJwt(); + saveConfig(config); + noteSubagentModelFailure("cursorcustom/auto", "429", config); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = await startOwnedServer(); + try { + const response = await postChatCompletions(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer cursor-upstream-token", + "x-openai-subagent": "collab_spawn", + }); + expect(capturedAuth).toEqual([]); + expect(nativeAuth).toEqual([]); + expect(response.status).toBe(401); + } finally { + await server.stop(true); + } + }); + }); + + test("Responses thread-spawn fallback never carries the provisional Cursor bearer into Direct", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + defaultModel: "gpt-5.6-luna", + }; + config.subagentModelFallback = ["gpt-5.6-luna"]; + const stored = liveJwt(); + saveConfig(config); + noteSubagentModelFailure("cursorcustom/auto", "429", config); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = await startOwnedServer(); + try { + const response = await postResponses(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer cursor-upstream-token", + "x-openai-subagent": "collab_spawn", + }); + expect(capturedAuth).toEqual([]); + expect(nativeAuth).toEqual([]); + expect(response.status).toBe(401); + } finally { + await server.stop(true); + } + }); + }); + + test("Chat thread marker without a route rewrite preserves the caller's native credential", async () => { + saveConfig(mixedConfig()); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = await startOwnedServer(); + try { + const response = await postChatCompletions(server.url, "gpt-5.6-luna", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer caller-native-token", + "x-openai-subagent": "collab_spawn", + }); + expect(response.status).toBe(200); + expect(nativeAuth).toEqual(["Bearer caller-native-token"]); + expect(routedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + + for (const surface of ["Chat", "Responses"] as const) { + test(`${surface} policy routes never assign one provisional bearer to a selected provider`, async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.routingProfiles = { + cursor: { candidates: [{ provider: "cursorcustom", model: "auto" }] }, + }; + saveConfig(config); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + const authHeaders = { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer cursor-upstream-token", + }; + const response = surface === "Chat" + ? await postChatCompletions(server.url, "policy/cursor", authHeaders) + : await postResponses(server.url, "policy/cursor", authHeaders); + const status = response.status; + await response.arrayBuffer(); + expect(capturedAuth).toEqual([]); + // Chat returns a pre-stream provider failure; Responses may encode the same terminal + // failure inside its normal response envelope. The wire observation is authoritative. + expect([200, 502]).toContain(status); + } finally { + await server.stop(true); + } + }); + }); + } + + test("Claude policy routing preserves trusted main auth only for its final Direct target", async () => { + const config = mixedConfig(); + config.routingProfiles = { + native: { candidates: [{ provider: "openai", model: "gpt-5.6-luna" }] }, + }; + const stored = liveJwt(); + saveConfig(config); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = await startOwnedServer(); + try { + const response = await postClaudeMessages(server.url, "policy/native"); + expect(response.status).toBe(200); + expect(nativeAuth).toEqual([`Bearer ${stored}`]); + } finally { + await server.stop(true); + } + }); + + test("Claude Combo routing reconstructs trusted main auth for its final Direct target", async () => { + const config = mixedConfig(); + config.combos = { + native: { strategy: "failover", targets: [{ provider: "openai", model: "gpt-5.6-luna" }] }, + }; + const stored = liveJwt(); + saveConfig(config); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = await startOwnedServer(); + try { + const response = await postClaudeMessages(server.url, "combo/native"); + expect(response.status).toBe(200); + expect(nativeAuth).toEqual([`Bearer ${stored}`]); + } finally { + await server.stop(true); + } + }); + + test("Claude Combo routing cannot reconstruct main auth when the profile claim was fenced", async () => { + const config = mixedConfig(); + config.combos = { + native: { strategy: "failover", targets: [{ provider: "openai", model: "gpt-5.6-luna" }] }, + }; + saveConfig(config); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: liveJwt(), account_id: "stored_main_acc" } }), + ); + + const server = startServer(0, { + inspectNativeCodexOwnership: () => ({ ownership: "foreign", reason: "fixture-owned by another service" }), + }); + try { + expect(await waitForNativeMainStartupGate()).toMatchObject({ + status: "blocked", + reason: "foreign-ownership", + }); + const response = await postClaudeMessages(server.url, "combo/native"); + expect(response.status).toBe(401); + expect(nativeAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + + for (const surface of ["Chat", "Responses"] as const) { + test(`${surface} shadow-call rewrites never carry the source route bearer into Cursor`, async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + defaultModel: "gpt-5.6-luna", + }; + config.shadowCallIntercept = { + enabled: true, + model: "cursorcustom/auto", + sourceModels: ["gpt-5.6-luna"], + }; + saveConfig(config); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + const authHeaders = { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer source-route-token", + }; + const response = surface === "Chat" + ? await postChatCompletions(server.url, "gpt-5.6-luna", authHeaders) + : await postResponses(server.url, "gpt-5.6-luna", authHeaders); + const status = response.status; + await response.arrayBuffer(); + expect(capturedAuth).toEqual([]); + expect([200, 401, 502]).toContain(status); + } finally { + await server.stop(true); + } + }); + }); + } + + test("Claude replay never treats stored main auth as a Cursor credential", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + })); + + const server = await startOwnedServer(); + try { + const response = await postClaudeMessages(server.url, "cursorcustom/auto"); + expect(response.status).toBe(502); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); +}); + /** * The predicate above must be keyed on TRANSPORT, not on the provider's name. * diff --git a/tests/routing/combo-child-headers.test.ts b/tests/routing/combo-child-headers.test.ts index 16fb99d4d3..27c9e89960 100644 --- a/tests/routing/combo-child-headers.test.ts +++ b/tests/routing/combo-child-headers.test.ts @@ -17,6 +17,8 @@ describe("combo child request headers", () => { const parent = new Request("http://127.0.0.1:10100/v1/responses", { method: "POST", headers: { + authorization: "Bearer fixture", + "chatgpt-account-id": "caller-account", "content-type": "application/json", "content-encoding": "zstd", }, @@ -40,6 +42,8 @@ describe("combo child request headers", () => { ).rejects.toThrow(/Unknown frame descriptor|Invalid JSON|Unexpected token/i); const fixedHeaders = buildComboChildHeaders(parent.headers); + expect(fixedHeaders.has("authorization")).toBe(false); + expect(fixedHeaders.has("chatgpt-account-id")).toBe(false); expect(fixedHeaders.has("content-length")).toBe(false); expect(fixedHeaders.has("content-encoding")).toBe(false); const childDecoded = await readJsonRequestBody( diff --git a/tests/routing/routing-policy-fallback.test.ts b/tests/routing/routing-policy-fallback.test.ts index 205c70b872..1883c1277d 100644 --- a/tests/routing/routing-policy-fallback.test.ts +++ b/tests/routing/routing-policy-fallback.test.ts @@ -5,6 +5,7 @@ import { RequestPacingQueueOverloadError } from "../../src/providers/request-pac import type { OcxConfig } from "../../src/types"; import { beginRequestAttempt, type RequestLogContext } from "../../src/server/request-log"; import type { RouteDecisionTraceV1 } from "../../src/routing/trace"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { handleResponsesWithPolicyFallback, rankPolicyFallbackCandidates, @@ -47,6 +48,36 @@ function seedAttempt(logCtx: RequestLogContext, provider: string, model: string) } describe("policy candidate fallback", () => { + test("policy hops retain only the original sidecar snapshot outside primary headers", async () => { + const authorization = `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "sidecar-account" })}`; + const initial = request(); + const headers = new Headers(initial.headers); + headers.set("authorization", authorization); + headers.set("chatgpt-account-id", "sidecar-account"); + const log = { model: "", provider: "" } as RequestLogContext; + const snapshots: unknown[] = []; + const primaryAuth: Array = []; + const response = await handleResponsesWithPolicyFallback(new Request(initial, { headers }), { + port: 0, defaultProvider: "provider-a", providers: {}, + }, log, {}, { + runCore: async (req, _config, context, options) => { + snapshots.push(options.openAiSidecarAuth); + primaryAuth.push(req.headers.get("authorization")); + context.routeDecision = policyTrace(); + return snapshots.length === 1 + ? Response.json({ error: { message: "retry next candidate" } }, { status: 503 }) + : Response.json({ status: "completed" }); + }, + }); + expect(response.status).toBe(200); + expect(primaryAuth).toEqual([authorization, null]); + expect(snapshots).toEqual([ + { authorization, chatgptAccountId: "sidecar-account" }, + { authorization, chatgptAccountId: "sidecar-account" }, + ]); + expect(snapshots[1]).toBe(snapshots[0]); + }); + test("ranks only eligible untried candidates by score and stable original order", () => { const ranked = rankPolicyFallbackCandidates(policyTrace(), new Set(["provider-a\u0000model-a"])); expect(ranked.map(candidate => `${candidate.provider}/${candidate.model}`)).toEqual([ @@ -137,16 +168,26 @@ describe("policy candidate fallback", () => { const trace = policyTrace(); const logCtx = { requestedModel: "policy/daily", routeDecision: trace, attempts: [] } as unknown as RequestLogContext; const seenModels: string[] = []; + const seenAuthorization: Array = []; + const seenAccountIds: Array = []; const seenTerminalCodes: Array = []; let bodyAcceptedCount = 0; - const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, { + const initialRequest = request(); + const initialHeaders = new Headers(initialRequest.headers); + initialHeaders.set("authorization", "Bearer fixture"); + initialHeaders.set("chatgpt-account-id", "caller-account"); + const credentialedRequest = new Request(initialRequest, { headers: initialHeaders }); + + const response = await handleResponsesWithPolicyFallback(credentialedRequest, {} as OcxConfig, logCtx, { onRequestBodyRead: () => { bodyAcceptedCount += 1; }, }, { runCore: async (req, _config, childLog, options) => { options.onRequestBodyRead?.(); + seenAuthorization.push(req.headers.get("authorization")); + seenAccountIds.push(req.headers.get("chatgpt-account-id")); const body = await req.json() as { model: string }; seenModels.push(body.model); seenTerminalCodes.push(childLog.terminalErrorCode); @@ -170,6 +211,8 @@ describe("policy candidate fallback", () => { expect(response.status).toBe(200); expect(bodyAcceptedCount).toBe(1); expect(seenModels).toEqual(["policy/daily", "provider-b/model-b"]); + expect(seenAuthorization).toEqual(["Bearer fixture", null]); + expect(seenAccountIds).toEqual(["caller-account", null]); expect(seenTerminalCodes).toEqual([undefined, undefined]); expect(logCtx.requestedModel).toBe("policy/daily"); expect(logCtx.routeDecision).toBe(trace); diff --git a/tests/routing/subagent-fallback-handle-responses.test.ts b/tests/routing/subagent-fallback-handle-responses.test.ts index 83bdc0686b..da3ab4bc63 100644 --- a/tests/routing/subagent-fallback-handle-responses.test.ts +++ b/tests/routing/subagent-fallback-handle-responses.test.ts @@ -174,6 +174,30 @@ function installPoolCredential(accountId: string, chatgptAccountId: string, now: }); } +function storedMainFallbackAuthorization() { + const accountId = "normalization-main-account"; + const token = `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1_000) + 86_400 })) + .toString("base64url")}.signature`; + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: token, account_id: accountId }, + })); + let claims = 0; + const options: NonNullable[3]> = { + admission: { kind: "environment", source: "bearer" }, + turnAdmissionLease: { + release() {}, + beginCodexAccountSelection() { + return { + mainProfileDraining: false, + claimMainProfile: () => { claims += 1; return true; }, + release() {}, + }; + }, + }, + }; + return { accountId, token, options, headers: { authorization: "Bearer normalization-admission" }, claims: () => claims }; +} + function isCodexModelsFetch(input: unknown): boolean { try { const url = new URL(String(input)); @@ -572,6 +596,7 @@ describe("subagent fallback final-route normalization", () => { }); test("routed primary falling back to native gpt-5.5 clamps max effort to xhigh", async () => { + const credentials = storedMainFallbackAuthorization(); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", subagentModelFallback: ["gpt-5.5"], @@ -607,8 +632,9 @@ describe("subagent fallback final-route normalization", () => { stream: false, reasoning: { effort: "max" }, }, - {}, + credentials.options, logCtx, + credentials.headers, ); expect(response.status).toBe(200); @@ -619,9 +645,12 @@ describe("subagent fallback final-route normalization", () => { }; expect(body.model).toBe("gpt-5.5"); expect(body.reasoning?.effort).toBe("xhigh"); + expect(capture.auths).toEqual([`Bearer ${credentials.token}`]); + expect(credentials.claims()).toBeGreaterThan(0); }); test("routed primary falling back to native gpt-5.6 keeps real max effort", async () => { + const credentials = storedMainFallbackAuthorization(); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", subagentModelFallback: ["gpt-5.6-terra"], @@ -646,18 +675,20 @@ describe("subagent fallback final-route normalization", () => { noteSubagentModelFailure("grok-4.5", "429", cfg); const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; - mockUpstream(capture, { "Bearer caller-codex-token": ["gpt-5.6-terra"] }); + mockUpstream(capture, { [credentials.accountId]: ["gpt-5.6-terra"] }); const response = await postSpawn(cfg, { model: "xai/grok-4.5", input: readableAgentInput(), stream: false, reasoning: { effort: "max" }, - }); + }, credentials.options, { model: "", provider: "" }, credentials.headers); expect(response.status).toBe(200); const body = JSON.parse(capture.bodies[0]!) as { reasoning?: { effort?: string } }; expect(body.reasoning?.effort).toBe("max"); + expect(capture.auths).toEqual([`Bearer ${credentials.token}`]); + expect(credentials.claims()).toBeGreaterThan(0); }); test("native primary falling back to routed does not receive a native clamp", async () => { @@ -699,6 +730,7 @@ describe("subagent fallback final-route normalization", () => { }); test("routed primary falls back to native and preserves encrypted task passthrough", async () => { + const credentials = storedMainFallbackAuthorization(); resetSubagentModelFallbackStateForTests(); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", @@ -721,13 +753,13 @@ describe("subagent fallback final-route normalization", () => { }); const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; - mockUpstream(capture, { "Bearer caller-codex-token": ["gpt-5.6-terra"] }); + mockUpstream(capture, { [credentials.accountId]: ["gpt-5.6-terra"] }); const response = await postSpawn(cfg, { model: "xai/grok-4.5", input: encryptedAgentInput(), stream: false, - }); + }, credentials.options, { model: "", provider: "" }, credentials.headers); if (response.status !== 200) { const body = await response.text(); @@ -735,6 +767,8 @@ describe("subagent fallback final-route normalization", () => { } expect(capture.urls.some((url) => url.includes("chatgpt.com/backend-api/codex"))).toBe(true); expect(capture.bodies[0]).toContain(FERNET_TASK); + expect(capture.auths).toEqual([`Bearer ${credentials.token}`]); + expect(credentials.claims()).toBeGreaterThan(0); }); test("native primary falls back to routed for readable child tasks", async () => { @@ -1834,6 +1868,44 @@ describe("account-gated retry entitlement boundary", () => { expect(entitlementCalls).toBe(3); }); + test.each(["absent", "present"])("a shadow rewrite cannot restore an opaque source bearer (explicit account: %s)", async accountHeader => { + const hasAccount = accountHeader === "present"; + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + const cfg = retryConfig(); + cfg.shadowCallIntercept = { enabled: true, model: `openai/${model}`, sourceModels: ["gpt-5.6-luna"] }; + let entitlementCalls = 0; + let callerRosterReads = 0; + const observed: Array<{ authorization: string | null; accountId: string | null }> = []; + globalThis.fetch = (async (input, init) => { + const headers = new Headers(init?.headers); + if (new URL(String(input)).pathname.endsWith("/models")) { + callerRosterReads += 1; + return Response.json({ models: [{ slug: model, supported_in_api: true, visibility: "list" }] }); + } + observed.push({ authorization: headers.get("authorization"), accountId: headers.get("chatgpt-account-id") }); + return observed.length === 1 ? unsupportedCodexModelResponse(model) + : Response.json({ id: "unexpected-source-credential-retry", status: "completed", output: [] }); + }) as typeof fetch; + const response = await postDirectCodex(cfg, { model: "gpt-5.6-luna", input: "hello", stream: false }, { + admission: { kind: "environment", source: "dedicated" }, + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + return entitlementCalls === 1 ? entitlementSnapshot({ "pool-a": [model] }) + : entitlementSnapshot({ "pool-a": ["gpt-5.6-sol"] }); + }, + }, { + authorization: "Bearer source-route-token", + ...(hasAccount ? { "chatgpt-account-id": "source-route-account" } : {}), + }); + await response.arrayBuffer(); + expect(observed).toEqual([{ authorization: "Bearer pool-a_token", accountId: "pool_acc_a" }]); + expect(callerRosterReads).toBe(0); + expect(entitlementCalls).toBeGreaterThan(1); + expect(response.status).toBe(400); + }); + test("a first-refresh programmer error cancels the 400 and releases its quota probe", async () => { const cooldownAt = 1_800_000_000_000; const probeAt = cooldownAt + CODEX_QUOTA_PROBE_INTERVAL_MS; diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index c08c706bac..38f0368d54 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -1858,12 +1858,14 @@ describe("server combo failover 030 activation matrix", () => { }); test("hosted web-search eager model failure hops through the loop path", async () => { - const modelHits: Array<{ model?: string; hasWebTool: boolean }> = []; + const modelHits: Array<{ model?: string; hasWebTool: boolean; authorization: string | null; account: string | null }> = []; const routed = serve(async request => { const body = await request.json() as { model?: string; tools?: Array<{ type?: string }> }; modelHits.push({ model: body.model, hasWebTool: body.tools?.some(tool => tool.type === "function") ?? false, + authorization: request.headers.get("authorization"), + account: request.headers.get("chatgpt-account-id"), }); if (body.model === "m1") { return Response.json({ error: { message: "loop unavailable" } }, { status: 503 }); @@ -1897,10 +1899,78 @@ describe("server combo failover 030 activation matrix", () => { expect(JSON.stringify(await collectSse(response))).toContain("web loop backup"); expect(modelHits.map(hit => hit.model)).toEqual(["m1", "m2"]); expect(modelHits.every(hit => hit.hasWebTool)).toBe(true); + expect(modelHits.map(hit => hit.authorization)).toEqual(["Bearer key-a", "Bearer key-b"]); + expect(modelHits.every(hit => hit.account === null)).toBe(true); expect(models).toEqual(["m2"]); expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "web-search-recall" })), "m2")).toBe("free"); }); + test.each(["valid", "chat-valid", "mismatched-account", "proxy-secret", "joined-proxy-secret", "explicit-null", "org-only-jwt"])("Combo sidecar auth stays off primary wires: %s", async authKind => { + const valid = authKind === "valid" || authKind === "chat-valid"; + const nativeToken = fakeChatGptJwt({ chatgpt_account_id: "acct-scoped-sidecar" }); + // A generic organizations claim is not OpenAI-domain evidence for a sidecar snapshot. + const token = authKind === "proxy-secret" ? `ocx_data_${nativeToken}` + : authKind === "joined-proxy-secret" ? `${nativeToken}, Bearer ocx_data_embedded` + : authKind === "org-only-jwt" ? fakeChatGptJwt({ organizations: [{ id: "org-foreign" }] }) : nativeToken; + const sidecarHits: Array<{ authorization: string | null; account: string | null }> = []; + const primaryHits: Array<{ model?: string; authorization: string | null; account: string | null; webTool: boolean }> = []; + let requestedSearch = false; + const sidecar = serve(request => { + sidecarHits.push({ authorization: request.headers.get("authorization"), account: request.headers.get("chatgpt-account-id") }); + return new Response( + 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"synthetic web result"}\n\n' + + 'event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + }); + const routed = serve(async request => { + const body = await request.json() as { model?: string; tools?: Array<{ type?: string; function?: { name?: string } }> }; + const tool = body.tools?.find(tool => tool.type === "function")?.function?.name; + primaryHits.push({ model: body.model, authorization: request.headers.get("authorization"), account: request.headers.get("chatgpt-account-id"), webTool: !!tool }); + if (body.model === "m1") return Response.json({ error: { message: "try next model" } }, { status: 503 }); + if (tool && !requestedSearch) { + requestedSearch = true; + return new Response(`data: ${JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_search", type: "function", function: { name: tool, arguments: '{"query":"synthetic query"}' } }] }, finish_reason: null }] })}\n\n` + + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\ndata: [DONE]\n\n', + { headers: { "content-type": "text/event-stream" } }); + } + return chatStream("scoped sidecar complete"); + }); + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof globalThis.Request ? input.url : String(input)); + if (url.origin === "https://chatgpt.com" && url.pathname === "/backend-api/codex/responses") { + return originalFetch(sidecar.url, init); + } + if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") throw new Error("unexpected external request"); + return originalFetch(input, init); + }) as typeof globalThis.fetch; + const config = comboConfig({ + a: provider("openai-chat", baseUrl(routed), "key-a"), + b: provider("openai-chat", baseUrl(routed), "key-b"), + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" }, + }, [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }]); + config.webSearchSidecar = { enabled: true, backend: "openai" }; + const headers = { + authorization: `Bearer ${token}`, + "chatgpt-account-id": authKind === "mismatched-account" ? "other-account" + : authKind === "org-only-jwt" ? "org-foreign" : "acct-scoped-sidecar", + }; + const response = authKind === "chat-valid" + ? await (await import("../../src/server/chat-completions")).handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ model: "combo/free", messages: [{ role: "user", content: "search" }], stream: true, tools: [{ type: "web_search" }] }), + }), config, { model: "", provider: "" }) + : await post(config, { stream: true, tools: [{ type: "web_search" }] }, authKind === "explicit-null" ? { openAiSidecarAuth: null } : {}, headers); + expect(response.status).toBe(200); + expect(JSON.stringify(await collectSse(response))).toContain("scoped sidecar complete"); + expect(sidecarHits).toEqual(valid + ? [{ authorization: `Bearer ${nativeToken}`, account: "acct-scoped-sidecar" }] : []); + expect(primaryHits.map(hit => hit.authorization)).toEqual(valid + ? ["Bearer key-a", "Bearer key-b", "Bearer key-b"] : ["Bearer key-a", "Bearer key-b"]); + expect(primaryHits.every(hit => hit.account === null)).toBe(true); + expect(primaryHits.every(hit => hit.webTool === valid)).toBe(true); + }); + test("context 400 stops while exhausted retryable targets return the sanitized last status", async () => { let stopBackupHits = 0; const context = serve(() => Response.json({ error: { code: "context_length_exceeded", message: "too many tokens" } }, { status: 400 })); diff --git a/tests/server/v2-agent-message-failfast.test.ts b/tests/server/v2-agent-message-failfast.test.ts index 0dec3b4071..0d2b77dcdd 100644 --- a/tests/server/v2-agent-message-failfast.test.ts +++ b/tests/server/v2-agent-message-failfast.test.ts @@ -4,6 +4,7 @@ import { hasUnreadableEncryptedAgentTask, } from "../../src/server/responses"; import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; const originalFetch = globalThis.fetch; @@ -212,9 +213,13 @@ describe("V2 routed agent-message ciphertext guard", () => { test("filters a combo to a decrypt-capable native target before dispatch", async () => { const fetchedUrls: string[] = []; + const nativeToken = fakeChatGptJwt({ chatgpt_account_id: "native-combo-caller" }); + const forwardedAuth: Array<{ authorization: string | null; account: string | null }> = []; let forwardedBody = ""; globalThis.fetch = (async (input, init) => { fetchedUrls.push(String(input)); + const headers = new Headers(init?.headers); + forwardedAuth.push({ authorization: headers.get("authorization"), account: headers.get("chatgpt-account-id") }); forwardedBody = typeof init?.body === "string" ? init.body : ""; return Response.json({ id: "resp_combo_native", @@ -233,13 +238,14 @@ describe("V2 routed agent-message ciphertext guard", () => { { type: "input_text", text: ROUTING_ENVELOPE }, { type: "encrypted_content", encrypted_content: FERNET_TASK }, ]), - { authorization: "Bearer caller-codex-token" }, + { authorization: `Bearer ${nativeToken}`, "chatgpt-account-id": "native-combo-caller" }, ); expect(response.status).toBe(200); expect(fetchedUrls).toHaveLength(1); expect(fetchedUrls[0]).toContain("chatgpt.com/backend-api/codex"); expect(fetchedUrls[0]).not.toContain("api.x.ai"); + expect(forwardedAuth).toEqual([{ authorization: `Bearer ${nativeToken}`, account: "native-combo-caller" }]); expect(forwardedBody).toContain(FERNET_TASK); }); @@ -283,7 +289,11 @@ describe("V2 routed agent-message ciphertext guard", () => { ]; const forwardedModels: string[] = []; const forwardedBodies: string[] = []; + const nativeToken = fakeChatGptJwt({ chatgpt_account_id: "native-combo-caller" }); + const forwardedAuth: Array<{ authorization: string | null; account: string | null }> = []; globalThis.fetch = (async (_input, init) => { + const headers = new Headers(init?.headers); + forwardedAuth.push({ authorization: headers.get("authorization"), account: headers.get("chatgpt-account-id") }); const raw = typeof init?.body === "string" ? init.body : ""; forwardedBodies.push(raw); const parsed = JSON.parse(raw) as { model?: string }; @@ -308,12 +318,13 @@ describe("V2 routed agent-message ciphertext guard", () => { { type: "input_text", text: ROUTING_ENVELOPE }, { type: "encrypted_content", encrypted_content: FERNET_TASK }, ]), - { authorization: "Bearer caller-codex-token" }, + { authorization: `Bearer ${nativeToken}`, "chatgpt-account-id": "native-combo-caller" }, ); expect(response.status).toBe(200); expect(forwardedModels).toEqual(["gpt-native-primary", "gpt-native-backup"]); expect(forwardedBodies).toHaveLength(2); + expect(forwardedAuth).toEqual(Array(2).fill({ authorization: `Bearer ${nativeToken}`, account: "native-combo-caller" })); expect(forwardedBodies.every(body => body.includes(FERNET_TASK))).toBe(true); });