mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 14:12:59 +03:00
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:
@@ -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();
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
144
open-sse/config/codexTurnState.ts
Normal file
144
open-sse/config/codexTurnState.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}$/);
|
||||
}
|
||||
});
|
||||
|
||||
111
tests/unit/codex-fingerprint-seed-persistence.test.ts
Normal file
111
tests/unit/codex-fingerprint-seed-persistence.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
147
tests/unit/codex-turn-state.test.ts
Normal file
147
tests/unit/codex-turn-state.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user