diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 619fa667f3e..ad448473269 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 cadcb2a38c2..058471f1fb3 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", @@ -80,14 +81,50 @@ export const FORWARD_HEADERS = [ CODEX_RESPONSES_LITE_HEADER, ]; -/** Preserve the caller fingerprint unless the provider explicitly owns that header. */ -function applyCallerUserAgentFallback( - headers: Record, - incoming: IncomingMeta, +/** + * 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 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 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 (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 userAgent = incoming.headers.get("user-agent"); - if (userAgent) headers["User-Agent"] = userAgent; + const caller = 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. */ @@ -228,7 +265,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. } } } @@ -250,7 +289,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/server/audio-upstream.ts b/src/server/audio-upstream.ts index cefeaee2665..947c4d0a592 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/context-history.ts b/src/server/context-history.ts index 759157a406f..12aee158244 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 fdf944b1c3f..6a9ce4a3b04 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 673e93ea8f2..876c156473e 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/responses/compact.ts b/src/server/responses/compact.ts index 89e40123789..40f3db05d85 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,6 +848,7 @@ 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); } @@ -869,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/src/server/search.ts b/src/server/search.ts index 51e418346e9..67bbfc1f518 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/src/vision/describe.ts b/src/vision/describe.ts index 14629f01664..4da11f96aa6 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 33b6a1eb6cc..7574b8eb59d 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 b4399c97bab..be89627a189 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -18,7 +18,14 @@ 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. +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. 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. 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 d6f21f87e86..e9c6a46a3a8 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) => @@ -39,6 +43,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 +180,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"); } @@ -203,6 +212,126 @@ 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 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"); + // 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"); + // 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", () => { + 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("); + } + 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 () => { + 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; });