diff --git a/open-sse/config/codexClient.ts b/open-sse/config/codexClient.ts index 5c71e931a5..e824f2b68b 100644 --- a/open-sse/config/codexClient.ts +++ b/open-sse/config/codexClient.ts @@ -33,13 +33,67 @@ export function getCodexClientVersion(): string { ); } -export function getCodexUserAgent(): string { +/** + * Extract the Codex client version the CALLER actually reported, so OmniRoute + * forwards it upstream instead of substituting a pinned default. The official + * CLI sends it in User-Agent, e.g. + * codex_cli_rs/0.154.0 (Mac OS 26.6.2; arm64) ... + * codex_exec/0.154.0 (Mac OS 26.6.2; arm64) xterm-256color (codex_exec; 0.154.0) + * Some clients also send a `version` header. + * + * Why this matters: the ChatGPT backend gates newer models on the client + * version ("The 'gpt-6-astra' model requires a newer version of Codex"). + * A pinned default silently rots every time the user upgrades their CLI. + * + * Returns null when the caller sent nothing usable, so callers can fall back + * to getCodexClientVersion(). + */ +const CODEX_CLIENT_VERSION_IN_UA_PATTERN = /(?:codex[-_][A-Za-z0-9_]*|codex-cli)\/(\d+\.\d+\.\d+)/i; + +export function getCodexClientVersionFromHeaders( + clientHeaders?: Record | null +): string | null { + if (!clientHeaders) return null; + + const pick = (name: string): string | null => { + const direct = clientHeaders[name]; + if (typeof direct === "string" && direct.trim()) return direct.trim(); + const lower = name.toLowerCase(); + for (const [k, v] of Object.entries(clientHeaders)) { + if (k.toLowerCase() === lower && typeof v === "string" && v.trim()) { + return v.trim(); + } + } + return null; + }; + + const fromVersionHeader = pick("version"); + if (fromVersionHeader && SAFE_HEADER_TOKEN_PATTERN.test(fromVersionHeader)) { + return fromVersionHeader; + } + + const userAgent = pick("user-agent"); + if (!userAgent) return null; + + const match = CODEX_CLIENT_VERSION_IN_UA_PATTERN.exec(userAgent); + if (!match) return null; + + const version = match[1]; + return SAFE_HEADER_TOKEN_PATTERN.test(version) ? version : null; +} + +export function getCodexUserAgent(versionOverride?: string | null): string { const override = getSafeEnvValue(CODEX_USER_AGENT_OVERRIDE_ENV, SAFE_HEADER_VALUE_PATTERN); if (override) { return override; } - return `codex-cli/${getCodexClientVersion()} (${DEFAULT_CODEX_USER_AGENT_PLATFORM}; ${DEFAULT_CODEX_USER_AGENT_ARCH})`; + const version = + versionOverride && SAFE_HEADER_TOKEN_PATTERN.test(versionOverride) + ? versionOverride + : getCodexClientVersion(); + + return `codex-cli/${version} (${DEFAULT_CODEX_USER_AGENT_PLATFORM}; ${DEFAULT_CODEX_USER_AGENT_ARCH})`; } export function getCodexDefaultHeaders(): Record { diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 6940ee89f5..5e6c4175ac 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -23,9 +23,11 @@ import { stripCodexPassthroughRejectedParams } from "./codex/stripPassthroughRej import { CODEX_CLI_RS_ORIGINATOR, getCodexClientVersion, + getCodexClientVersionFromHeaders, getCodexUserAgent, normalizeCodexSessionId, } from "../config/codexClient.ts"; +import type { KeyHealth } from "../services/apiKeyRotator.ts"; import { applyCodexClientIdentityHeaders, applyCodexClientMetadata, @@ -1117,11 +1119,30 @@ export class CodexExecutor extends BaseExecutor { * Always request event-stream from upstream, even when client requested stream=false. * Includes chatgpt-account-id header for strict workspace binding. */ - buildHeaders(credentials: ProviderCredentials, stream = true) { + buildHeaders( + credentials: ProviderCredentials, + stream = true, + clientHeaders?: Record | null, + model?: string, + health?: Record + ) { const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath); - const headers = super.buildHeaders(credentials, isCompactRequest ? false : true); - headers.Version = getCodexClientVersion(); - setUserAgentHeader(headers, getCodexUserAgent()); + const headers = super.buildHeaders( + credentials, + isCompactRequest ? false : true, + clientHeaders, + model, + health + ); + + // Forward the CALLER's own Codex client version upstream instead of a pinned + // default. The ChatGPT backend gates newer models on the reported client + // version (e.g. "The 'gpt-6-astra' model requires a newer version of Codex"), + // so a hardcoded value silently rots whenever the user upgrades their CLI. + // Falls back to the configured/default version when the caller sends none. + const clientVersion = getCodexClientVersionFromHeaders(clientHeaders); + headers.Version = clientVersion ?? getCodexClientVersion(); + setUserAgentHeader(headers, getCodexUserAgent(clientVersion)); // Add workspace binding header if workspaceId is persisted const workspaceId = credentials?.providerSpecificData?.workspaceId; diff --git a/tests/unit/codex-client-headers.test.ts b/tests/unit/codex-client-headers.test.ts new file mode 100644 index 0000000000..02db806724 --- /dev/null +++ b/tests/unit/codex-client-headers.test.ts @@ -0,0 +1,113 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getCodexClientVersionFromHeaders } from "../../open-sse/config/codexClient.ts"; +import { CodexExecutor } from "../../open-sse/executors/codex.ts"; + +test("getCodexClientVersionFromHeaders: extracts the version from a real Codex CLI User-Agent", () => { + assert.equal( + getCodexClientVersionFromHeaders({ + "user-agent": "codex_cli_rs/0.154.0 (Mac OS 26.6.2; arm64)", + }), + "0.154.0" + ); + assert.equal( + getCodexClientVersionFromHeaders({ + "user-agent": + "codex_exec/0.154.0 (Mac OS 26.6.2; arm64) xterm-256color (codex_exec; 0.154.0)", + }), + "0.154.0" + ); +}); + +test("getCodexClientVersionFromHeaders: prefers a valid generic version header over User-Agent", () => { + assert.equal( + getCodexClientVersionFromHeaders({ + version: "9.9.9", + "user-agent": "codex_cli_rs/0.154.0 (Mac OS 26.6.2; arm64)", + }), + "9.9.9" + ); +}); + +test("getCodexClientVersionFromHeaders: returns null when headers are absent or empty", () => { + assert.equal(getCodexClientVersionFromHeaders(null), null); + assert.equal(getCodexClientVersionFromHeaders(undefined), null); + assert.equal(getCodexClientVersionFromHeaders({}), null); +}); + +test("getCodexClientVersionFromHeaders: returns null for a non-Codex User-Agent with no version header", () => { + assert.equal(getCodexClientVersionFromHeaders({ "user-agent": "curl/8.4.0" }), null); +}); + +test("getCodexClientVersionFromHeaders: rejects a CRLF injection attempt in the version header", () => { + assert.equal(getCodexClientVersionFromHeaders({ version: "1.0.0\r\nX-Injected: evil" }), null); +}); + +test("getCodexClientVersionFromHeaders: rejects a version header longer than the 32-char safe token limit", () => { + const overlong = "1.0.0-" + "a".repeat(30); + assert.ok(overlong.length > 32); + assert.equal(getCodexClientVersionFromHeaders({ version: overlong }), null); +}); + +test("getCodexClientVersionFromHeaders: a CRLF/oversized User-Agent injection only ever yields the captured digits", () => { + assert.equal( + getCodexClientVersionFromHeaders({ + "user-agent": "codex_cli_rs/1.0.0\r\nX-Evil: 1", + }), + "1.0.0" + ); +}); + +test("CodexExecutor.buildHeaders forwards the caller's Codex client version from clientHeaders", () => { + const executor = new CodexExecutor(); + + const fromUserAgent = executor.buildHeaders({ accessToken: "codex-token" }, true, { + "user-agent": "codex_cli_rs/0.160.2 (Mac OS 26.6.2; arm64)", + }); + assert.equal(fromUserAgent.Version, "0.160.2"); + assert.equal(fromUserAgent["User-Agent"], "codex-cli/0.160.2 (Windows 10.0.26200; x64)"); + + const fromVersionHeader = executor.buildHeaders({ accessToken: "codex-token" }, true, { + version: "9.9.9", + }); + assert.equal(fromVersionHeader.Version, "9.9.9"); + assert.equal(fromVersionHeader["User-Agent"], "codex-cli/9.9.9 (Windows 10.0.26200; x64)"); +}); + +test("CodexExecutor.buildHeaders falls back to the default client version when clientHeaders is absent, empty, or unusable", () => { + const executor = new CodexExecutor(); + + const noHeaders = executor.buildHeaders({ accessToken: "codex-token" }, true); + assert.equal(noHeaders.Version, "0.153.4"); + + const emptyHeaders = executor.buildHeaders({ accessToken: "codex-token" }, true, {}); + assert.equal(emptyHeaders.Version, "0.153.4"); + + const nonCodexUserAgent = executor.buildHeaders({ accessToken: "codex-token" }, true, { + "user-agent": "curl/8.4.0", + }); + assert.equal(nonCodexUserAgent.Version, "0.153.4"); +}); + +test("CodexExecutor.buildHeaders rejects injection attempts in the caller's version/User-Agent headers", () => { + const executor = new CodexExecutor(); + + const crlfVersion = executor.buildHeaders({ accessToken: "codex-token" }, true, { + version: "1.0.0\r\nX-Injected: evil", + }); + assert.equal(crlfVersion.Version, "0.153.4"); + assert.equal(crlfVersion["User-Agent"].includes("\r\n"), false); + + const overlongVersion = executor.buildHeaders({ accessToken: "codex-token" }, true, { + version: "1.0.0-" + "a".repeat(30), + }); + assert.equal(overlongVersion.Version, "0.153.4"); + + const injectedUserAgent = executor.buildHeaders({ accessToken: "codex-token" }, true, { + "user-agent": "codex_cli_rs/1.0.0\r\nX-Evil: 1", + }); + assert.equal(injectedUserAgent.Version, "1.0.0"); + assert.equal(injectedUserAgent["User-Agent"].includes("\r\n"), false); + assert.equal(injectedUserAgent["User-Agent"], "codex-cli/1.0.0 (Windows 10.0.26200; x64)"); +});