Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions open-sse/config/codexClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | 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<string, string> {
Expand Down
29 changes: 25 additions & 4 deletions open-sse/executors/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ import { readCodexPeekChunk, buildCodexTimeoutSafePassthroughBody } from "./code
import {
CODEX_CLI_RS_ORIGINATOR,
getCodexClientVersion,
getCodexClientVersionFromHeaders,
getCodexUserAgent,
normalizeCodexSessionId,
} from "../config/codexClient.ts";
import type { KeyHealth } from "../services/apiKeyRotator.ts";
import {
applyCodexClientIdentityHeaders,
applyCodexClientMetadata,
Expand Down Expand Up @@ -1085,11 +1087,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<string, string> | null,
model?: string,
health?: Record<string, KeyHealth>
) {
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;
Expand Down
113 changes: 113 additions & 0 deletions tests/unit/codex-client-headers.test.ts
Original file line number Diff line number Diff line change
@@ -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)");
});