Skip to content
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@
"codex-account-mode-state.test.ts": "gui",
"codex-account-namespaces.test.ts": "codex-integration",
"codex-account-selection-preferences.test.ts": "codex-integration",
"codex-account-store-refresh-classification.test.ts": "codex-integration",
"codex-account-store.test.ts": "codex-integration",
"codex-account-unusable-reason.test.ts": "codex-integration",
"codex-admission-primitives.test.ts": "codex-integration",
Expand Down Expand Up @@ -459,6 +460,7 @@
"codex-cooldown-recovery.test.ts": "codex-integration",
"codex-coordinator-doctor.test.ts": "codex-integration",
"codex-desired-state.test.ts": "codex-integration",
"codex-entitlement-identity-read-fence.test.ts": "codex-integration",
"codex-envkey-admission-substitution.test.ts": "codex-integration",
"codex-exec-invocation.test.ts": "codex-integration",
"codex-features-cache.test.ts": "codex-integration",
Expand Down
39 changes: 34 additions & 5 deletions src/codex/account-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,23 @@ export function readCodexAccountRecord(id: string): CodexAccountCredentialRecord
return loadCodexAccountRecordStore()[id] ?? null;
}

/**
* One store load, every record, for a caller that resolves MANY ids in a single synchronous pass.
*
* `readCodexAccountRecord` reloads, reparses and renormalizes the whole file per id. That is the
* right shape for one lookup and the wrong shape for a loop: the entitlement denial reader holds
* up to 64 accounts with four client versions each, so scoring one warm flagship request could
* perform up to 256 full-store reads on the request path.
*
* These are the same normalized records `readCodexAccountRecord` hands out, tombstones included,
* so the caller keeps its own `deletedAt` and `generation` checks instead of trusting a filtered
* view. That is the difference from `loadCodexAccountStore`, which drops both and cannot answer a
* question about credential generation.
*/
export function loadCodexAccountRecordSnapshot(): Readonly<Record<string, CodexAccountCredentialRecord>> {
return loadCodexAccountRecordStore();
Comment on lines +284 to +285

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the owning structure documents

This adds account-store snapshot and entitlement-identity semantics under src/codex/, but the commit updates none of the structure/ documents mapped to that source area. Update the owning documents in the same change so the repository's credential and entitlement architecture remains synchronized with the implementation.

AGENTS.md reference: src/AGENTS.md:L11-L11

Useful? React with 👍 / 👎.

}

const QUOTA_HISTORY_IDENTITY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;

