@bitkyc08/opencodex 2.36.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 (70) hide show
  1. package/bin/ocx.mjs +69 -10
  2. package/gui/dist/assets/index-C14iCj_Q.js +112 -0
  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/adapters/kiro-constants.ts +36 -2
  15. package/src/adapters/kiro.ts +13 -2
  16. package/src/cli/capabilities.ts +14 -0
  17. package/src/cli/claude.ts +12 -0
  18. package/src/cli/codex-cli-update.ts +96 -0
  19. package/src/cli/codex-shim-autorestore.ts +3 -0
  20. package/src/cli/export-command.ts +18 -17
  21. package/src/cli/help.ts +2 -2
  22. package/src/cli/index.ts +3 -2
  23. package/src/cli/launcher-context.ts +53 -2
  24. package/src/cli/opencode.ts +126 -33
  25. package/src/cli/registry.ts +16 -10
  26. package/src/cli/system-command.ts +6 -1
  27. package/src/clients/config-export.ts +293 -28
  28. package/src/codex/account-store.ts +10 -4
  29. package/src/codex/autostart-health.ts +3 -3
  30. package/src/codex/catalog/provider-fetch.ts +20 -1
  31. package/src/codex/catalog/sync.ts +4 -3
  32. package/src/codex/cli-install-provenance.ts +795 -0
  33. package/src/codex/convergence.ts +4 -3
  34. package/src/codex/credential-mutation-epoch.ts +11 -0
  35. package/src/codex/main-account.ts +2 -0
  36. package/src/codex/model-entitlements.ts +489 -30
  37. package/src/codex/native-profile-manager.ts +4 -0
  38. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  39. package/src/codex/reset-credit-recovery.ts +20 -2
  40. package/src/codex/shim.ts +204 -18
  41. package/src/codex/user-identity.ts +2 -1
  42. package/src/config/paths.ts +18 -3
  43. package/src/config.ts +23 -0
  44. package/src/generated/compatibility-version.json +84 -48
  45. package/src/integrations/registry.ts +112 -0
  46. package/src/integrations/state.ts +67 -5
  47. package/src/integrations/writer.ts +25 -9
  48. package/src/lib/bounded-subprocess.ts +36 -0
  49. package/src/lib/strict-semver.ts +47 -0
  50. package/src/lib/windows-elevation.ts +32 -1
  51. package/src/lib/windows-secret-acl.ts +47 -25
  52. package/src/lib/windows-service-mutation-lock.ts +133 -0
  53. package/src/lib/windows-user-principal.ts +15 -17
  54. package/src/responses/spill-store.ts +334 -29
  55. package/src/responses/state.ts +488 -7
  56. package/src/server/index.ts +4 -3
  57. package/src/server/lifecycle.ts +5 -1
  58. package/src/server/management/model-rows.ts +11 -2
  59. package/src/server/management/provider-routes.ts +4 -0
  60. package/src/server/management/system-restart.ts +5 -5
  61. package/src/server/management-api.ts +7 -2
  62. package/src/server/startup-action-control.ts +3 -2
  63. package/src/service.ts +594 -33
  64. package/src/sidecar/candidates.ts +1 -1
  65. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  66. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  67. package/src/update/index.ts +3 -2
  68. package/src/update/job.ts +10 -11
  69. package/gui/dist/assets/index-Cy7Z_pl0.css +0 -1
  70. package/gui/dist/assets/index-DO8liQVL.js +0 -112
@@ -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
 
@@ -70,6 +71,37 @@ export function deriveGatedClientVersionFloor(
70
71
  );
71
72
  }
72
73
 
