From f700c56172d79f1222690d7a1ccfb9d6cc4c53bf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:22:17 +0000 Subject: [PATCH 1/6] test(openai-chat): declare role acceptance in suites that assert the forwarded role (#5334 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5334 made the developer wire role tri-state: an undeclared destination folds it to system. Two suites asserting role:"developer" on the Chat wire were missed because they are about tool-result repair ordering and document parts, not role selection — declare the destination, per the convention the change established. Verified: both files fail on dev@600075d2 with system-for-developer wire roles and pass with the declaration. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts | 4 ++++ tests/responses/chat-inline-document-bytes.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts index 41a61c2102..075e3c3318 100644 --- a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts +++ b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts @@ -12,6 +12,10 @@ const provider: OcxProviderConfig = { baseUrl: "https://example.test/v1", apiKey: "sk-test", authMode: "key", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; this suite is about tool-result repair ordering, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; interface ChatMsg { diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts index dd88fa0578..a718c1c376 100644 --- a/tests/responses/chat-inline-document-bytes.test.ts +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -28,6 +28,10 @@ const chatProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; the document test asserts the role a turn keeps, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; const anthropicProvider = { adapter: "anthropic", From 1074d31cc9b15121fc3d3c880a14df31908a0276 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 21 Sep 2026 01:20:42 +0000 Subject: [PATCH 2/6] fix(responses): retain caller user agent through auth --- src/adapters/openai-responses/passthrough.ts | 1 + structure/transports/responses.md | 2 +- tests/codex-integration/codex-metadata-integrity.test.ts | 7 ++++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index cadcb2a38c..efff7422b8 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -67,6 +67,7 @@ export const FORWARD_HEADERS = [ "session_id", "session-id", "thread-id", + "user-agent", "x-client-request-id", "x-codex-beta-features", "x-codex-installation-id", diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 09bf0d1ccb..34f9227242 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -18,7 +18,7 @@ Responses-compatible streaming output. For an opted-in key-auth provider, a host The `openai-responses` adapter preserves the incoming `User-Agent` as a non-credential fallback in both key and forward modes. A configured provider header with that name wins case-insensitively; when the caller omits it, the adapter invents no client identity. This does not widen the canonical -forward credential/metadata allowlist or copy any other caller header. +forward credential/metadata allowlist beyond that single header or copy any other caller header. Retired Codex Spark has no model-specific tool or Responses Lite override; general Lite handling and namespace scrubbing remain shared compatibility behavior. Codex quota/reset evidence follows the diff --git a/tests/codex-integration/codex-metadata-integrity.test.ts b/tests/codex-integration/codex-metadata-integrity.test.ts index d6f21f87e8..b66e830d3c 100644 --- a/tests/codex-integration/codex-metadata-integrity.test.ts +++ b/tests/codex-integration/codex-metadata-integrity.test.ts @@ -39,6 +39,7 @@ describe("Codex metadata integrity", () => { "session-id", "thread-id", "chatgpt-account-id", + "user-agent", "x-codex-parent-thread-id", ]) { expect(FORWARD_HEADERS).toContain(name); @@ -175,8 +176,12 @@ describe("Codex metadata integrity", () => { authMode: "forward", }, ] satisfies OcxProviderConfig[]) { + const selected = headersForCodexAuthContext( + new Headers({ "User-Agent": "codex_cli_rs/0.154.0" }), + poolAuthContext, + ); const request = await createResponsesPassthroughAdapter(provider).buildRequest(minimalParsed(), { - headers: new Headers({ "User-Agent": "codex_cli_rs/0.154.0" }), + headers: selected, }); expect(new Headers(request.headers).get("user-agent")).toBe("codex_cli_rs/0.154.0"); } From 83dd7256895256d350cb095302e185a2357e0497 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:04:02 +0000 Subject: [PATCH 3/6] fix(responses): keep configured User-Agent authoritative over forwarded caller value A caller User-Agent now reaches the canonical forward path through auth materialization, but the generic FORWARD_HEADERS overlay wrote it after provider.headers, replacing or duplicating a configured value. Apply it through applyCallerUserAgentFallback only, shared by the web-search and vision sidecar replays, so a configured provider header wins case-insensitively and the caller fingerprint fills only the gap. Co-Authored-By: Epinephrine --- src/adapters/openai-responses.ts | 2 +- src/adapters/openai-responses/passthrough.ts | 19 +++++++++++++------ src/vision/describe.ts | 6 +++++- src/web-search/executor.ts | 6 +++++- structure/transports/responses.md | 2 ++ .../codex-metadata-integrity.test.ts | 16 ++++++++++++++++ 6 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 619fa667f3..ad44847326 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,6 +1,6 @@ export { stripCanonicalForwardSamplingParams } from "./openai-responses/canonical-forward"; -export { FORWARD_HEADERS, createResponsesPassthroughAdapter } from "./openai-responses/passthrough"; +export { applyCallerUserAgentFallback, FORWARD_HEADERS, createResponsesPassthroughAdapter } from "./openai-responses/passthrough"; export { sanitizeReasoningInputContent } from "./openai-responses/reasoning"; export { stripOpenAiOnlyWebSearchFields } from "./openai-responses/web-search"; diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index efff7422b8..2e8da2cba0 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -81,13 +81,18 @@ export const FORWARD_HEADERS = [ CODEX_RESPONSES_LITE_HEADER, ]; -/** Preserve the caller fingerprint unless the provider explicitly owns that header. */ -function applyCallerUserAgentFallback( +/** + * Preserve the caller fingerprint unless the provider explicitly owns that header. The one + * non-credential caller header this adapter forwards is applied here rather than in the + * FORWARD_HEADERS overlay loops so a configured provider header always wins case-insensitively. + * Exported so the web-search and vision sidecars apply the same precedence on their replays. + */ +export function applyCallerUserAgentFallback( headers: Record, - incoming: IncomingMeta, + callerHeaders: Headers, ): void { if (Object.keys(headers).some(name => name.toLowerCase() === "user-agent")) return; - const userAgent = incoming.headers.get("user-agent"); + const userAgent = callerHeaders.get("user-agent"); if (userAgent) headers["User-Agent"] = userAgent; } @@ -229,7 +234,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (name.toLowerCase() === h) delete headers[name]; } } - headers[h] = v; // …so genuine forwarded fields win. + // user-agent stays available through auth materialization but is fallback-only + // here: applyCallerUserAgentFallback below keeps a configured header authoritative. + if (h !== "user-agent") headers[h] = v; // …so genuine forwarded fields win. } } } @@ -251,7 +258,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Some Responses-compatible gateways select their Codex compatibility path from the real // client fingerprint. This is a single non-credential fallback, not broader caller-header // forwarding. Static provider headers remain authoritative in either auth mode. - applyCallerUserAgentFallback(headers, incoming); + applyCallerUserAgentFallback(headers, incoming.headers); const forward = provider.authMode === "forward"; let convertedRoutedCustomToolNames: Set | undefined; diff --git a/src/vision/describe.ts b/src/vision/describe.ts index 14629f0166..4da11f96aa 100644 --- a/src/vision/describe.ts +++ b/src/vision/describe.ts @@ -1,6 +1,6 @@ import type { OcxProviderConfig } from "../types"; import type { VisionReasoningEffort } from "../reasoning-effort"; -import { FORWARD_HEADERS } from "../adapters/openai-responses"; +import { applyCallerUserAgentFallback, FORWARD_HEADERS } from "../adapters/openai-responses"; import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; import { sidecarEnter } from "../lib/sidecar-tracker"; @@ -70,9 +70,13 @@ export async function describeImage( const headers: Record = { "Content-Type": "application/json" }; if (forwardProvider.headers) Object.assign(headers, forwardProvider.headers); for (const h of FORWARD_HEADERS) { + if (h === "user-agent") continue; const v = selectedForwardHeaders.get(h); if (v) headers[h] = v; } + // Same precedence as the forward adapter: a configured provider User-Agent stays + // authoritative and the caller fingerprint only fills the name when unconfigured. + applyCallerUserAgentFallback(headers, selectedForwardHeaders); const content: unknown[] = []; if (contextText) content.push({ type: "input_text", text: `The user's request about this image: ${contextText}` }); content.push({ type: "input_image", image_url: imageUrl, detail: detail ?? "high" }); diff --git a/src/web-search/executor.ts b/src/web-search/executor.ts index 33b6a1eb6c..7574b8eb59 100644 --- a/src/web-search/executor.ts +++ b/src/web-search/executor.ts @@ -1,5 +1,5 @@ import type { OcxProviderConfig } from "../types"; -import { FORWARD_HEADERS } from "../adapters/openai-responses"; +import { applyCallerUserAgentFallback, FORWARD_HEADERS } from "../adapters/openai-responses"; import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; import { sidecarEnter } from "../lib/sidecar-tracker"; @@ -81,9 +81,13 @@ export async function runWebSearch( const headers: Record = { "Content-Type": "application/json" }; if (forwardProvider.headers) Object.assign(headers, forwardProvider.headers); for (const h of FORWARD_HEADERS) { + if (h === "user-agent") continue; const v = selectedForwardHeaders.get(h); if (v) headers[h] = v; } + // Same precedence as the forward adapter: a configured provider User-Agent stays + // authoritative and the caller fingerprint only fills the name when unconfigured. + applyCallerUserAgentFallback(headers, selectedForwardHeaders); const body = { model: settings.model, instructions: settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION, diff --git a/structure/transports/responses.md b/structure/transports/responses.md index ae3f1729f3..eb3236e836 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -19,6 +19,8 @@ The `openai-responses` adapter preserves the incoming `User-Agent` as a non-cred both key and forward modes. A configured provider header with that name wins case-insensitively; when the caller omits it, the adapter invents no client identity. This does not widen the canonical forward credential/metadata allowlist beyond that single header or copy any other caller header. +The web-search and vision sidecar replays apply the same precedence: the caller fingerprint fills +the name only when the provider's configured headers do not already carry it. Retired Codex Spark has no model-specific tool or Responses Lite override; general Lite handling and namespace scrubbing remain shared compatibility behavior. Codex quota/reset evidence follows the diff --git a/tests/codex-integration/codex-metadata-integrity.test.ts b/tests/codex-integration/codex-metadata-integrity.test.ts index b66e830d3c..fdab79743b 100644 --- a/tests/codex-integration/codex-metadata-integrity.test.ts +++ b/tests/codex-integration/codex-metadata-integrity.test.ts @@ -208,6 +208,22 @@ describe("Codex metadata integrity", () => { expect(new Headers(absent.headers).has("user-agent")).toBe(false); }); + test("canonical forward mode keeps a configured User-Agent over the caller value", async () => { + const selected = headersForCodexAuthContext( + new Headers({ "User-Agent": "codex_cli_rs/0.154.0" }), + poolAuthContext, + ); + const request = await createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + headers: { "uSeR-aGeNt": "operator-agent/1" }, + }).buildRequest(minimalParsed(), { headers: selected }); + expect(new Headers(request.headers).get("user-agent")).toBe("operator-agent/1"); + expect(Object.keys(request.headers) + .filter(name => name.toLowerCase() === "user-agent")).toHaveLength(1); + }); + test("the preserved User-Agent is the value received by the HTTP upstream", async () => { let resolveObserved!: (value: string | null) => void; const observed = new Promise(resolve => { resolveObserved = resolve; }); From 23a26d6be31fe11a5c8f5cf2697f2cf56eb6b35f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:37:28 +0000 Subject: [PATCH 4/6] fix(responses): apply User-Agent fallback precedence in direct forward relays The standalone search, images, live, and context-history relays overlay materialized auth headers after configured provider headers, so the caller User-Agent newly retained by FORWARD_HEADERS replaced the configured fingerprint there too. Skip the name in each overlay and defer to applyCallerUserAgentFallback, which now also accepts a Headers target for the context relay. Sidecar tests cover configured precedence and caller fallback; a wiring guard pins all four relays to the shared helper. Co-Authored-By: Epinephrine --- src/adapters/openai-responses/passthrough.ts | 15 +++- src/server/context-history.ts | 10 ++- src/server/images.ts | 7 +- src/server/live.ts | 7 +- src/server/search.ts | 7 +- structure/transports/responses.md | 5 +- .../codex-metadata-integrity.test.ts | 83 ++++++++++++++++++- 7 files changed, 122 insertions(+), 12 deletions(-) diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 2e8da2cba0..7be2624e00 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -85,15 +85,22 @@ export const FORWARD_HEADERS = [ * Preserve the caller fingerprint unless the provider explicitly owns that header. The one * non-credential caller header this adapter forwards is applied here rather than in the * FORWARD_HEADERS overlay loops so a configured provider header always wins case-insensitively. - * Exported so the web-search and vision sidecars apply the same precedence on their replays. + * Exported so the web-search and vision sidecars and the standalone search/images/live/context + * relays apply the same precedence on their replays. Accepts either the mutable header record + * most callers build or a `Headers` object (context-history materializes into one). */ export function applyCallerUserAgentFallback( - headers: Record, + headers: Record | Headers, callerHeaders: Headers, ): void { - if (Object.keys(headers).some(name => name.toLowerCase() === "user-agent")) return; + const present = headers instanceof Headers + ? headers.has("user-agent") + : Object.keys(headers).some(name => name.toLowerCase() === "user-agent"); + if (present) return; const userAgent = callerHeaders.get("user-agent"); - if (userAgent) headers["User-Agent"] = userAgent; + if (!userAgent) return; + if (headers instanceof Headers) headers.set("user-agent", userAgent); + else headers["User-Agent"] = userAgent; } /** Replace every `input_image` part under a routed-compaction body with a short marker. */ diff --git a/src/server/context-history.ts b/src/server/context-history.ts index 759157a406..12aee15824 100644 --- a/src/server/context-history.ts +++ b/src/server/context-history.ts @@ -1,4 +1,5 @@ /** Native history/notes JSON relay. No interpretation of encrypted tool arguments or retries. */ +import { applyCallerUserAgentFallback } from "../adapters/openai-responses"; import { formatErrorResponse } from "../bridge"; import { CodexAccountCooldownError, CodexAuthContextError, CodexMainProfileDrainingError, CodexDirectAuthenticationError, @@ -131,11 +132,14 @@ async function relayContextHistory( logCtx.provider = formatCodexProviderForLog(candidate.providerName, codexLogAccountId(authContext), config); // Materialization rechecks the current account policy after async selection. // Synthetic lane IDs are local selection metadata, never upstream headers. - for (const [key, value] of materializeCodexUpstreamAuth(req.headers, authContext, { + const materializedAuthHeaders = materializeCodexUpstreamAuth(req.headers, authContext, { config, modelId: "context_history", admission, substituteMainCredential, - })) { - headers.set(key, value); + }); + for (const [key, value] of materializedAuthHeaders) { + if (key !== "user-agent") headers.set(key, value); } + // Configured provider User-Agent stays authoritative; the caller fingerprint fills the gap. + applyCallerUserAgentFallback(headers, materializedAuthHeaders); // Check the assembled outbound headers, including configured provider headers. validateForwardAdmissionCredential(headers, config); // Recheck actual wire identity after async selection/materialization. A replaced diff --git a/src/server/images.ts b/src/server/images.ts index fdf944b1c3..6a9ce4a3b0 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -12,6 +12,7 @@ * codex's images client parses `{created, data:[{b64_json}]}` strictly and Debug-prints * error bodies into the model-visible failure, so upstream errors must stay legible. */ +import { applyCallerUserAgentFallback } from "../adapters/openai-responses"; import { formatErrorResponse } from "../bridge"; import { CodexAccountCooldownError, @@ -739,7 +740,11 @@ export async function handleImages( } const { provider } = forward; if (provider.headers) Object.assign(headers, provider.headers); - for (const [name, value] of forward.headers) headers[name] = value; + for (const [name, value] of forward.headers) { + if (name !== "user-agent") headers[name] = value; + } + // Configured provider User-Agent stays authoritative; the caller fingerprint fills the gap. + applyCallerUserAgentFallback(headers, forward.headers); // The ChatGPT codex backend takes bare paths (matches the adapter's `${baseUrl}/responses`). url = `${provider.baseUrl}/images/${endpoint}`; } else if (forwardAuthError) { diff --git a/src/server/live.ts b/src/server/live.ts index 673e93ea8f..876c156473 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -1,3 +1,4 @@ +import { applyCallerUserAgentFallback } from "../adapters/openai-responses"; import { codexCompatibleUrl } from "../codex/context-compat"; /** * /v1/live and /v1/realtime/calls relay (issue #371). @@ -699,7 +700,11 @@ export async function resolveLiveRelay( return denial; } if (provider.headers) Object.assign(headers, provider.headers); - for (const [name, value] of forward.headers) headers[name] = value; + for (const [name, value] of forward.headers) { + if (name !== "user-agent") headers[name] = value; + } + // Configured provider User-Agent stays authoritative; the caller fingerprint fills the gap. + applyCallerUserAgentFallback(headers, forward.headers); logCtx.model = "gpt-live"; return { headers, diff --git a/src/server/search.ts b/src/server/search.ts index 51e418346e..67bbfc1f51 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -10,6 +10,7 @@ * That fallback never runs while a forward candidate exists, and never borrows a different * paid backend than the one the operator named. */ +import { applyCallerUserAgentFallback } from "../adapters/openai-responses"; import { formatErrorResponse } from "../bridge"; import { CodexAccountCooldownError, @@ -174,7 +175,11 @@ export async function handleSearch( const headers: Record = { "content-type": "application/json" }; if (upstream.provider.headers) Object.assign(headers, upstream.provider.headers); - for (const [name, value] of upstream.headers) headers[name] = value; + for (const [name, value] of upstream.headers) { + if (name !== "user-agent") headers[name] = value; + } + // Configured provider User-Agent stays authoritative; the caller fingerprint fills the gap. + applyCallerUserAgentFallback(headers, upstream.headers); const url = `${upstream.provider.baseUrl}/alpha/search`; const timeoutMs = config.search?.timeoutMs ?? SEARCH_UPSTREAM_TIMEOUT_MS; const linkedSignal = signalWithTimeout(timeoutMs, req.signal); diff --git a/structure/transports/responses.md b/structure/transports/responses.md index eb3236e836..a4f01877e6 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -20,7 +20,10 @@ both key and forward modes. A configured provider header with that name wins cas when the caller omits it, the adapter invents no client identity. This does not widen the canonical forward credential/metadata allowlist beyond that single header or copy any other caller header. The web-search and vision sidecar replays apply the same precedence: the caller fingerprint fills -the name only when the provider's configured headers do not already carry it. +the name only when the provider's configured headers do not already carry it. So do the standalone +search, images, live, and context-history relays, which receive the materialized headers rather +than the caller's originals — each skips `user-agent` in its overlay and defers to the shared +fallback so a configured provider value still wins. Retired Codex Spark has no model-specific tool or Responses Lite override; general Lite handling and namespace scrubbing remain shared compatibility behavior. Codex quota/reset evidence follows the diff --git a/tests/codex-integration/codex-metadata-integrity.test.ts b/tests/codex-integration/codex-metadata-integrity.test.ts index fdab79743b..fb1ae3b475 100644 --- a/tests/codex-integration/codex-metadata-integrity.test.ts +++ b/tests/codex-integration/codex-metadata-integrity.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { FORWARD_HEADERS, createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; +import { readFileSync } from "node:fs"; +import { applyCallerUserAgentFallback, FORWARD_HEADERS, createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; import { headersForCodexAuthContext } from "../../src/codex/auth-context"; +import { runWebSearch } from "../../src/web-search/executor"; +import { describeImage } from "../../src/vision/describe"; import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { repoPath } from "../helpers/repo-root"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; const createResponsesPassthroughAdapter = (...args: Parameters) => @@ -224,6 +228,83 @@ describe("Codex metadata integrity", () => { .filter(name => name.toLowerCase() === "user-agent")).toHaveLength(1); }); + test("the shared fallback keeps configured User-Agent authoritative on record and Headers overlays", () => { + const caller = new Headers({ "user-agent": "codex_cli_rs/0.154.0", authorization: "Bearer pool" }); + const record: Record = { "uSeR-aGeNt": "operator-agent/1" }; + applyCallerUserAgentFallback(record, caller); + expect(new Headers(record).get("user-agent")).toBe("operator-agent/1"); + const unfilled: Record = {}; + applyCallerUserAgentFallback(unfilled, caller); + expect(unfilled["User-Agent"]).toBe("codex_cli_rs/0.154.0"); + const headers = new Headers({ "user-agent": "operator-agent/1" }); + applyCallerUserAgentFallback(headers, caller); + expect(headers.get("user-agent")).toBe("operator-agent/1"); + const unconfigured = new Headers(); + applyCallerUserAgentFallback(unconfigured, caller); + expect(unconfigured.get("user-agent")).toBe("codex_cli_rs/0.154.0"); + }); + + test("every direct relay that overlays materialized headers defers User-Agent to the shared fallback", () => { + for (const file of [ + "src/server/search.ts", "src/server/images.ts", + "src/server/live.ts", "src/server/context-history.ts", + ]) { + const source = readFileSync(repoPath(file), "utf8"); + expect(source).toContain("applyCallerUserAgentFallback("); + expect(source).toMatch(/(?:name|key) !== "user-agent"/); + } + }); + + test("web-search and vision sidecars keep a configured User-Agent over the caller value", async () => { + const selected = new Headers({ "user-agent": "codex_cli_rs/0.154.0", authorization: "Bearer pool" }); + const seen: Headers[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + seen.push(new Headers(init?.headers)); + return new Response("upstream error", { status: 500 }); + }) as typeof fetch; + try { + const provider: OcxProviderConfig = { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", headers: { "uSeR-aGeNt": "operator-agent/1" }, + }; + const settings = { model: "gpt-5.5-mini", reasoning: "low" as const, timeoutMs: 1_000 }; + await runWebSearch("q", { type: "web_search" }, provider, selected, settings); + await describeImage("https://example.com/i.png", undefined, "ctx", provider, selected, settings); + } finally { + globalThis.fetch = realFetch; + } + expect(seen.length).toBe(2); + for (const headers of seen) { + expect(headers.get("user-agent")).toBe("operator-agent/1"); + expect([...headers.keys()].filter(name => name === "user-agent")).toHaveLength(1); + } + }); + + test("web-search and vision sidecars fill User-Agent from the caller when unconfigured", async () => { + const selected = new Headers({ "user-agent": "codex_cli_rs/0.154.0", authorization: "Bearer pool" }); + const seen: Headers[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + seen.push(new Headers(init?.headers)); + return new Response("upstream error", { status: 500 }); + }) as typeof fetch; + try { + const provider: OcxProviderConfig = { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", + }; + const settings = { model: "gpt-5.5-mini", reasoning: "low" as const, timeoutMs: 1_000 }; + await runWebSearch("q", { type: "web_search" }, provider, selected, settings); + await describeImage("https://example.com/i.png", undefined, "ctx", provider, selected, settings); + } finally { + globalThis.fetch = realFetch; + } + expect(seen.length).toBe(2); + for (const headers of seen) { + expect(headers.get("user-agent")).toBe("codex_cli_rs/0.154.0"); + } + }); + test("the preserved User-Agent is the value received by the HTTP upstream", async () => { let resolveObserved!: (value: string | null) => void; const observed = new Promise(resolve => { resolveObserved = resolve; }); From 759f486540c43eb0fd18dcb501200d8173e25a66 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:44:54 +0000 Subject: [PATCH 5/6] fix(responses): keep configured User-Agent authoritative on compact and audio sends Compact's FORWARD_HEADERS overlays and the audio upstream both write the materialized caller header set without ever merging provider.headers, so the allowlist addition sent the caller fingerprint even when the provider config carries a User-Agent of its own. Extend the shared fallback with a provider header oracle so those paths keep the configured value; the caller User-Agent still fills the name when the provider leaves it unset. Co-Authored-By: Epinephrine --- src/adapters/openai-responses/passthrough.ts | 33 ++++++++++++++----- src/server/audio-upstream.ts | 7 +++- src/server/responses/compact.ts | 18 +++++++++- structure/transports/responses.md | 4 ++- .../codex-metadata-integrity.test.ts | 25 ++++++++++++-- 5 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 7be2624e00..fd0c17e9f3 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -88,19 +88,36 @@ export const FORWARD_HEADERS = [ * Exported so the web-search and vision sidecars and the standalone search/images/live/context * relays apply the same precedence on their replays. Accepts either the mutable header record * most callers build or a `Headers` object (context-history materializes into one). + * + * `providerHeaders` is consulted directly — not via the outbound set — because compact and audio + * materialize caller headers without ever merging provider.headers; a configured value must still + * win there. */ export function applyCallerUserAgentFallback( headers: Record | Headers, callerHeaders: Headers, + providerHeaders?: Record | Headers, ): void { - const present = headers instanceof Headers - ? headers.has("user-agent") - : Object.keys(headers).some(name => name.toLowerCase() === "user-agent"); - if (present) return; - const userAgent = callerHeaders.get("user-agent"); - if (!userAgent) return; - if (headers instanceof Headers) headers.set("user-agent", userAgent); - else headers["User-Agent"] = userAgent; + const configured = providerHeaders === undefined ? null : readUserAgentHeader(providerHeaders); + if (headers instanceof Headers) { + if (configured !== null) headers.set("user-agent", configured); + else if (!headers.has("user-agent")) { + const caller = callerHeaders.get("user-agent"); + if (caller) headers.set("user-agent", caller); + } + return; + } + if (Object.keys(headers).some(name => name.toLowerCase() === "user-agent")) return; + const caller = configured ?? callerHeaders.get("user-agent"); + if (caller) headers["User-Agent"] = caller; +} + +function readUserAgentHeader(source: Record | Headers): string | null { + if (source instanceof Headers) return source.get("user-agent"); + for (const [name, value] of Object.entries(source)) { + if (name.toLowerCase() === "user-agent") return value; + } + return null; } /** Replace every `input_image` part under a routed-compaction body with a short marker. */ diff --git a/src/server/audio-upstream.ts b/src/server/audio-upstream.ts index cefeaee266..947c4d0a59 100644 --- a/src/server/audio-upstream.ts +++ b/src/server/audio-upstream.ts @@ -1,3 +1,4 @@ +import { applyCallerUserAgentFallback } from "../adapters/openai-responses"; import { formatErrorResponse } from "../bridge"; import { CodexAccountCooldownError, @@ -134,10 +135,14 @@ export async function resolveAudioUpstream( } log.provider = formatCodexProviderForLog(candidate.providerName, context.accountId, config); log.model = options.model; + const outboundHeaders = new Headers(selected); + // Audio materializes caller headers without merging provider.headers; a configured + // provider User-Agent still wins, and the caller fingerprint fills only the gap. + applyCallerUserAgentFallback(outboundHeaders, selected, candidate.provider.headers); return { providerName: candidate.providerName, providerBaseUrl: candidate.provider.baseUrl, - headers: Object.fromEntries(selected), + headers: Object.fromEntries(outboundHeaders), keyed: false, authContext: context, recordOutcome, diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 89e4012378..8adec4c3c2 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -14,7 +14,7 @@ import { import { resolveProviderApiKey } from "../../providers/key-store"; import { parseRequest } from "../../responses/parser"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; -import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; +import { applyCallerUserAgentFallback, FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state"; import { repairLegacyDottedToolCallNames } from "../../responses/legacy-dotted-tool-name-repair"; import { NoEligiblePolicyCandidateError, routeCompactionModel } from "../../router"; @@ -353,9 +353,13 @@ async function refreshNativeMainCompactContext(args: { nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, }); for (const name of FORWARD_HEADERS) { + if (name === "user-agent") continue; const value = selected.get(name); if (value) headers.set(name, value); } + // Compact builds its own header set without provider.headers; a configured provider + // User-Agent still wins, and the caller fingerprint fills only the gap. + applyCallerUserAgentFallback(headers, selected, refreshedProvider.headers); const override = (refreshedProvider as { _codexAccountOverride?: { accessToken: string; chatgptAccountId: string } })._codexAccountOverride; if (override) { headers.set("authorization", `Bearer ${override.accessToken}`); @@ -445,9 +449,13 @@ async function refreshPoolCompactContext(args: { nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, }); for (const name of FORWARD_HEADERS) { + if (name === "user-agent") continue; const value = selected.get(name); if (value) headers.set(name, value); } + // Compact builds its own header set without provider.headers; a configured provider + // User-Agent still wins, and the caller fingerprint fills only the gap. + applyCallerUserAgentFallback(headers, selected, refreshedProvider.headers); const override = (refreshedProvider as { _codexAccountOverride?: { accessToken: string; chatgptAccountId: string } })._codexAccountOverride; if (override) { headers.set("authorization", `Bearer ${override.accessToken}`); @@ -508,9 +516,13 @@ async function resolveAlternateCompactContext(args: { const headers = new Headers({ "content-type": "application/json" }); const selected = headersForCodexAuthContext(req.headers, authCtx, config, selectedModelId, args.admission); for (const name of FORWARD_HEADERS) { + if (name === "user-agent") continue; const value = selected.get(name); if (value) headers.set(name, value); } + // Compact builds its own header set without provider.headers; a configured provider + // User-Agent still wins, and the caller fingerprint fills only the gap. + applyCallerUserAgentFallback(headers, selected, provider.headers); const override = (provider as { _codexAccountOverride?: { accessToken: string; chatgptAccountId: string } })._codexAccountOverride; if (override) { headers.set("authorization", `Bearer ${override.accessToken}`); @@ -836,9 +848,13 @@ export async function handleResponsesCompact( }); compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode); for (const name of FORWARD_HEADERS) { + if (name === "user-agent") continue; const value = selected.get(name); if (value) headers.set(name, value); } + // Compact builds its own header set without provider.headers; a configured provider + // User-Agent still wins, and the caller fingerprint fills only the gap. + applyCallerUserAgentFallback(headers, selected, compactProvider.headers); const override = (compactProvider as { _codexAccountOverride?: { accessToken: string; chatgptAccountId: string } })._codexAccountOverride; if (override) { headers.set("authorization", `Bearer ${override.accessToken}`); diff --git a/structure/transports/responses.md b/structure/transports/responses.md index a4f01877e6..be89627a18 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -23,7 +23,9 @@ The web-search and vision sidecar replays apply the same precedence: the caller the name only when the provider's configured headers do not already carry it. So do the standalone search, images, live, and context-history relays, which receive the materialized headers rather than the caller's originals — each skips `user-agent` in its overlay and defers to the shared -fallback so a configured provider value still wins. +fallback so a configured provider value still wins. Native compact and audio materialize caller +headers without merging `provider.headers` at all; the shared fallback reads the provider config +directly there, so a configured value is still applied over the caller fingerprint. Retired Codex Spark has no model-specific tool or Responses Lite override; general Lite handling and namespace scrubbing remain shared compatibility behavior. Codex quota/reset evidence follows the diff --git a/tests/codex-integration/codex-metadata-integrity.test.ts b/tests/codex-integration/codex-metadata-integrity.test.ts index fb1ae3b475..cdf46a3a52 100644 --- a/tests/codex-integration/codex-metadata-integrity.test.ts +++ b/tests/codex-integration/codex-metadata-integrity.test.ts @@ -242,17 +242,38 @@ describe("Codex metadata integrity", () => { const unconfigured = new Headers(); applyCallerUserAgentFallback(unconfigured, caller); expect(unconfigured.get("user-agent")).toBe("codex_cli_rs/0.154.0"); + // Compact and audio never merge provider.headers: the configured value still + // wins over a caller fingerprint already present in the materialized set. + const materialized = new Headers({ "user-agent": "codex_cli_rs/0.154.0", authorization: "Bearer pool" }); + const audioShaped = new Headers(materialized); + applyCallerUserAgentFallback(audioShaped, materialized, { "uSeR-aGeNt": "operator-agent/1" }); + expect(audioShaped.get("user-agent")).toBe("operator-agent/1"); + const audioUnconfigured = new Headers(materialized); + applyCallerUserAgentFallback(audioUnconfigured, materialized, undefined); + expect(audioUnconfigured.get("user-agent")).toBe("codex_cli_rs/0.154.0"); }); - test("every direct relay that overlays materialized headers defers User-Agent to the shared fallback", () => { + test("every relay and standalone send that overlays materialized headers defers User-Agent to the shared fallback", () => { for (const file of [ "src/server/search.ts", "src/server/images.ts", "src/server/live.ts", "src/server/context-history.ts", + "src/server/audio-upstream.ts", ]) { const source = readFileSync(repoPath(file), "utf8"); expect(source).toContain("applyCallerUserAgentFallback("); - expect(source).toMatch(/(?:name|key) !== "user-agent"/); } + for (const file of [ + "src/server/search.ts", "src/server/images.ts", + "src/server/live.ts", "src/server/context-history.ts", + "src/server/responses/compact.ts", + ]) { + const source = readFileSync(repoPath(file), "utf8"); + expect(source).toContain("applyCallerUserAgentFallback("); + expect(source).toMatch(/(?:name|key) [!=]== "user-agent"/); + } + // Every FORWARD_HEADERS overlay in compact applies the configured-provider oracle. + const compact = readFileSync(repoPath("src/server/responses/compact.ts"), "utf8"); + expect(compact.split("applyCallerUserAgentFallback(").length - 1).toBe(4); }); test("web-search and vision sidecars keep a configured User-Agent over the caller value", async () => { From feba174429e5bfb864c95f6a10e8abe690cde6d6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:48:25 +0000 Subject: [PATCH 6/6] fix(responses): cover key-auth compact sends and record-shape precedence The native compact fallback only ran while materializing a Codex account, so a canonical API-key provider sent neither the configured nor the caller User-Agent. Apply the fallback after the auth-mode branch against the final compactProvider so every compact send gets the same precedence. The record branch of applyCallerUserAgentFallback also returned early on an existing User-Agent even when a configured provider value was supplied; it now evicts stale duplicates and writes the configured value, matching the Headers shape. Co-Authored-By: Epinephrine --- src/adapters/openai-responses/passthrough.ts | 9 ++++++++- src/server/responses/compact.ts | 7 ++++--- tests/codex-integration/codex-metadata-integrity.test.ts | 6 ++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index fd0c17e9f3..058471f1fb 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -107,8 +107,15 @@ export function applyCallerUserAgentFallback( } return; } + if (configured !== null) { + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === "user-agent") delete headers[name]; + } + headers["User-Agent"] = configured; + return; + } if (Object.keys(headers).some(name => name.toLowerCase() === "user-agent")) return; - const caller = configured ?? callerHeaders.get("user-agent"); + const caller = callerHeaders.get("user-agent"); if (caller) headers["User-Agent"] = caller; } diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 8adec4c3c2..40f3db05d8 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -852,9 +852,6 @@ export async function handleResponsesCompact( const value = selected.get(name); if (value) headers.set(name, value); } - // Compact builds its own header set without provider.headers; a configured provider - // User-Agent still wins, and the caller fingerprint fills only the gap. - applyCallerUserAgentFallback(headers, selected, compactProvider.headers); const override = (compactProvider as { _codexAccountOverride?: { accessToken: string; chatgptAccountId: string } })._codexAccountOverride; if (override) { headers.set("authorization", `Bearer ${override.accessToken}`); @@ -885,6 +882,10 @@ export async function handleResponsesCompact( if (warmKeyProvider?.apiKey) compactProvider = warmKeyProvider; headers.set("authorization", `Bearer ${resolveProviderApiKey(compactProvider.apiKey)}`); } + // Applies to every native compact send — the key-auth path materializes no caller + // headers at all. A configured provider User-Agent still wins; the caller + // fingerprint fills only the gap. + applyCallerUserAgentFallback(headers, req.headers, compactProvider.headers); const { reasoning: _reasoning, ...compactBodyRaw } = raw as typeof raw & { reasoning?: unknown }; // The regular /v1/responses path applies sanitizeReasoningInputContent via the adapter's // buildRequest, but the compact endpoint forwards directly. Apply the same sanitizer here diff --git a/tests/codex-integration/codex-metadata-integrity.test.ts b/tests/codex-integration/codex-metadata-integrity.test.ts index cdf46a3a52..e9c6a46a3a 100644 --- a/tests/codex-integration/codex-metadata-integrity.test.ts +++ b/tests/codex-integration/codex-metadata-integrity.test.ts @@ -251,6 +251,12 @@ describe("Codex metadata integrity", () => { const audioUnconfigured = new Headers(materialized); applyCallerUserAgentFallback(audioUnconfigured, materialized, undefined); expect(audioUnconfigured.get("user-agent")).toBe("codex_cli_rs/0.154.0"); + // The record shape applies the same precedence: a configured value evicts a + // stale caller User-Agent rather than losing to it. + const recordBoth: Record = { "user-agent": "codex_cli_rs/0.154.0" }; + applyCallerUserAgentFallback(recordBoth, caller, { "User-Agent": "operator-agent/1" }); + expect(recordBoth["User-Agent"]).toBe("operator-agent/1"); + expect(recordBoth["user-agent"]).toBeUndefined(); }); test("every relay and standalone send that overlays materialized headers defers User-Agent to the shared fallback", () => {