function validQuotaHistoryIdentity(value: unknown): value is string {
Expand Down Expand Up @@ -1205,11 +1222,23 @@ async function resolveCodexToken(
// Matched on the exact `error` CODE, not anywhere in the combined text: a transient
// `server_error` whose description happens to mention invalid_grant would otherwise
// retire a healthy account, which is the failure this whole change exists to remove.
const reason = errCodeExact === "invalid_grant"
|| errCodeExact === "refresh_token_invalidated"
|| errDesc.includes("invalidated") || errDesc.includes("revoked") ? "revoked" as const
: errCodeExact === "refresh_token_expired"
|| errDesc.includes("expired") ? "expired" as const
//
// That rule binds the DESCRIPTION words too. "invalidated", "revoked" and "expired" read
// as terminal prose, but upstream puts arbitrary text there: a `server_error` whose
// description says "token was revoked" or "session expired" is still a 5xx blip, and
// retiring the account on it is exactly the false quarantine #2887 exists to prevent.
// So a body that carries a structured code is classified by that code ALONE. The
// substring fallback survives only where there is no structured code to read at all --
// a description-only body, or one this parser could not decode -- because there the
// prose is the only signal upstream gave us.
const structuredCode = errCodeExact ? errCodeExact : undefined;
const proseIsOnlySignal = structuredCode === undefined;
const reason = structuredCode === "invalid_grant"
|| structuredCode === "refresh_token_invalidated"
|| (proseIsOnlySignal
&& (errDesc.includes("invalidated") || errDesc.includes("revoked"))) ? "revoked" as const
: structuredCode === "refresh_token_expired"
|| (proseIsOnlySignal && errDesc.includes("expired")) ? "expired" as const
: "unknown" as const;
throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`);
}
Expand Down
85 changes: 64 additions & 21 deletions src/codex/model-entitlements.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { createHash } from "node:crypto";
import { readBoundedResponseBody } from "../lib/bounded-body";
import type { OcxConfig } from "../types";
import type { CodexAccountCredentialRecord, OcxConfig } from "../types";
import { isSelectableCodexPoolAccount } from "./account-id";
import { getValidCodexToken, readCodexAccountRecord } from "./account-store";
import { getValidCodexToken, loadCodexAccountRecordSnapshot } from "./account-store";
import {
getMainAccountToken,
getValidMainAccountToken,
Expand Down Expand Up @@ -523,17 +523,48 @@ function boundedCacheSet(accountId: string, value: CachedAccountModels): void {
evictClass(accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX));
}

/**
* An identity resolver scoped to one caller's pass, reading each backing store at most once.
*
* The identity check itself is unchanged -- same prefix rule, same tombstone and missing-credential
* rejection, same `pool:<generation>:<chatgptAccountId>` shape -- but the READ is hoisted. Per-id
* resolution reloads and reparses the whole `codex-accounts.json` every call, so a loop over cache
* entries paid one full-store read per entry: the denial reader admits 64 accounts with four client
* versions each, which is up to 256 synchronous reads to score a single warm flagship request.
*
* Both stores are read lazily, so a pass that touches only Direct callers, or only native main,
* still opens nothing it does not need. Neither backing read is memoized across passes: a resolver
* lives for one synchronous loop, and that loop has no suspension point, so nothing this process
* does can change the file underneath it. A snapshot is therefore not staler than per-entry reads
* would have been -- it is strictly more coherent, because a foreign writer landing mid-loop can no
* longer give the earlier entries one generation and the later ones another.
*/
function credentialIdentityResolver(): (accountId: string) => string | undefined {
let records: Readonly<Record<string, CodexAccountCredentialRecord>> | undefined;
let mainRead = false;
let mainIdentity: string | undefined;
return (accountId: string): string | undefined => {
if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) {
return `direct:${accountId.slice(DIRECT_CALLER_ACCOUNT_PREFIX.length)}`;
}
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
if (!mainRead) {
const token = getMainAccountToken();
mainIdentity = token ? `main:${token.chatgptAccountId}` : undefined;
mainRead = true;
}
return mainIdentity;
}
records ??= loadCodexAccountRecordSnapshot();
const record = records[accountId];
if (!record?.credential || record.deletedAt != null) return undefined;
return `pool:${record.generation}:${record.credential.chatgptAccountId}`;
};
}

/** Single-id resolution. Identical to one call through a fresh {@link credentialIdentityResolver}. */
function currentCredentialIdentity(accountId: string): string | undefined {
if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) {
return `direct:${accountId.slice(DIRECT_CALLER_ACCOUNT_PREFIX.length)}`;
}
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
const token = getMainAccountToken();
return token ? `main:${token.chatgptAccountId}` : undefined;
}
const record = readCodexAccountRecord(accountId);
if (!record?.credential || record.deletedAt != null) return undefined;
return `pool:${record.generation}:${record.credential.chatgptAccountId}`;
return credentialIdentityResolver()(accountId);
}

async function accountCredentialSnapshot(
Expand Down Expand Up @@ -929,8 +960,10 @@ export async function ensureCodexEntitlementFreshness(
);
const candidates = normalizedCandidateAccountIds(config);
const mutationEpoch = codexCredentialMutationEpoch();
// Same hoist as the denial pass: this prologue is synchronous and reads once per candidate.
const identityOf = credentialIdentityResolver();
const identityEntries = candidates.map(accountId => (
[accountId, currentCredentialIdentity(accountId) ?? null] as const
[accountId, identityOf(accountId) ?? null] as const
));
const identityVector = new Map(identityEntries);
const workset = candidates.filter(accountId => needsEntitlementRefresh(
Expand Down Expand Up @@ -983,8 +1016,9 @@ export function getCodexModelEntitlementStatus(
clientVersion?: string | null,
): CodexModelEntitlementStatus {
const version = resolveCodexEntitlementClientVersion(clientVersion);
const identityOf = credentialIdentityResolver();
const accounts = candidateAccountIds(config).flatMap(accountId => {
const credentialIdentity = currentCredentialIdentity(accountId);
const credentialIdentity = identityOf(accountId);
return credentialIdentity ? [{ accountId, credentialIdentity }] : [];
});
if (accounts.length === 0) return { status: "unavailable" };
Expand Down Expand Up @@ -1214,24 +1248,28 @@ export function cachedDeniedCodexAccountIdsForModel(
if (!modelId || !ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS.has(modelId)) return undefined;
const denied = new Set<string>();
const granted = new Set<string>();
// One resolver for the whole pass: the loop below runs once per cached (account, client version)
// entry, and resolving an identity per entry meant a full account-store read per entry.
const identityOf = credentialIdentityResolver();
for (const [key, entry] of accountModelsCache) {
const accountId = accountIdOfCacheKey(key);
// A forwarded Direct credential is one request's caller, never a pool candidate.
if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) continue;
// The caller's read fence, honoured BEFORE `currentCredentialIdentity` below, because that
// is the read: for native main it resolves the physical stored token, once per cached client
// version. A request that is forbidden to read main -- a profile switch draining it, or a
// request-owned credential that owns no main state -- must not reread account storage just to
// score an ordering preference. Dropping the account leaves it UNKNOWN rather than denied,
// which is the same outcome as having no cached roster for it and changes no selection.
// The caller's read fence, honoured BEFORE `identityOf` below, because that is the read: for
// native main it resolves the physical stored token. A request that is forbidden to read main
// -- a profile switch draining it, or a request-owned credential that owns no main state --
// must not reread account storage just to score an ordering preference. Dropping the account
// leaves it UNKNOWN rather than denied, which is the same outcome as having no cached roster
// for it and changes no selection. The resolver reads lazily for the same reason: an excluded
// account `continue`s here, so its store is never opened at all.
if (options.excludeAccountIds?.has(accountId)) continue;
if (entry.expiresAt <= now) continue;
// A credential we can currently read AND that differs is proof the entry answers for a
// different account than this id now names, so its denial is not evidence about the current
// one. An UNREADABLE credential is not proof of anything, and the same unknown-is-not-denied
// discipline that governs rosters governs identities: it leaves the entry in place rather
// than manufacturing a reason to ignore it.
const identity = currentCredentialIdentity(accountId);
const identity = identityOf(accountId);
if (identity !== undefined && identity !== entry.credentialIdentity) continue;
const state = codexModelEntitlementStateForRoster(
entry.models,
Expand Down Expand Up @@ -1286,6 +1324,11 @@ export function cachedAvailableAccountGatedNativeModels(

export function isCodexModelEntitlementSnapshotCurrent(snapshot: CodexModelEntitlementSnapshot): boolean {
for (const [accountId, identity] of snapshot.credentialIdentities) {
// Deliberately per-id, unlike the passes above. This is a fail-closed publication gate asking
// whether a snapshot is STILL current, so the freshest possible answer per account is the
// point of the read. A pass-wide snapshot would be a coherence win everywhere else and a
// small weakening here: it could answer "current" for a later account from a record a
// concurrent reauth had already replaced.
if (currentCredentialIdentity(accountId) !== identity) return false;
}
return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl";
import { flushConfigDirHardeningForTests } from "../../src/config/paths";
import { removeTreeWithRetry } from "../helpers/remove-tree";

/**
* Refresh-failure classification: which upstream bodies are allowed to retire an account.
*
* The rule the runtime states is that a terminal verdict comes from the exact structured
* `error` code. The terminal WORDS were never held to it: "invalidated", "revoked" and
* "expired" were matched anywhere in the combined code+description text, so a transient
* `server_error` whose description happened to say "the token was revoked" retired a healthy
* account -- the false quarantine #2887 exists to prevent, reached through the description
* instead of through the code.
*
* Every terminal word is pinned here with its own negative case. These live in their own file
* rather than in codex-account-store.test.ts because that file is 1.8k lines and each case
* needs the same scratch-home isolation the parent file installs.
*/

let TEST_DIR = "";

const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" };

describe("codex refresh-failure classification", () => {
beforeEach(() => {
// Credential-store behavior, not Windows ACL behavior: stub both runners so hardening
// never spawns icacls.exe.
setIcaclsRunnerForTests(() => ICACLS_OK);
setAsyncIcaclsRunnerForTests(async () => ICACLS_OK);
TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-codex-refresh-class-"));
process.env.OPENCODEX_HOME = TEST_DIR;
});

afterEach(async () => {
await flushConfigDirHardeningForTests();
setIcaclsRunnerForTests(null);
setAsyncIcaclsRunnerForTests(null);
delete process.env.OPENCODEX_HOME;
if (TEST_DIR) removeTreeWithRetry(TEST_DIR);
TEST_DIR = "";
});

/** Drive one forced refresh against a stubbed token endpoint and return the thrown reason. */
async function classify(accountId: string, respond: () => Response): Promise<string> {
const { forceRefreshCodexPoolToken, readCodexAccountRecord, saveCodexAccountCredential, TokenRefreshError } =
await import("../../src/codex/account-store");
saveCodexAccountCredential(accountId, {
accessToken: "rejected",
refreshToken: `grant-${accountId}`,
expiresAt: Date.now() + 3600_000,
chatgptAccountId: "acc",
});
const generation = readCodexAccountRecord(accountId)!.generation;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => respond()) as typeof fetch;
try {
await forceRefreshCodexPoolToken(accountId, {
rejectedGeneration: generation,
rejectedAccessToken: "rejected",
});
throw new Error("expected a TokenRefreshError");
} catch (error) {
expect(error).toBeInstanceOf(TokenRefreshError);
return (error as InstanceType<typeof TokenRefreshError>).reason;
} finally {
globalThis.fetch = originalFetch;
}
}

// --- negative cases: a structured code that is not terminal wins over terminal prose ---

test("a server_error whose description says the token was revoked stays transient", async () => {
const reason = await classify("prose-revoked", () => Response.json({
error: "server_error",
error_description: "upstream reported the refresh token was revoked; retry shortly",
}, { status: 503 }));
expect(reason).toBe("unknown");
});

test("a server_error whose description says the grant was invalidated stays transient", async () => {
const reason = await classify("prose-invalidated", () => Response.json({
error: "server_error",
error_description: "a peer cache entry was invalidated while refreshing",
}, { status: 503 }));
expect(reason).toBe("unknown");
});

test("a server_error whose description says the session expired stays transient", async () => {
const reason = await classify("prose-expired", () => Response.json({
error: "server_error",
error_description: "the upstream session expired mid-request; try again",
}, { status: 503 }));
expect(reason).toBe("unknown");
});

test("a nested error object with a transient code and terminal prose stays transient", async () => {
// The nested shape carries the code in `error.code`, and its `message` is the same
// free-text field: reading the message as proof is the same defect in the other shape.
const reason = await classify("nested-prose", () => Response.json({
error: {
code: "server_error",
message: "Your session has expired and the token was revoked.",
type: "server_error",
param: null,
},
}, { status: 503 }));
expect(reason).toBe("unknown");
});

test("an unrelated OAuth code with terminal prose stays transient", async () => {
// `invalid_request` is a client-shape complaint, not a statement about the grant.
const reason = await classify("unrelated-code", () => Response.json({
error: "invalid_request",
error_description: "refresh_token was invalidated by an unknown parameter",
}, { status: 400 }));
expect(reason).toBe("unknown");
});

// --- positive cases: the exact codes still retire, and prose still speaks when alone ---

test("the exact terminal codes still classify as terminal", async () => {
expect(await classify("code-invalid-grant", () => Response.json({ error: "invalid_grant" }, { status: 400 })))
.toBe("revoked");
expect(await classify("code-invalidated", () => Response.json({
error: { code: "refresh_token_invalidated", message: "Your session has ended." },
}, { status: 401 }))).toBe("revoked");
expect(await classify("code-expired", () => Response.json({
error: { code: "refresh_token_expired", message: "The refresh token has expired." },
}, { status: 401 }))).toBe("expired");
});

test("with no structured code at all the description is still the only signal there is", async () => {
// A body carrying only `error_description` gives the classifier nothing else to read, so
// the substring fallback survives exactly there -- removing it would regress the opposite
// direction and leave a genuinely dead grant retrying forever.
expect(await classify("desc-only-revoked", () => Response.json({
error_description: "refresh token revoked",
}, { status: 400 }))).toBe("revoked");
expect(await classify("desc-only-expired", () => Response.json({
error_description: "refresh token expired",
}, { status: 400 }))).toBe("expired");
});

test("an unparseable body carries no terminal evidence and stays transient", async () => {
const reason = await classify("unparseable", () => new Response("<html>502 revoked</html>", { status: 502 }));
expect(reason).toBe("unknown");
});
});
Loading
Loading