mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 05:42:19 +03:00
fix(codex): forward the caller client version upstream instead of a pinned default (#13708)
* fix(codex): forward the caller client version upstream instead of pinning 0.144.1 The Codex provider reported a hardcoded client version (DEFAULT_CODEX_CLIENT_VERSION) to the ChatGPT backend. Newer gated models reject older clients, e.g.: The 'gpt-6-astra' model requires a newer version of Codex. so the pinned value silently rots on every CLI upgrade, and a user on the latest CLI is still refused. CodexExecutor.buildHeaders also dropped the clientHeaders/model/health arguments that the base class accepts (base.ts:509), so the caller User-Agent never reached the version resolution. - codexClient.ts: add getCodexClientVersionFromHeaders(), which reads the version the caller reports in its User-Agent (codex_cli_rs/<v>, codex_exec/<v>) or a version header, validated against SAFE_HEADER_TOKEN_PATTERN. getCodexUserAgent() now takes an optional version override. - codex.ts: forward clientHeaders/model/health to super.buildHeaders(), and use getCodexClientVersionFromHeaders(clientHeaders) ?? getCodexClientVersion(). Falls back to the existing env override / default when the caller sends nothing. * test(codex): cover getCodexClientVersionFromHeaders and clientHeaders-aware buildHeaders Adds unit coverage for the caller-version forwarding introduced in this PR: real Codex CLI User-Agent parsing, the generic version header, missing/empty headers, non-Codex User-Agent, and CRLF/oversized injection attempts safely returning null/falling back to the default version. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Zeeshan Haque <zeeshan@moonscape.local> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
113
tests/unit/codex-client-headers.test.ts
Normal file
113
tests/unit/codex-client-headers.test.ts
Normal file
@@ -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)");
|
||||
});
|
||||
Reference in New Issue
Block a user