@bitkyc08/opencodex 2.37.0 → 2.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/bin/ocx.mjs +69 -10
  2. package/gui/dist/assets/{index-CowztZdo.js → index-C14iCj_Q.js} +13 -13
  3. package/gui/dist/assets/index-D7PIz7_g.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/aside.svg +3 -0
  6. package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
  7. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  8. package/gui/dist/provider-icons/openclaw.svg +54 -0
  9. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  10. package/gui/dist/provider-icons/zcode.svg +219 -0
  11. package/package.json +1 -1
  12. package/src/adapters/cursor/protobuf-request.ts +4 -1
  13. package/src/adapters/cursor/tool-definitions.ts +36 -4
  14. package/src/cli/capabilities.ts +14 -0
  15. package/src/cli/codex-cli-update.ts +96 -0
  16. package/src/cli/codex-shim-autorestore.ts +3 -0
  17. package/src/cli/export-command.ts +18 -17
  18. package/src/cli/help.ts +2 -2
  19. package/src/cli/index.ts +3 -2
  20. package/src/cli/launcher-context.ts +53 -2
  21. package/src/cli/opencode.ts +126 -33
  22. package/src/cli/registry.ts +16 -10
  23. package/src/cli/system-command.ts +6 -1
  24. package/src/clients/config-export.ts +293 -28
  25. package/src/codex/account-store.ts +10 -4
  26. package/src/codex/autostart-health.ts +3 -3
  27. package/src/codex/catalog/provider-fetch.ts +20 -1
  28. package/src/codex/catalog/sync.ts +4 -3
  29. package/src/codex/cli-install-provenance.ts +795 -0
  30. package/src/codex/convergence.ts +4 -3
  31. package/src/codex/credential-mutation-epoch.ts +11 -0
  32. package/src/codex/main-account.ts +2 -0
  33. package/src/codex/model-entitlements.ts +430 -27
  34. package/src/codex/native-profile-manager.ts +4 -0
  35. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  36. package/src/codex/reset-credit-recovery.ts +20 -2
  37. package/src/codex/shim.ts +204 -18
  38. package/src/codex/user-identity.ts +2 -1
  39. package/src/config/paths.ts +18 -3
  40. package/src/config.ts +23 -0
  41. package/src/generated/compatibility-version.json +81 -45
  42. package/src/integrations/registry.ts +112 -0
  43. package/src/integrations/state.ts +67 -5
  44. package/src/integrations/writer.ts +25 -9
  45. package/src/lib/bounded-subprocess.ts +36 -0
  46. package/src/lib/strict-semver.ts +47 -0
  47. package/src/lib/windows-elevation.ts +32 -1
  48. package/src/lib/windows-secret-acl.ts +47 -25
  49. package/src/lib/windows-service-mutation-lock.ts +133 -0
  50. package/src/lib/windows-user-principal.ts +15 -17
  51. package/src/responses/spill-store.ts +334 -29
  52. package/src/responses/state.ts +488 -7
  53. package/src/server/index.ts +4 -3
  54. package/src/server/lifecycle.ts +5 -1
  55. package/src/server/management/model-rows.ts +11 -2
  56. package/src/server/management/provider-routes.ts +4 -0
  57. package/src/server/management/system-restart.ts +5 -5
  58. package/src/server/management-api.ts +7 -2
  59. package/src/server/startup-action-control.ts +3 -2
  60. package/src/service.ts +594 -33
  61. package/src/sidecar/candidates.ts +1 -1
  62. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  63. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  64. package/src/update/index.ts +3 -2
  65. package/src/update/job.ts +10 -11
  66. package/gui/dist/assets/index-jqE_VOKI.css +0 -1
@@ -73,6 +73,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "./accoun
73
73
  import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
74
74
  import {
75
75
  availableAccountGatedNativeModels,
76
+ codexModelEntitlementStateForAccount,
76
77
  isCodexModelEntitlementSnapshotCurrent,
77
78
  resolveCodexModelEntitlements,
78
79
  type CodexModelEntitlementSnapshot,
@@ -277,10 +278,10 @@ function prepareCatalog(
277
278
  ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => {
278
279
  const target = accountTargets.get(selector);
279
280
  const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
280
- const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined;
281
- const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false;
282
281
  return [selector, slugs.filter(slug => (
283
- !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true)
282
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)
283
+ || (accountId !== undefined
284
+ && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted")
284
285
  ))] as const;
285
286
  }))
286
287
  : new Map<string, readonly string[]>();