74
+ /**
75
+ * Lowest `client_version` MEASURED to actually return the account-gated rows.
76
+ *
77
+ * The bundled snapshot is not sufficient on its own. It records `0.142.2` for the gpt-5.6
78
+ * rows, and `0.142.2` is a version upstream answers with 200 and five models, none of them
79
+ * gpt-5.6; `0.144.0` and above answer with the gated rows present
80
+ * (devlog/_fin/260817_native_gpt56_1m_context/001_measurement_evidence.md, independently
81
+ * reproduced by the #2886 and #3022 reporters). So a floor derived from the snapshot alone
82
+ * asks a question whose honest answer is an empty gated set — and the fail-closed gate then
83
+ * reads that absence as a confirmed denial, which is how 2.36.0 removed sol/terra/luna from
84
+ * accounts that own them (#3022).
85
+ *
86
+ * This is a measurement, not a preference, which is why it composes with the derivation
87
+ * instead of replacing it: see `composeGatedClientVersionFloor`.
88
+ */
89
+ const MEASURED_GATED_CLIENT_VERSION_MINIMUM = "0.144.0";
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
+
73
105
  /**
74
106
  * Fallback when the snapshot records no usable gated floor.
75
107
  *
@@ -80,10 +112,41 @@ export function deriveGatedClientVersionFloor(
80
112
  */
81
113
  const GATED_MODEL_CLIENT_VERSION_FLOOR_FALLBACK = "0.142.2";
82
114
 
83
- export const GATED_MODEL_CLIENT_VERSION_FLOOR: string =
84
- deriveGatedClientVersionFloor(
85
- (upstreamModelsSnapshot as { models?: Array<Record<string, unknown>> }).models ?? [],
86
- ) ?? GATED_MODEL_CLIENT_VERSION_FLOOR_FALLBACK;
115
+ /**
116
+ * The floor actually used: the highest of what the snapshot derives, what we have measured
117
+ * upstream to honour, and the fallback.
118
+ *
119
+ * Composed rather than hardcoded so the two sources cannot drift into a contradiction. The
120
+ * snapshot may raise the floor; it may never lower it below a measurement. When a future
121
+ * snapshot refresh records `0.144.0` or higher, the derivation takes over naturally and
122
+ * `MEASURED_GATED_CLIENT_VERSION_MINIMUM` goes inert instead of fighting it.
123
+ */
124
+ function composeGatedClientVersionFloor(
125
+ rows: ReadonlyArray<Record<string, unknown>>,
126
+ gatedSlugs: ReadonlySet<string> = ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
127
+ ): string {
128
+ const derived = deriveGatedClientVersionFloor(rows, gatedSlugs) ?? GATED_MODEL_CLIENT_VERSION_FLOOR_FALLBACK;
129
+ return compareClientVersions(derived, MEASURED_GATED_CLIENT_VERSION_MINIMUM) >= 0
130
+ ? derived
131
+ : MEASURED_GATED_CLIENT_VERSION_MINIMUM;
132
+ }
133
+
134
+ export const GATED_MODEL_CLIENT_VERSION_FLOOR: string = composeGatedClientVersionFloor(
135
+ (upstreamModelsSnapshot as { models?: Array<Record<string, unknown>> }).models ?? [],
136
+ );
137
+
138
+ /** Test-only seam: the composition on synthetic rows, so both directions can be proven. */
139
+ export function composeGatedClientVersionFloorForTests(
140
+ rows: ReadonlyArray<Record<string, unknown>>,
141
+ gatedSlugs?: ReadonlySet<string>,
142
+ ): string {
143
+ return composeGatedClientVersionFloor(rows, gatedSlugs);
144
+ }
145
+
146
+ /** Test-only seam: the ordering the floor composition relies on. */
147
+ export function compareClientVersionsForTests(left: string, right: string): number {
148
+ return compareClientVersions(left, right);
149
+ }
87
150
 
88
151
  /** Numeric-segment comparison. Only used to pick the highest floor in a known-good set. */
