@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.
- package/bin/ocx.mjs +69 -10
- package/gui/dist/assets/{index-CowztZdo.js → index-C14iCj_Q.js} +13 -13
- package/gui/dist/assets/index-D7PIz7_g.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/aside.svg +3 -0
- package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
- package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
- package/gui/dist/provider-icons/openclaw.svg +54 -0
- package/gui/dist/provider-icons/prime-agent.svg +21 -0
- package/gui/dist/provider-icons/zcode.svg +219 -0
- package/package.json +1 -1
- package/src/adapters/cursor/protobuf-request.ts +4 -1
- package/src/adapters/cursor/tool-definitions.ts +36 -4
- package/src/cli/capabilities.ts +14 -0
- package/src/cli/codex-cli-update.ts +96 -0
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/export-command.ts +18 -17
- package/src/cli/help.ts +2 -2
- package/src/cli/index.ts +3 -2
- package/src/cli/launcher-context.ts +53 -2
- package/src/cli/opencode.ts +126 -33
- package/src/cli/registry.ts +16 -10
- package/src/cli/system-command.ts +6 -1
- package/src/clients/config-export.ts +293 -28
- package/src/codex/account-store.ts +10 -4
- package/src/codex/autostart-health.ts +3 -3
- package/src/codex/catalog/provider-fetch.ts +20 -1
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/cli-install-provenance.ts +795 -0
- package/src/codex/convergence.ts +4 -3
- package/src/codex/credential-mutation-epoch.ts +11 -0
- package/src/codex/main-account.ts +2 -0
- package/src/codex/model-entitlements.ts +430 -27
- package/src/codex/native-profile-manager.ts +4 -0
- package/src/codex/reset-credit-operation-ledger.ts +1411 -0
- package/src/codex/reset-credit-recovery.ts +20 -2
- package/src/codex/shim.ts +204 -18
- package/src/codex/user-identity.ts +2 -1
- package/src/config/paths.ts +18 -3
- package/src/config.ts +23 -0
- package/src/generated/compatibility-version.json +81 -45
- package/src/integrations/registry.ts +112 -0
- package/src/integrations/state.ts +67 -5
- package/src/integrations/writer.ts +25 -9
- package/src/lib/bounded-subprocess.ts +36 -0
- package/src/lib/strict-semver.ts +47 -0
- package/src/lib/windows-elevation.ts +32 -1
- package/src/lib/windows-secret-acl.ts +47 -25
- package/src/lib/windows-service-mutation-lock.ts +133 -0
- package/src/lib/windows-user-principal.ts +15 -17
- package/src/responses/spill-store.ts +334 -29
- package/src/responses/state.ts +488 -7
- package/src/server/index.ts +4 -3
- package/src/server/lifecycle.ts +5 -1
- package/src/server/management/model-rows.ts +11 -2
- package/src/server/management/provider-routes.ts +4 -0
- package/src/server/management/system-restart.ts +5 -5
- package/src/server/management-api.ts +7 -2
- package/src/server/startup-action-control.ts +3 -2
- package/src/service.ts +594 -33
- package/src/sidecar/candidates.ts +1 -1
- package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/update/index.ts +3 -2
- package/src/update/job.ts +10 -11
- package/gui/dist/assets/index-jqE_VOKI.css +0 -1
package/src/codex/convergence.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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 +
|
|
485
|
-
|
|
486
|
-
|
|
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 (
|
|
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(
|
|
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
|
|
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(
|
|
635
|
-
snapshot
|
|
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(
|
|
1029
|
+
[...snapshot.modelsByAccount.keys()].some(accountId => (
|
|
645
1030
|
(!eligibleAccountIds || eligibleAccountIds.has(accountId))
|
|
646
|
-
&& snapshot
|
|
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
|
-
&&
|
|
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
|
|
1125
|
+
credentialIdentity,
|
|
723
1126
|
clientVersion,
|
|
724
1127
|
expiresAt: now + MODEL_ROSTER_TTL_MS,
|
|
725
1128
|
models: new Set(models),
|