diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 5e3074bec4a..ec81a456c44 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -732,6 +732,7 @@ async function handleClaudeMessagesWithBudget( // Without this the replay would look native and a Responses-scoped wire default // would fire, disagreeing with the pre-flight decision above. inboundWire: "anthropic", + stripClaudeMainAuthForNoncanonicalForward: true, translatorBudget, ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 327376fc596..01a58b79490 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -631,6 +631,12 @@ export interface HandleResponsesOptions { inboundWire?: InboundWire; /** Internal transport identity for route-scoped upstream compatibility policy. */ inboundTransport?: "websocket"; + /** + * Claude replay may add native-main auth so OpenAI sidecars remain available. + * Strip only that internal credential when the final route is a noncanonical + * forward destination; final routing can differ from Claude's preflight route. + */ + stripClaudeMainAuthForNoncanonicalForward?: boolean; /** Internal recursion guard; callers outside this module must not set it. */ comboAttempt?: boolean; /** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */ @@ -1701,7 +1707,24 @@ async function handleResponsesInner( parsed.options.promptCacheKey, route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined, ); - const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); + let adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); + const stripClaudeMainAuth = options.stripClaudeMainAuthForNoncanonicalForward === true + && adapterProvider.adapter === "openai-responses" + && adapterProvider.authMode === "forward" + && !isCanonicalOpenAiForwardProvider(adapterProvider); + if (stripClaudeMainAuth) { + releaseCodexAuthContextProbeLease(authCtx); + authCtx = { kind: "main", accountId: null }; + route.provider = stripCodexRuntimeProviderFields(route.provider); + adapterProvider = stripCodexRuntimeProviderFields(adapterProvider); + selectedForwardHeaders = new Headers(selectedForwardHeaders); + selectedForwardHeaders.delete("authorization"); + selectedForwardHeaders.delete("chatgpt-account-id"); + delete route.codexAccountMode; + delete route.codexAccountId; + delete route.codexAccountNamespace; + logCtx.provider = route.providerName; + } const adapter = resolveAdapter(adapterProvider, config.cacheRetention); logCtx.providerAdapter = adapter.name; // Ordinary requests receive one durable attempt only after their final initial diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index 9269160db5f..e365299c52c 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -686,6 +686,121 @@ test("native openai-responses route carries prompt_cache_key + synthesized sessi } }); +test("custom forward openai-responses route never receives the main ChatGPT credential", async () => { + writeFileSync(join(isolatedCodexHome!.path, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-secret-must-not-leave", account_id: "main-account-must-not-leave" }, + })); + const captured: Array<{ authorization: string | null; accountId: string | null }> = []; + const upstream = Bun.serve({ + port: 0, + fetch(req) { + captured.push({ + authorization: req.headers.get("authorization"), + accountId: req.headers.get("chatgpt-account-id"), + }); + return new Response([ + 'event: response.created\ndata: {"response":{"id":"resp_1","status":"in_progress"}}\n\n', + 'event: response.output_text.delta\ndata: {"delta":"ok"}\n\n', + 'event: response.completed\ndata: {"response":{"status":"completed","usage":{"input_tokens":1,"output_tokens":1}}}\n\n', + ].join(""), { headers: { "content-type": "text/event-stream" } }); + }, + }); + saveConfig({ + port: 0, + defaultProvider: "custom", + providers: { + custom: { + adapter: "openai-chat", + baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, + authMode: "forward", + allowPrivateNetwork: true, + modelAdapters: { "gpt-test": "openai-responses" }, + }, + }, + } as OcxConfig); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/messages", server.url), { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer claude-placeholder" }, + body: JSON.stringify({ + model: "custom/gpt-test", + max_tokens: 16, + messages: [{ role: "user", content: "hi" }], + }), + }); + const responseBody = await response.text(); + expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 }); + expect(captured).toEqual([{ authorization: null, accountId: null }]); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("shadow-call rerouting cannot carry the main ChatGPT credential to a custom forward route", async () => { + writeFileSync(join(isolatedCodexHome!.path, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-secret-must-not-leave", account_id: "main-account-must-not-leave" }, + })); + const captured: Array<{ authorization: string | null; accountId: string | null }> = []; + const upstream = Bun.serve({ + port: 0, + fetch(req) { + captured.push({ + authorization: req.headers.get("authorization"), + accountId: req.headers.get("chatgpt-account-id"), + }); + return new Response([ + 'event: response.created\ndata: {"response":{"id":"resp_1","status":"in_progress"}}\n\n', + 'event: response.output_text.delta\ndata: {"delta":"ok"}\n\n', + 'event: response.completed\ndata: {"response":{"status":"completed","usage":{"input_tokens":1,"output_tokens":1}}}\n\n', + ].join(""), { headers: { "content-type": "text/event-stream" } }); + }, + }); + saveConfig({ + port: 0, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + custom: { + adapter: "openai-chat", + baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, + authMode: "forward", + allowPrivateNetwork: true, + modelAdapters: { "gpt-test": "openai-responses" }, + }, + }, + shadowCallIntercept: { + enabled: true, + model: "custom/gpt-test", + sourceModels: ["gpt-5.6-luna"], + }, + } as OcxConfig); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/messages", server.url), { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer claude-placeholder" }, + body: JSON.stringify({ + model: "gpt-5.6-luna", + max_tokens: 16, + messages: [{ role: "user", content: "hi" }], + }), + }); + const responseBody = await response.text(); + expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 }); + expect(captured).toEqual([{ authorization: null, accountId: null }]); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + test("Claude replay owns optional main enrichment while routed work survives drain and recovery", async () => { resetLifecycleDrainStateForTests(); writeFileSync(join(isolatedCodexHome!.path, "auth.json"), JSON.stringify({