From f565a4884bbcdeef457b38a32c60d46aca5a51d7 Mon Sep 17 00:00:00 2001 From: Rafael Moreira Date: Sat, 26 Sep 2026 08:32:36 -0300 Subject: [PATCH 1/3] fix(images): allow managed pool candidates to serve image generation with admission bearer auth When a client authenticates to the OpenCodex proxy using the proxy admission bearer ('Authorization: Bearer '), the images handler previously set 'skipOpenAiForwardForAdmissionBearer = true', dropping all forward candidates. This prevented healthy managed pool accounts (which use proxy-managed OAuth credentials rather than forwarding caller credentials) from serving /v1/images/generations, causing an unintended fallback to Google Antigravity or returning a 400 error. This patch: 1. Replaces the blind drop of all forward candidates with candidate-level filtering: candidates in 'direct' mode (which forward caller credentials) are skipped when the caller authenticates with proxy admission secrets, while managed pool candidates remain eligible. 2. Adds a post-assembly validation asserting that assembled outbound headers never contain proxy admission secrets. 3. Adds automated regressions in server-images.test.ts verifying pool account routing with admission bearers and safe fallback behavior. Fixes #4213 (partially: built-in image generation routing through proxy) --- src/server/images.ts | 17 +++--- tests/server/server-images.test.ts | 87 ++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/src/server/images.ts b/src/server/images.ts index fdf944b1c3f..627407494fc 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -659,21 +659,25 @@ export async function handleImages( } const explicitKeyedProvider = config.images?.provider !== undefined && candidates.keyed !== undefined; // Admission bearer is valid proxy auth (requireApiAuth already passed) but must never be - // forwarded as OpenAI ChatGPT credentials. When the caller sent it, skip OpenAI forward - // and allow CCA / keyed paths instead of rejecting the whole request. - let skipOpenAiForwardForAdmissionBearer = false; + // forwarded as OpenAI ChatGPT credentials. When the caller sent it, direct candidates + // that would forward caller credentials are skipped, but managed pool candidates remain + // eligible because resolveFirstUsableOpenAiSidecar substitutes them with local OAuth credentials. + let callerBearerMayBeForwarded = true; if (!explicitKeyedProvider) { try { validateForwardAdmissionCredential(req.headers, config); } catch (err) { if (err instanceof ForwardAdmissionCredentialError) { - skipOpenAiForwardForAdmissionBearer = true; + callerBearerMayBeForwarded = false; } else { throw err; } } } - const canUseOpenAiForward = !skipOpenAiForwardForAdmissionBearer && candidates.forwardCandidates.length > 0; + const eligibleForwardCandidates = callerBearerMayBeForwarded + ? candidates.forwardCandidates + : candidates.forwardCandidates.filter(c => c.accountMode !== "direct"); + const canUseOpenAiForward = eligibleForwardCandidates.length > 0; if (!canUseOpenAiForward && !candidates.keyed) { const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint, admission); @@ -696,7 +700,7 @@ export async function handleImages( let forwardAuthError: Response | undefined; if (canUseOpenAiForward) { try { - forward = await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, req.headers, config, { + forward = await resolveFirstUsableOpenAiSidecar(eligibleForwardCandidates, req.headers, config, { beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease), }); if (forward) logCtx.provider = formatCodexProviderForLog(forward.providerName, codexLogAccountId(forward.authContext), config); @@ -742,6 +746,7 @@ export async function handleImages( for (const [name, value] of forward.headers) headers[name] = value; // The ChatGPT codex backend takes bare paths (matches the adapter's `${baseUrl}/responses`). url = `${provider.baseUrl}/images/${endpoint}`; + validateForwardAdmissionCredential(new Headers(headers), config); } else if (forwardAuthError) { // Before surfacing the OpenAI auth failure, try CCA — the user may have a // valid Google Antigravity login even though their OpenAI pool is broken. diff --git a/tests/server/server-images.test.ts b/tests/server/server-images.test.ts index f08dfac858f..c867019f9fe 100644 --- a/tests/server/server-images.test.ts +++ b/tests/server/server-images.test.ts @@ -1682,6 +1682,93 @@ test("the proxy admission secret is never relayed to the forward upstream", asyn } }); +test("proxy admission bearer with pool forward candidate routes to OpenAI using pool credentials", async () => { + process.env.OPENCODEX_API_AUTH_TOKEN = "proxy-secret-pool"; + const captured: CapturedRequest[] = []; + const upstream = fakeImagesUpstream(captured); + saveConfig({ + ...forwardConfig(upstream.url.toString().replace(/\/$/, "")), + port: 0, + hostname: "0.0.0.0", + defaultProvider: "openai", + providers: { + openai: { ...canonicalOpenAiProvider, codexAccountMode: "pool" }, + }, + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-img", email: "pool-img@example.test", isMain: false, chatgptAccountId: "acct-pool-img" }, + ], + activeCodexAccountId: "pool-img", + } as OcxConfig); + saveCodexAccountCredential("pool-img", { + accessToken: "pool-img-access-token", + refreshToken: "pool-img-refresh-token", + expiresAt: Date.now() + 3_600_000, + chatgptAccountId: "acct-pool-img", + }); + + const server = startServer(0); + try { + // Standard client authenticates with the proxy admission secret in Authorization: Bearer. + // In pool mode, this must route to OpenAI forward and replace the bearer with the managed pool token. + const response = await fetch(`http://127.0.0.1:${server.port}/v1/images/generations`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer proxy-secret-pool", + }, + body: JSON.stringify({ prompt: "editorial cartoon in ink", model: "gpt-image-2" }), + }); + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + expect(captured[0].path).toBe("/images/generations"); + expect(captured[0].headers.get("authorization")).toBe("Bearer pool-img-access-token"); + expect(captured[0].headers.get("chatgpt-account-id")).toBe("acct-pool-img"); + expect([...captured[0].headers.values()].some(v => v.includes("proxy-secret-pool"))).toBe(false); + } finally { + await server.stop(true); + await upstream.stop(true); + } +}); + +test("proxy admission bearer with only direct forward candidate skips forward and falls back safely", async () => { + process.env.OPENCODEX_API_AUTH_TOKEN = "proxy-secret-dir"; + const captured: CapturedRequest[] = []; + const upstream = fakeImagesUpstream(captured); + // Default canonicalOpenAiProvider has accountMode direct and no pool accounts configured + saveConfig({ + port: 0, + hostname: "0.0.0.0", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: canonicalOpenAiProvider, + "openai-apikey": keyedProvider(upstream.url.toString().replace(/\/$/, "")), + }, + } as OcxConfig); + + const server = startServer(0); + try { + // With direct candidate only, forwarding the proxy secret is prohibited, so forward is skipped + // and keyedProvider serves the request instead. + const response = await fetch(`http://127.0.0.1:${server.port}/v1/images/generations`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer proxy-secret-dir", + }, + body: JSON.stringify({ prompt: "test prompt", model: "gpt-image-2" }), + }); + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + expect(captured[0].headers.get("authorization")).toBe("Bearer " + "sk-platform-key"); + expect([...captured[0].headers.values()].some(v => v.includes("proxy-secret-dir"))).toBe(false); + } finally { + await server.stop(true); + await upstream.stop(true); + } +}); + // ── Google Antigravity (CCA) image generation fallback ── /** From 37c8f687f79207b8f37a821db23c47a4913a0462 Mon Sep 17 00:00:00 2001 From: Rafael Moreira Date: Sat, 26 Sep 2026 09:55:08 -0300 Subject: [PATCH 2/3] fix(images): harden post-assembly validation and test pool auth failure under admission bearer 1. Wrap post-assembly admission secret validation in try/catch to safely release probe lease on failure and return 500 internal_error instead of unhandled exceptions. 2. Replace redundant direct-fallback test with pool-auth-failure test, verifying that admission bearer requests surface pool auth failures cleanly without hiding them behind keyed provider fallback. --- src/server/images.ts | 20 +++++++++++++++++++- tests/server/server-images.test.ts | 27 +++++++++++++++------------ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/server/images.ts b/src/server/images.ts index 627407494fc..243d4936600 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -746,7 +746,25 @@ export async function handleImages( for (const [name, value] of forward.headers) headers[name] = value; // The ChatGPT codex backend takes bare paths (matches the adapter's `${baseUrl}/responses`). url = `${provider.baseUrl}/images/${endpoint}`; - validateForwardAdmissionCredential(new Headers(headers), config); + try { + validateForwardAdmissionCredential(new Headers(headers), config); + } catch (err) { + try { + forward.releaseProbeLease?.(); + } catch {} + if (err instanceof ForwardAdmissionCredentialError) { + return formatErrorResponse( + 500, + "internal_error", + "proxy admission secret cannot be forwarded upstream", + ); + } + return formatErrorResponse( + 500, + "internal_error", + "image generation request failed forward credential validation", + ); + } } else if (forwardAuthError) { // Before surfacing the OpenAI auth failure, try CCA — the user may have a // valid Google Antigravity login even though their OpenAI pool is broken. diff --git a/tests/server/server-images.test.ts b/tests/server/server-images.test.ts index c867019f9fe..a8f70044e39 100644 --- a/tests/server/server-images.test.ts +++ b/tests/server/server-images.test.ts @@ -8,7 +8,7 @@ import { existsSync, mkdirSync} from "node:fs"; import { join } from "node:path"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { clearAccountNeedsReauth, clearAccountQuota } from "../../src/codex/auth-api"; -import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth } from "../../src/codex/routing"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth, recordCodexUpstreamOutcome } from "../../src/codex/routing"; import { loadConfig, saveConfig } from "../../src/config"; import { clearKeyCooldowns, rotateKeyOn429 } from "../../src/providers/key-failover"; import { selectImagesProvider } from "../../src/providers/openai-sidecar"; @@ -1731,38 +1731,41 @@ test("proxy admission bearer with pool forward candidate routes to OpenAI using } }); -test("proxy admission bearer with only direct forward candidate skips forward and falls back safely", async () => { - process.env.OPENCODEX_API_AUTH_TOKEN = "proxy-secret-dir"; +test("proxy admission bearer with pool auth failure surfaces pool error without falling back to keyed provider", async () => { + process.env.OPENCODEX_API_AUTH_TOKEN = "proxy-secret-pool"; const captured: CapturedRequest[] = []; const upstream = fakeImagesUpstream(captured); - // Default canonicalOpenAiProvider has accountMode direct and no pool accounts configured saveConfig({ port: 0, hostname: "0.0.0.0", defaultProvider: "openai", openaiProviderTierVersion: 2, providers: { - openai: canonicalOpenAiProvider, + openai: { ...canonicalOpenAiProvider, codexAccountMode: "pool" }, "openai-apikey": keyedProvider(upstream.url.toString().replace(/\/$/, "")), }, + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "pool@example.test", isMain: false, chatgptAccountId: "acct-pool-a" }, + ], + // pool-a has NO stored credential, so forward-auth resolution throws CodexAuthContextError. + activeCodexAccountId: "pool-a", } as OcxConfig); const server = startServer(0); try { - // With direct candidate only, forwarding the proxy secret is prohibited, so forward is skipped - // and keyedProvider serves the request instead. const response = await fetch(`http://127.0.0.1:${server.port}/v1/images/generations`, { method: "POST", headers: { "content-type": "application/json", - authorization: "Bearer proxy-secret-dir", + authorization: "Bearer proxy-secret-pool", }, body: JSON.stringify({ prompt: "test prompt", model: "gpt-image-2" }), }); - expect(response.status).toBe(200); - expect(captured).toHaveLength(1); - expect(captured[0].headers.get("authorization")).toBe("Bearer " + "sk-platform-key"); - expect([...captured[0].headers.values()].some(v => v.includes("proxy-secret-dir"))).toBe(false); + expect(response.status).toBe(401); + expect(captured).toHaveLength(0); + const json = (await response.json()) as { error: { message: string } }; + expect(json.error.message).toContain("reauthentication"); } finally { await server.stop(true); await upstream.stop(true); From 6ff9e6338daaf726a02f1cf83fabac66f3a1f9af Mon Sep 17 00:00:00 2001 From: Rafael Moreira Date: Sat, 26 Sep 2026 09:57:12 -0300 Subject: [PATCH 3/3] fix(images): log lease release errors on validation failure --- src/server/images.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server/images.ts b/src/server/images.ts index 243d4936600..215ca46aa3d 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -751,7 +751,9 @@ export async function handleImages( } catch (err) { try { forward.releaseProbeLease?.(); - } catch {} + } catch (releaseErr) { + console.error("[images] Failed to release probe lease:", releaseErr); + } if (err instanceof ForwardAdmissionCredentialError) { return formatErrorResponse( 500,