diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index b7ab78cfb8..34a5d34d89 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -36,9 +36,9 @@ import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; import { availableAccountGatedNativeModels, isCodexModelEntitlementSnapshotCurrent, - resolveCodexModelEntitlements, type CodexModelEntitlementSnapshot, } from "../model-entitlements"; +import { resolveAdmittedCodexModelEntitlements } from "../model-entitlement-admission"; import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing"; @@ -1836,7 +1836,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 diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index e33e654481..99d4e36794 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -74,9 +74,9 @@ import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { availableAccountGatedNativeModels, 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"; @@ -412,7 +412,7 @@ export async function gatherCodexCatalogCandidate( providerModelOutcomes, discoveryPolicySnapshots: discoveryPolicies, }), - resolveCodexModelEntitlements(snapshot.config), + resolveAdmittedCodexModelEntitlements(snapshot.config), ]); const processLocal = processEvidence(source); const sourceEvidence = sealCatalogGatherEvidenceSession(session); diff --git a/src/codex/model-entitlement-admission.ts b/src/codex/model-entitlement-admission.ts new file mode 100644 index 0000000000..4e95642e9f --- /dev/null +++ b/src/codex/model-entitlement-admission.ts @@ -0,0 +1,63 @@ +import type { AdmissionLease } from "../lib/admission"; +import type { OcxConfig } from "../types"; +import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; +import { + resolveCodexModelEntitlements, + type CodexModelEntitlementResolveOptions, + type CodexModelEntitlementSnapshot, +} from "./model-entitlements"; +import { tryAcquireNativeMainProfileClaim } from "./native-main-admission"; +import { withNativeMainSharedClaim } from "./native-main-claim"; +import { resolveNativeProfileContext } from "./native-profile-store"; +import { NativeProfileError } from "./native-profile-types"; + +interface ModelEntitlementAdmissionDeps { + readonly acquireNativeMain?: () => AdmissionLease | null; + readonly resolve?: typeof resolveCodexModelEntitlements; + readonly withSharedClaim?: (operation: () => Promise) => Promise; +} + +function excludeNativeMain( + options: CodexModelEntitlementResolveOptions, +): CodexModelEntitlementResolveOptions { + return { + ...options, + excludeAccountIds: new Set([ + ...(options.excludeAccountIds ?? []), + MAIN_CODEX_ACCOUNT_ID, + ]), + }; +} + +/** + * Resolve background/data-plane entitlements inside both 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 + * and cross-process shared claim cover its complete read/possible refresh. + */ +export async function resolveAdmittedCodexModelEntitlements( + config: Pick, + options: CodexModelEntitlementResolveOptions = {}, + deps: ModelEntitlementAdmissionDeps = {}, +): Promise { + const resolve = deps.resolve ?? resolveCodexModelEntitlements; + const lease = (deps.acquireNativeMain ?? tryAcquireNativeMainProfileClaim)(); + if (!lease) return resolve(config, excludeNativeMain(options)); + + try { + const operation = () => resolve(config, options); + const withSharedClaim = deps.withSharedClaim + ?? ((work: () => Promise) => withNativeMainSharedClaim(resolveNativeProfileContext(), work)); + try { + return await withSharedClaim(operation); + } catch (error) { + // A foreign exclusive holder or an unsupported claim filesystem makes + // main unavailable; it must not suppress independent Pool discovery. + if (!(error instanceof NativeProfileError)) throw error; + return await resolve(config, excludeNativeMain(options)); + } + } finally { + lease.release(); + } +} diff --git a/src/server/index.ts b/src/server/index.ts index 6c7e53f062..08c54fabbd 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -66,8 +66,8 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../codex import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; import { availableAccountGatedNativeModels, - resolveCodexModelEntitlements, } from "../codex/model-entitlements"; +import { resolveAdmittedCodexModelEntitlements } from "../codex/model-entitlement-admission"; export { clearThreadAccountMap, formatCodexProviderForLog, @@ -1160,7 +1160,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server>(), + confirmedAccountIds: new Set(), + credentialIdentities: new Map(), +}; + +describe("Codex model entitlement admission", () => { + test("excludes native main before credential discovery when lifecycle admission is blocked", async () => { + let received: CodexModelEntitlementResolveOptions | undefined; + + await resolveAdmittedCodexModelEntitlements({ codexAccounts: [] }, {}, { + acquireNativeMain: () => null, + resolve: async (_config, options) => { + received = options; + return emptySnapshot; + }, + }); + + expect(received?.excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + }); + + test("holds lifecycle and shared claims through credential discovery", async () => { + const events: string[] = []; + let released = false; + + await resolveAdmittedCodexModelEntitlements({ codexAccounts: [] }, {}, { + acquireNativeMain: () => ({ release: () => { + released = true; + events.push("lifecycle-release"); + } }), + withSharedClaim: async operation => { + events.push("shared-enter"); + const result = await operation(); + events.push("shared-release"); + return result; + }, + resolve: async () => { + expect(released).toBe(false); + events.push("credential-discovery"); + return emptySnapshot; + }, + }); + + expect(events).toEqual([ + "shared-enter", + "credential-discovery", + "shared-release", + "lifecycle-release", + ]); + }); + + test("falls back to Pool-only discovery when the shared claim is unavailable", async () => { + const exclusions: boolean[] = []; + + await resolveAdmittedCodexModelEntitlements({ codexAccounts: [] }, {}, { + acquireNativeMain: () => ({ release: () => undefined }), + withSharedClaim: async () => { + throw new NativeProfileError("NATIVE_MAIN_CLAIM_BUSY", "busy", 503, true); + }, + resolve: async (_config, options) => { + exclusions.push(options.excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID) === true); + return emptySnapshot; + }, + }); + + expect(exclusions).toEqual([true]); + }); +});