@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.
- package/bin/ocx.mjs +69 -10
- package/gui/dist/assets/index-C14iCj_Q.js +112 -0
- 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/adapters/kiro-constants.ts +36 -2
- package/src/adapters/kiro.ts +13 -2
- package/src/cli/capabilities.ts +14 -0
- package/src/cli/claude.ts +12 -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 +489 -30
- 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 +84 -48
- 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-Cy7Z_pl0.css +0 -1
- 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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
clientVersion,
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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 +
|
|
429
|
-
|
|
430
|
-
|
|
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 (
|
|
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(
|
|
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
|
|
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(
|
|
579
|
-
snapshot
|
|
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(
|
|
1029
|
+
[...snapshot.modelsByAccount.keys()].some(accountId => (
|
|
589
1030
|
(!eligibleAccountIds || eligibleAccountIds.has(accountId))
|
|
590
|
-
&& snapshot
|
|
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
|
-
&&
|
|
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
|
|
1125
|
+
credentialIdentity,
|
|
667
1126
|
clientVersion,
|
|
668
1127
|
expiresAt: now + MODEL_ROSTER_TTL_MS,
|
|
669
1128
|
models: new Set(models),
|