@@ -0,0 +1,11 @@
1
+ let credentialMutationEpoch = 0;
2
+
3
+ /** Process-local fence advanced after every OpenCodex-owned credential publication. */
4
+ export function codexCredentialMutationEpoch(): number {
5
+ return credentialMutationEpoch;
6
+ }
7
+
8
+ export function advanceCodexCredentialMutationEpoch(): number {
9
+ credentialMutationEpoch += 1;
10
+ return credentialMutationEpoch;
11
+ }
@@ -18,6 +18,7 @@ import { atomicWriteFile, resolveWriteTarget } from "../config/atomic-write";
18
18
  import { resolveCodexHomeDir } from "./home";
19
19
  import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard";
20
20
  import { clearAccountNeedsReauth } from "./account-runtime-state";
21
+ import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
21
22
 
22
23
  export { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
23
24
 
@@ -160,6 +161,7 @@ function persistRefreshedMainAuthJson(
160
161
  validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected),
161
162
  },
162
163
  );
164
+ advanceCodexCredentialMutationEpoch();
163
165
  return { accessToken, chatgptAccountId };
164
166
  }
165
167
 
@@ -13,6 +13,7 @@ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
13
13
  import { loadPersistedCodexRuntime } from "./runtime";
14
14
  import { codexRuntimeStateEpoch } from "./runtime";
15
15
  import upstreamModelsSnapshot from "./data/upstream-models.json";
16
+ import { codexCredentialMutationEpoch } from "./credential-mutation-epoch";
16
17
 
17
18
  const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models";
18
19
 
@@ -87,6 +88,20 @@ export function deriveGatedClientVersionFloor(
87
88
  */
88
89
  const MEASURED_GATED_CLIENT_VERSION_MINIMUM = "0.144.0";
89
90
 
91
+ /**
92
+ * Lowest versions measured to return each account-gated model when the account owns it.
93
+ *
94
+ * This is deliberately independent of the bundled upstream snapshot. The snapshot still
95
+ * records 0.142.2 for sol/terra/luna, while live measurements show that upstream omits them
96
+ * below 0.144.0. Daybreak has no snapshot row or independent minimum, so its omission remains
97
+ * authoritative instead of inheriting a guessed floor from another model.
98
+ */
99
+ export const ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS: ReadonlyMap<string, string> = new Map([
100
+ ["gpt-5.6-sol", MEASURED_GATED_CLIENT_VERSION_MINIMUM],
101
+ ["gpt-5.6-terra", MEASURED_GATED_CLIENT_VERSION_MINIMUM],
102
+ ["gpt-5.6-luna", MEASURED_GATED_CLIENT_VERSION_MINIMUM],
103
+ ]);
104
+
90
105
  /**
91
106
  * Fallback when the snapshot records no usable gated floor.
92
107
  *
@@ -230,6 +245,7 @@ function memoizedPersistedRuntimeVersion(
230
245
  return selected;
231
246
  }
232
247
  const MODEL_ROSTER_FAILURE_TTL_MS = 15_000;
248
+ const MODEL_ROSTER_NEGATIVE_CREDENTIAL_TTL_MS = 5_000;
233
249
  const MODEL_ROSTER_TIMEOUT_MS = 8_000;
234
250
  const MODEL_ROSTER_MAX_BYTES = 2 * 1024 * 1024;
235
251
  const MODEL_ROSTER_CACHE_MAX = 64;
@@ -268,6 +284,25 @@ export interface CodexModelEntitlementCredentialSnapshot {
268
284
  readonly credentialIdentity: string;
269
285
  }
270
286
 
287
+ export type CodexModelEntitlementProvenance =
288
+ | { readonly kind: "parsed-empty" }
289
+ | { readonly kind: "http-error"; readonly httpStatus: number }
290
+ | { readonly kind: "network-error" }
291
+ | { readonly kind: "timeout" }
292
+ | { readonly kind: "unparseable" };
293
+
294
+ export type CodexModelEntitlementStatus =
295
+ | { readonly status: "unavailable" }
296
+ | { readonly status: "fresh" }
297
+ | { readonly status: "unconfirmed-empty" }
298
+ | { readonly status: "failed"; readonly reason: "http-error"; readonly httpStatus: number }
299
+ | {
300
+ readonly status: "failed";
301
+ readonly reason: Exclude<CodexModelEntitlementProvenance["kind"], "parsed-empty" | "http-error">;
302
+ readonly httpStatus?: never;
303
+ }
304
+ | { readonly status: "expired-refresh-in-flight" };
305
+
271
306
  interface CachedAccountModels {
272
307
  readonly credentialIdentity: string;
273
308
  /**
@@ -279,14 +314,18 @@ interface CachedAccountModels {
279
314
  readonly expiresAt: number;
280
315
  readonly models: ReadonlySet<string>;
281
316
  readonly confirmed: boolean;
317
+ readonly provenance?: CodexModelEntitlementProvenance;
282
318
  }
283
319
 
284
320
  export interface CodexModelEntitlementSnapshot {
285
321
  readonly modelsByAccount: ReadonlyMap<string, ReadonlySet<string>>;
322
+ readonly clientVersionByAccount: ReadonlyMap<string, string>;
286
323
  readonly confirmedAccountIds: ReadonlySet<string>;
287
324
  readonly credentialIdentities: ReadonlyMap<string, string>;
288
325
  }
289
326
 
327
+ export type CodexModelEntitlementState = "granted" | "denied" | "unknown";
328
+
290
329
  export interface CodexModelEntitlementResolveOptions {
291
330
  readonly fetcher?: typeof fetch;
292
331
  /**
@@ -309,11 +348,43 @@ export interface CodexModelEntitlementResolveOptions {
309
348
  readonly credentialSnapshot?: typeof accountCredentialSnapshot;
310
349
  /** Accounts whose credentials must not be read while another lifecycle owns them. */
311
350
  readonly excludeAccountIds?: ReadonlySet<string>;
351
+ /** Ensure-only fence; ordinary request resolvers retain their established flight identity. */
352
+ readonly credentialMutationEpoch?: number;
353
+ }
354
+
355
+ export interface CodexEntitlementFreshnessOptions extends Pick<
356
+ CodexModelEntitlementResolveOptions,
357
+ | "clientVersion"
358
+ | "credentialSnapshot"
359
+ | "fetcher"
360
+ | "loadPersistedRuntime"
361
+ | "nativeMainRefreshDependencies"
362
+ | "now"
363
+ | "signal"
364
+ > {
365
+ readonly waitMs?: number;
312
366
  }