89
152
  function compareClientVersions(left: string, right: string): number {
@@ -182,6 +245,7 @@ function memoizedPersistedRuntimeVersion(
182
245
  return selected;
183
246
  }
184
247
  const MODEL_ROSTER_FAILURE_TTL_MS = 15_000;
248
+ const MODEL_ROSTER_NEGATIVE_CREDENTIAL_TTL_MS = 5_000;
185
249
  const MODEL_ROSTER_TIMEOUT_MS = 8_000;
186
250
  const MODEL_ROSTER_MAX_BYTES = 2 * 1024 * 1024;
187
251
  const MODEL_ROSTER_CACHE_MAX = 64;
@@ -220,6 +284,25 @@ export interface CodexModelEntitlementCredentialSnapshot {
220
284
  readonly credentialIdentity: string;
221
285
  }
222
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
+
223
306
  interface CachedAccountModels {
224
307
  readonly credentialIdentity: string;
225
308
  /**
@@ -231,14 +314,18 @@ interface CachedAccountModels {
231
314
  readonly expiresAt: number;
232
315
  readonly models: ReadonlySet<string>;
233
316
  readonly confirmed: boolean;
317
+ readonly provenance?: CodexModelEntitlementProvenance;
234
318
  }
235
319
 
236
320
  export interface CodexModelEntitlementSnapshot {
237
321
  readonly modelsByAccount: ReadonlyMap<string, ReadonlySet<string>>;
322
+ readonly clientVersionByAccount: ReadonlyMap<string, string>;
238
323
  readonly confirmedAccountIds: ReadonlySet<string>;
239
324
  readonly credentialIdentities: ReadonlyMap<string, string>;
240
325
  }
241
326
 
327
+ export type CodexModelEntitlementState = "granted" | "denied" | "unknown";
328
+
242
329
  export interface CodexModelEntitlementResolveOptions {
243
330
  readonly fetcher?: typeof fetch;
244
331
  /**
@@ -261,11 +348,43 @@ export interface CodexModelEntitlementResolveOptions {
261
348
  readonly credentialSnapshot?: typeof accountCredentialSnapshot;
262
349
  /** Accounts whose credentials must not be read while another lifecycle owns them. */
263
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;
264
366
  }
265
367
 
266
368
  const accountModelsCache = new Map<string, CachedAccountModels>();
267
369
  const accountModelsFlights = new Map<string, Promise<CachedAccountModels>>();
268
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
+
269
388
  /**
270
389
  * Cache key. The roster is version-specific, so the version has to be part of the identity —
271
390
  * with an account-only key, two versions in flight for one account race to overwrite each
@@ -387,6 +506,26 @@ function parseAccountModels(text: string): ReadonlySet<string> | null {
387
506
  }
388
507
  }
389
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
+
390
529
  async function fetchAccountModels(
391
530
  credential: CodexModelEntitlementCredentialSnapshot,
392
531
  fetcher: typeof fetch,
@@ -406,29 +545,53 @@ async function fetchAccountModels(
406
545
  redirect: "error",
407
546
  signal: controller.signal,
408
547
  });
548
+ if (!response.ok) {
549
+ return unconfirmedAccountModels(credential, clientVersion, now, {
550
+ kind: "http-error",
551
+ httpStatus: response.status,
552
+ });
553
+ }
409
554
  const body = await readBoundedResponseBody(response, {
410
555
  signal: controller.signal,
411
556
  maxBytes: MODEL_ROSTER_MAX_BYTES,
412
557
  fatalUtf8: true,
413
558
  });
414
- const models = response.ok && body.displaySafe && !body.truncated
415
- ? parseAccountModels(body.text)
416
- : null;
417
- return {
418
- credentialIdentity: credential.credentialIdentity,
419
- clientVersion,
420
- expiresAt: now + (models ? MODEL_ROSTER_TTL_MS : MODEL_ROSTER_FAILURE_TTL_MS),
421
- models: models ?? new Set(),
422
- confirmed: models !== null,
423
- };
424
- } catch {
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
+ }
566
+ // A roster is a confirmation only when it lists something usable. `models` is a Set, and an
567
+ // empty Set is truthy, so `models !== null` used to call `{"models":[]}` — and a response
568
+ // whose every row was hidden or api-disabled — a confirmed answer, and lock it in for the
569
+ // five-minute success TTL. Absence of evidence is not evidence of absence: an entitled
570
+ // account asked under too old a client version answers with no gated rows, and treating
571
+ // that as authoritative is exactly how 2.36.0 denied sol/terra/luna to accounts that own
572
+ // them (#3022). No usable rows means unconfirmed, on the 15s failure TTL, asked again.
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
+ ));
425
581
  return {
426
582
  credentialIdentity: credential.credentialIdentity,
427
583
  clientVersion,
428
- expiresAt: now + MODEL_ROSTER_FAILURE_TTL_MS,
429
- models: new Set(),
430
- confirmed: false,
584
+ expiresAt: now + (!hasUnknownGatedAbsence
585
+ ? MODEL_ROSTER_TTL_MS
586
+ : MODEL_ROSTER_FAILURE_TTL_MS),
587
+ models,
588
+ confirmed: true,
431
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);
432
595
  } finally {
433
596
  clearTimeout(timer);
434
597
  }
@@ -457,6 +620,7 @@ async function modelsForCredential(
457
620
  fetcher: typeof fetch,
458
621
  now: number,
459
622
  clientVersion: string,
623
+ credentialMutationEpoch?: number,
460
624
  ): Promise<CachedAccountModels> {
461
625
  const cached = accountModelsCache.get(cacheKeyFor(credential.accountId, clientVersion));
462
626
  if (
@@ -465,7 +629,8 @@ async function modelsForCredential(
465
629
  && cached.expiresAt > now
466
630
  ) return cached;
467
631
 
468
- const flightKey = `${credential.accountId}\u0000${credential.credentialIdentity}\u0000${clientVersion}`;
632
+ const flightKey = `${credential.accountId}\u0000${credential.credentialIdentity}\u0000${clientVersion}`
633
+ + (credentialMutationEpoch === undefined ? "" : `\u0000${credentialMutationEpoch}`);
469
634
  const existing = accountModelsFlights.get(flightKey);
470
635
  if (existing) return existing;
471
636
 
@@ -485,7 +650,11 @@ async function modelsForCredential(
485
650
  }
486
651
  const flight = fetchAccountModels(credential, fetcher, now, clientVersion)
487
652
  .then(result => {
488
- if (currentCredentialIdentity(credential.accountId) === credential.credentialIdentity) {
653
+ if (
654
+ currentCredentialIdentity(credential.accountId) === credential.credentialIdentity
655
+ && (credentialMutationEpoch === undefined
656
+ || codexCredentialMutationEpoch() === credentialMutationEpoch)
657
+ ) {
489
658
  boundedCacheSet(credential.accountId, result);
490
659
  }
491
660
  return result;
@@ -506,6 +675,232 @@ function candidateAccountIds(config: Pick<OcxConfig, "codexAccounts">): string[]
506
675
  ];
507
676
  }
508
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
+
509
904
  /**
510
905
  * Fetch the authenticated model roster for every locally usable Codex account.
511
906
  *
@@ -542,15 +937,54 @@ export async function resolveCodexModelEntitlements(
542
937
  .filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null);
543
938
  const results = await Promise.all(credentials.map(async credential => ({
544
939
  credential,
545
- result: await modelsForCredential(credential, fetcher, now, clientVersion),
940
+ result: await modelsForCredential(
941
+ credential,
942
+ fetcher,
943
+ now,
944
+ clientVersion,
945
+ options.credentialMutationEpoch,
946
+ ),
546
947
  })));
547
948
  return {
548
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
+ ))),
549
953
  confirmedAccountIds: new Set(results.flatMap(({ credential, result }) => result.confirmed ? [credential.accountId] : [])),
550
954
  credentialIdentities: new Map(results.map(({ credential }) => [credential.accountId, credential.credentialIdentity])),
551
955
  };
552
956
  }
553
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
+
554
988
  /** Fail-closed entitlement check for a Direct request's own forwarded ChatGPT credential. */
555
989
  export async function isDirectCallerEntitledToCodexModel(
556
990
  headers: Headers,
@@ -567,7 +1001,12 @@ export async function isDirectCallerEntitledToCodexModel(
567
1001
  options.now ?? Date.now(),
568
1002
  clientVersion,
569
1003
  );
570
- return result.confirmed && result.models.has(modelId);
1004
+ return codexModelEntitlementStateForRoster(
1005
+ result.models,
1006
+ result.confirmed,
1007
+ result.clientVersion,
1008
+ modelId,
1009
+ ) === "granted";
571
1010
  }
572
1011
 
573
1012
  export function entitledCodexAccountIdsForModel(
@@ -575,8 +1014,10 @@ export function entitledCodexAccountIdsForModel(
575
1014
  modelId: string | undefined,
576
1015
  ): ReadonlySet<string> | undefined {
577
1016
  if (!modelId || !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(modelId)) return undefined;
578
- return new Set([...snapshot.modelsByAccount].flatMap(([accountId, models]) => (
579
- 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
+ : []
580
1021
  )));
581
1022
  }
582
1023
 
@@ -585,10 +1026,9 @@ export function availableAccountGatedNativeModels(
585
1026
  eligibleAccountIds?: ReadonlySet<string>,
586
1027
  ): ReadonlySet<string> {
587
1028
  return new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(modelId => (
588
- [...snapshot.modelsByAccount].some(([accountId, models]) => (
1029
+ [...snapshot.modelsByAccount.keys()].some(accountId => (
589
1030
  (!eligibleAccountIds || eligibleAccountIds.has(accountId))
590
- && snapshot.confirmedAccountIds.has(accountId)
591
- && models.has(modelId)
1031
+ && codexModelEntitlementStateForAccount(snapshot, accountId, modelId) === "granted"
592
1032
  ))
593
1033
  )));
594
1034
  }
@@ -613,9 +1053,13 @@ export function cachedAvailableAccountGatedNativeModels(
613
1053
  (!eligibleAccountIds || eligibleAccountIds.has(accountIdOfCacheKey(accountId)))
614
1054
  && !accountIdOfCacheKey(accountId).startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)
615
1055
  && (version === null || entry.clientVersion === version)
616
- && entry.confirmed
617
1056
  && entry.expiresAt > now
618
- && entry.models.has(modelId)
1057
+ && codexModelEntitlementStateForRoster(
1058
+ entry.models,
1059
+ entry.confirmed,
1060
+ entry.clientVersion,
1061
+ modelId,
1062
+ ) === "granted"
619
1063
  ))
620
1064
  )));
621
1065
  }
@@ -639,9 +1083,23 @@ export function invalidateCodexModelEntitlementsForAccount(accountId: string | n
639
1083
  export function resetCodexModelEntitlementCacheForTests(): void {
640
1084
  accountModelsCache.clear();
641
1085
  accountModelsFlights.clear();
1086
+ negativeCredentialMemo.clear();
1087
+ entitlementEnsureFlights.clear();
642
1088
  runtimeVersionMemo = null;
643
1089
  }
644
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
+
645
1103
  /**
646
1104
  * Test-only seam for the memoized tier-2 read.
647
1105
  *
@@ -661,9 +1119,10 @@ export function seedCodexModelEntitlementsForTests(
661
1119
  models: readonly string[],
662
1120
  now = Date.now(),
663
1121
  clientVersion = "0.146.0",
1122
+ credentialIdentity = `test:${accountId}`,
664
1123
  ): void {
665
1124
  boundedCacheSet(accountId, {
666
- credentialIdentity: `test:${accountId}`,
1125
+ credentialIdentity,
667
1126
  clientVersion,
668
1127
  expiresAt: now + MODEL_ROSTER_TTL_MS,
669
1128
  models: new Set(models),