Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down
11 changes: 9 additions & 2 deletions src/providers/openai-sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
}];
}

Expand Down
6 changes: 6 additions & 0 deletions src/server/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/server/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)}`);
}
Expand Down
4 changes: 4 additions & 0 deletions src/server/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/vision/describe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
);
Expand Down
4 changes: 4 additions & 0 deletions src/web-search/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
);
Expand Down
14 changes: 13 additions & 1 deletion tests/claude-messages-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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);
Expand Down Expand Up @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions tests/codex-metadata-integrity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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);
Expand Down
86 changes: 86 additions & 0 deletions tests/credential-redirect-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | null> = {};
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"');
});
});
Loading
Loading