313
367
 
314
368
  const accountModelsCache = new Map<string, CachedAccountModels>();
315
369
  const accountModelsFlights = new Map<string, Promise<CachedAccountModels>>();
316
370
 
371
+ interface NegativeCredentialMemo {
372
+ readonly credentialIdentity: string | null;
373
+ readonly mutationEpoch: number;
374
+ readonly expiresAt: number;
375
+ }
376
+
377
+ interface EntitlementEnsureFlight {
378
+ readonly startedAt: number;
379
+ readonly promise: Promise<void>;
380
+ readonly clientVersion: string;
381
+ readonly identityVector: ReadonlyMap<string, string | null>;
382
+ readonly workset: readonly string[];
383
+ }
384
+
385
+ const negativeCredentialMemo = new Map<string, NegativeCredentialMemo>();
386
+ const entitlementEnsureFlights = new Map<string, EntitlementEnsureFlight>();
387
+
317
388
  /**
318
389
  * Cache key. The roster is version-specific, so the version has to be part of the identity —
319
390
  * with an account-only key, two versions in flight for one account race to overwrite each
@@ -435,6 +506,26 @@ function parseAccountModels(text: string): ReadonlySet<string> | null {
435
506
  }
436
507
  }
437
508
 
509
+ function unconfirmedAccountModels(
510
+ credential: CodexModelEntitlementCredentialSnapshot,
511
+ clientVersion: string,
512
+ now: number,
513
+ provenance: CodexModelEntitlementProvenance,
514
+ ): CachedAccountModels {
515
+ return {
516
+ credentialIdentity: credential.credentialIdentity,
517
+ clientVersion,
518
+ expiresAt: now + MODEL_ROSTER_FAILURE_TTL_MS,
519
+ models: new Set(),
520
+ confirmed: false,
521
+ provenance,
522
+ };
523
+ }
524
+
525
+ function isTimeoutError(error: unknown): boolean {
526
+ return error instanceof Error && error.name === "TimeoutError";
527
+ }
528
+
438
529
  async function fetchAccountModels(
439
530
  credential: CodexModelEntitlementCredentialSnapshot,
440
531
  fetcher: typeof fetch,
@@ -454,14 +545,24 @@ async function fetchAccountModels(
454
545
  redirect: "error",
455
546
  signal: controller.signal,
456
547
  });
548
+ if (!response.ok) {
549
+ return unconfirmedAccountModels(credential, clientVersion, now, {
550
+ kind: "http-error",
551
+ httpStatus: response.status,
552
+ });
553
+ }
457
554
  const body = await readBoundedResponseBody(response, {
458
555
  signal: controller.signal,
459
556
  maxBytes: MODEL_ROSTER_MAX_BYTES,
460
557
  fatalUtf8: true,
461
558
  });
462
- const models = response.ok && body.displaySafe && !body.truncated
463
- ? parseAccountModels(body.text)
464
- : null;
559
+ if (!body.displaySafe || body.truncated) {
560
+ return unconfirmedAccountModels(credential, clientVersion, now, { kind: "unparseable" });
561
+ }
562
+ const models = parseAccountModels(body.text);
563
+ if (models === null) {
564
+ return unconfirmedAccountModels(credential, clientVersion, now, { kind: "unparseable" });
565
+ }
465
566
  // A roster is a confirmation only when it lists something usable. `models` is a Set, and an
466
567
  // empty Set is truthy, so `models !== null` used to call `{"models":[]}` — and a response
467
568
  // whose every row was hidden or api-disabled — a confirmed answer, and lock it in for the
@@ -469,22 +570,28 @@ async function fetchAccountModels(
469
570
  // account asked under too old a client version answers with no gated rows, and treating
470
571
  // that as authoritative is exactly how 2.36.0 denied sol/terra/luna to accounts that own
471
572
  // them (#3022). No usable rows means unconfirmed, on the 15s failure TTL, asked again.
472
- const usable = models !== null && models.size > 0;
473
- return {
474
- credentialIdentity: credential.credentialIdentity,
475
- clientVersion,
476
- expiresAt: now + (usable ? MODEL_ROSTER_TTL_MS : MODEL_ROSTER_FAILURE_TTL_MS),
477
- models: models ?? new Set(),
478
- confirmed: usable,
479
- };
480
- } catch {
573
+ const usable = models.size > 0;
574
+ if (!usable) {
575
+ return unconfirmedAccountModels(credential, clientVersion, now, { kind: "parsed-empty" });
576
+ }
577
+ const hasUnknownGatedAbsence = [...ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS]
578
+ .some(([modelId, minimum]) => (
579
+ !models.has(modelId) && compareClientVersions(clientVersion, minimum) < 0
580
+ ));
481
581
  return {
482
582
  credentialIdentity: credential.credentialIdentity,
483
583
  clientVersion,
484
- expiresAt: now + MODEL_ROSTER_FAILURE_TTL_MS,
485
- models: new Set(),
486
- confirmed: false,
584
+ expiresAt: now + (!hasUnknownGatedAbsence
585
+ ? MODEL_ROSTER_TTL_MS
586
+ : MODEL_ROSTER_FAILURE_TTL_MS),
587
+ models,
588
+ confirmed: true,
487
589
  };
590
+ } catch (error) {
591
+ const provenance = isTimeoutError(error) || isTimeoutError(controller.signal.reason)
592
+ ? { kind: "timeout" } as const
593
+ : { kind: "network-error" } as const;
594
+ return unconfirmedAccountModels(credential, clientVersion, now, provenance);
488
595
  } finally {
489
596
  clearTimeout(timer);
490
597
  }
@@ -513,6 +620,7 @@ async function modelsForCredential(
513
620
  fetcher: typeof fetch,
514
621
  now: number,
515
622
  clientVersion: string,
623
+ credentialMutationEpoch?: number,
516
624
  ): Promise<CachedAccountModels> {
517
625
  const cached = accountModelsCache.get(cacheKeyFor(credential.accountId, clientVersion));
518
626
  if (
@@ -521,7 +629,8 @@ async function modelsForCredential(
521
629
  && cached.expiresAt > now
522
630
  ) return cached;
523
631
 
524
- const flightKey = `${credential.accountId}\u0000${credential.credentialIdentity}\u0000${clientVersion}`;
632
+ const flightKey = `${credential.accountId}\u0000${credential.credentialIdentity}\u0000${clientVersion}`
633
+ + (credentialMutationEpoch === undefined ? "" : `\u0000${credentialMutationEpoch}`);
525
634
  const existing = accountModelsFlights.get(flightKey);
526
635
  if (existing) return existing;
527
636
 
@@ -541,7 +650,11 @@ async function modelsForCredential(
541
650
  }
542
651
  const flight = fetchAccountModels(credential, fetcher, now, clientVersion)
543
652
  .then(result => {
544
- if (currentCredentialIdentity(credential.accountId) === credential.credentialIdentity) {
653
+ if (
654
+ currentCredentialIdentity(credential.accountId) === credential.credentialIdentity
655
+ && (credentialMutationEpoch === undefined
656
+ || codexCredentialMutationEpoch() === credentialMutationEpoch)
657
+ ) {
545
658
  boundedCacheSet(credential.accountId, result);
546
659
  }
547
660
  return result;
@@ -562,6 +675,232 @@ function candidateAccountIds(config: Pick<OcxConfig, "codexAccounts">): string[]
562
675
  ];
563
676
  }
564
677
 
678
+ function normalizedCandidateAccountIds(config: Pick<OcxConfig, "codexAccounts">): string[] {
679
+ return [...new Set(candidateAccountIds(config))].sort();
680
+ }
681
+
682
+ function freshNegativeCredentialMemo(
683
+ accountId: string,
684
+ credentialIdentity: string | null,
685
+ mutationEpoch: number,
686
+ now: number,
687
+ ): boolean {
688
+ const memo = negativeCredentialMemo.get(accountId);
689
+ if (
690
+ memo
691
+ && memo.credentialIdentity === credentialIdentity
692
+ && memo.mutationEpoch === mutationEpoch
693
+ && memo.expiresAt > now
694
+ ) return true;
695
+ if (memo) negativeCredentialMemo.delete(accountId);
696
+ return false;
697
+ }
698
+
699
+ function boundedNegativeCredentialMemoSet(accountId: string, memo: NegativeCredentialMemo): void {
700
+ negativeCredentialMemo.delete(accountId);
701
+ negativeCredentialMemo.set(accountId, memo);
702
+ for (const oldest of [...negativeCredentialMemo.keys()].slice(0, Math.max(
703
+ 0,
704
+ negativeCredentialMemo.size - MODEL_ROSTER_CACHE_MAX,
705
+ ))) negativeCredentialMemo.delete(oldest);
706
+ }
707
+
708
+ function needsEntitlementRefresh(
709
+ accountId: string,
710
+ credentialIdentity: string | null,
711
+ clientVersion: string,
712
+ mutationEpoch: number,
713
+ now: number,
714
+ ): boolean {
715
+ const cached = accountModelsCache.get(cacheKeyFor(accountId, clientVersion));
716
+ if (cached && cached.credentialIdentity !== credentialIdentity) {
717
+ invalidateCodexModelEntitlementsForAccount(accountId);
718
+ } else if (cached && cached.expiresAt > now) {
719
+ return false;
720
+ }
721
+ return !freshNegativeCredentialMemo(accountId, credentialIdentity, mutationEpoch, now);
722
+ }
723
+
724
+ function entitlementEnsureFlightKey(
725
+ candidateAccountIds: readonly string[],
726
+ clientVersion: string,
727
+ mutationEpoch: number,
728
+ identityVector: readonly (readonly [string, string | null])[],
729
+ workset: readonly string[],
730
+ ): string {
731
+ return JSON.stringify([candidateAccountIds, clientVersion, mutationEpoch, identityVector, workset]);
732
+ }
733
+
734
+ async function refreshCodexEntitlementWorkset(
735
+ config: Pick<OcxConfig, "codexAccounts">,
736
+ workset: readonly string[],
737
+ identityVector: ReadonlyMap<string, string | null>,
738
+ clientVersion: string,
739
+ mutationEpoch: number,
740
+ options: CodexEntitlementFreshnessOptions,
741
+ ): Promise<void> {
742
+ const credentialSnapshot = options.credentialSnapshot ?? accountCredentialSnapshot;
743
+ const observations = await Promise.all(workset.map(async accountId => {
744
+ const credential = await credentialSnapshot(accountId, options);
745
+ return {
746
+ accountId,
747
+ credential,
748
+ absenceObservedAt: options.now ?? Date.now(),
749
+ };
750
+ }));
751
+ const credentials = observations.flatMap(observation => observation.credential
752
+ ? [observation.credential]
753
+ : []);
754
+ if (credentials.length > 0) {
755
+ await resolveCodexModelEntitlements(config, {
756
+ ...options,
757
+ clientVersion,
758
+ credentialMutationEpoch: mutationEpoch,
759
+ credentials,
760
+ });
761
+ }
762
+
763
+ for (const observation of observations) {
764
+ if (observation.credential) continue;
765
+ const capturedIdentity = identityVector.get(observation.accountId) ?? null;
766
+ if (codexCredentialMutationEpoch() !== mutationEpoch) continue;
767
+ if ((currentCredentialIdentity(observation.accountId) ?? null) !== capturedIdentity) continue;
768
+ boundedNegativeCredentialMemoSet(observation.accountId, {
769
+ credentialIdentity: capturedIdentity,
770
+ mutationEpoch,
771
+ expiresAt: observation.absenceObservedAt + MODEL_ROSTER_NEGATIVE_CREDENTIAL_TTL_MS,
772
+ });
773
+ }
774
+ }
775
+
776
+ function waitForEntitlementEnsureFlight(
777
+ flight: EntitlementEnsureFlight,
778
+ waitMs: number,
779
+ ): Promise<void> {
780
+ const remaining = Math.max(0, waitMs - Math.max(0, Date.now() - flight.startedAt));
781
+ if (remaining === 0) return Promise.resolve();
782
+ return new Promise(resolve => {
783
+ const timer = setTimeout(resolve, remaining);
784
+ void flight.promise.then(() => {
785
+ clearTimeout(timer);
786
+ resolve();
787
+ });
788
+ });
789
+ }
790
+
791
+ /**
792
+ * Refreshes only missing, expired, or credential-mismatched local entitlement entries.
793
+ * Management deadlines bound the wait, not the upstream work, so a timed-out poll still warms
794
+ * the cache for the first poll after the shared flight settles.
795
+ */
796
+ export async function ensureCodexEntitlementFreshness(
797
+ config: Pick<OcxConfig, "codexAccounts">,
798
+ options: CodexEntitlementFreshnessOptions = {},
799
+ ): Promise<void> {
800
+ try {
801
+ const now = options.now ?? Date.now();
802
+ const clientVersion = resolveCodexEntitlementClientVersion(
803
+ options.clientVersion,
804
+ options.loadPersistedRuntime ?? loadPersistedCodexRuntime,
805
+ );
806
+ const candidates = normalizedCandidateAccountIds(config);
807
+ const mutationEpoch = codexCredentialMutationEpoch();
808
+ const identityEntries = candidates.map(accountId => (
809
+ [accountId, currentCredentialIdentity(accountId) ?? null] as const
810
+ ));
811
+ const identityVector = new Map(identityEntries);
812
+ const workset = candidates.filter(accountId => needsEntitlementRefresh(
813
+ accountId,
814
+ identityVector.get(accountId) ?? null,
815
+ clientVersion,
816
+ mutationEpoch,
817
+ now,
818
+ ));
819
+ if (workset.length === 0) return;
820
+
821
+ const key = entitlementEnsureFlightKey(
822
+ candidates,
823
+ clientVersion,
824
+ mutationEpoch,
825
+ identityEntries,
826
+ workset,
827
+ );
828
+ let flight = entitlementEnsureFlights.get(key);
829
+ if (!flight) {
830
+ const startedAt = Date.now();
831
+ let created!: EntitlementEnsureFlight;
832
+ const promise = refreshCodexEntitlementWorkset(
833
+ config,
834
+ workset,
835
+ identityVector,
836
+ clientVersion,
837
+ mutationEpoch,
838
+ options,
839
+ ).catch(() => {
840
+ // Entitlement discovery is fail-closed: callers project only confirmed cache entries.
841
+ }).finally(() => {
842
+ if (entitlementEnsureFlights.get(key) === created) entitlementEnsureFlights.delete(key);
843
+ });
844
+ created = { startedAt, promise, clientVersion, identityVector, workset };
845
+ entitlementEnsureFlights.set(key, created);
846
+ flight = created;
847
+ }
848
+ const requestedWaitMs = options.waitMs ?? 3_000;
849
+ const waitMs = Number.isFinite(requestedWaitMs) ? Math.max(0, requestedWaitMs) : 0;
850
+ await waitForEntitlementEnsureFlight(flight, waitMs);
851
+ } catch {
852
+ // The shared management boundary must degrade to the last confirmed fail-closed projection.
853
+ }
854
+ }
855
+
856
+ export function getCodexModelEntitlementStatus(
857
+ config: Pick<OcxConfig, "codexAccounts">,
858
+ now = Date.now(),
859
+ clientVersion?: string | null,
860
+ ): CodexModelEntitlementStatus {
861
+ const version = resolveCodexEntitlementClientVersion(clientVersion);
862
+ const accounts = candidateAccountIds(config).flatMap(accountId => {
863
+ const credentialIdentity = currentCredentialIdentity(accountId);
864
+ return credentialIdentity ? [{ accountId, credentialIdentity }] : [];
865
+ });
866
+ if (accounts.length === 0) return { status: "unavailable" };
867
+
868
+ const entries = accounts.map(({ accountId, credentialIdentity }) => ({
869
+ accountId,
870
+ credentialIdentity,
871
+ entry: accountModelsCache.get(cacheKeyFor(accountId, version)),
872
+ }));
873
+ const hasRefreshFlight = (accountId: string, credentialIdentity: string): boolean => {
874
+ return [...entitlementEnsureFlights.values()].some(flight => (
875
+ flight.clientVersion === version
876
+ && flight.identityVector.get(accountId) === credentialIdentity
877
+ && flight.workset.includes(accountId)
878
+ ));
879
+ };
880
+ if (entries.some(({ accountId, credentialIdentity, entry }) => (
881
+ entry
882
+ && entry.credentialIdentity === credentialIdentity
883
+ && entry.expiresAt <= now
884
+ && hasRefreshFlight(accountId, credentialIdentity)
885
+ ))) return { status: "expired-refresh-in-flight" };
886
+ const live = entries.flatMap(({ credentialIdentity, entry }) => (
887
+ entry && entry.credentialIdentity === credentialIdentity && entry.expiresAt > now ? [entry] : []
888
+ ));
889
+ // Deliberate failure-first aggregation keeps a partial Pool refresh failure visible.
890
+ const failed = live.find(entry => entry.provenance && entry.provenance.kind !== "parsed-empty");
891
+ if (failed?.provenance?.kind === "http-error") {
892
+ return { status: "failed", reason: "http-error", httpStatus: failed.provenance.httpStatus };
893
+ }
894
+ if (failed?.provenance?.kind === "network-error") return { status: "failed", reason: "network-error" };
895
+ if (failed?.provenance?.kind === "timeout") return { status: "failed", reason: "timeout" };
896
+ if (failed?.provenance?.kind === "unparseable") return { status: "failed", reason: "unparseable" };
897
+ if (live.some(entry => entry.provenance?.kind === "parsed-empty")) {
898
+ return { status: "unconfirmed-empty" };
899
+ }
900
+ if (live.some(entry => entry.confirmed)) return { status: "fresh" };
901
+ return { status: "unavailable" };
902
+ }
903
+
565
904
  /**
566
905
  * Fetch the authenticated model roster for every locally usable Codex account.
567
906
  *
@@ -598,15 +937,54 @@ export async function resolveCodexModelEntitlements(
598
937
  .filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null);
599
938
  const results = await Promise.all(credentials.map(async credential => ({
600
939
  credential,
601
- result: await modelsForCredential(credential, fetcher, now, clientVersion),
940
+ result: await modelsForCredential(
941
+ credential,
942
+ fetcher,
943
+ now,
944
+ clientVersion,
945
+ options.credentialMutationEpoch,
946
+ ),
602
947
  })));
603
948
  return {
604
949
  modelsByAccount: new Map(results.map(({ credential, result }) => [credential.accountId, result.models])),
950
+ clientVersionByAccount: new Map(results.map(({ credential, result }) => (
951
+ [credential.accountId, result.clientVersion]
952
+ ))),
605
953
  confirmedAccountIds: new Set(results.flatMap(({ credential, result }) => result.confirmed ? [credential.accountId] : [])),
606
954
  credentialIdentities: new Map(results.map(({ credential }) => [credential.accountId, credential.credentialIdentity])),
607
955
  };
608
956
  }
609
957
 
958
+ function codexModelEntitlementStateForRoster(
959
+ models: ReadonlySet<string> | undefined,
960
+ confirmed: boolean,
961
+ clientVersion: string | undefined,
962
+ modelId: string,
963
+ ): CodexModelEntitlementState {
964
+ if (!models || !confirmed) return "unknown";
965
+ // Positive evidence is authoritative regardless of which client version asked for it.
966
+ if (models.has(modelId)) return "granted";
967
+ const minimum = ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS.get(modelId);
968
+ if (minimum && (!clientVersion || compareClientVersions(clientVersion, minimum) < 0)) {
969
+ return "unknown";
970
+ }
971
+ return "denied";
972
+ }
973
+
974
+ /** Per-account tri-state authority. Positive projections admit only `granted`. */
975
+ export function codexModelEntitlementStateForAccount(
976
+ snapshot: CodexModelEntitlementSnapshot,
977
+ accountId: string,
978
+ modelId: string,
979
+ ): CodexModelEntitlementState {
980
+ return codexModelEntitlementStateForRoster(
981
+ snapshot.modelsByAccount.get(accountId),
982
+ snapshot.confirmedAccountIds.has(accountId),
983
+ snapshot.clientVersionByAccount?.get(accountId),
984
+ modelId,
985
+ );
986
+ }
987
+
610
988
  /** Fail-closed entitlement check for a Direct request's own forwarded ChatGPT credential. */
