diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index b5be70d16..8ab6c3adb 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -6,7 +6,7 @@ import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../resp import { collectResponsesToolGroups } from "../responses/tool-groups"; import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy"; import { decodeServerSentEvents } from "../lib/sse-decoder"; -import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; +import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; import { modelRecordValue } from "../reasoning-effort"; import type { TranslatorBudget } from "../lib/translator-budget"; @@ -1213,22 +1213,32 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let url: string; if (provider.authMode === "forward") { + const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider); // OAuth passthrough: ChatGPT backend path is `${baseUrl}/responses` (no /v1). - url = `${provider.baseUrl}/responses`; + const baseUrl = mayForwardCallerCredentials + ? CODEX_FORWARD_BASE_URL + : provider.baseUrl.replace(/\/+$/, ""); + url = `${baseUrl}/responses`; if (provider.headers) Object.assign(headers, provider.headers); // static headers first… const runtimeProvider = provider as { _codexAccountOverride?: { accessToken: string; chatgptAccountId: string }; _codexAccountRequired?: boolean; }; - if (runtimeProvider._codexAccountRequired && !runtimeProvider._codexAccountOverride) { + if ( + mayForwardCallerCredentials + && runtimeProvider._codexAccountRequired + && !runtimeProvider._codexAccountOverride + ) { throw new Error("Codex pool account auth is required but unavailable"); } - for (const h of FORWARD_HEADERS) { - const v = incoming?.headers.get(h); - if (v) headers[h] = v; // …so forwarded auth always wins. + if (mayForwardCallerCredentials) { + for (const h of FORWARD_HEADERS) { + const v = incoming?.headers.get(h); + if (v) headers[h] = v; // …so forwarded auth always wins. + } } const override = runtimeProvider._codexAccountOverride; - if (override) { + if (override && mayForwardCallerCredentials) { headers["authorization"] = `Bearer ${override.accessToken}`; headers["chatgpt-account-id"] = override.chatgptAccountId; } diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index 43b4e2396..e1ffc397f 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -13,6 +13,7 @@ import { extractAccountId } from "../oauth/chatgpt"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../server/auth-cors"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { + CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, @@ -62,10 +63,16 @@ export function listOpenAiForwardSidecarCandidates(config: OcxConfig): OpenAiFor ? { ...provider, authMode: "forward" as const } : provider; if (!isCanonicalOpenAiForwardProvider(canonicalProvider)) return []; + // The predicate accepts harmless trailing-slash variants. Pin the provider returned + // to credential-bearing sidecars so every consumer builds one exact ChatGPT path + // instead of independently concatenating the operator's equivalent spelling. + const pinnedProvider = canonicalProvider.baseUrl === CODEX_FORWARD_BASE_URL + ? canonicalProvider + : { ...canonicalProvider, baseUrl: CODEX_FORWARD_BASE_URL }; return [{ providerName: OPENAI_CODEX_PROVIDER_ID, - provider: canonicalProvider, - accountMode: providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, canonicalProvider) ?? "pool", + provider: pinnedProvider, + accountMode: providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, pinnedProvider) ?? "pool", }]; } diff --git a/src/server/images.ts b/src/server/images.ts index c3ee1f464..5e65a1a17 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -499,6 +499,12 @@ export async function handleImages( headers, body: JSON.stringify(body), signal: linkedSignal.signal, + // Do not follow a cross-origin 3xx while carrying Codex credentials. Bun strips + // `Authorization` across origins but forwards nonstandard headers, so + // `chatgpt-account-id`, `session_id`, and `x-codex-turn-metadata` would reach the + // redirect target. Verified with a two-server probe. The Responses path and native + // compact already set this; the credential-bearing sidecars did not. + redirect: "manual", }); const observed = await readImageResponseBytes(upstreamResponse, { maxBytes: IMAGES_RESPONSE_MAX_BYTES, diff --git a/src/server/live.ts b/src/server/live.ts index 3ecbc907b..79d0f9a8f 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -223,7 +223,7 @@ export function keyedLiveUrl(baseUrl: string): string { } export function forwardLiveUrl(baseUrl: string, usesBackendShape: boolean): string { - const root = baseUrl.replace(/\/$/, ""); + const root = baseUrl.replace(/\/+$/, ""); if (usesBackendShape) return withAvasQuery(`${root}/realtime/calls`); // Frameless API shape posts to /live without the AVAS query (codex RealtimeCallClient). return `${root}/live`; @@ -647,6 +647,10 @@ export async function handleLive( headers, body: outboundBody, signal: linkedSignal.signal, + // Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization` + // across origins but forwards nonstandard headers such as `chatgpt-account-id`, + // `session_id`, and `x-codex-turn-metadata` to the redirect target. + redirect: "manual", }); // Record every completed upstream response before body size handling so account health / // cooldown still updates when we reject an oversized payload. diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index b0270fe84..49649d62e 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -82,7 +82,7 @@ import { } from "../../codex/upstream-host-health"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; -import { isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers"; +import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers"; import { slugsEquivalent } from "../../providers/slug-codec"; import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models"; import { isUsageDebugEnabled } from "../../usage/debug"; @@ -392,7 +392,9 @@ export async function handleResponsesCompact( } throw err; } - const base = (compactProvider.baseUrl ?? "").replace(/\/$/, ""); + const base = isCanonicalOpenAiForwardProvider(compactProvider) + ? CODEX_FORWARD_BASE_URL + : (compactProvider.baseUrl ?? "").replace(/\/+$/, ""); if (compactProvider.authMode !== "forward" && compactProvider.apiKey) { headers.set("authorization", `Bearer ${resolveEnvValue(compactProvider.apiKey)}`); } diff --git a/src/server/search.ts b/src/server/search.ts index 94508f3d7..54a74fa8b 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -152,6 +152,10 @@ export async function handleSearch( headers, body: JSON.stringify(relayBody), signal: linkedSignal.signal, + // Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization` + // across origins but forwards nonstandard headers such as `chatgpt-account-id`, + // `session_id`, and `x-codex-turn-metadata` to the redirect target. + redirect: "manual", }); const observed = await readBoundedResponseBytes(upstreamResponse, { maxBytes: SEARCH_RESPONSE_MAX_BYTES, diff --git a/src/vision/describe.ts b/src/vision/describe.ts index f51eb8c61..444bb4923 100644 --- a/src/vision/describe.ts +++ b/src/vision/describe.ts @@ -95,6 +95,10 @@ export async function describeImage( headers, body: JSON.stringify(body), signal: linkedSignal.signal, + // Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization` + // across origins but forwards nonstandard headers such as `chatgpt-account-id`, + // `session_id`, and `x-codex-turn-metadata` to the redirect target. + redirect: "manual", }), { abortSignal: linkedSignal.signal, label: "vision-sidecar" }, ); diff --git a/src/web-search/executor.ts b/src/web-search/executor.ts index 5ad7f67a7..e5cd585b6 100644 --- a/src/web-search/executor.ts +++ b/src/web-search/executor.ts @@ -78,6 +78,10 @@ export async function runWebSearch( headers, body: JSON.stringify(body), signal: linkedSignal.signal, + // Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization` + // across origins but forwards nonstandard headers such as `chatgpt-account-id`, + // `session_id`, and `x-codex-turn-metadata` to the redirect target. + redirect: "manual", }), { abortSignal: linkedSignal.signal, label: "web-search-sidecar" }, ); diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index 6e5bd89f0..03cacabe8 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -654,11 +654,22 @@ test("native openai-responses route carries prompt_cache_key + synthesized sessi return new Response(frames.join(""), { headers: { "Content-Type": "text/event-stream" } }); }, }); + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + if (url.origin === "https://chatgpt.com") { + if (url.pathname !== "/backend-api/codex/responses") { + throw new Error(`unexpected canonical Codex path ${url.pathname}`); + } + return originalFetch(new URL("/responses", upstream.url), init); + } + return originalFetch(input, init); + }) as typeof fetch; saveConfig({ port: 0, defaultProvider: "native", providers: { - native: { adapter: "openai-responses", baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, authMode: "forward", allowPrivateNetwork: true }, + native: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, }, } as OcxConfig); const server = startServer(0); @@ -686,6 +697,7 @@ test("native openai-responses route carries prompt_cache_key + synthesized sessi expect(capture.body?.reasoning?.effort).toBe("high"); expect(capture.headers?.["session_id"]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-8[0-9a-f]{3}-[0-9a-f]{12}$/); } finally { + globalThis.fetch = originalFetch; await server.stop(true); upstream.stop(true); } diff --git a/tests/codex-metadata-integrity.test.ts b/tests/codex-metadata-integrity.test.ts index c9357029f..4182770d3 100644 --- a/tests/codex-metadata-integrity.test.ts +++ b/tests/codex-metadata-integrity.test.ts @@ -117,7 +117,7 @@ describe("Codex metadata integrity", () => { _codexAccountRequired: boolean; } = { adapter: "openai-responses", - baseUrl: "https://chatgpt.test/backend-api/codex", + baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", _codexAccountRequired: true, _codexAccountOverride: { @@ -142,7 +142,7 @@ describe("Codex metadata integrity", () => { test("adapter forward mode preserves genuine client metadata", () => { const provider: OcxProviderConfig = { adapter: "openai-responses", - baseUrl: "https://chatgpt.test/backend-api/codex", + baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", }; const adapter = createResponsesPassthroughAdapter(provider); diff --git a/tests/credential-redirect-guard.test.ts b/tests/credential-redirect-guard.test.ts new file mode 100644 index 000000000..783ca2dac --- /dev/null +++ b/tests/credential-redirect-guard.test.ts @@ -0,0 +1,86 @@ +/** + * 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. + */ +import { describe, expect, test } from "bun:test"; + +describe("Bun forwards nonstandard headers across a redirect", () => { + test("Authorization is dropped but Codex identity headers are not", async () => { + const captured: Record = {}; + 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"); + 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` }, + }), + }); + + try { + await fetch(`http://127.0.0.1:${origin.port}/start`, { + headers: { + authorization: "Bearer secret-token", + "chatgpt-account-id": "acct-123", + session_id: "sess-456", + "x-codex-turn-metadata": "turn-789", + }, + }); + } finally { + origin.stop(true); + 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"); + }); +}); + +describe("credential-bearing sidecars refuse to follow redirects", () => { + const sites: Array<{ file: string; label: string }> = [ + { file: "../src/server/images.ts", label: "images relay" }, + { file: "../src/server/live.ts", label: "live relay" }, + { file: "../src/server/search.ts", label: "search relay" }, + { file: "../src/web-search/executor.ts", label: "web-search sidecar" }, + { file: "../src/vision/describe.ts", label: "vision sidecar" }, + ]; + + for (const { file, label } of sites) { + test(`${label} sets redirect: "manual"`, async () => { + const source = await Bun.file(new URL(file, import.meta.url)).text(); + expect(source).toContain('redirect: "manual"'); + }); + } + + // 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"'); + }); +}); diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index f5db97fa0..c2077668c 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -11,10 +11,107 @@ const createResponsesPassthroughAdapter = (...args: Parameters { + const userInfoUrl = new URL("https://chatgpt.com/backend-api/codex"); + userInfoUrl.username = "user"; + userInfoUrl.password = "secret"; + for (const baseUrl of [ + "https://provider.example/v1/", + "https://chatgpt.com/backend-api/not-codex", + "https://chatgpt.example/backend-api/codex", + "https://chatgpt.com/backend-api/codex?target=custom", + "https://chatgpt.com/backend-api/codex#custom", + userInfoUrl.toString(), + ]) { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl, + authMode: "forward", + headers: { "x-provider-option": "enabled" }, + _codexAccountRequired: true, + _codexAccountOverride: { + accessToken: "runtime-secret", + chatgptAccountId: "runtime-account", + }, + } as Parameters[0]); + const request = adapter.buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "test-model", input: "ping" }, + }, { + headers: new Headers({ + authorization: "Bearer caller-secret", + "chatgpt-account-id": "caller-account", + session_id: "caller-session", + }), + }); + + expect(request.url).toBe(`${baseUrl.replace(/\/+$/, "")}/responses`); + expect(request.headers["x-provider-option"]).toBe("enabled"); + expect(request.headers.authorization).toBeUndefined(); + expect(request.headers["chatgpt-account-id"]).toBeUndefined(); + expect(request.headers.session_id).toBeUndefined(); + } +}); + +test("canonical forward providers normalize trailing slashes and let the pool override win", () => { + const adapter = createResponsesPassthroughAdapter({ + ...provider, + baseUrl: "https://chatgpt.com/backend-api/codex///", + _codexAccountRequired: true, + _codexAccountOverride: { + accessToken: "runtime-secret", + chatgptAccountId: "runtime-account", + }, + } as Parameters[0]); + const request = adapter.buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "test-model", input: "ping" }, + }, { headers: new Headers({ authorization: "Bearer caller-secret" }) }); + + expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(request.headers.authorization).toBe("Bearer runtime-secret"); + expect(request.headers["chatgpt-account-id"]).toBe("runtime-account"); +}); + +test("noncanonical pool-required providers use only their configured static credentials", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://provider.example/v1/", + authMode: "forward", + headers: { authorization: "Bearer provider-static", "x-provider-option": "enabled" }, + _codexAccountRequired: true, + } as Parameters[0]); + const request = adapter.buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "test-model", input: "ping" }, + }, { + headers: new Headers({ + authorization: "Bearer caller-secret", + "chatgpt-account-id": "caller-account", + session_id: "caller-session", + }), + }); + + expect(request.url).toBe("https://provider.example/v1/responses"); + expect(request.headers["x-provider-option"]).toBe("enabled"); + expect(request.headers.authorization).toBe("Bearer provider-static"); + expect(request.headers["chatgpt-account-id"]).toBeUndefined(); + expect(request.headers.session_id).toBeUndefined(); +}); + test("passthrough serialized-body observation releases after the request settles", () => { const budget = createTranslatorBudget(); const request = createResponsesPassthroughAdapter(provider).buildRequest({ diff --git a/tests/passthrough-override.test.ts b/tests/passthrough-override.test.ts index c6435ce26..85a863088 100644 --- a/tests/passthrough-override.test.ts +++ b/tests/passthrough-override.test.ts @@ -6,7 +6,7 @@ import { withTestTranslatorBudget } from "./helpers/translator-budget"; const createResponsesPassthroughAdapter = (...args: Parameters) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); -const forwardProvider = { adapter: "openai-responses", baseUrl: "https://chat.openai.com/backend-api/codex", authMode: "forward" as const }; +const forwardProvider = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }; describe("passthrough token override", () => { test("buildRequest uses original auth when no override", () => { diff --git a/tests/responses-compaction-routing.test.ts b/tests/responses-compaction-routing.test.ts index 98a40b4e1..3e2cb7252 100644 --- a/tests/responses-compaction-routing.test.ts +++ b/tests/responses-compaction-routing.test.ts @@ -668,6 +668,30 @@ describe("compact alternate-account attempt (#913)", () => { }); } + test("canonical trailing slashes are pinned before native compact sends pool credentials", async () => { + await withPoolEnv("ocx-compact-canonical-url-", async config => { + config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///"; + let observedUrl = ""; + let observedHeaders = new Headers(); + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + observedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + observedHeaders = new Headers(init?.headers); + return jsonResponse(completedPayload("canonical compact response")); + }) as typeof fetch; + + const res = await handleResponsesCompact( + compactionRequest(baseCompactionBody({ model: "gpt-5.5" })), + config, + { model: "", provider: "" }, + ); + + expect(res.status).toBe(200); + expect(observedUrl).toBe("https://chatgpt.com/backend-api/codex/responses/compact"); + expect(observedHeaders.get("authorization")).toBe("Bearer pool-a-access-token"); + expect(observedHeaders.get("chatgpt-account-id")).toBe("pool_acc_a"); + }); + }); + for (const rejection of [429, 402] as const) { test(`a pre-body ${rejection} tries exactly one alternate account`, async () => { await withPoolEnv(`ocx-compact-alt-${rejection}-`, async config => { diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 843bec4a2..b68e9b4d9 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -706,6 +706,7 @@ describe("routed Responses custom-tool compatibility", () => { const savedFetch = globalThis.fetch; let outboundBody: Record | undefined; let outboundAuthorization: string | null = null; + let outboundUrl = ""; const upstreamItem = { type: "function_call", id: "fc_exec", @@ -714,7 +715,8 @@ describe("routed Responses custom-tool compatibility", () => { arguments: "{\"input\":\"native\"}", status: "completed", }; - globalThis.fetch = (async (_input, init) => { + globalThis.fetch = (async (input, init) => { + outboundUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; outboundBody = JSON.parse(String(init?.body)) as Record; outboundAuthorization = new Headers(init?.headers).get("authorization"); return new Response(JSON.stringify({ id: "resp_forward", status: "completed", output: [upstreamItem] }), { @@ -727,7 +729,7 @@ describe("routed Responses custom-tool compatibility", () => { providers: { fixture: { adapter: "openai-responses", - baseUrl: "https://forward.fixture.test", + baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", }, }, @@ -747,6 +749,7 @@ describe("routed Responses custom-tool compatibility", () => { const clientBody = await response.json() as { output: Array> }; const outboundTools = outboundBody?.tools as Array> | undefined; + expect(outboundUrl).toBe("https://chatgpt.com/backend-api/codex/responses"); expect(outboundAuthorization).toBe("Bearer caller-token"); expect(outboundTools?.[0]).toMatchObject({ type: "custom", name: "exec" }); expect(clientBody.output[0]).toMatchObject({ type: "function_call", name: "exec" }); diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index d286c192f..9499de4d8 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -158,7 +158,9 @@ test("image response byte reader enforces the stream cap when Content-Length is test("POST /v1/images/generations relays to the ChatGPT forward provider with forwarded auth", async () => { const captured: CapturedRequest[] = []; const upstream = fakeImagesUpstream(captured); - saveConfig(forwardConfig(upstream.url.toString().replace(/\/$/, ""))); + const config = forwardConfig(); + config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///"; + saveConfig(config); const server = startServer(0); try { diff --git a/tests/server-live.test.ts b/tests/server-live.test.ts index a32645fd7..a92e4ec0a 100644 --- a/tests/server-live.test.ts +++ b/tests/server-live.test.ts @@ -152,7 +152,9 @@ function multipartLiveBody( test("POST /v1/live rewrites ChatGPT multipart into backend realtime/calls JSON", async () => { const captured: CapturedRequest[] = []; const upstream = fakeLiveUpstream(captured); - saveConfig(forwardConfig()); + const config = forwardConfig(); + config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///"; + saveConfig(config); const server = startServer(0); try { @@ -801,6 +803,9 @@ test("buildLiveSidebandUpstreamWsUrl maps Frameless and Realtime join shapes", a expect(forwardLiveUrl("https://chatgpt.com/backend-api/codex", true)).toBe( "https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas", ); + expect(forwardLiveUrl("https://chatgpt.com/backend-api/codex///", true)).toBe( + "https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas", + ); expect(keyedLiveUrl("https://api.openai.com/v1")).toBe( "https://api.openai.com/v1/realtime/calls?intent=quicksilver&architecture=avas", ); diff --git a/tests/vision-sidecar-e2e.test.ts b/tests/vision-sidecar-e2e.test.ts index a504b91c4..421de5b3e 100644 --- a/tests/vision-sidecar-e2e.test.ts +++ b/tests/vision-sidecar-e2e.test.ts @@ -154,6 +154,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { let sidecarBody = ""; let sidecarAuth: string | null = null; let sidecarAccount: string | null = null; + let sidecarPath = ""; let sidecarHits = 0; upstream = serveUpstream(b => { upstreamBody = b; }); sidecar = serveSidecar((req, b) => { @@ -161,6 +162,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { sidecarBody = b; sidecarAuth = req.headers.get("authorization"); sidecarAccount = req.headers.get("chatgpt-account-id"); + sidecarPath = new URL(req.url).pathname; }); globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; @@ -185,7 +187,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { openai: { adapter: "openai-responses", authMode: "forward", - baseUrl: "https://chatgpt.com/backend-api/codex", + baseUrl: "https://chatgpt.com/backend-api/codex///", codexAccountMode: "direct", }, }, @@ -207,6 +209,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { // Activation evidence: the sidecar actually ran, got the image + OAuth passthrough. expect(sidecarHits).toBe(1); + expect(sidecarPath).toBe("/responses"); expect(sidecarAuth).toBe(`Bearer ${token}`); expect(sidecarAccount).toBe("acct-vision-sidecar"); expect(sidecarBody).toContain("input_image"); diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 06970e6c7..062fa6f05 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { parseRequest } from "../src/responses/parser"; import { planWebSearch, shouldResolveOpenAiWebSearchSidecar, webSearchStallTimeoutSec } from "../src/web-search"; import { runWithWebSearch as runWithWebSearchProduction, type WebSearchLoopDeps } from "../src/web-search/loop"; +import { runWebSearch as runOpenAiWebSearch } from "../src/web-search/executor"; import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; import { headersForCodexAuthContext } from "../src/codex/auth-context"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar } from "../src/providers/openai-sidecar"; @@ -162,14 +163,17 @@ describe("web-search sidecar planning", () => { providers: { openai: { adapter: "openai-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", + baseUrl: "https://chatgpt.com/backend-api/codex///", codexAccountMode: "direct", }, }, }; expect(listOpenAiForwardSidecarCandidates(canonicalWithoutAuthMode)).toMatchObject([{ providerName: "openai", - provider: { authMode: "forward" }, + provider: { + authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, accountMode: "direct", }]); @@ -378,6 +382,40 @@ describe("web-search sidecar planning", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); +test("OpenAI web-search execution uses the pinned canonical URL and selected credentials", async () => { + const cfg = config({ + providers: { + routed: routedProvider, + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex///", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + }); + const candidate = listOpenAiForwardSidecarCandidates(cfg)[0]!; + let observedUrl = ""; + let observedHeaders = new Headers(); + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + observedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + observedHeaders = new Headers(init?.headers); + return new Response("data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + await runOpenAiWebSearch( + "current docs", + { type: "web_search" }, + candidate.provider, + new Headers({ authorization: "Bearer selected-token", "chatgpt-account-id": "selected-account" }), + { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 1_000 }, + ); + + expect(observedUrl).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(observedHeaders.get("authorization")).toBe("Bearer selected-token"); + expect(observedHeaders.get("chatgpt-account-id")).toBe("selected-account"); +}); + async function collectSse(stream: ReadableStream): Promise<{ event?: string; data: Record }[]> { const reader = stream.getReader(); const decoder = new TextDecoder();