From a5584c067e7a4b8a49a2be8e901f05d8b2d34e74 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 8 Aug 2026 19:27:05 +0900 Subject: [PATCH] fix(claude): contain main account credentials --- src/server/claude-messages.ts | 9 ++++- tests/claude-messages-endpoint.test.ts | 50 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 875368ccca..bc9fa9362e 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -27,6 +27,7 @@ import { clearableDeadline, idleDeadline } from "../lib/abort"; import { estimateTokens } from "../lib/token-estimate"; import { NoEligiblePolicyCandidateError, routeModel } from "../router"; import { evidenceFromBody } from "../routing/request-evidence"; +import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { resolveWireProtocolOverride } from "./adapter-resolve"; import type { OcxConfig } from "../types"; import { readJsonRequestBody } from "./request-decompress"; @@ -628,11 +629,17 @@ async function handleClaudeMessagesWithBudget( // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens", // verified live 2026-07-11). Strip them for that route; routed providers keep them. let nativeRoute = false; + let allowMainAccountEnrichment = false; try { const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody)); // Settle the wire once so the sampling decision below reads the effective // adapter rather than the provider-wide default (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic"); + // Main-account headers may reach an openai-responses forward destination verbatim. + // Only the canonical ChatGPT destination is allowed to receive that credential; + // other adapters replace it with their own auth before the upstream request. + allowMainAccountEnrichment = route.provider.adapter !== "openai-responses" + || isCanonicalOpenAiForwardProvider(route.provider); logCtx.routeDecision = route.routeDecision; if (route.provider.adapter === "openai-responses") { nativeRoute = true; @@ -685,7 +692,7 @@ async function handleClaudeMessagesWithBudget( // native replays have no caller ChatGPT credential. This enrichment is optional: // auth-context later rejects a real physical-main selection, while routed/pool // traffic continues without reading native credentials during a fence/recovery. - if (tryClaimNativeMainProfileForTurn(logIds?.turnAdmissionLease)) { + if (allowMainAccountEnrichment && tryClaimNativeMainProfileForTurn(logIds?.turnAdmissionLease)) { const { getMainAccountToken } = await import("../codex/main-account"); const token = getMainAccountToken(); if (token) { diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index 01a74e455b..09ade9ecc7 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -620,6 +620,56 @@ 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.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-responses", + baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, + authMode: "forward", + allowPrivateNetwork: true, + }, + }, + } 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" }], + }), + }); + expect(response.status).toBe(200); + await response.text(); + 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({