611
989
  export async function isDirectCallerEntitledToCodexModel(
612
990
  headers: Headers,
@@ -623,7 +1001,12 @@ export async function isDirectCallerEntitledToCodexModel(
623
1001
  options.now ?? Date.now(),
624
1002
  clientVersion,
625
1003
  );
626
- return result.confirmed && result.models.has(modelId);
1004
+ return codexModelEntitlementStateForRoster(
1005
+ result.models,
1006
+ result.confirmed,
1007
+ result.clientVersion,
1008
+ modelId,
1009
+ ) === "granted";
627
1010
  }
628
1011
 
629
1012
  export function entitledCodexAccountIdsForModel(
@@ -631,8 +1014,10 @@ export function entitledCodexAccountIdsForModel(
631
1014
  modelId: string | undefined,
632
1015
  ): ReadonlySet<string> | undefined {
633
1016
  if (!modelId || !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(modelId)) return undefined;
634
- return new Set([...snapshot.modelsByAccount].flatMap(([accountId, models]) => (
635
- snapshot.confirmedAccountIds.has(accountId) && models.has(modelId) ? [accountId] : []
1017
+ return new Set([...snapshot.modelsByAccount.keys()].flatMap(accountId => (
1018
+ codexModelEntitlementStateForAccount(snapshot, accountId, modelId) === "granted"
1019
+ ? [accountId]
1020
+ : []
636
1021
  )));
637
1022
  }
638
1023
 
@@ -641,10 +1026,9 @@ export function availableAccountGatedNativeModels(
641
1026
  eligibleAccountIds?: ReadonlySet<string>,
642
1027
  ): ReadonlySet<string> {
643
1028
  return new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(modelId => (
644
- [...snapshot.modelsByAccount].some(([accountId, models]) => (
1029
+ [...snapshot.modelsByAccount.keys()].some(accountId => (
645
1030
  (!eligibleAccountIds || eligibleAccountIds.has(accountId))
646
- && snapshot.confirmedAccountIds.has(accountId)
647
- && models.has(modelId)
1031
+ && codexModelEntitlementStateForAccount(snapshot, accountId, modelId) === "granted"
648
1032
  ))
649
1033
  )));
650
1034
  }
@@ -669,9 +1053,13 @@ export function cachedAvailableAccountGatedNativeModels(
669
1053
  (!eligibleAccountIds || eligibleAccountIds.has(accountIdOfCacheKey(accountId)))
670
1054
  && !accountIdOfCacheKey(accountId).startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)
671
1055
  && (version === null || entry.clientVersion === version)
672
- && entry.confirmed
673
1056
  && entry.expiresAt > now
674
- && entry.models.has(modelId)
1057
+ && codexModelEntitlementStateForRoster(
1058
+ entry.models,
1059
+ entry.confirmed,
1060
+ entry.clientVersion,
1061
+ modelId,
1062
+ ) === "granted"
675
1063
  ))
676
1064
  )));
