Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,11 +597,11 @@ async function handleStart(options: { block?: boolean } = {}) {
try {
const { fetchAllModels } = await import("../server/management-api");
const { desktopVisibleNativeSlugs } = await import("../codex/catalog");
const { resolveCodexModelEntitlements } = await import("../codex/model-entitlements");
const { resolveAdmittedCodexModelEntitlements } = await import("../codex/model-entitlement-admission");
const { buildDesktopDiscoveryInputs } = await import("../claude/desktop-discovery-inputs");
const [models, modelEntitlements] = await Promise.all([
fetchAllModels(config),
resolveCodexModelEntitlements(config, { clientVersion: null }),
resolveAdmittedCodexModelEntitlements(config, { clientVersion: null }),
]);
const inputs = buildDesktopDiscoveryInputs({
config, models, modelEntitlements,
Expand Down
4 changes: 2 additions & 2 deletions src/codex/catalog/retained-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import {
availableAccountGatedNativeModels,
codexModelEntitlementStateForAccount,
isCodexModelEntitlementSnapshotCurrent,
resolveCodexModelEntitlements,
type CodexModelEntitlementSnapshot,
} from "../model-entitlements";
import { resolveAdmittedCodexModelEntitlements } from "../model-entitlement-admission";
import { isAccountNeedsReauth } from "../account-runtime-state";
import { codexRuntimeStatePath } from "../runtime";
import {
Expand Down Expand Up @@ -601,7 +601,7 @@ export async function syncCatalogModels(
comboOmissions,
providerModelOutcomes,
}),
resolveCodexModelEntitlements(config),
resolveAdmittedCodexModelEntitlements(config),
]);
const committed = withCatalogWriteSerialization(owningCodexHome, permit => {
// Desired state can flip OFF during the provider await above. The catalog
Expand Down
4 changes: 2 additions & 2 deletions src/codex/convergence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ import {
availableAccountGatedNativeModels,
codexModelEntitlementStateForAccount,
isCodexModelEntitlementSnapshotCurrent,
resolveCodexModelEntitlements,
type CodexModelEntitlementSnapshot,
} from "./model-entitlements";
import { resolveAdmittedCodexModelEntitlements } from "./model-entitlement-admission";
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
import { providerCodexAccountMode } from "../providers/registry";
import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
Expand Down Expand Up @@ -434,7 +434,7 @@ export async function gatherCodexCatalogCandidate(
providerModelOutcomes,
discoveryPolicySnapshots: discoveryPolicies,
}),
resolveCodexModelEntitlements(snapshot.config),
resolveAdmittedCodexModelEntitlements(snapshot.config),
]);
const processLocal = processEvidence(source);
const sourceEvidence = sealCatalogGatherEvidenceSession(session);
Expand Down
59 changes: 59 additions & 0 deletions src/codex/model-entitlement-admission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { OcxConfig } from "../types";
import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
import {
resolveCodexModelEntitlements,
type CodexModelEntitlementResolveOptions,
type CodexModelEntitlementSnapshot,
} from "./model-entitlements";
import {
type NativeMainCredentialAdmissionDeps,
withNativeMainCredentialAdmission,
} from "./native-main-admission";

interface ModelEntitlementAdmissionDeps extends NativeMainCredentialAdmissionDeps {
readonly resolve?: typeof resolveCodexModelEntitlements;
}

function excludeNativeMain(
options: CodexModelEntitlementResolveOptions,
): CodexModelEntitlementResolveOptions {
return {
...options,
excludeAccountIds: new Set([
...(options.excludeAccountIds ?? []),
MAIN_CODEX_ACCOUNT_ID,
]),
};
}

/**
* Resolve background/data-plane entitlements inside the native-main fences.
*
* Pool discovery remains available when startup recovery or a profile drain
* owns the physical credential. When main is admitted, the process-local lease
* covers only the credential snapshot: getValidMainAccountToken holds the
* cross-process exclusive claim just for the auth.json refresh write, and the
* lease is released before the upstream entitlement request so a profile drain
* never waits on a network fetch.
*/
export async function resolveAdmittedCodexModelEntitlements(
config: Pick<OcxConfig, "codexAccounts">,
options: CodexModelEntitlementResolveOptions = {},
deps: ModelEntitlementAdmissionDeps = {},
): Promise<CodexModelEntitlementSnapshot> {
const resolve = deps.resolve ?? resolveCodexModelEntitlements;
// A caller-supplied roster or an already-excluded main never reads auth.json,
// so there is no native credential to fence.
if (options.credentials || options.excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID)) {
return resolve(config, options);
}
return withNativeMainCredentialAdmission(
(excludedAccountIds, releaseMainLease) => resolve(
config,
excludedAccountIds.size === 0
? { ...options, releaseNativeMainCredentialLease: releaseMainLease }
: excludeNativeMain(options),
),
{ acquireNativeMain: deps.acquireNativeMain },
);
}
131 changes: 87 additions & 44 deletions src/codex/model-entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
MAIN_CODEX_ACCOUNT_ID,
type NativeMainRefreshDependencies,
} from "./main-account";
import { withNativeMainCredentialAdmission } from "./native-main-admission";
import {
ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
NATIVE_GPT6_ASTRA_MODEL,
Expand Down Expand Up @@ -439,6 +440,12 @@ export interface CodexModelEntitlementResolveOptions {
readonly excludeAccountIds?: ReadonlySet<string>;
/** Ensure-only fence; ordinary request resolvers retain their established flight identity. */
readonly credentialMutationEpoch?: number;
/**
* Internal plumbing from `withNativeMainCredentialAdmission`: releases the
* native-main lifecycle lease once the credential phase settles, before any
* upstream roster fetch, so a profile drain never waits on network work.
*/
readonly releaseNativeMainCredentialLease?: () => void;
}

export interface CodexEntitlementFreshnessOptions extends Pick<
Expand All @@ -452,6 +459,8 @@ export interface CodexEntitlementFreshnessOptions extends Pick<
| "signal"
> {
readonly waitMs?: number;
/** Test seam for the native-main admission fence around the refresh workset. */
readonly nativeMainCredentialAdmission?: typeof withNativeMainCredentialAdmission;
}

const accountModelsCache = new Map<string, CachedAccountModels>();
Expand Down Expand Up @@ -581,21 +590,31 @@ function currentCredentialIdentity(accountId: string): string | undefined {

async function accountCredentialSnapshot(
accountId: string,
options: Pick<CodexModelEntitlementResolveOptions, "nativeMainRefreshDependencies" | "signal"> = {},
options: Pick<
CodexModelEntitlementResolveOptions,
"nativeMainRefreshDependencies" | "releaseNativeMainCredentialLease" | "signal"
> = {},
): Promise<CodexModelEntitlementCredentialSnapshot | null> {
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
const token = await getValidMainAccountToken({
signal: options.signal,
...(options.nativeMainRefreshDependencies ?? {}),
});
return token
? {
accountId,
accessToken: token.accessToken,
chatgptAccountId: token.chatgptAccountId,
credentialIdentity: `main:${token.chatgptAccountId}`,
}
: null;
try {
const token = await getValidMainAccountToken({
signal: options.signal,
...(options.nativeMainRefreshDependencies ?? {}),
});
return token
? {
accountId,
accessToken: token.accessToken,
chatgptAccountId: token.chatgptAccountId,
credentialIdentity: `main:${token.chatgptAccountId}`,
}
: null;
} finally {
// The lifecycle lease fences only this credential read; releasing here —
// on success and on a credential-ownership failure alike — keeps a
// profile drain from waiting on the roster fetches that follow.
options.releaseNativeMainCredentialLease?.();
}
}
try {
const token = await getValidCodexToken(accountId);
Expand Down Expand Up @@ -911,38 +930,58 @@ async function refreshCodexEntitlementWorkset(
mutationEpoch: number,
options: CodexEntitlementFreshnessOptions,
): Promise<void> {
const credentialSnapshot = options.credentialSnapshot ?? accountCredentialSnapshot;
const observations = await Promise.all(workset.map(async accountId => {
const credential = await credentialSnapshot(accountId, options);
return {
accountId,
credential,
absenceObservedAt: options.now ?? Date.now(),
};
}));
const credentials = observations.flatMap(observation => observation.credential
? [observation.credential]
: []);
if (credentials.length > 0) {
await resolveCodexModelEntitlements(config, {
...options,
clientVersion,
credentialMutationEpoch: mutationEpoch,
credentials,
});
}
const run = async (
excludedAccountIds: ReadonlySet<string>,
releaseMainLease?: () => void,
): Promise<void> => {
// An excluded main is filtered before the snapshot phase, not just before the
// roster fetch: it never produces an absence observation, so a denied
// admission cannot memoize a credential read that never happened.
const admittedWorkset = excludedAccountIds.size === 0
? workset
: workset.filter(accountId => !excludedAccountIds.has(accountId));
const credentialSnapshot = options.credentialSnapshot ?? accountCredentialSnapshot;
const observations = await Promise.all(admittedWorkset.map(async accountId => {
const credential = await credentialSnapshot(accountId, {
...options,
releaseNativeMainCredentialLease: releaseMainLease,
});
return {
accountId,
credential,
absenceObservedAt: options.now ?? Date.now(),
};
}));
// The lease fences only the credential phase; release before roster fetches
// so a profile drain never waits on upstream network work.
releaseMainLease?.();
const credentials = observations.flatMap(observation => observation.credential
? [observation.credential]
: []);
if (credentials.length > 0) {
await resolveCodexModelEntitlements(config, {
...options,
clientVersion,
credentialMutationEpoch: mutationEpoch,
credentials,
});
}

for (const observation of observations) {
if (observation.credential) continue;
const capturedIdentity = identityVector.get(observation.accountId) ?? null;
if (codexCredentialMutationEpoch() !== mutationEpoch) continue;
if ((currentCredentialIdentity(observation.accountId) ?? null) !== capturedIdentity) continue;
boundedNegativeCredentialMemoSet(observation.accountId, {
credentialIdentity: capturedIdentity,
mutationEpoch,
expiresAt: observation.absenceObservedAt + MODEL_ROSTER_NEGATIVE_CREDENTIAL_TTL_MS,
});
}
for (const observation of observations) {
if (observation.credential) continue;
const capturedIdentity = identityVector.get(observation.accountId) ?? null;
if (codexCredentialMutationEpoch() !== mutationEpoch) continue;
if ((currentCredentialIdentity(observation.accountId) ?? null) !== capturedIdentity) continue;
boundedNegativeCredentialMemoSet(observation.accountId, {
credentialIdentity: capturedIdentity,
mutationEpoch,
expiresAt: observation.absenceObservedAt + MODEL_ROSTER_NEGATIVE_CREDENTIAL_TTL_MS,
});
}
};
if (!workset.includes(MAIN_CODEX_ACCOUNT_ID)) return run(new Set<string>());
const admission = options.nativeMainCredentialAdmission ?? withNativeMainCredentialAdmission;
return admission(run);
}

function waitForEntitlementEnsureFlight(
Expand Down Expand Up @@ -1110,6 +1149,10 @@ export async function resolveCodexModelEntitlements(
? [...options.credentials].filter(credential => !options.excludeAccountIds?.has(credential.accountId))
: (await Promise.all(allowedAccountIds.map(accountId => credentialSnapshot(accountId, options))))
.filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null);
// The credential phase is the only part the native-main lease fences. The
// real snapshot releases it as soon as the main token settles; this boundary
// release keeps the guarantee when a seam snapshot never invokes it.
options.releaseNativeMainCredentialLease?.();
const results = await Promise.all(credentials.map(async credential => ({
credential,
result: await modelsForCredential(
Expand Down
83 changes: 83 additions & 0 deletions src/codex/native-main-admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import {
tryAcquireNativeMainProfileClaim as tryAcquireLifecycleNativeMainProfileClaim,
tryClaimNativeMainProfileForTurn as tryClaimLifecycleNativeMainProfileForTurn,
} from "../server/lifecycle";
import {
MAIN_CODEX_ACCOUNT_ID,
MainAccountTokenRefreshError,
MainAuthJsonChangedDuringRefreshError,
} from "./main-account";
import { isNativeMainTrafficBlocked } from "./native-profile-startup";
import { NativeProfileError } from "./native-profile-types";

export interface NativeMainTurnClaimDeps {
/** Test seams for the synchronous precheck/claim/postcheck transition. */
Expand Down Expand Up @@ -45,3 +51,80 @@ export function tryAcquireNativeMainProfileClaim(): AdmissionLease | null {
claim.release();
return null;
}

export interface NativeMainCredentialAdmissionDeps {
/** Test seam for the synchronous admission precheck. */
readonly acquireNativeMain?: () => AdmissionLease | null;
}

const NO_EXCLUDED_ACCOUNT_IDS: ReadonlySet<string> = new Set();
const NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS: ReadonlySet<string> = new Set([MAIN_CODEX_ACCOUNT_ID]);
const RELEASE_NOTHING = () => {};

/**
* Run credential-backed work inside the native-main lifecycle fence.
*
* The lease keeps startup recovery and profile drains from owning the physical
* credential while the operation reads it. Cross-process ownership of the file
* itself is already coordinated inside the refresh path's exclusive claim, so
* this fence deliberately does not take the shared claim: holding it across an
* operation that may refresh would ask for exclusive ownership against our own
* shared lock, and holding it across the upstream work that follows would
* stall an unrelated credential commit behind a network fetch.
*
* The fence covers only the credential read. The operation must invoke
* `releaseMainLease` as soon as the native-main credential settles — on both
* the success and the credential-error path — and before any upstream model
* listing, so a profile drain never waits on a network fetch while this turn
* is still counted. The wrapper releases on settle regardless, so an
* operation without a fenced credential phase may ignore the callback.
*
* When the gate refuses, or the credential cannot be read because another
* lifecycle owns it, the operation reruns with main excluded so independent
* Pool work is never suppressed by main's unavailability.
*/
export async function withNativeMainCredentialAdmission<T>(
operation: (
excludeAccountIds: ReadonlySet<string>,
releaseMainLease?: () => void,
) => Promise<T>,
deps: NativeMainCredentialAdmissionDeps = {},
): Promise<T> {
const lease = (deps.acquireNativeMain ?? tryAcquireNativeMainProfileClaim)();
if (!lease) return operation(NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS, RELEASE_NOTHING);
let released = false;
const releaseMainLease = () => {
if (released) return;
released = true;
lease.release();
};
try {
return await operation(NO_EXCLUDED_ACCOUNT_IDS, releaseMainLease);
} catch (error) {
// The Pool-only retry never reads the native-main credential; release first
// so a profile drain is not kept waiting behind Pool network work.
releaseMainLease();
if (!isNativeMainCredentialUnavailableError(error)) throw error;
return await operation(NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS, RELEASE_NOTHING);
} finally {
releaseMainLease();
}
}

/**
* The credential-ownership failures that make main unavailable for one
* operation: a foreign exclusive holder or an unsupported claim filesystem
* (NATIVE_MAIN_CLAIM_BUSY / NATIVE_MAIN_CLAIM_UNAVAILABLE), a writer that moved
* auth.json mid-refresh, or a grant that no longer refreshes. Like a Pool
* credential failure, none of them may suppress independent Pool discovery.
* Any other NativeProfileError — MAIN_REQUESTS_ACTIVE, VAULT_INVALID,
* INTERNAL_ERROR — is not a credential-ownership failure and propagates.
*/
function isNativeMainCredentialUnavailableError(error: unknown): boolean {
if (error instanceof NativeProfileError) {
return error.code === "NATIVE_MAIN_CLAIM_BUSY"
|| error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE";
}
return error instanceof MainAuthJsonChangedDuringRefreshError
|| error instanceof MainAccountTokenRefreshError;
}
Loading
Loading