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..c5275f8ffe 100644 --- a/docs-site/src/content/docs/fr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md @@ -20,6 +20,10 @@ la sécurité des réponses se produit toujours à la limite du proxy. Configure [Configuration](/fr/reference/configuration/); utilisez [Combos](/fr/guides/combos/) lorsqu'un identifiant de modèle public doit choisir parmi plusieurs cibles. +## Redirections en amont + +Les requêtes de modèle, d’image, de vidéo et de recherche contenant des identifiants ne suivent pas automatiquement les redirections HTTP, même vers la même origine. Configurez l’URL finale de l’API plutôt qu’un alias qui redirige. Le serveur ne renvoie ni les identifiants ni le corps de la requête à la destination d’une redirection. Chaque chemin conserve sa gestion des erreurs ou son relais existant ; les routes Responses natives et compact peuvent renvoyer le 3xx et le `Location` d’origine au client. Le comportement de redirection du client est distinct de cette politique de transport du serveur. + ## Présentation du point de terminaison | Espace client | Point de terminaison | Résultat non-stream réussi | Résultat de flux ou de socket réussi | 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..34da36508b 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -14,6 +14,10 @@ provider events → internal adapter events → client dialect 応答表現はブリッジの中心です。ネイティブ互換ルートは、変換の一部をスキップしてリクエストを通過させる可能性がありますが、認証、ルーティング、アドミッション コントロール、および応答の安全性は依然としてプロキシ境界で発生します。 [構成](/reference/configuration/) でリスナーとアドミッション キーを構成します。 1 つのパブリック モデル ID を複数のターゲットから選択する必要がある場合は、[コンボ](/guides/combos/) を使用します。 +## 上流のリダイレクト + +認証情報を含むモデル・画像・動画・検索リクエストは、同一オリジンを含む HTTP リダイレクトを自動追跡しません。リダイレクトする別名ではなく、最終的な上流 API URL を設定してください。サーバーはリダイレクト先に認証情報やリクエスト本文を再送しません。各応答処理の既存のエラー処理・中継動作は維持され、native Responses と compact は元の 3xx と `Location` をクライアントへ返す場合があります。クライアントのリダイレクト動作は、このサーバー転送ポリシーとは別です。 + ## エンドポイントの概要 |クライアントサーフェス |エンドポイント |非ストリームの結果が成功 |成功したストリームまたはソケットの結果 | 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..a94778e5b1 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -19,6 +19,10 @@ Responses 표현이 이 연결의 중심입니다. 네이티브 호환 경로는 [Configuration](/reference/configuration/)에서 리스너와 admission 키를 설정하십시오. 하나의 공개 모델 id가 여러 대상 중 하나를 골라야 할 때는 [Combos](/guides/combos/)를 사용하십시오. +## 업스트림 리다이렉트 + +자격 증명을 포함하는 모델·이미지·동영상·검색 요청은 동일 출처를 포함한 HTTP 리다이렉트를 자동으로 따라가지 않습니다. 리다이렉트하는 별칭 대신 최종 업스트림 API URL을 설정하세요. 서버는 리다이렉트 대상으로 자격 증명이나 요청 본문을 다시 보내지 않습니다. 각 응답 처리 경로의 기존 오류·전달 동작은 유지되며, native Responses와 compact 경로는 원래 3xx와 `Location`을 클라이언트에 반환할 수 있습니다. 클라이언트의 리다이렉트 동작은 이 서버 전송 정책과 별개입니다. + ## 엔드포인트 개요 | 클라이언트 표면 | 엔드포인트 | 성공한 비스트리밍 결과 | 성공한 스트리밍 또는 소켓 결과 | diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 1f2e589252..54e369b5b3 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -20,6 +20,10 @@ response safety still happen at the proxy boundary. Configure the listener and a [Configuration](/reference/configuration/); use [Combos](/guides/combos/) when one public model id should select among several targets. +## Upstream redirects + +Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | 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..ad5b5d7b6e 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -21,6 +21,10 @@ control и safety ответа всё равно происходят на гр настраиваются в [Конфигурации](/reference/configuration/); если один публичный id модели должен выбирать между несколькими целями, используйте [Combos](/guides/combos/). +## Перенаправления upstream + +Запросы к моделям, изображениям, видео и поиску, содержащие учётные данные, не следуют HTTP-перенаправлениям автоматически, в том числе в пределах одного origin. Укажите конечный URL API вместо перенаправляющего адреса. Сервер не отправляет учётные данные и тело запроса по адресу перенаправления. Существующая обработка ошибок и передача ответа сохраняются; маршруты native Responses и compact могут вернуть клиенту исходные 3xx и `Location`. Поведение перенаправлений клиента не определяется этой транспортной политикой сервера. + ## Обзор endpoint'ов | Клиентская поверхность | Endpoint | Успешный non-stream результат | Успешный результат потока или сокета | 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..33b8e391d0 100644 --- a/docs-site/src/content/docs/tr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/tr/reference/proxy-formats.md @@ -23,6 +23,10 @@ sınırında gerçekleşir. Dinleyiciyi ve kabul anahtarlarını genel model kimliği birkaç hedef arasından seçim yapması gerektiğinde [Kombolar](/tr/guides/combos/) kullanın. +## Üst sunucu yönlendirmeleri + +Kimlik bilgisi taşıyan model, görsel, video ve arama istekleri, aynı origin içindeki yönlendirmeler dâhil HTTP yönlendirmelerini otomatik izlemez. Yönlendiren bir adres yerine son API URL’sini yapılandırın. Sunucu, kimlik bilgilerini veya istek gövdesini yönlendirme hedefine yeniden göndermez. Mevcut hata işleme ve yanıt aktarma davranışı korunur; native Responses ve compact yolları, özgün 3xx ve `Location` değerini istemciye döndürebilir. İstemcinin yönlendirme davranışı bu sunucu aktarım politikasından ayrıdır. + ## Uç nokta genel bakışı | İstemci yüzeyi | Uç nokta | Başarılı akışsız sonuç | Başarılı akış veya soket sonucu | 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..48ac4c3670 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 @@ -19,6 +19,10 @@ Responses 表示是这座桥的中心。原生兼容的路由可以跳过部分 [Configuration](/reference/configuration/) 中配置监听器和准入密钥;当一个公开模型 ID 需要在多个目标之间选择时,请使用 [Combos](/guides/combos/)。 +## 上游重定向 + +携带凭据的模型、图像、视频和搜索请求不会自动跟随 HTTP 重定向,包括同源重定向。请配置最终上游 API URL,而不是会重定向的别名。服务器不会向重定向目标重新发送凭据或请求正文。各响应处理路径保留原有的错误处理或转发行为;原生 Responses 和 compact 路径仍可向客户端返回原始 3xx 和 `Location`。客户端的重定向行为与此服务器传输策略是不同的边界。 + ## 端点总览 | 客户端表面 | 端点 | 成功的非流式结果 | 成功的流式或套接字结果 | 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..795044ea7b 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 @@ -14,6 +14,10 @@ opencodex 以多種客戶端方言呈現一個本機代理。Codex 客戶端可 Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯並 passthrough 請求,但認證、路由、許可控制與回應安全仍在代理邊界發生。在[設定](/zh-tw/reference/configuration/)中設定監聽器與許可金鑰;當一個公開模型 id 應在多個目標間選擇時使用[組合](/zh-tw/guides/combos/)。 +## 上游重新導向 + +攜帶憑證的模型、圖片、影片和搜尋請求不會自動跟隨 HTTP 重新導向,包括同源重新導向。請設定最終上游 API URL,而非會重新導向的別名。伺服器不會向重新導向目標再次傳送憑證或請求內文。各回應處理路徑保留原有的錯誤處理或轉送行為;原生 Responses 和 compact 路徑仍可向用戶端回傳原始 3xx 與 `Location`。用戶端的重新導向行為與此伺服器傳輸政策屬於不同邊界。 + ## 端點概覽 | 客戶端介面 | 端點 | 成功的非串流結果 | 成功的串流或 socket 結果 | diff --git a/src/adapters/mimo-free.ts b/src/adapters/mimo-free.ts index a257e7d4c9..d394423cb1 100644 --- a/src/adapters/mimo-free.ts +++ b/src/adapters/mimo-free.ts @@ -110,6 +110,7 @@ async function fetchJwt(signal?: AbortSignal): Promise { const combined = signal ? AbortSignal.any([signal, timeout]) : timeout; const response = await fetch(BOOTSTRAP_URL, { method: "POST", + redirect: "manual", headers: { "Content-Type": "application/json", "User-Agent": randomUserAgent(), @@ -249,6 +250,7 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap async fetchResponse(request: AdapterRequest, ctx): Promise { const response = await fetch(request.url, { method: request.method, + redirect: "manual", headers: request.headers as Record, body: request.body, signal: ctx?.abortSignal, @@ -268,6 +270,7 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap }; return fetch(request.url, { method: request.method, + redirect: "manual", headers: retryHeaders, body: request.body, signal: ctx?.abortSignal, diff --git a/src/images/loop.ts b/src/images/loop.ts index 7d4855f91b..e33ae02b41 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -537,6 +537,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { const deadline = makeDeadline(timeoutMs, parent); try { - const upstream = await fetchImpl(input, { ...init, signal: deadline.signal, timeout: 0 }); + const upstream = await fetchImpl(input, { ...init, redirect: "manual", signal: deadline.signal, timeout: 0 }); return { kind: "response", upstream }; } catch (error) { if (deadline.didExpire()) return { kind: "timeout" }; diff --git a/src/server/images.ts b/src/server/images.ts index ade4c8348e..02e56fcacf 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -288,6 +288,7 @@ async function tryCcaImageGeneration( try { upstream = await fetch(`${baseUrl}/v1internal:generateContent`, { method: "POST", + redirect: "manual", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index b961e7cef9..7806490f87 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -4036,7 +4036,8 @@ async function handleResponsesInner( const ownsBearer = snapshot !== undefined && sentHeaders?.get("authorization") === `Bearer ${snapshot.accessToken}` && !sentHeaders?.has("x-api-key"); - const response = await fetchImpl(destination, dispatchInit); + // Reselection can choose a provider override instead of the supplied executor. + const response = await fetchImpl(destination, { ...dispatchInit, redirect: "manual" }); // Observe each physical response before retries replace it. The binding belongs to // this dispatch, so a manual switch cannot file A's headers against B. Header // overrides and credential replacement make ownership unprovable: skip those writes. diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index b6365be4be..00bdbdc0f2 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -74,13 +74,20 @@ export function providerFetch( const preconnect = (...args: Parameters): void => { base.preconnect?.(...args); }; + // Rebuilt dispatches must use the same physical-send boundary as ordinary HTTP sends. + // Return the original 3xx so the response owner retains its retry/health/relay contract. + const dispatch = Object.assign( + (input: Parameters[0], init?: RequestInit) => + base(input, { ...init, redirect: "manual" }), + { preconnect }, + ) as typeof globalThis.fetch; const httpFetch = Object.assign( async (input: Parameters[0], init?: RequestInit) => { options.beforeDispatch?.(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))); const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }; return options.dispatchOverride - ? options.dispatchOverride(input, dispatchInit, base) - : base(input, dispatchInit); + ? options.dispatchOverride(input, dispatchInit, dispatch) + : dispatch(input, dispatchInit); }, { preconnect }, ) as typeof globalThis.fetch; @@ -163,6 +170,10 @@ export function storedPoolReplayDispatchNotifier( }) as ProviderFetch; } +/** + * Fetch through the header deadline with redirects always manual. + * @param _manualRedirect Ignored; retained for call compatibility. Even false uses manual. + */ export async function fetchWithHeaderTimeout( url: string, init: Omit, @@ -170,7 +181,8 @@ export async function fetchWithHeaderTimeout( timeoutMs: number, preferIdentityEncoding = false, executor: typeof globalThis.fetch = globalThis.fetch, - manualRedirect = false, + // Retained for existing callers; credential-bearing transport no longer opts out. + _manualRedirect = false, ): Promise { const pacing = executor as ProviderFetch; await pacing.waitForPacing?.(abortSignal); @@ -189,10 +201,9 @@ export async function fetchWithHeaderTimeout( return await fetchExecutor(url, { ...init, headers, - // Credential-bearing sends opt into manual redirects so a 3xx is relayed - // as a Response instead of being followed into a rejection that is - // indistinguishable from a pre-connection failure (#914). - ...(manualRedirect ? { redirect: "manual" as const } : {}), + // Never replay provider credentials or request bodies to a redirect destination. + // Preserve the 3xx for the owner's existing response/health policy (#914, #1471). + redirect: "manual", signal: AbortSignal.any([abortSignal, timeout.signal]), timeout: 0, }); diff --git a/src/vision/anthropic-describe.ts b/src/vision/anthropic-describe.ts index 280096f033..4ca6ae00fa 100644 --- a/src/vision/anthropic-describe.ts +++ b/src/vision/anthropic-describe.ts @@ -200,6 +200,7 @@ export async function describeImageAnthropic( const res = await fetchWithResetRetry( recovery => fetch(`${base}/v1/messages`, applyUpstreamRecoveryInit({ method: "POST", + redirect: "manual", headers, body: JSON.stringify(body), signal: linkedSignal.signal, diff --git a/src/web-search/anthropic-executor.ts b/src/web-search/anthropic-executor.ts index cd3893900c..4b62702f0e 100644 --- a/src/web-search/anthropic-executor.ts +++ b/src/web-search/anthropic-executor.ts @@ -210,6 +210,7 @@ export async function runAnthropicWebSearch( // ignored a bare `Connection: close` (oven-sh/bun#20492). recovery => fetch(url, applyUpstreamRecoveryInit({ method: "POST", + redirect: "manual", headers, body: JSON.stringify(body), signal: linkedSignal.signal, diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 0c957e1c17..99b275ed8d 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -478,6 +478,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise ({ + ...actualResolver, + resolveAdapter(...args: Parameters) { + const adapter = actualResolveAdapter(...args); + if (!adapterRequestsFollow) return adapter; + return { + ...adapter, + // Exercise the production OAuth dispatch callback with adapter-owned request options. + // Keep the real request builder/parser; only this caller asks for default-follow. + fetchResponse: (request: Parameters>[0], context: Parameters>[1]) => + context!.executor!(request.url, { + method: request.method, headers: request.headers, body: request.body, + signal: context?.abortSignal, redirect: "follow", + }), + }; + }, +})); + const originalHome = process.env.OPENCODEX_HOME; let originalFetch: typeof globalThis.fetch; let unexpectedGlobalFetches = 0; @@ -19,6 +40,7 @@ let home: string; let sent: { authorization: string | null; apiKey: string | null; body: Record }[]; beforeEach(() => { + adapterRequestsFollow = false; home = ""; originalFetch = globalThis.fetch; unexpectedGlobalFetches = 0; @@ -38,6 +60,7 @@ beforeEach(() => { }); afterEach(() => { + adapterRequestsFollow = false; try { // Provider code may catch the guard's rejection; the attempted network call still fails the test. expect(unexpectedGlobalFetches).toBe(0); @@ -143,6 +166,43 @@ function deferred() { return { promise, resolve }; } +test.each([307, 308])("OAuth provider override pins manual dispatch after adapter init for %i", async status => { + await seed(1); + adapterRequestsFollow = true; + let targetHits = 0; + let originHits = 0; + const redirects: Array = []; + const statuses: number[] = []; + const target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + targetHits++; + return answer(false); + } }); + const origin = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + originHits++; + return new Response("redirect", { status, headers: { location: `http://127.0.0.1:${target.port}/target` } }); + } }); + const config = configFor(() => { throw new Error("unused canned transport"); }); + (config.providers.anthropic as OcxProviderConfig & { fetch: typeof fetch }).fetch = (async (input, init) => { + expect(new URL(String(input)).hostname).toBe("anthropic-quota.test"); + redirects.push(init?.redirect); + // Remap only the URL; the production callback must supply the safe request options. + const result = await originalFetch(`http://127.0.0.1:${origin.port}/messages`, init); + statuses.push(result.status); + return result; + }) as typeof fetch; + try { + const response = await post(config); + await response.text(); + expect(targetHits).toBe(0); + expect(originHits).toBe(1); + expect(redirects).toEqual(["manual"]); + expect(statuses).toEqual([status]); + } finally { + await origin.stop(true); + await target.stop(true); + } +}); + test("main A429 -> B200 records both physical responses against their sending accounts", async () => { const [a, b] = await seed(); const config = configFor(body => { diff --git a/tests/images/loop.test.ts b/tests/images/loop.test.ts index 01db03bf0c..4b9d6423b7 100644 --- a/tests/images/loop.test.ts +++ b/tests/images/loop.test.ts @@ -104,6 +104,39 @@ async function runAndGetSSE(streams: AdapterEvent[][], fulfill?: ImageCallResult } describe("runWithImageBridge", () => { + test.each([307, 308])("the direct image-loop send does not follow %i", async status => { + let targetHits = 0; + let originHits = 0; + const target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + targetHits++; + return new Response("{}"); + } }); + const origin = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + originHits++; + return new Response("redirect", { status, headers: { location: `http://127.0.0.1:${target.port}/target` } }); + } }); + try { + const response = await runWithImageBridge({ + parsed: makeParsed(), plan, + adapter: { + ...mockAdapter, + fetchResponse: undefined, + buildRequest: async () => ({ url: `http://127.0.0.1:${origin.port}/model`, method: "POST", headers: { "x-api-key": "synthetic-key" }, body: "synthetic prompt" }), + }, + }); + const error = await response.json() as { error: { type: string; message: string } }; + expect(targetHits).toBe(0); + expect(originHits).toBe(1); + expect(response.status).toBe(status); + expect(error.error.type).toBe("upstream_error"); + expect(error.error.message).toBe(`Provider error ${status}`); + expect(response.headers.get("location")).toBeNull(); + } finally { + await origin.stop(true); + await target.stop(true); + } + }); + test("translator overflow remains typed through the image loop and bridge", async () => { const sse = await runAndGetSSE([[ { diff --git a/tests/lib/credential-redirect-guard.test.ts b/tests/lib/credential-redirect-guard.test.ts index 81213abd1e..6a4ff7534b 100644 --- a/tests/lib/credential-redirect-guard.test.ts +++ b/tests/lib/credential-redirect-guard.test.ts @@ -1,58 +1,97 @@ /** * Cross-origin redirect guard for credential-bearing sidecars (#1471 review). * - * Bun follows 3xx by default. It drops `Authorization` when the redirect crosses origins, but - * it forwards NONSTANDARD headers unchanged — which is exactly where the Codex identity lives: - * `chatgpt-account-id`, `session_id`, `x-codex-turn-metadata`. So a canonical ChatGPT endpoint - * answering 302 would hand those to the redirect target while `Authorization` looked safely - * stripped. The first test proves that runtime behavior rather than asserting it from memory; - * the second pins the fix at every credential-bearing call site. + * Exercise production transports against two loopback origins. Safety is a property of the + * application send boundary, independent of which headers a particular runtime happens to + * strip when following redirects. Existing explicit sidecar guards remain checked below. */ import { describe, expect, test } from "bun:test"; import { repoPath } from "../helpers/repo-root"; +import { fetchWithHeaderTimeout, providerFetch } from "../../src/server/responses/fetch-helpers"; +import { fetchWithAttemptDeadline } from "../../src/lib/upstream-retry"; +import { fetchWithHeaderDeadline } from "../../src/server/claude-messages"; +import { fetchGoogleWithRetry } from "../../src/adapters/google-http"; +import { fetchKiroWithRetry } from "../../src/adapters/kiro-retry"; +import type { OcxProviderConfig } from "../../src/types"; -describe("Bun forwards nonstandard headers across a redirect", () => { - test("Authorization is dropped but Codex identity headers are not", async () => { - const captured: Record = {}; +describe("credential-bearing production transports do not follow redirects", () => { + const nativeFetch = globalThis.fetch; + const senders = ["header", "header-legacy-false", "deadline", "provider", "provider-rebuilt", "claude", "google", "kiro"] as const; + for (const sender of senders) for (const sameOrigin of [false, true]) test.each([301, 302, 303, 307, 308])(`${sender} ${sameOrigin ? "same" : "cross"}-origin: preserves %i without a target send`, async status => { + let targetHits = 0; + let originHits = 0; + const observedRedirect: Array = []; const target = Bun.serve({ - port: 0, - fetch(req) { - captured.authorization = req.headers.get("authorization"); - captured.account = req.headers.get("chatgpt-account-id"); - captured.session = req.headers.get("session_id"); - captured.turn = req.headers.get("x-codex-turn-metadata"); + hostname: "127.0.0.1", port: 0, + fetch() { + targetHits += 1; return new Response("ok"); }, }); const origin = Bun.serve({ - port: 0, - fetch: () => new Response(null, { - status: 302, - headers: { location: `http://127.0.0.1:${target.port}/landed` }, - }), + hostname: "127.0.0.1", port: 0, + fetch: req => { + if (new URL(req.url).pathname === "/landed") { + targetHits += 1; + return new Response("ok"); + } + originHits += 1; + return new Response("untrusted redirect body", { + status, + headers: { location: sameOrigin ? "/landed" : `http://127.0.0.1:${target.port}/landed` }, + }); + }, }); - + let response: Response | undefined; try { - await fetch(`http://127.0.0.1:${origin.port}/start`, { + const url = `http://127.0.0.1:${origin.port}/start`; + const init: RequestInit = { + method: "POST", body: "synthetic request", redirect: "follow", headers: { - authorization: "Bearer secret-token", + authorization: "Bearer synthetic-token", + "x-api-key": "synthetic-provider-key", "chatgpt-account-id": "acct-123", - session_id: "sess-456", - "x-codex-turn-metadata": "turn-789", }, - }); + }; + const executor = (async (input, sentInit) => { + observedRedirect.push(sentInit?.redirect); + return nativeFetch(input, sentInit); + }) as typeof globalThis.fetch; + const signal = new AbortController().signal; + if (sender === "header") response = await fetchWithHeaderTimeout(url, init, signal, 2_000, false, executor); + else if (sender === "header-legacy-false") response = await fetchWithHeaderTimeout(url, init, signal, 2_000, false, executor, false); + else if (sender === "deadline") response = await fetchWithAttemptDeadline(url, init, 2_000, signal, false, executor); + else if (sender === "claude") { + const result = await fetchWithHeaderDeadline(url, init, 2_000, signal, undefined, executor); + expect(result.kind).toBe("response"); + if (result.kind === "response") response = result.upstream; + } else if (sender === "google" || sender === "kiro") { + const request = { url, method: "POST", headers: init.headers as Record, body: init.body as string }; + const context = { abortSignal: signal, timeoutMs: 2_000, returnRawErrors: true, executor }; + if (sender === "google") response = await fetchGoogleWithRetry("test", request, context); + else { + globalThis.fetch = executor; + response = await fetchKiroWithRetry(request, context); + } + } + else { + const provider = { adapter: "openai-chat", baseUrl: url, fetch: executor } as OcxProviderConfig & { fetch: typeof globalThis.fetch }; + const fetcher = providerFetch(provider, undefined, sender === "provider-rebuilt" ? { + dispatchOverride: (input, sentInit, execute) => execute(input, { ...sentInit, redirect: "follow" }), + } : {}); + response = await fetcher(url, init); + } + expect(targetHits).toBe(0); + expect(originHits).toBe(1); + expect(observedRedirect).toEqual(["manual"]); + expect(response?.status).toBe(status); + expect(response?.headers.get("location")).toBe(sameOrigin ? "/landed" : `http://127.0.0.1:${target.port}/landed`); } finally { - origin.stop(true); - target.stop(true); + globalThis.fetch = nativeFetch; + await response?.body?.cancel(); + await origin.stop(true); + await target.stop(true); } - - // The half that looks safe... - expect(captured.authorization).toBeNull(); - // ...and the half that is not. This is why `redirect: "manual"` is required and why - // relying on Authorization stripping alone would be a false sense of safety. - expect(captured.account).toBe("acct-123"); - expect(captured.session).toBe("sess-456"); - expect(captured.turn).toBe("turn-789"); }); }); @@ -73,16 +112,5 @@ describe("credential-bearing sidecars refuse to follow redirects", () => { }); } - // The Responses and compact paths reach the same policy through a different mechanism: - // `fetchWithHeaderTimeout` takes a `manualRedirect` flag and applies `redirect: "manual"` - // centrally (#914). Assert the shared helper still does that, so the two families cannot - // drift apart silently. - test("the shared credential-bearing fetch helper still applies manual redirects", async () => { - const helper = await Bun.file(new URL("../../src/server/responses/fetch-helpers.ts", import.meta.url)).text(); - expect(helper).toContain('redirect: "manual" as const'); - - // And the callers still opt in for forward auth rather than dropping the flag. - const compact = await Bun.file(new URL("../../src/server/responses/compact.ts", import.meta.url)).text(); - expect(compact).toContain('sendProvider.authMode === "forward"'); - }); + // These source checks supplement, rather than replace, the physical-send tests above. }); diff --git a/tests/providers/mimo-free-provider.test.ts b/tests/providers/mimo-free-provider.test.ts index 04cd2c908e..00176d2589 100644 --- a/tests/providers/mimo-free-provider.test.ts +++ b/tests/providers/mimo-free-provider.test.ts @@ -12,6 +12,64 @@ import { createMimoFreeAdapter, } from "../../src/adapters/mimo-free"; import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +for (const phase of ["bootstrap", "chat", "401-replay"] as const) test.each([307, 308])(`MiMo ${phase} never follows %i`, async status => { + const nativeFetch = globalThis.fetch; + const previousHome = process.env.OPENCODEX_HOME; + const testHome = mkdtempSync(join(tmpdir(), "ocx-mimo-redirect-")); + process.env.OPENCODEX_HOME = testHome; + resetMimoClientIdCache(); + resetMimoJwtCache(); + let targetHits = 0; + let originHits = 0; + let chatSends = 0; + const observed: Array = []; + const target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + targetHits++; + return Response.json({ jwt: "redirected", ok: true }); + } }); + const origin = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + originHits++; + return new Response("redirect", { status, headers: { location: `http://127.0.0.1:${target.port}/target` } }); + } }); + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url !== MIMO_CHAT_URL && url !== "https://api.xiaomimimo.com/api/free-ai/bootstrap") throw new Error("unexpected external request"); + observed.push(init?.redirect); + if (phase === "401-replay") { + if (url.endsWith("/bootstrap")) return Response.json({ jwt: "fresh-token" }); + if (++chatSends === 1) return new Response("expired", { status: 401 }); + } + // Only remap the canonical URL; do not repair the production redirect option. + return nativeFetch(`http://127.0.0.1:${origin.port}/mimo`, init); + }) as typeof fetch; + let response: Response | undefined; + try { + if (phase === "bootstrap") await expect(getMimoJwt()).rejects.toThrow(`MiMo bootstrap failed: ${status}`); + else { + const adapter = createMimoFreeAdapter(providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === "mimo-free")!)); + response = await adapter.fetchResponse!({ url: MIMO_CHAT_URL, method: "POST", headers: { authorization: "Bearer synthetic-token" }, body: "synthetic prompt" }, {}); + expect(response.status).toBe(status); + } + expect(targetHits).toBe(0); + expect(originHits).toBe(1); + expect(observed).toEqual(phase === "401-replay" ? ["manual", "manual", "manual"] : ["manual"]); + } finally { + globalThis.fetch = nativeFetch; + await response?.body?.cancel(); + await origin.stop(true); + await target.stop(true); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + resetMimoClientIdCache(); + resetMimoJwtCache(); + removeTreeWithRetry(testHome); + } +}); function minimalRequest(model = "mimo-auto"): OcxParsedRequest { return { @@ -148,7 +206,10 @@ describe("mimo-free JWT cache", () => { test("getMimoJwt fetches from bootstrap and caches", async () => { const fakeJwt = "header." + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64") + ".sig"; const originalFetch = globalThis.fetch; - globalThis.fetch = mock(async () => new Response(JSON.stringify({ jwt: fakeJwt }), { status: 200 })); + globalThis.fetch = mock(async (_input, init) => { + expect(init?.redirect).toBe("manual"); + return new Response(JSON.stringify({ jwt: fakeJwt }), { status: 200 }); + }); try { const jwt1 = await getMimoJwt(); expect(jwt1).toBe(fakeJwt); @@ -263,6 +324,7 @@ describe("mimo-free auth retry predicate", () => { const calls: string[] = []; const originalFetch = globalThis.fetch; globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => { + expect(init?.redirect).toBe("manual"); const u = String(url); if (u.includes("/bootstrap")) { calls.push("bootstrap"); diff --git a/tests/server/server-images.test.ts b/tests/server/server-images.test.ts index a6b78da18c..371aa264c0 100644 --- a/tests/server/server-images.test.ts +++ b/tests/server/server-images.test.ts @@ -1604,6 +1604,7 @@ function ccaFetchMock( try { parsedBody = JSON.parse(init.body); } catch { /* non-JSON body */ } } if (url.hostname === "daily-cloudcode-pa.googleapis.com") { + expect(init?.redirect).toBe("manual"); registryHits.push({ url: requestUrl, headers, body: parsedBody }); return Response.json(payload, { status }); } @@ -1621,6 +1622,47 @@ const CCA_CREDENTIAL = { projectId: "cca-project-123", } as const; +test.each([307, 308])("CCA image transport does not follow a canonical endpoint's %i", async status => { + let targetHits = 0; + let originHits = 0; + const target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + targetHits++; + return Response.json({ response: { candidates: [] } }); + } }); + const origin = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + originHits++; + return new Response("redirect", { status, headers: { location: `http://127.0.0.1:${target.port}/target` } }); + } }); + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "daily-cloudcode-pa.googleapis.com") { + // Map only the canonical URL; pass production init unchanged to the real transport. + return originalFetch(`http://127.0.0.1:${origin.port}/cca`, init); + } + if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") throw new Error("unexpected external request"); + return originalFetch(input, init); + }) as typeof fetch; + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/images/generations", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "synthetic prompt", model: "gpt-image-2" }), + }); + await response.text(); + expect(targetHits).toBe(0); + expect(originHits).toBe(1); + expect(response.status).toBe(502); + expect(response.headers.get("location")).toBeNull(); + } finally { + globalThis.fetch = originalFetch; + await server.stop(true); + await origin.stop(true); + await target.stop(true); + } +}); + test("CCA image fallback generates images via Google Antigravity when no OpenAI upstream exists", async () => { const registryHits: CcaFetchRequest[] = []; const otherHits: CcaFetchRequest[] = []; diff --git a/tests/videos/xai-video-client.test.ts b/tests/videos/xai-video-client.test.ts index fd8d773484..2b56ab3e4a 100644 --- a/tests/videos/xai-video-client.test.ts +++ b/tests/videos/xai-video-client.test.ts @@ -16,6 +16,31 @@ function mockFetchResponse(body: unknown, status = 200): Response { }); } +for (const phase of ["submit", "poll"] as const) test.each([307, 308])(`video ${phase} never follows %i`, async status => { + let targetHits = 0; + let originHits = 0; + const target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + targetHits++; + return Response.json({ request_id: "redirected", status: "done" }); + } }); + const origin = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + originHits++; + return new Response("redirect", { status, headers: { location: `http://127.0.0.1:${target.port}/target` } }); + } }); + try { + const scopedAuth = { baseUrl: `http://127.0.0.1:${origin.port}`, token: "synthetic-video-token" }; + const result = phase === "submit" ? submitVideoJob({ prompt: "synthetic prompt" }, scopedAuth) : pollVideoJob("job", scopedAuth); + const error = await result.catch(error => error as Error & { status: number }); + expect(targetHits).toBe(0); + expect(originHits).toBe(1); + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ status }); + } finally { + await origin.stop(true); + await target.stop(true); + } +}); + describe("submitVideoJob", () => { test("returns request_id from response", async () => { const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ request_id: "vid-123" }))); diff --git a/tests/vision/vision-anthropic.test.ts b/tests/vision/vision-anthropic.test.ts index 0c0ef3c095..f956cf6a4d 100644 --- a/tests/vision/vision-anthropic.test.ts +++ b/tests/vision/vision-anthropic.test.ts @@ -181,6 +181,7 @@ describe("Anthropic vision executor", () => { test("POSTs /v1/messages with the Claude Code OAuth fingerprint and a base64 image block", async () => { let captured: { url: string; headers: Headers; body: Record } | undefined; globalThis.fetch = (async (url, init) => { + expect(init?.redirect).toBe("manual"); captured = { url: String(url), headers: new Headers(init?.headers), diff --git a/tests/web-search/web-search-anthropic.test.ts b/tests/web-search/web-search-anthropic.test.ts index 980eacddaa..bba64616be 100644 --- a/tests/web-search/web-search-anthropic.test.ts +++ b/tests/web-search/web-search-anthropic.test.ts @@ -291,6 +291,7 @@ describe("runAnthropicWebSearch request shape", () => { test("POSTs /v1/messages with the OAuth fingerprint, disabled thinking, and the web_search tool", async () => { let captured: { url: string; headers: Record; body: Record } | null = null; globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + expect(init?.redirect).toBe("manual"); const headers: Record = {}; new Headers(init?.headers).forEach((v, k) => { headers[k] = v; }); captured = { url: String(url), headers, body: JSON.parse(String(init?.body)) }; diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index ce8f6e0f8a..c116743d86 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -2649,6 +2649,6 @@ describe("connection-reset recovery parity on the web-search legs", () => { // The loop sets accept-encoding: identity so raw byte progress stays observable; the recovery // helper clones headers into a Headers instance and must not drop it. expect(typeof attempts[1]!.body).toBe("string"); + expect(attempts.every(attempt => attempt.redirect === "manual")).toBe(true); }); }); -