diff --git a/open-sse/config/codexClient.ts b/open-sse/config/codexClient.ts index 0cb1b05772..5c71e931a5 100644 --- a/open-sse/config/codexClient.ts +++ b/open-sse/config/codexClient.ts @@ -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 { 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 { + 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 { + 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(); diff --git a/open-sse/config/codexIdentity.ts b/open-sse/config/codexIdentity.ts index bc45fa1cf6..a081c5402b 100644 --- a/open-sse/config/codexIdentity.ts +++ b/open-sse/config/codexIdentity.ts @@ -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 | 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 | null, + credentials?: { accessToken?: unknown; refreshToken?: unknown } | null, + existingProviderSpecificData?: Record | null +): Record | undefined { + const psd: Record = { ...(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 | 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 | 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 | 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(); +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 | 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; +} diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 93e5a2fe16..d73d520650 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -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): void { } } - function stripOrphanedCodexFunctionCallOutputs(body: Record): 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 | 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; } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d54689472c..e8d0c7835b 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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( diff --git a/open-sse/handlers/chatCore/responseHeaders.ts b/open-sse/handlers/chatCore/responseHeaders.ts index 59c45ba829..8206304544 100644 --- a/open-sse/handlers/chatCore/responseHeaders.ts +++ b/open-sse/handlers/chatCore/responseHeaders.ts @@ -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; } diff --git a/open-sse/services/codexQuotaFetcher.ts b/open-sse/services/codexQuotaFetcher.ts index eb588ac3ba..12f955906d 100644 --- a/open-sse/services/codexQuotaFetcher.ts +++ b/open-sse/services/codexQuotaFetcher.ts @@ -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) { diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 6b8b018a04..2ca3c5df62 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -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, }) diff --git a/open-sse/services/usage/codex.ts b/open-sse/services/usage/codex.ts index 564cbdad9d..64b37c0183 100644 --- a/open-sse/services/usage/codex.ts +++ b/open-sse/services/usage/codex.ts @@ -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; diff --git a/src/app/api/providers/[id]/models/discovery/codex.ts b/src/app/api/providers/[id]/models/discovery/codex.ts index 6e71592a3f..4d113863f3 100644 --- a/src/app/api/providers/[id]/models/discovery/codex.ts +++ b/src/app/api/providers/[id]/models/discovery/codex.ts @@ -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; diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 46d9742e61..a800ee7852 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -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 | 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. diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index 1fb62c0bdc..015df460f7 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -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; } diff --git a/src/lib/usage/codexResetCredits.ts b/src/lib/usage/codexResetCredits.ts index a0097173d0..ab54b40a9d 100644 --- a/src/lib/usage/codexResetCredits.ts +++ b/src/lib/usage/codexResetCredits.ts @@ -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 { + 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}$/); + } +}); diff --git a/tests/unit/codex-fingerprint-seed-persistence.test.ts b/tests/unit/codex-fingerprint-seed-persistence.test.ts new file mode 100644 index 0000000000..d65b7197b4 --- /dev/null +++ b/tests/unit/codex-fingerprint-seed-persistence.test.ts @@ -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) { + 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; + 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).codexFingerprintSeed; + + const renamed = await providersDb.updateProviderConnection(connection.id, { name: "renamed" }); + assert.equal( + (renamed?.providerSpecificData as Record).codexFingerprintSeed, + seed + ); + + const resaved = await providersDb.updateProviderConnection(connection.id, { + providerSpecificData: { workspaceId: "ws-1", codexFingerprintMode: "full" }, + }); + assert.equal( + (resaved?.providerSpecificData as Record).codexFingerprintSeed, + seed + ); + assert.equal( + (resaved?.providerSpecificData as Record).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).codexFingerprintSeed, + undefined + ); + + const switched = await providersDb.updateProviderConnection(connection.id, { + providerSpecificData: { codexFingerprintMode: "full" }, + }); + const psd = switched?.providerSpecificData as Record; + assert.match(String(psd.codexFingerprintSeed), UUID_V4_PATTERN); + + const again = await providersDb.updateProviderConnection(connection.id, { name: "again" }); + assert.equal( + (again?.providerSpecificData as Record).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).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; + assert.equal(psd.codexFingerprintSeed, undefined); +}); diff --git a/tests/unit/codex-import-token-route.test.ts b/tests/unit/codex-import-token-route.test.ts index bff8c49b83..f9bbbc4ec2 100644 --- a/tests/unit/codex-import-token-route.test.ts +++ b/tests/unit/codex-import-token-route.test.ts @@ -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 () => { diff --git a/tests/unit/codex-turn-state.test.ts b/tests/unit/codex-turn-state.test.ts new file mode 100644 index 0000000000..bbe9b67fd2 --- /dev/null +++ b/tests/unit/codex-turn-state.test.ts @@ -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; + assert.equal(record["x-codex-turn-state"], "s".repeat(300)); + assert.equal(record["x-codex-primary-used-percent"], "41"); +}); diff --git a/tests/unit/middleware-header-strip-5849.test.ts b/tests/unit/middleware-header-strip-5849.test.ts index ed9ea6be4a..0d6eaf4634 100644 --- a/tests/unit/middleware-header-strip-5849.test.ts +++ b/tests/unit/middleware-header-strip-5849.test.ts @@ -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", () => {