From 01d1186544770c08e2acba0ff2b9b93ee0f22d29 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 31 Aug 2026 10:00:49 +0900 Subject: [PATCH] fix(codex): bound entitlement version cache misses --- src/codex/model-entitlements.ts | 41 +++++++++++++++++++++++++- tests/codex-model-entitlements.test.ts | 23 +++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index 6dd58c172c..d203ea4226 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -210,6 +210,9 @@ const MODEL_ROSTER_VERSIONS_PER_ACCOUNT_MAX = 4; * roster. */ const MODEL_ROSTER_FLIGHTS_PER_ACCOUNT_MAX = 4; +/** Distinct, caller-selected roster versions admitted per account in one roster TTL. */ +const MODEL_ROSTER_VERSION_MISSES_PER_ACCOUNT_MAX = 4; +const accountModelsMisses = new Map(); const DIRECT_CALLER_ACCOUNT_PREFIX = "__direct_codex__:"; export interface CodexModelEntitlementCredentialSnapshot { @@ -457,6 +460,7 @@ async function modelsForCredential( fetcher: typeof fetch, now: number, clientVersion: string, + trustedClientVersion: string, ): Promise { const cached = accountModelsCache.get(cacheKeyFor(credential.accountId, clientVersion)); if ( @@ -469,6 +473,32 @@ async function modelsForCredential( const existing = accountModelsFlights.get(flightKey); if (existing) return existing; + // The inbound version is useful compatibility evidence, but it is also an untrusted cache-key + // dimension. Bound completed misses as well as concurrent flights so cycling versions cannot + // turn one data-plane request into renewable authenticated requests under every stored token. + // The locally selected runtime (or bundled floor) is exempt: it has one stable cache key and + // must remain refreshable even after an untrusted caller spends this account's allowance. + if ( + !credential.accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX) + && clientVersion !== trustedClientVersion + ) { + const missKey = `${credential.accountId}\u0000${credential.credentialIdentity}`; + const recent = (accountModelsMisses.get(missKey) ?? []) + .filter(startedAt => startedAt > now - MODEL_ROSTER_TTL_MS); + if (recent.length >= MODEL_ROSTER_VERSION_MISSES_PER_ACCOUNT_MAX) { + accountModelsMisses.set(missKey, recent); + return { + credentialIdentity: credential.credentialIdentity, + clientVersion, + expiresAt: now, + models: new Set(), + confirmed: false, + }; + } + recent.push(now); + accountModelsMisses.set(missKey, recent); + } + // Bound concurrency per account before opening another upstream request. let liveForAccount = 0; for (const key of accountModelsFlights.keys()) { @@ -529,6 +559,10 @@ export async function resolveCodexModelEntitlements( ): Promise { const now = options.now ?? Date.now(); const fetcher = options.fetcher ?? fetch; + const trustedClientVersion = resolveCodexEntitlementClientVersion( + null, + options.loadPersistedRuntime ?? loadPersistedCodexRuntime, + ); const clientVersion = resolveCodexEntitlementClientVersion( options.clientVersion, options.loadPersistedRuntime ?? loadPersistedCodexRuntime, @@ -542,7 +576,7 @@ export async function resolveCodexModelEntitlements( .filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null); const results = await Promise.all(credentials.map(async credential => ({ credential, - result: await modelsForCredential(credential, fetcher, now, clientVersion), + result: await modelsForCredential(credential, fetcher, now, clientVersion, trustedClientVersion), }))); return { modelsByAccount: new Map(results.map(({ credential, result }) => [credential.accountId, result.models])), @@ -566,6 +600,7 @@ export async function isDirectCallerEntitledToCodexModel( options.fetcher ?? fetch, options.now ?? Date.now(), clientVersion, + clientVersion, ); return result.confirmed && result.models.has(modelId); } @@ -634,11 +669,15 @@ export function invalidateCodexModelEntitlementsForAccount(accountId: string | n for (const key of [...accountModelsCache.keys()]) { if (accountIdOfCacheKey(key) === accountId) accountModelsCache.delete(key); } + for (const key of [...accountModelsMisses.keys()]) { + if (accountIdOfCacheKey(key) === accountId) accountModelsMisses.delete(key); + } } export function resetCodexModelEntitlementCacheForTests(): void { accountModelsCache.clear(); accountModelsFlights.clear(); + accountModelsMisses.clear(); runtimeVersionMemo = null; } diff --git a/tests/codex-model-entitlements.test.ts b/tests/codex-model-entitlements.test.ts index 832d8d3049..7959a79ce5 100644 --- a/tests/codex-model-entitlements.test.ts +++ b/tests/codex-model-entitlements.test.ts @@ -335,6 +335,29 @@ describe("entitlement client version (#2886)", () => { expect(results.filter(Boolean).length).toBeLessThanOrEqual(4); }); + test("completed caller-selected misses have a renewable-work budget", async () => { + let fetches = 0; + const backend = (async () => { fetches += 1; return roster(SOL); }) as typeof fetch; + const options = (clientVersion: string | null) => ({ + credentials: [credential("rate-bounded")], + fetcher: backend, + now: 1_000, + clientVersion, + loadPersistedRuntime: () => null, + }); + + for (let i = 0; i < 12; i += 1) { + await resolveCodexModelEntitlements({ codexAccounts: [] }, options(`0.${500 + i}.0`)); + } + expect(fetches).toBe(4); + + // An attacker cannot spend the stable local-version path's capacity. It remains available + // for catalog refresh and routing even after every caller-selected allowance was consumed. + const trusted = await resolveCodexModelEntitlements({ codexAccounts: [] }, options(null)); + expect(fetches).toBe(5); + expect(trusted.confirmedAccountIds.has("rate-bounded")).toBe(true); + }); + test("the placeholder 0.0.0 is never accepted as a client version", async () => { // 0.0.0 is exactly what shipped, and it is a syntactically valid version string, so the // guard has to reject it by value rather than by shape.