Skip to content
Open
2 changes: 1 addition & 1 deletion src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
@@ -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";
55 changes: 47 additions & 8 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const FORWARD_HEADERS = [
"session_id",
"session-id",
"thread-id",
"user-agent",
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Explicit security review is required

This changes the shared header set used by Codex authentication materialization. Repository policy requires explicit security review for authentication-boundary changes before merge.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this touches the shared allowlist consumed by auth materialization, so it needs the explicit security review per MAINTAINERS.md before merge. Leaving this thread open for the maintainer; the fix commit narrows the semantics so user-agent is fallback-only wherever it overlays configured headers.

Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
"x-client-request-id",
"x-codex-beta-features",
"x-codex-installation-id",
Expand All @@ -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<string, string>,
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<string, string> | Headers,
callerHeaders: Headers,
providerHeaders?: Record<string, string> | 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;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}

function readUserAgentHeader(source: Record<string, string> | 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. */
Expand Down Expand Up @@ -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.
}
}
}
Expand All @@ -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<string> | undefined;
Expand Down
7 changes: 6 additions & 1 deletion src/server/audio-upstream.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { applyCallerUserAgentFallback } from "../adapters/openai-responses";
import { formatErrorResponse } from "../bridge";
import {
CodexAccountCooldownError,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions src/server/context-history.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/server/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion src/server/live.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 18 additions & 1 deletion src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/server/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -174,7 +175,11 @@ export async function handleSearch(

const headers: Record<string, string> = { "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);
Expand Down
6 changes: 5 additions & 1 deletion src/vision/describe.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -70,9 +70,13 @@ export async function describeImage(
const headers: Record<string, string> = { "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" });
Expand Down
6 changes: 5 additions & 1 deletion src/web-search/executor.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -81,9 +81,13 @@ export async function runWebSearch(
const headers: Record<string, string> = { "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);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
const body = {
model: settings.model,
instructions: settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION,
Expand Down
9 changes: 8 additions & 1 deletion structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading