feat(codex): sync v178 identity mechanisms — turn-state relay, persisted seeds, identity faces (#10716)

Merged via merge-train (release/v3.8.50, batch1 2026-08-20) — static gates (typecheck/file-size/complexity/cognitive/changelog) green on the combined tree; test:unit reds observed in the boarded run were verified pre-existing on the pure release tip (unrelated flake), not caused by this PR. Thanks for the contribution!
This commit is contained in:
Xiangzhe
2026-08-20 17:28:45 +08:00
committed by GitHub
parent b11b000048
commit f330b21afd
18 changed files with 770 additions and 25 deletions

View File

@@ -1,9 +1,13 @@
import {
CODEX_CLI_RS_ORIGINATOR,
DEFAULT_CODEX_CLIENT_VERSION,
getCodexCliRsHeaders as buildCodexCliRsHeaders,
} from "@/shared/constants/codexClient";
export { DEFAULT_CODEX_CLIENT_VERSION } from "@/shared/constants/codexClient";
export {
DEFAULT_CODEX_CLIENT_VERSION,
CODEX_CLI_RS_ORIGINATOR,
} from "@/shared/constants/codexClient";
const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26200";
const DEFAULT_CODEX_USER_AGENT_ARCH = "x64";
const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION";
@@ -51,6 +55,35 @@ export function getCodexCliRsHeaders(): Record<string, string> {
return buildCodexCliRsHeaders(getCodexClientVersion());
}
/**
* Identity for the credential face (auth.openai.com: token exchange / refresh).
* The real Codex client sends only `originator` + `User-Agent` on that face
* (codex-rs login/default_client.rs default_headers()); the `Version` header
* gate exists only on the chatgpt.com/backend-api inference face, so it is
* deliberately omitted here. Mirrors sub2api v0.1.178
* ApplyCodexCanonicalAuthIdentity.
*/
export function getCodexAuthIdentityHeaders(): Record<string, string> {
return {
"User-Agent": getCodexUserAgent(),
originator: CODEX_CLI_RS_ORIGINATOR,
};
}
/**
* Canonical Codex CLI identity for server-initiated calls against the
* chatgpt.com/backend-api face that are not tied to one end-client request
* (usage / quota / models manifest / reset-credits). Same UA/version chain as
* inference so these calls do not show up upstream as anonymous half-identities.
*/
export function getCodexBackendIdentityHeaders(): Record<string, string> {
return {
"User-Agent": getCodexUserAgent(),
originator: CODEX_CLI_RS_ORIGINATOR,
Version: getCodexClientVersion(),
};
}
export function normalizeCodexSessionId(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();

View File

@@ -1,15 +1,30 @@
import { createHash, randomUUID } from "node:crypto";
import { normalizeCodexSessionId } from "./codexClient.ts";
import { isCrossAccountCodexTurnState, readCodexTurnStateHeader } from "./codexTurnState.ts";
const CODEX_INSTALLATION_SALT = "omniroute-codex-installation";
const CODEX_SESSION_SEED_PREFIX = "omniroute:codex-session-id:v1:";
const CODEX_THREAD_SEED_PREFIX = "omniroute:codex-thread-id:v1:";
// v2 derivations are keyed by the persisted per-connection random seed
// (codexFingerprintSeed) instead of the connection-id chain, mirroring
// sub2api v0.1.178 (#5696): deterministic derivation stays stable, but the
// seed is generated per connection so identities never collide across
// deployments and survive connection export/import.
const CODEX_INSTALLATION_SEED_PREFIX_V2 = "omniroute:codex-installation:v2:";
const CODEX_SESSION_SEED_PREFIX_V2 = "omniroute:codex-session-id:v2:";
const CODEX_THREAD_SEED_PREFIX_V2 = "omniroute:codex-thread-id:v2:";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export const CODEX_FINGERPRINT_MODES = ["off", "device", "session", "full"] as const;
export type CodexFingerprintMode = (typeof CODEX_FINGERPRINT_MODES)[number];
export const CODEX_FINGERPRINT_MODE_KEY = "codexFingerprintMode";
/**
* System-managed per-connection random seed used as the fingerprint
* derivation source. Never sent upstream, stripped from API responses, and
* preserved across connection updates (sub2api `codex_fingerprint_seed`).
*/
export const CODEX_FINGERPRINT_SEED_KEY = "codexFingerprintSeed";
export type CodexClientIdentity = {
mode: CodexFingerprintMode;
@@ -72,6 +87,61 @@ function accountSeed(
);
}
/** The persisted system-managed random seed, when present and a valid UUID. */
export function getCodexFingerprintSeed(
providerSpecificData?: Record<string, unknown> | null
): string | null {
return normalizeUuid(providerSpecificData?.[CODEX_FINGERPRINT_SEED_KEY]);
}
/** Modes that rewrite account-scoped identifiers and therefore need a stable seed. */
export function codexFingerprintModeRequiresSeed(mode: CodexFingerprintMode): boolean {
return mode === "device" || mode === "session" || mode === "full";
}
/**
* Ensure a Codex OAuth connection carries a persisted fingerprint seed when its
* convergence mode derives account-scoped identifiers. Called at connection
* create/update time (the persistence layer owns the write); the request path
* only ever READS the seed, so an identity never rotates mid-flight.
*
* Semantics mirror sub2api v0.1.178 `prepareCodexFingerprintExtraFor{Create,Update}`:
* - the key is system-managed: any client-supplied value is stripped first;
* - an existing valid seed is ALWAYS carried forward (even when the new mode
* is `off` — it stays dormant, ready if convergence is re-enabled later);
* - otherwise a fresh seed is created only when the mode requires one
* (device/session/full; the OmniRoute default is session).
*
* Returns the (possibly new) providerSpecificData, or undefined when there is
* nothing to store. Pre-seed connections keep their legacy connection-id
* derived identity until the next save — one deliberate rotation, same as
* sub2api's migration-225 backfill.
*/
export function ensureCodexFingerprintSeed(
providerSpecificData?: Record<string, unknown> | null,
credentials?: { accessToken?: unknown; refreshToken?: unknown } | null,
existingProviderSpecificData?: Record<string, unknown> | null
): Record<string, unknown> | undefined {
const psd: Record<string, unknown> = { ...(providerSpecificData || {}) };
// System-managed key: never trust an inbound value, regardless of auth type.
delete psd[CODEX_FINGERPRINT_SEED_KEY];
if (!isCodexOAuthCredentials(credentials)) {
return Object.keys(psd).length > 0 ? psd : undefined;
}
const existingSeed = getCodexFingerprintSeed(existingProviderSpecificData);
if (existingSeed) {
psd[CODEX_FINGERPRINT_SEED_KEY] = existingSeed;
return psd;
}
const mode = getCodexFingerprintMode(psd, true);
if (codexFingerprintModeRequiresSeed(mode)) {
psd[CODEX_FINGERPRINT_SEED_KEY] = randomUUID();
return psd;
}
return Object.keys(psd).length > 0 ? psd : undefined;
}
function readNamedHeader(
headers: Headers | Record<string, unknown> | null | undefined,
name: string
@@ -120,6 +190,11 @@ export function getCodexInstallationId(
const explicit = normalizeUuid(providerSpecificData?.codexInstallationId);
if (explicit) return explicit;
const persistedSeed = getCodexFingerprintSeed(providerSpecificData);
if (persistedSeed) {
return deriveStableUUIDv4(`${CODEX_INSTALLATION_SEED_PREFIX_V2}${persistedSeed}`);
}
const legacyStableSource =
nonEmptyString(providerSpecificData?.workspaceId) ||
nonEmptyString(providerSpecificData?.accountId) ||
@@ -137,6 +212,10 @@ export function getCodexConvergedSessionId(
providerSpecificData?: Record<string, unknown> | null,
accountKey?: string | null
): string {
const persistedSeed = getCodexFingerprintSeed(providerSpecificData);
if (persistedSeed) {
return deriveStableUUIDv4(`${CODEX_SESSION_SEED_PREFIX_V2}${persistedSeed}`);
}
return deriveStableUUIDv4(
`${CODEX_SESSION_SEED_PREFIX}${accountSeed(providerSpecificData, accountKey)}`
);
@@ -148,6 +227,10 @@ export function getCodexConvergedThreadId(
accountKey?: string | null
): string {
if (!nonEmptyString(clientSessionId)) return "";
const persistedSeed = getCodexFingerprintSeed(providerSpecificData);
if (persistedSeed) {
return deriveStableUUIDv4(`${CODEX_THREAD_SEED_PREFIX_V2}${persistedSeed}:${clientSessionId}`);
}
return deriveStableUUIDv4(
`${CODEX_THREAD_SEED_PREFIX}${accountSeed(providerSpecificData, accountKey)}:${clientSessionId}`
);
@@ -163,6 +246,26 @@ export function getCodexClientSessionId(
);
}
/**
* Decide what to do with the client's `x-codex-turn-state` echo for the
* account about to serve this request. The blob is minted per account by the
* upstream; replaying another account's blob after failover is a proxy-only
* contradiction, so a known cross-account echo is stripped. Same-account or
* unknown provenance passes through unchanged (strip only, never inject).
* Independent of the fingerprint-convergence mode — account consistency also
* applies to explicit `off` / passthrough.
*/
export function resolveCodexTurnStateEcho(
clientHeaders?: Headers | Record<string, unknown> | null,
accountKey?: string | null
): string | null {
const value = readCodexTurnStateHeader(clientHeaders);
if (!value) return null;
const sessionId = getCodexClientSessionId(clientHeaders);
if (sessionId && isCrossAccountCodexTurnState(sessionId, accountKey)) return null;
return value;
}
/**
* One identity object for every carrier in one upstream turn.
* accountKey may be the OmniRoute connection id; it is never sent upstream.
@@ -284,13 +387,19 @@ export function withCodexFingerprintCredentials<T extends CodexCredentialIdentit
): T {
const identity = resolveCodexFingerprintIdentity({ credentials, clientHeaders, body });
const original = resolveCodexOriginalIdentityHeaders({ credentials, clientHeaders });
if (!identity && !original) return credentials;
// The turn-state echo guard runs for every Codex request (including compact
// and explicit-off), unlike the convergence identity above.
const turnStateEcho = credentials
? resolveCodexTurnStateEcho(clientHeaders, credentials.connectionId ?? null)
: null;
if (!identity && !original && !turnStateEcho) return credentials;
return {
...credentials,
providerSpecificData: {
...(credentials.providerSpecificData || {}),
...(identity ? { codexClientIdentity: identity } : {}),
...(original ? { codexOriginalIdentityHeaders: original } : {}),
...(turnStateEcho ? { codexTurnStateEcho: turnStateEcho } : {}),
},
};
}

View File

@@ -0,0 +1,144 @@
/**
* codexTurnState.ts — `x-codex-turn-state` relay bookkeeping and the
* cross-account echo guard.
*
* The upstream mints the opaque turn-state blob under the outbound identity
* (including the fingerprint-converged installation/session/thread ids), and
* the real Codex client echoes it back on later requests of the same turn —
* codex-rs captures it from the /responses SSE, the /responses/compact JSON,
* and the WS handshake (codex-api/src/sse/responses.rs, endpoint/compact.rs).
*
* Replaying a blob to the SAME account is self-consistent. Replaying it to a
* DIFFERENT account (failover rotated the connection while the client still
* echoes the old account's blob) is a contradiction only a proxy chain can
* produce — a real Codex client never emits it. The provenance table records
* which connection minted the blob a downstream session last received, and
* the outbound guard strips echoes known to come from another account.
*
* Mirrors sub2api v0.1.177 `openai_codex_turn_state.go` (commit 8219dcfc8).
* OmniRoute keys the table by the client's original session id only — the
* executor pipeline does not carry the API key id, and a real Codex session
* id is a random UUID, so accidental cross-key collisions are not a
* practical concern.
*/
const CODEX_TURN_STATE_HEADER = "x-codex-turn-state";
/**
* How long a provenance record lives. The blob is echoed within one turn,
* but clients may hold it across a whole session; 2h covers the standard
* 5-hour quota window's early turns without letting the map grow stale
* entries for days.
*/
const CODEX_TURN_STATE_TTL_MS = 2 * 60 * 60 * 1000;
/** Opportunistic full sweep every N writes (the read side also lazily expires). */
const CODEX_TURN_STATE_SWEEP_EVERY_WRITES = 256;
type CodexTurnStateOrigin = {
accountKey: string;
expiresAt: number;
};
const turnStateOrigins = new Map<string, CodexTurnStateOrigin>();
let turnStateWrites = 0;
function normalizeAccountKey(accountKey: unknown): string | null {
if (typeof accountKey !== "string") return null;
const trimmed = accountKey.trim();
return trimmed || null;
}
/**
* Read the turn-state blob from a headers bag (Headers instance or a plain
* record with arbitrary casing). Returns null when absent/blank.
*/
export function readCodexTurnStateHeader(
headers: Headers | Record<string, unknown> | null | undefined
): string | null {
if (!headers) return null;
if (headers instanceof Headers) {
const value = headers.get(CODEX_TURN_STATE_HEADER);
return typeof value === "string" && value.trim() ? value.trim() : null;
}
if (typeof headers === "object") {
for (const [key, value] of Object.entries(headers)) {
if (
key.toLowerCase() === CODEX_TURN_STATE_HEADER &&
typeof value === "string" &&
value.trim()
) {
return value.trim();
}
}
}
return null;
}
function sweepExpiredTurnStateOrigins(now: number): void {
for (const [key, origin] of turnStateOrigins) {
if (origin.expiresAt <= now) {
turnStateOrigins.delete(key);
}
}
}
/**
* Record that `accountKey` minted the turn-state blob this downstream session
* just received. Must only be called at the response commit point — when the
* header is actually written to the client. Recording earlier (e.g. for an
* attempt later discarded by failover) would poison the table and make the
* guard strip the NEXT account's legitimate echo.
*/
export function noteCodexTurnStateProvenance(
clientSessionId: string | null | undefined,
accountKey: unknown,
nowMs?: number
): void {
const sessionId = typeof clientSessionId === "string" ? clientSessionId.trim() : "";
const account = normalizeAccountKey(accountKey);
if (!sessionId || !account) return;
const now = typeof nowMs === "number" ? nowMs : Date.now();
turnStateOrigins.set(sessionId, {
accountKey: account,
expiresAt: now + CODEX_TURN_STATE_TTL_MS,
});
turnStateWrites += 1;
if (turnStateWrites % CODEX_TURN_STATE_SWEEP_EVERY_WRITES === 0) {
sweepExpiredTurnStateOrigins(now);
}
}
/**
* Outbound guard: true when the echoed blob is KNOWN to have been minted by a
* different account and must be stripped before going upstream. Same-account
* or unknown provenance passes through unchanged — stripping only, never
* injection (clients that cannot echo are the Claude bridge's concern, not
* this module's).
*/
export function isCrossAccountCodexTurnState(
clientSessionId: string | null | undefined,
accountKey: unknown,
nowMs?: number
): boolean {
const sessionId = typeof clientSessionId === "string" ? clientSessionId.trim() : "";
const account = normalizeAccountKey(accountKey);
if (!sessionId || !account) return false;
const origin = turnStateOrigins.get(sessionId);
if (!origin) return false;
const now = typeof nowMs === "number" ? nowMs : Date.now();
if (origin.expiresAt <= now) {
turnStateOrigins.delete(sessionId);
return false;
}
return origin.accountKey !== account;
}
/** Test hook: forget all provenance records and reset the sweep counter. */
export function __resetCodexTurnStateOriginsForTesting(): void {
turnStateOrigins.clear();
turnStateWrites = 0;
}

View File

@@ -20,6 +20,7 @@ import {
import { FETCH_BODY_TIMEOUT_MS, HTTP_STATUS, PROVIDERS } from "../config/constants.ts";
import { readCodexPeekChunk, buildCodexTimeoutSafePassthroughBody } from "./codex/bodyTimeout.ts";
import {
CODEX_CLI_RS_ORIGINATOR,
getCodexClientVersion,
getCodexUserAgent,
normalizeCodexSessionId,
@@ -225,7 +226,6 @@ function convertSystemToDeveloperRole(body: Record<string, unknown>): void {
}
}
function stripOrphanedCodexFunctionCallOutputs(body: Record<string, unknown>): void {
if (!Array.isArray(body.input)) return;
const input = body.input;
@@ -1045,10 +1045,11 @@ export class CodexExecutor extends BaseExecutor {
CodexClientIdentity | null | undefined;
const originalIdentityHeaders = credentials?.providerSpecificData
?.codexOriginalIdentityHeaders as Record<string, string> | null | undefined;
const turnStateEcho = credentials?.providerSpecificData?.codexTurnStateEcho;
// Originator header — identifies the client type to the Codex backend.
// Ref: openai/codex login/src/auth/default_client.rs DEFAULT_ORIGINATOR = "codex_cli_rs"
headers["originator"] = "codex_cli_rs";
headers["originator"] = CODEX_CLI_RS_ORIGINATOR;
// session_id header — enables prompt cache affinity on the Codex backend.
// The official Codex client sets this to conversation_id (a stable UUID per session).
@@ -1060,6 +1061,13 @@ export class CodexExecutor extends BaseExecutor {
applyCodexOriginalIdentityHeaders(headers, originalIdentityHeaders);
applyCodexClientIdentityHeaders(headers, clientIdentity);
// x-codex-turn-state: forward the client's echo when the provenance guard
// (in withCodexFingerprintCredentials) cleared it as same-account. The
// blob is account-bound; a stripped (absent) value must stay absent.
if (typeof turnStateEcho === "string" && turnStateEcho) {
headers["x-codex-turn-state"] = turnStateEcho;
}
return headers;
}

View File

@@ -41,7 +41,11 @@ import {
isStripReasoningRequested,
} from "./chatCore/headers.ts";
import { markCodexScopeRateLimited } from "./chatCore/codexFailover.ts";
import { isCodexOriginatedHeaders } from "../config/codexIdentity.ts";
import { getCodexClientSessionId, isCodexOriginatedHeaders } from "../config/codexIdentity.ts";
import {
noteCodexTurnStateProvenance,
readCodexTurnStateHeader,
} from "../config/codexTurnState.ts";
import { trackDevice, extractIpFromHeaders } from "../services/deviceTracker.ts";
import { getCombosCached } from "./chatCore/comboContextCache.ts";
export { clearCombosCache, clearUpstreamProxyConfigCache } from "./chatCore/comboContextCache.ts";
@@ -722,12 +726,13 @@ export async function handleChatCore({
copilotCompatibleReasoning,
clientResponseFormat,
} = resolveChatCoreRequestFormat({ clientRawRequest, body, provider, userAgent });
const nativeOpenAICompatibleResponsesPassthrough = shouldUseNativeOpenAICompatibleResponsesPassthrough({
provider,
sourceFormat,
endpointPath,
providerSpecificData: credentials?.providerSpecificData,
});
const nativeOpenAICompatibleResponsesPassthrough =
shouldUseNativeOpenAICompatibleResponsesPassthrough({
provider,
sourceFormat,
endpointPath,
providerSpecificData: credentials?.providerSpecificData,
});
const responsesInputItems = Array.isArray(body?.input) ? body.input : [];
const customToolNames = collectCustomToolNamesForSourceFormat(
sourceFormat,
@@ -3387,6 +3392,15 @@ export async function handleChatCore({
const responseHeaders = new Headers(headersObj);
stripStaleForwardingHeaders(responseHeaders);
stripNextMiddlewareControlHeaders(responseHeaders);
// The upstream headers (turn-state included) are about to be committed
// to the client — record which connection minted the blob so a later
// cross-account echo can be stripped (Codex failover guard).
if (provider === "codex" && readCodexTurnStateHeader(responseHeaders)) {
noteCodexTurnStateProvenance(
getCodexClientSessionId(clientRawRequest?.headers),
rawResult._executionCredentials?.connectionId ?? credentials?.connectionId
);
}
const contentType = (responseHeaders.get("content-type") || "").toLowerCase();
const payload = await readNonStreamingResponseBody(
rawResult.response,
@@ -5106,6 +5120,17 @@ export async function handleChatCore({
comboStrategy,
});
// The streaming headers (turn-state included, when present) are committed to
// the client from here on — record which connection minted the blob so a
// later cross-account echo can be stripped (Codex failover guard). The
// in-place failover update means `credentials` is the winning account.
if (provider === "codex" && readCodexTurnStateHeader(providerResponse.headers)) {
noteCodexTurnStateProvenance(
getCodexClientSessionId(clientRawRequest?.headers),
credentials?.connectionId
);
}
// Create transform stream with logger for streaming response
let transformStream;
const responseToolNameMap = mergeResponseToolNameMap(

View File

@@ -28,11 +28,18 @@ const STREAMING_RESPONSE_HEADER_DENYLIST = new Set([
"x-amz-security-token",
"x-auth-token",
"x-accel-buffering",
// 314-byte Codex session blob. It is not a client rate-limit signal and
// alone ate ~40% of the old 768-byte budget, evicting x-codex-*-used-percent.
"x-codex-turn-state",
]);
/**
* `x-codex-turn-state` is forwarded verbatim and EXEMPT from the forwarding
* budget. The real Codex client captures this ~314-byte blob from /responses
* (and echoes it back within the same turn), so dropping it breaks the
* protocol chain — but naively counting it against the budget used to evict
* the x-codex-*-used-percent quota headers (the reason it was denylisted
* under #10315-era budgeting). Carving it out keeps both.
*/
const CODEX_TURN_STATE_RESPONSE_HEADER = "x-codex-turn-state";
const DEFAULT_FORWARDED_HEADER_BUDGET_BYTES = 768;
/**
@@ -206,7 +213,9 @@ export function buildStreamingResponseHeaders(
STREAMING_RESPONSE_HEADER_DENYLIST.has(normalized) ||
connectionScopedHeaders.has(normalized) ||
isNextMiddlewareControlHeader(normalized) ||
isOmniRouteInternalHeader(normalized)
isOmniRouteInternalHeader(normalized) ||
// Forwarded separately below, outside the byte budget.
normalized === CODEX_TURN_STATE_RESPONSE_HEADER
) {
return;
}
@@ -269,6 +278,10 @@ export function buildStreamingResponseHeaders(
"X-Accel-Buffering": "no",
[OMNIROUTE_RESPONSE_HEADERS.cache]: "MISS",
};
const codexTurnState = providerHeaders.get(CODEX_TURN_STATE_RESPONSE_HEADER)?.trim();
if (codexTurnState) {
responseHeaders[CODEX_TURN_STATE_RESPONSE_HEADER] = codexTurnState;
}
attachOmniRouteMetaHeaders(responseHeaders, meta);
return responseHeaders;
}

View File

@@ -25,6 +25,7 @@ import {
import { registerQuotaFetcher, registerQuotaWindows, type QuotaInfo } from "./quotaPreflight.ts";
import { registerMonitorFetcher } from "./quotaMonitor.ts";
import { throttleQuotaFetch } from "./quotaFetchThrottle.ts";
import { getCodexBackendIdentityHeaders } from "../config/codexClient.ts";
/**
* Stable identifiers for Codex's quota windows. These match the quota keys
@@ -222,6 +223,9 @@ export async function fetchCodexQuota(
Authorization: `Bearer ${meta.accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
// Canonical Codex backend identity (UA + originator + version), same
// chain as inference — see getCodexUsage.
...getCodexBackendIdentityHeaders(),
};
if (meta.workspaceId) {

View File

@@ -12,6 +12,7 @@
// tests) keep a stable surface.
import { AsyncLocalStorage } from "node:async_hooks";
import { PROVIDERS } from "../config/constants.ts";
import { getCodexAuthIdentityHeaders } from "../config/codexClient.ts";
import { runWithProxyContext } from "../utils/proxyFetch.ts";
import { serializeRefresh } from "./refreshSerializer.ts";
import {
@@ -254,6 +255,12 @@ export async function refreshAccessToken(
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
// Credential face (auth.openai.com): the real Codex client sends only
// originator + User-Agent here — no version header (that gate exists
// only on the /backend-api/codex inference face). Refreshing with a
// bare/anonymous identity is a half-identity no real client emits.
// Mirrors sub2api v0.1.178 ApplyCodexCanonicalAuthIdentity.
...(provider === "codex" ? getCodexAuthIdentityHeaders() : null),
},
body: params,
})

View File

@@ -9,6 +9,7 @@
*/
import { buildCodexUsageQuotas } from "../codexUsageQuotas.ts";
import { getCodexBackendIdentityHeaders } from "../../config/codexClient.ts";
import { getFieldValue } from "./scalars.ts";
// Codex (OpenAI) API config
@@ -36,6 +37,10 @@ export async function getCodexUsage(
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
// Same UA/version identity chain as Codex inference (sub2api v0.1.178
// unified-outbound-identity): usage probes must not show up upstream as
// an anonymous half-identity next to the converged inference traffic.
...getCodexBackendIdentityHeaders(),
};
if (accountId) {
headers["chatgpt-account-id"] = accountId;

View File

@@ -1,4 +1,5 @@
import {
CODEX_CLI_RS_ORIGINATOR,
getCodexClientVersion,
getCodexDefaultHeaders,
} from "@omniroute/open-sse/config/codexClient.ts";
@@ -464,7 +465,7 @@ export async function fetchCodexDiscoveryModels({
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
originator: "codex_cli_rs",
originator: CODEX_CLI_RS_ORIGINATOR,
};
if (workspaceId) headers["chatgpt-account-id"] = workspaceId;

View File

@@ -19,6 +19,7 @@ import {
} from "@omniroute/open-sse/services/apiKeyRotator.ts";
import { invalidateReasoningRoutingRuleCache } from "./reasoningRoutingRules";
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
import { ensureCodexFingerprintSeed } from "@omniroute/open-sse/config/codexIdentity.ts";
import { bumpProxyConfigGeneration, getSettings } from "./settings";
import {
getStoredManagementPassword,
@@ -28,6 +29,32 @@ import {
import { webSessionCredentialKey, parseProviderSpecificData } from "./webSessionDedup";
import { pickCodexConnectionForUser } from "@/lib/oauth/utils/codexConnectionSelection";
import { reconcileCodexUsageHistory } from "./providers/usageIdentityReconciliation";
/**
* normalizeProviderSpecificData + the Codex fingerprint-seed invariant: Codex
* OAuth connections whose convergence mode derives account-scoped identities
* (device/session/full — the default session included) carry a persisted
* random seed (`codexFingerprintSeed`) as the derivation source. Created here
* at the persistence choke point so every write path (manual create, OAuth
* persist, edit, import) is covered; the seed is never regenerated once valid,
* so identities stay put across saves. Pre-seed connections rotate from the
* legacy connection-id derivation exactly once on their next write — the
* OmniRoute analog of sub2api's migration-225 backfill (v0.1.178, #5696).
*/
function normalizeConnectionProviderSpecificData(
provider: string | null,
providerSpecificData: unknown,
credentials: { accessToken?: unknown; refreshToken?: unknown },
existingProviderSpecificData?: unknown
) {
const normalized = normalizeProviderSpecificData(provider, providerSpecificData);
if (provider !== "codex") return normalized;
return ensureCodexFingerprintSeed(
normalized,
credentials,
(existingProviderSpecificData as Record<string, unknown> | null) ?? null
);
}
import {
withNullableMaxConcurrent,
withNullableQuotaWindowThresholds,
@@ -353,9 +380,10 @@ export async function createProviderConnection(data: JsonRecord) {
await assertApiKeyIsNotManagementPassword(data.apiKey);
const db = getDbInstance() as unknown as DbLike;
const now = new Date().toISOString();
const normalizedProviderSpecificData = normalizeProviderSpecificData(
const normalizedProviderSpecificData = normalizeConnectionProviderSpecificData(
toStringOrNull(data.provider),
data.providerSpecificData
data.providerSpecificData,
data
);
let existing: JsonRecord | null = null;
@@ -483,9 +511,11 @@ export async function createProviderConnection(data: JsonRecord) {
const rawExisting = toRecord(rowToCamel(existing));
const decryptedExisting = decryptConnectionFields({ ...rawExisting });
const merged: JsonRecord = { ...decryptedExisting, ...data, updatedAt: now };
merged.providerSpecificData = normalizeProviderSpecificData(
merged.providerSpecificData = normalizeConnectionProviderSpecificData(
toStringOrNull(merged.provider),
merged.providerSpecificData
merged.providerSpecificData,
merged,
decryptedExisting.providerSpecificData
);
const persistence: JsonRecord = { ...merged };
for (const field of CONNECTION_CREDENTIAL_FIELDS) {
@@ -805,14 +835,17 @@ export async function updateProviderConnection(id: string, data: JsonRecord) {
// on every unrelated field edit.
await assertApiKeyIsNotManagementPassword(data.apiKey);
const existingCamel = toRecord(rowToCamel(existing));
const merged: JsonRecord = {
...toRecord(rowToCamel(existing)),
...existingCamel,
...data,
updatedAt: new Date().toISOString(),
};
merged.providerSpecificData = normalizeProviderSpecificData(
merged.providerSpecificData = normalizeConnectionProviderSpecificData(
toStringOrNull(merged.provider),
merged.providerSpecificData
merged.providerSpecificData,
merged,
existingCamel.providerSpecificData
);
// Mirror the sanitization the create path applies — keep the returned
// object in lockstep with what we persist.

View File

@@ -316,6 +316,15 @@ export function sanitizeProviderSpecificDataForResponse(value: unknown): JsonRec
delete sanitized.usageCookie;
delete sanitized.runtimeKey;
delete sanitized.validationId;
// System-managed Codex fingerprint seed: never exposed through the API
// (mirrors sub2api stripping `codex_fingerprint_seed`); the server-side
// partial-update merge keeps it alive without the client round-tripping it.
delete sanitized.codexFingerprintSeed;
// Runtime-only Codex identity carriers (in-memory per request, never
// persisted) — strip defensively if they ever leak into a response payload.
delete sanitized.codexClientIdentity;
delete sanitized.codexOriginalIdentityHeaders;
delete sanitized.codexTurnStateEcho;
if (sanitized.browserCdpEndpoint) sanitized.browserCdpEndpoint = "configured";
return sanitized;
}

View File

@@ -5,6 +5,7 @@ import {
refreshAndUpdateCredentials,
} from "@/lib/usage/providerLimits";
import { invalidateCodexQuotaCache } from "@omniroute/open-sse/services/codexQuotaFetcher.ts";
import { getCodexBackendIdentityHeaders } from "@omniroute/open-sse/config/codexClient.ts";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
@@ -291,6 +292,9 @@ function buildCodexResetCreditHeaders(connection: CodexConnectionLike): Record<s
Authorization: `Bearer ${connection.accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
// Canonical Codex backend identity (UA + originator + version), same
// chain as inference — see getCodexUsage.
...getCodexBackendIdentityHeaders(),
};
const workspaceId = getWorkspaceId(connection);

View File

@@ -6,6 +6,7 @@ import {
applyCodexClientMetadata,
applyCodexOriginalIdentityHeaders,
createCodexClientIdentity,
ensureCodexFingerprintSeed,
getCodexClientSessionId,
getCodexConvergedSessionId,
getCodexConvergedThreadId,
@@ -371,3 +372,84 @@ test("Codex websocket headers and payload share one fingerprint identity", async
assert.equal(wsHeaders["x-codex-window-id"], metadata["x-codex-window-id"]);
assert.equal(payload.type, "response.create");
});
test("Codex fingerprint seed: persisted seed drives v2 derivation deterministically", () => {
const seed = "11111111-2222-4233-8444-555555555555";
const seeded = { workspaceId: "workspace-42", codexFingerprintSeed: seed };
const installationId = getCodexInstallationId(seeded, "connection-42");
const sessionId = getCodexConvergedSessionId(seeded, "connection-42");
const threadId = getCodexConvergedThreadId("client-session", seeded, "connection-42");
// Deterministic: same seed → same ids, regardless of the connection key.
assert.equal(getCodexInstallationId(seeded, "another-connection"), installationId);
assert.equal(getCodexConvergedSessionId(seeded, "another-connection"), sessionId);
assert.equal(getCodexConvergedThreadId("client-session", seeded, null), threadId);
// v2 derivation deliberately rotates away from the legacy workspace-derived ids.
const legacy = { workspaceId: "workspace-42" };
assert.notEqual(installationId, getCodexInstallationId(legacy, "connection-42"));
assert.notEqual(sessionId, getCodexConvergedSessionId(legacy, "connection-42"));
// Two connections never share an identity once seeded (sub2api #5696).
const otherSeed = { codexFingerprintSeed: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" };
assert.notEqual(getCodexInstallationId(otherSeed, "connection-42"), installationId);
assert.notEqual(getCodexConvergedSessionId(otherSeed, "connection-42"), sessionId);
// Admin-configured explicit installation id still wins over the seed.
assert.equal(
getCodexInstallationId(
{ ...seeded, codexInstallationId: "99999999-8888-4777-8666-555555555555" },
"connection-42"
),
"99999999-8888-4777-8666-555555555555"
);
});
test("ensureCodexFingerprintSeed creates once, preserves, and skips non-converged", () => {
const oauth = { accessToken: "oauth-token" };
// Default mode (session) on OAuth → seed created.
const created = ensureCodexFingerprintSeed(undefined, oauth);
assert.match(created?.codexFingerprintSeed as string, /^[0-9a-f-]{36}$/);
// The stored seed ALWAYS wins over an incoming payload that omits it
// (partial update) or carries a client-forged one (system-managed key).
const stored = { codexFingerprintSeed: "11111111-2222-4233-8444-555555555555" };
assert.deepEqual(ensureCodexFingerprintSeed(undefined, oauth, stored), stored);
assert.deepEqual(
ensureCodexFingerprintSeed(
{ codexFingerprintSeed: "99999999-8888-4777-8666-555555555555" },
oauth,
stored
),
stored
);
// The stored seed stays dormant when the mode is switched off.
assert.deepEqual(ensureCodexFingerprintSeed({ codexFingerprintMode: "off" }, oauth, stored), {
codexFingerprintMode: "off",
codexFingerprintSeed: "11111111-2222-4233-8444-555555555555",
});
// Explicit off without a stored seed stays seedless; a client-supplied seed
// on create is stripped and replaced by a system-generated one.
assert.deepEqual(ensureCodexFingerprintSeed({ codexFingerprintMode: "off" }, oauth), {
codexFingerprintMode: "off",
});
const forgedOnCreate = ensureCodexFingerprintSeed(
{ codexFingerprintSeed: "99999999-8888-4777-8666-555555555555" },
oauth
);
assert.match(forgedOnCreate?.codexFingerprintSeed as string, /^[0-9a-f-]{36}$/);
assert.notEqual(forgedOnCreate?.codexFingerprintSeed, "99999999-8888-4777-8666-555555555555");
// Non-OAuth (API key) connections are never seeded.
assert.equal(ensureCodexFingerprintSeed(undefined, { apiKey: "sk-x" }), undefined);
assert.equal(ensureCodexFingerprintSeed(undefined, undefined), undefined);
// device/full modes require the seed as well.
for (const mode of ["device", "full"]) {
const result = ensureCodexFingerprintSeed({ codexFingerprintMode: mode }, oauth);
assert.match(result?.codexFingerprintSeed as string, /^[0-9a-f-]{36}$/);
}
});

View File

@@ -0,0 +1,111 @@
import { after, beforeEach, test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-seed-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
beforeEach(resetStorage);
after(() => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
async function createCodexOAuthConnection(providerSpecificData?: Record<string, unknown>) {
const suffix = Math.random().toString(16).slice(2, 10);
const connection = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
name: `codex-${suffix}`,
accessToken: `access-${suffix}`,
refreshToken: `refresh-${suffix}`,
providerSpecificData,
});
assert.ok(connection && typeof connection.id === "string");
return connection;
}
test("codex OAuth connections persist a fingerprint seed at creation (default session mode)", async () => {
const connection = await createCodexOAuthConnection();
const psd = connection.providerSpecificData as Record<string, unknown>;
assert.match(String(psd.codexFingerprintSeed), UUID_V4_PATTERN);
});
test("the seed survives unrelated edits and explicit re-saves (identity stability)", async () => {
const connection = await createCodexOAuthConnection({ workspaceId: "ws-1" });
const seed = (connection.providerSpecificData as Record<string, unknown>).codexFingerprintSeed;
const renamed = await providersDb.updateProviderConnection(connection.id, { name: "renamed" });
assert.equal(
(renamed?.providerSpecificData as Record<string, unknown>).codexFingerprintSeed,
seed
);
const resaved = await providersDb.updateProviderConnection(connection.id, {
providerSpecificData: { workspaceId: "ws-1", codexFingerprintMode: "full" },
});
assert.equal(
(resaved?.providerSpecificData as Record<string, unknown>).codexFingerprintSeed,
seed
);
assert.equal(
(resaved?.providerSpecificData as Record<string, unknown>).codexFingerprintMode,
"full"
);
});
test("explicit off is not seeded; switching to full seeds once and keeps it", async () => {
const connection = await createCodexOAuthConnection({ codexFingerprintMode: "off" });
assert.equal(
(connection.providerSpecificData as Record<string, unknown>).codexFingerprintSeed,
undefined
);
const switched = await providersDb.updateProviderConnection(connection.id, {
providerSpecificData: { codexFingerprintMode: "full" },
});
const psd = switched?.providerSpecificData as Record<string, unknown>;
assert.match(String(psd.codexFingerprintSeed), UUID_V4_PATTERN);
const again = await providersDb.updateProviderConnection(connection.id, { name: "again" });
assert.equal(
(again?.providerSpecificData as Record<string, unknown>).codexFingerprintSeed,
psd.codexFingerprintSeed
);
});
test("a client-supplied seed is replaced by a system-managed one", async () => {
const connection = await createCodexOAuthConnection({
codexFingerprintSeed: "client-supplied-not-a-uuid",
});
const seed = String(
(connection.providerSpecificData as Record<string, unknown>).codexFingerprintSeed
);
assert.match(seed, UUID_V4_PATTERN);
assert.notEqual(seed, "client-supplied-not-a-uuid");
});
test("non-OAuth codex connections are never seeded", async () => {
const suffix = Math.random().toString(16).slice(2, 10);
const connection = await providersDb.createProviderConnection({
provider: "codex",
authType: "apikey",
name: `codex-key-${suffix}`,
apiKey: `sk-codex-${suffix}`,
});
assert.ok(connection);
const psd = (connection.providerSpecificData ?? {}) as Record<string, unknown>;
assert.equal(psd.codexFingerprintSeed, undefined);
});

View File

@@ -75,7 +75,14 @@ test("import-token: decodes email + workspace claims from the access token and c
assert.deepEqual(created?.providerSpecificData, {
chatgptAccountId: "acct-bare",
chatgptPlanType: "plus",
// Convergence is on by default (session mode), so the connection persists
// its system-managed fingerprint seed at creation time (v178 parity).
codexFingerprintSeed: created?.providerSpecificData?.codexFingerprintSeed,
});
assert.match(
String(created?.providerSpecificData?.codexFingerprintSeed),
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
);
});
test("import-token: falls back to the explicit `name` when the JWT carries no email", async () => {

View File

@@ -0,0 +1,147 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
__resetCodexTurnStateOriginsForTesting,
isCrossAccountCodexTurnState,
noteCodexTurnStateProvenance,
readCodexTurnStateHeader,
} from "../../open-sse/config/codexTurnState.ts";
import {
resolveCodexTurnStateEcho,
withCodexFingerprintCredentials,
} from "../../open-sse/config/codexIdentity.ts";
import { buildStreamingResponseHeaders } from "../../open-sse/handlers/chatCore/responseHeaders.ts";
const TURN_STATE = "ts-blob-0123456789";
function reset() {
__resetCodexTurnStateOriginsForTesting();
}
test("readCodexTurnStateHeader reads Headers and plain records case-insensitively", () => {
reset();
assert.equal(readCodexTurnStateHeader(null), null);
assert.equal(readCodexTurnStateHeader({}), null);
assert.equal(readCodexTurnStateHeader({ "x-codex-turn-state": " " }), null);
assert.equal(
readCodexTurnStateHeader(new Headers({ "x-codex-turn-state": TURN_STATE })),
TURN_STATE
);
assert.equal(readCodexTurnStateHeader({ "X-Codex-Turn-State": TURN_STATE }), TURN_STATE);
});
test("provenance: same account passes, cross account is flagged, unknown session passes", () => {
reset();
noteCodexTurnStateProvenance("session-1", "conn-a");
assert.equal(isCrossAccountCodexTurnState("session-1", "conn-a"), false);
assert.equal(isCrossAccountCodexTurnState("session-1", "conn-b"), true);
assert.equal(isCrossAccountCodexTurnState("session-unknown", "conn-b"), false);
});
test("provenance: missing session or account does not track", () => {
reset();
noteCodexTurnStateProvenance("", "conn-a");
noteCodexTurnStateProvenance("session-1", "");
noteCodexTurnStateProvenance(null, "conn-a");
assert.equal(isCrossAccountCodexTurnState("session-1", "conn-b"), false);
});
test("provenance: expired records stop guarding", () => {
reset();
const t0 = 1_000_000;
noteCodexTurnStateProvenance("session-1", "conn-a", t0);
assert.equal(isCrossAccountCodexTurnState("session-1", "conn-b", t0 + 1000), true);
// 2h TTL — after it lapses the record is lazily dropped and the echo passes.
assert.equal(
isCrossAccountCodexTurnState("session-1", "conn-b", t0 + 2 * 60 * 60 * 1000 + 1),
false
);
});
test("provenance: newest commit wins for a re-minted session blob", () => {
reset();
noteCodexTurnStateProvenance("session-1", "conn-a");
// Failover committed a response from conn-b — the client now holds b's blob.
noteCodexTurnStateProvenance("session-1", "conn-b");
assert.equal(isCrossAccountCodexTurnState("session-1", "conn-b"), false);
assert.equal(isCrossAccountCodexTurnState("session-1", "conn-a"), true);
});
test("resolveCodexTurnStateEcho strips only known cross-account echoes", () => {
reset();
const clientHeaders = {
"session-id": "session-1",
"x-codex-turn-state": TURN_STATE,
};
// No provenance yet → pass through unchanged.
assert.equal(resolveCodexTurnStateEcho(clientHeaders, "conn-a"), TURN_STATE);
// Blob minted by conn-a and client now served by conn-a again → pass.
noteCodexTurnStateProvenance("session-1", "conn-a");
assert.equal(resolveCodexTurnStateEcho(clientHeaders, "conn-a"), TURN_STATE);
// Failover to conn-b while the client echoes conn-a's blob → strip.
assert.equal(resolveCodexTurnStateEcho(clientHeaders, "conn-b"), null);
// No echo header → nothing to forward.
assert.equal(resolveCodexTurnStateEcho({ "session-id": "session-1" }, "conn-a"), null);
// Echo without a session id cannot be provenance-checked → pass through
// (same as sub2api: no tracking key, keep passthrough behavior).
assert.equal(
resolveCodexTurnStateEcho({ "x-codex-turn-state": TURN_STATE }, "conn-b"),
TURN_STATE
);
});
test("withCodexFingerprintCredentials stashes the allowed echo independent of mode", () => {
reset();
noteCodexTurnStateProvenance("session-1", "conn-a");
const baseCredentials = {
accessToken: "oauth-token",
connectionId: "conn-a",
providerSpecificData: { codexFingerprintMode: "off" as const },
};
const clientHeaders = { "session-id": "session-1", "x-codex-turn-state": TURN_STATE };
// Same account, explicit off: echo survives alongside original identity passthrough.
const sameAccount = withCodexFingerprintCredentials(baseCredentials, clientHeaders, {});
assert.equal(sameAccount.providerSpecificData?.codexTurnStateEcho, TURN_STATE);
assert.ok(sameAccount.providerSpecificData?.codexOriginalIdentityHeaders);
assert.equal(sameAccount.providerSpecificData?.codexClientIdentity, undefined);
// Cross account: echo stripped, original client identity still preserved.
const crossAccount = withCodexFingerprintCredentials(
{ ...baseCredentials, connectionId: "conn-b" },
clientHeaders,
{}
);
assert.equal(crossAccount.providerSpecificData?.codexTurnStateEcho, undefined);
// Compact endpoint: convergence identity is skipped but the echo guard still runs.
const compact = withCodexFingerprintCredentials(
{ ...baseCredentials, requestEndpointPath: "/responses/compact" },
clientHeaders,
{}
);
assert.equal(compact.providerSpecificData?.codexClientIdentity, undefined);
assert.equal(compact.providerSpecificData?.codexTurnStateEcho, TURN_STATE);
});
test("streaming response headers forward x-codex-turn-state outside the byte budget", () => {
reset();
const upstream = new Headers();
upstream.set("x-codex-turn-state", "s".repeat(300));
// Fill the budget with low-priority noise the blob would otherwise evict into.
for (let index = 0; index < 12; index += 1) {
upstream.set(`x-noise-${index}`, "n".repeat(60));
}
upstream.set("x-codex-primary-used-percent", "41");
const out = buildStreamingResponseHeaders(upstream, {}, null);
const record = out as Record<string, string>;
assert.equal(record["x-codex-turn-state"], "s".repeat(300));
assert.equal(record["x-codex-primary-used-percent"], "41");
});

View File

@@ -100,7 +100,7 @@ test("streaming path bounds the aggregate size of many small upstream response h
assert.equal(getHeaderValue(out, "x-request-id"), "req-many-small-headers");
});
test("streaming path keeps Codex quota headers and drops x-codex-turn-state", () => {
test("streaming path keeps Codex quota headers and forwards x-codex-turn-state budget-exempt", () => {
const upstream = new Headers();
upstream.set("x-codex-turn-state", "s".repeat(300));
upstream.set("content-security-policy", "default-src 'none'");
@@ -116,7 +116,10 @@ test("streaming path keeps Codex quota headers and drops x-codex-turn-state", ()
assert.equal(getHeaderValue(out, "x-codex-primary-used-percent"), "41");
assert.equal(getHeaderValue(out, "x-codex-primary-reset-after-seconds"), "120");
assert.equal(getHeaderValue(out, "x-codex-credits-has-credits"), "true");
assert.equal(getHeaderValue(out, "x-codex-turn-state"), undefined);
// The turn-state blob rides along without evicting quota headers from the
// budget — the real Codex client echoes it back within the turn, so
// dropping it would break the protocol chain (sub2api v0.1.177 parity).
assert.equal(getHeaderValue(out, "x-codex-turn-state"), "s".repeat(300));
});
test("streaming path prioritizes request and rate-limit headers over diagnostics", () => {