677
1065
  }
@@ -695,9 +1083,23 @@ export function invalidateCodexModelEntitlementsForAccount(accountId: string | n
695
1083
  export function resetCodexModelEntitlementCacheForTests(): void {
696
1084
  accountModelsCache.clear();
697
1085
  accountModelsFlights.clear();
1086
+ negativeCredentialMemo.clear();
1087
+ entitlementEnsureFlights.clear();
698
1088
  runtimeVersionMemo = null;
699
1089
  }
700
1090
 
1091
+ /** Test-only snapshot for proving publication fences, which cache lookup intentionally masks. */
1092
+ export function codexEntitlementNegativeMemoForTests(
1093
+ accountId: string,
1094
+ ): Readonly<{
1095
+ credentialIdentity: string | null;
1096
+ mutationEpoch: number;
1097
+ expiresAt: number;
1098
+ }> | null {
1099
+ const memo = negativeCredentialMemo.get(accountId);
1100
+ return memo ? { ...memo } : null;
1101
+ }
1102
+
701
1103
  /**
702
1104
  * Test-only seam for the memoized tier-2 read.
703
1105
  *
@@ -717,9 +1119,10 @@ export function seedCodexModelEntitlementsForTests(
717
1119
  models: readonly string[],
718
1120
  now = Date.now(),
719
1121
  clientVersion = "0.146.0",
1122
+ credentialIdentity = `test:${accountId}`,
720
1123
  ): void {
721
1124
  boundedCacheSet(accountId, {
722
- credentialIdentity: `test:${accountId}`,
1125
+ credentialIdentity,
723
1126
  clientVersion,
724
1127
  expiresAt: now + MODEL_ROSTER_TTL_MS,
725
1128
  models: new Set(models),