@bitkyc08/opencodex 2.37.0 → 2.39.0-preview.20260901
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 +127 -16
- package/gui/dist/assets/index-6HZDkIpX.js +112 -0
- package/gui/dist/assets/index-uvENYLin.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/aside.svg +3 -0
- package/gui/dist/provider-icons/baseten.svg +13 -0
- package/gui/dist/provider-icons/bizrouter.svg +41 -0
- package/gui/dist/provider-icons/cerebras.svg +26 -0
- package/gui/dist/provider-icons/deepinfra.svg +75 -0
- package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
- package/gui/dist/provider-icons/digitalocean.svg +10 -0
- package/gui/dist/provider-icons/featherless.svg +4 -0
- package/gui/dist/provider-icons/gajae-code.svg +410 -0
- package/gui/dist/provider-icons/hermes-agent.svg +207 -0
- package/gui/dist/provider-icons/hyperbolic.svg +18 -0
- package/gui/dist/provider-icons/kilo.svg +13 -0
- package/gui/dist/provider-icons/litellm.svg +1 -0
- package/gui/dist/provider-icons/minimax.svg +1 -0
- package/gui/dist/provider-icons/nanogpt.svg +74 -0
- package/gui/dist/provider-icons/nebius.svg +1 -0
- package/gui/dist/provider-icons/neuralwatt.svg +27 -0
- package/gui/dist/provider-icons/nous.svg +149 -0
- package/gui/dist/provider-icons/novita.svg +32 -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/orcarouter.svg +175 -0
- package/gui/dist/provider-icons/parallel.svg +13 -0
- package/gui/dist/provider-icons/prime-agent.svg +21 -0
- package/gui/dist/provider-icons/sambanova.svg +276 -0
- package/gui/dist/provider-icons/scaleway.svg +11 -0
- package/gui/dist/provider-icons/siliconflow.svg +18 -0
- package/gui/dist/provider-icons/synthetic.svg +12 -0
- package/gui/dist/provider-icons/together.svg +18 -0
- package/gui/dist/provider-icons/umans.svg +30 -0
- package/gui/dist/provider-icons/venice.svg +165 -0
- package/gui/dist/provider-icons/vultr.svg +15 -0
- package/gui/dist/provider-icons/zai.svg +218 -0
- package/gui/dist/provider-icons/zcode.svg +219 -0
- package/gui/dist/provider-icons/zenmux.svg +1 -0
- package/package.json +1 -1
- package/src/adapters/cursor/live-models.ts +1 -0
- package/src/adapters/cursor/protobuf-request.ts +4 -1
- package/src/adapters/cursor/tool-definitions.ts +36 -4
- package/src/adapters/openai-responses.ts +72 -10
- package/src/bridge.ts +15 -15
- 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/dispatch.ts +54 -18
- package/src/cli/export-command.ts +18 -17
- package/src/cli/help.ts +2 -2
- package/src/cli/index.ts +423 -32
- package/src/cli/integrations.ts +24 -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/cli/uninstall-plan.ts +86 -0
- package/src/clients/config-export.ts +293 -28
- package/src/codex/account-store.ts +131 -12
- package/src/codex/auth-api.ts +202 -33
- package/src/codex/autostart-health.ts +3 -3
- package/src/codex/catalog/provider-fetch.ts +96 -10
- 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/history-job.ts +10 -0
- package/src/codex/history-manifest.ts +35 -2
- package/src/codex/history-provider.ts +196 -19
- package/src/codex/history-worker.ts +3 -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/quota-401-recovery.ts +190 -0
- package/src/codex/quota-recovery-timing.ts +28 -0
- package/src/codex/quota.ts +6 -0
- package/src/codex/reset-credit-operation-ledger.ts +1411 -0
- package/src/codex/reset-credit-recovery.ts +20 -2
- package/src/codex/routing.ts +68 -16
- package/src/codex/shim.ts +204 -18
- package/src/codex/subagent-model-fallback.ts +4 -1
- package/src/codex/user-identity.ts +2 -1
- package/src/config/paths.ts +18 -3
- package/src/config/pending-teardown-names.d.mts +8 -0
- package/src/config/pending-teardown-names.mjs +69 -0
- package/src/config/pending-teardown.ts +286 -0
- package/src/config.ts +23 -0
- package/src/generated/compatibility-version.json +155 -67
- package/src/integrations/journal.ts +12 -1
- package/src/integrations/registry.ts +112 -0
- package/src/integrations/state.ts +67 -5
- package/src/integrations/writer.ts +107 -16
- package/src/lib/bounded-subprocess.ts +36 -0
- package/src/lib/process-control.ts +30 -5
- package/src/lib/state-store-registrations.ts +8 -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/oauth/index.ts +220 -13
- package/src/oauth/store.ts +220 -18
- package/src/responses/spill-store.ts +354 -29
- package/src/responses/state.ts +775 -8
- package/src/server/index.ts +4 -3
- package/src/server/lifecycle.ts +5 -1
- package/src/server/management/integration-routes.ts +36 -5
- 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 +72 -13
- package/src/server/responses/encrypted-payload.ts +18 -1
- package/src/server/startup-action-control.ts +3 -2
- package/src/server/stop-teardown.ts +84 -0
- package/src/service.ts +749 -53
- 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 +39 -6
- package/src/update/job.ts +10 -11
- package/src/update/proxy-liveness-probe.d.mts +6 -0
- package/src/update/proxy-liveness-probe.mjs +84 -0
- package/src/update/stop-contract.d.mts +2 -0
- package/src/update/stop-contract.mjs +15 -0
- package/src/update/stop-decision.d.mts +10 -0
- package/src/update/stop-decision.mjs +34 -0
- package/src/vision/eligibility.ts +19 -1
- package/src/vision/index.ts +4 -18
- package/gui/dist/assets/index-CowztZdo.js +0 -112
- package/gui/dist/assets/index-jqE_VOKI.css +0 -1
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
} from "../config";
|
|
13
13
|
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
14
14
|
import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
|
|
15
|
+
import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
|
|
16
|
+
import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing";
|
|
15
17
|
|
|
16
18
|
type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
|
|
17
19
|
type CodexAccountStore = Record<string, CodexAccountCredentialRecord>;
|
|
@@ -111,6 +113,11 @@ function persist(store: CodexAccountStore): void {
|
|
|
111
113
|
atomicWriteFile(codexAccountsPath(), JSON.stringify(store, null, 2) + "\n");
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
function persistCredentialMutation(store: CodexAccountStore): void {
|
|
117
|
+
persist(store);
|
|
118
|
+
advanceCodexCredentialMutationEpoch();
|
|
119
|
+
}
|
|
120
|
+
|
|
114
121
|
function preservedValidationMetadata(record: CodexAccountCredentialRecord | undefined): Pick<
|
|
115
122
|
CodexAccountCredentialRecord,
|
|
116
123
|
"lastCodexValidatedAt" | "lastCodexValidationStatus" | "lastCodexValidationError"
|
|
@@ -142,7 +149,7 @@ export function saveCodexAccountCredential(id: string, cred: CodexAccountCredent
|
|
|
142
149
|
replacedAt: current ? Date.now() : undefined,
|
|
143
150
|
...preservedValidationMetadata(current),
|
|
144
151
|
};
|
|
145
|
-
|
|
152
|
+
persistCredentialMutation(store);
|
|
146
153
|
});
|
|
147
154
|
}
|
|
148
155
|
|
|
@@ -213,7 +220,7 @@ export function saveCodexAccountCredentialIfGeneration(
|
|
|
213
220
|
replacedAt: current.replacedAt,
|
|
214
221
|
...preservedValidationMetadata(current),
|
|
215
222
|
};
|
|
216
|
-
|
|
223
|
+
persistCredentialMutation(store);
|
|
217
224
|
return true;
|
|
218
225
|
});
|
|
219
226
|
}
|
|
@@ -304,7 +311,7 @@ export function commitRefreshedCodexCredentialWithAliases(
|
|
|
304
311
|
propagatedAliases.push({ id: aliasId, generation: aliasGeneration });
|
|
305
312
|
}
|
|
306
313
|
}
|
|
307
|
-
|
|
314
|
+
persistCredentialMutation(store);
|
|
308
315
|
return { committed: true, propagatedAliases };
|
|
309
316
|
});
|
|
310
317
|
}
|
|
@@ -315,7 +322,7 @@ export function tombstoneCodexAccount(id: string): number {
|
|
|
315
322
|
const current = store[id];
|
|
316
323
|
const generation = (current?.generation ?? 0) + 1;
|
|
317
324
|
store[id] = { generation, deletedAt: Date.now() };
|
|
318
|
-
|
|
325
|
+
persistCredentialMutation(store);
|
|
319
326
|
return generation;
|
|
320
327
|
});
|
|
321
328
|
}
|
|
@@ -401,7 +408,29 @@ type CodexRefreshResult = CodexTokenResult & {
|
|
|
401
408
|
* refresh of the one the caller was holding, not somebody else's replacement.
|
|
402
409
|
*/
|
|
403
410
|
selfRefreshed?: boolean;
|
|
411
|
+
/**
|
|
412
|
+
* Three-way form of {@link selfRefreshed}, kept alongside it so existing callers are
|
|
413
|
+
* unaffected (#3019). `selfRefreshed` is `provenance === "self-refresh"`.
|
|
414
|
+
*/
|
|
415
|
+
provenance?: CodexRefreshProvenance;
|
|
404
416
|
};
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* How THIS caller arrived at the credential it is returning (#3019).
|
|
420
|
+
*
|
|
421
|
+
* `selfRefreshed` is a boolean, and a boolean cannot carry three cases. Its `false` means
|
|
422
|
+
* both "somebody else replaced the credential" and "I joined an in-flight refresh of the
|
|
423
|
+
* same grant and adopted its result" — and a recovery budget has to treat those opposite
|
|
424
|
+
* ways. Joining is the same lineage getting its one refresh; replacement is a NEW lineage
|
|
425
|
+
* that has not had one yet, and charging it for somebody else's attempt would deny the
|
|
426
|
+
* fresh credential the recovery this exists to grant.
|
|
427
|
+
*/
|
|
428
|
+
export type CodexRefreshProvenance = "self-refresh" | "joined-lineage" | "external-replacement";
|
|
429
|
+
|
|
430
|
+
/** Terminal outcome of one forced refresh, as seen by the caller that requested it. */
|
|
431
|
+
export type ForcedRefreshOutcome =
|
|
432
|
+
| { kind: "resolved"; provenance: CodexRefreshProvenance; generation: number; rotated: boolean }
|
|
433
|
+
| { kind: "failed"; error: unknown };
|
|
405
434
|
const MAX_CODEX_REFRESH_FLIGHTS = 32;
|
|
406
435
|
const CODEX_REFRESH_FLIGHT_STALE_MS = 120_000;
|
|
407
436
|
interface RefreshFlight {
|
|
@@ -581,22 +610,81 @@ function awaitOwnCancellation<T>(work: Promise<T>, callerSignal?: AbortSignal):
|
|
|
581
610
|
*/
|
|
582
611
|
export async function forceRefreshCodexPoolToken(
|
|
583
612
|
id: string,
|
|
584
|
-
options: {
|
|
585
|
-
|
|
586
|
-
|
|
613
|
+
options: {
|
|
614
|
+
rejectedGeneration: number;
|
|
615
|
+
rejectedAccessToken: string;
|
|
616
|
+
signal?: AbortSignal;
|
|
617
|
+
/**
|
|
618
|
+
* Fires with THIS caller's classified outcome, regardless of `signal` (#3019).
|
|
619
|
+
*
|
|
620
|
+
* Cancellation rejects what the caller awaits; the shared flight keeps running and
|
|
621
|
+
* commits. A recovery budget claimed before the refresh therefore has no one left to
|
|
622
|
+
* settle it — the claim expires and the already-refreshed lineage gets a second
|
|
623
|
+
* refresh, which is the loop the budget exists to close. This callback is attached to
|
|
624
|
+
* the resolution itself, so it fires with no waiter present.
|
|
625
|
+
*
|
|
626
|
+
* It is called exactly once per call, for both success and failure, and its own
|
|
627
|
+
* failures are swallowed: settlement bookkeeping must never reject a credential the
|
|
628
|
+
* caller successfully obtained, nor disturb another waiter on the same flight.
|
|
629
|
+
*/
|
|
630
|
+
onSettled?: (outcome: ForcedRefreshOutcome) => void | Promise<void>;
|
|
631
|
+
},
|
|
632
|
+
): Promise<CodexTokenResult & { rotated: boolean; selfRefreshed: boolean; provenance: CodexRefreshProvenance }> {
|
|
633
|
+
const settle = (outcome: ForcedRefreshOutcome) => {
|
|
634
|
+
// Both halves matter: a synchronous throw and a rejected thenable are equally capable
|
|
635
|
+
// of turning settlement bookkeeping into an unhandled rejection that fails the process.
|
|
636
|
+
try { void Promise.resolve(options.onSettled?.(outcome)).catch(() => {}); } catch { /* ignore */ }
|
|
637
|
+
};
|
|
638
|
+
const classify = (result: CodexRefreshResult): CodexRefreshProvenance =>
|
|
639
|
+
// Default to the conservative reading. A path that did not classify itself is not
|
|
640
|
+
// assumed to be this caller's own lineage: charging a replacement for somebody else's
|
|
641
|
+
// attempt is the failure mode, so an unlabelled path leaves the returned lineage its
|
|
642
|
+
// own budget.
|
|
643
|
+
result.provenance ?? (result.selfRefreshed === true ? "self-refresh" : "external-replacement");
|
|
644
|
+
|
|
645
|
+
// The completion is NOT the caller's await.
|
|
646
|
+
//
|
|
647
|
+
// `options.signal` cancels what this function returns, while the shared flight keeps
|
|
648
|
+
// running and commits. Settling from the cancelled await therefore reported "failed" for
|
|
649
|
+
// a refresh that was about to succeed — releasing the budget, and letting the newly
|
|
650
|
+
// refreshed lineage claim again moments later. So the settlement rides an uncancelled
|
|
651
|
+
// resolution and the caller's cancellation is layered on top of it.
|
|
652
|
+
// A caller that is already gone must not start work. `resolveCodexToken` is called
|
|
653
|
+
// without the caller signal below, which bypasses its own pre-abort guard, so a
|
|
654
|
+
// pre-aborted request would otherwise rotate a credential nobody is waiting for.
|
|
655
|
+
if (options.signal?.aborted) {
|
|
656
|
+
settle({ kind: "failed", error: options.signal.reason });
|
|
657
|
+
throw options.signal.reason;
|
|
658
|
+
}
|
|
659
|
+
const completion = resolveCodexToken(
|
|
587
660
|
id,
|
|
588
661
|
{ rejectedGeneration: options.rejectedGeneration, rejectedAccessToken: options.rejectedAccessToken },
|
|
589
|
-
|
|
662
|
+
// Deliberately no caller signal: the flight is shared and this settlement speaks for
|
|
663
|
+
// the credential, not for whoever happened to be waiting.
|
|
664
|
+
undefined,
|
|
665
|
+
);
|
|
666
|
+
completion.then(
|
|
667
|
+
resolved => settle({
|
|
668
|
+
kind: "resolved",
|
|
669
|
+
provenance: classify(resolved),
|
|
670
|
+
generation: resolved.generation,
|
|
671
|
+
rotated: resolved.accessToken !== options.rejectedAccessToken,
|
|
672
|
+
}),
|
|
673
|
+
error => settle({ kind: "failed", error }),
|
|
590
674
|
);
|
|
675
|
+
const result = await awaitOwnCancellation(completion, options.signal);
|
|
676
|
+
const provenance = classify(result);
|
|
677
|
+
const rotated = result.accessToken !== options.rejectedAccessToken;
|
|
591
678
|
return {
|
|
592
679
|
accessToken: result.accessToken,
|
|
593
680
|
chatgptAccountId: result.chatgptAccountId,
|
|
594
681
|
generation: result.generation,
|
|
595
|
-
rotated
|
|
682
|
+
rotated,
|
|
596
683
|
// Only a CAS this call performed itself proves the new credential descends from the
|
|
597
684
|
// rejected one; anything else is somebody else's replacement and must not be treated
|
|
598
685
|
// as this request's own lineage.
|
|
599
|
-
selfRefreshed:
|
|
686
|
+
selfRefreshed: provenance === "self-refresh",
|
|
687
|
+
provenance,
|
|
600
688
|
};
|
|
601
689
|
}
|
|
602
690
|
|
|
@@ -627,7 +715,15 @@ async function resolveCodexToken(
|
|
|
627
715
|
// correct again and refreshing would burn a rotation for nothing.
|
|
628
716
|
const forcedTargetsStoredCredential = forced !== undefined && !forcedFenceSuperseded(record.generation, forced);
|
|
629
717
|
if (cred.expiresAt > Date.now() + REFRESH_SKEW_MS && !forcedTargetsStoredCredential) {
|
|
630
|
-
|
|
718
|
+
// The freshness shortcut: nothing was refreshed and nothing was adopted. A forced
|
|
719
|
+
// caller reaches it only once its fence was superseded, which is a replacement by
|
|
720
|
+
// definition; an ordinary caller does not read this field.
|
|
721
|
+
return {
|
|
722
|
+
accessToken: cred.accessToken,
|
|
723
|
+
chatgptAccountId: cred.chatgptAccountId,
|
|
724
|
+
generation: record.generation,
|
|
725
|
+
provenance: "external-replacement",
|
|
726
|
+
};
|
|
631
727
|
}
|
|
632
728
|
|
|
633
729
|
const existing = refreshLocks.get(refreshGrantFingerprint);
|
|
@@ -652,6 +748,14 @@ async function resolveCodexToken(
|
|
|
652
748
|
accessToken: currentCred.accessToken,
|
|
653
749
|
chatgptAccountId: currentCred.chatgptAccountId,
|
|
654
750
|
generation: current.generation,
|
|
751
|
+
// Adopted the stored result of a flight this caller joined: same grant, same
|
|
752
|
+
// lineage. Not a replacement — that distinction is the whole point of #3019.
|
|
753
|
+
//
|
|
754
|
+
// Only `external-replacement` is inherited. The flight's own success is tagged
|
|
755
|
+
// `self-refresh` for the caller that performed the CAS, and copying that here
|
|
756
|
+
// would tell a caller that did no CAS that the credential is its own lineage.
|
|
757
|
+
// Everything this branch adopts is, by definition, a join.
|
|
758
|
+
provenance: refreshed.provenance === "external-replacement" ? "external-replacement" : "joined-lineage",
|
|
655
759
|
};
|
|
656
760
|
}
|
|
657
761
|
}
|
|
@@ -687,6 +791,9 @@ async function resolveCodexToken(
|
|
|
687
791
|
accessToken: currentCred.accessToken,
|
|
688
792
|
chatgptAccountId: currentCred.chatgptAccountId,
|
|
689
793
|
generation: current.generation,
|
|
794
|
+
// `forcedFenceSuperseded` is exactly "somebody else moved this credential past
|
|
795
|
+
// the generation I was holding" — a new lineage, entitled to its own budget.
|
|
796
|
+
provenance: "external-replacement",
|
|
690
797
|
};
|
|
691
798
|
}
|
|
692
799
|
if (
|
|
@@ -714,6 +821,7 @@ async function resolveCodexToken(
|
|
|
714
821
|
// This joiner performed its own CAS onto its own record, so the resulting
|
|
715
822
|
// generation is its own lineage even though another caller drove the fetch.
|
|
716
823
|
selfRefreshed: true,
|
|
824
|
+
provenance: "self-refresh",
|
|
717
825
|
resolvedGrantFingerprint: refreshGrantFingerprint,
|
|
718
826
|
};
|
|
719
827
|
}
|
|
@@ -741,7 +849,7 @@ async function resolveCodexToken(
|
|
|
741
849
|
* eviction) and the 30s ceiling remain, because those bound the flight itself.
|
|
742
850
|
*/
|
|
743
851
|
const abort = new AbortController();
|
|
744
|
-
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(
|
|
852
|
+
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(CODEX_REFRESH_FLIGHT_CEILING_MS)]);
|
|
745
853
|
let flight!: RefreshFlight;
|
|
746
854
|
const fetchPromise = withCodexRefreshFileLock(refreshGrantFingerprint, signal, async (): Promise<CodexRefreshResult> => {
|
|
747
855
|
const current = readCodexAccountRecord(id);
|
|
@@ -759,6 +867,9 @@ async function resolveCodexToken(
|
|
|
759
867
|
credential: lockedCred,
|
|
760
868
|
// This credential belongs to a DIFFERENT grant than the flight was opened
|
|
761
869
|
// for. Tagging it keeps a joiner from adopting it as its own.
|
|
870
|
+
// It is also somebody else's credential by definition, so a joiner that ends up
|
|
871
|
+
// adopting it must not charge it to this lineage's budget (#3019).
|
|
872
|
+
provenance: "external-replacement",
|
|
762
873
|
...(lockedRefreshGrantFingerprint !== undefined
|
|
763
874
|
? { resolvedGrantFingerprint: lockedRefreshGrantFingerprint }
|
|
764
875
|
: {}),
|
|
@@ -777,6 +888,9 @@ async function resolveCodexToken(
|
|
|
777
888
|
chatgptAccountId: lockedCred.chatgptAccountId,
|
|
778
889
|
generation: startGeneration,
|
|
779
890
|
credential: lockedCred,
|
|
891
|
+
// The stored credential is fresh and no forced fence still targets it: whoever
|
|
892
|
+
// wrote it, it was not this call. A joiner adopting it inherits that provenance.
|
|
893
|
+
provenance: "external-replacement",
|
|
780
894
|
resolvedGrantFingerprint: refreshGrantFingerprint,
|
|
781
895
|
};
|
|
782
896
|
}
|
|
@@ -797,6 +911,7 @@ async function resolveCodexToken(
|
|
|
797
911
|
credential: sameGrantFreshCredential,
|
|
798
912
|
resolvedGrantFingerprint: refreshGrantFingerprint,
|
|
799
913
|
selfRefreshed: true,
|
|
914
|
+
provenance: "self-refresh",
|
|
800
915
|
};
|
|
801
916
|
}
|
|
802
917
|
const res = await fetch(CHATGPT_TOKEN_URL, {
|
|
@@ -876,6 +991,7 @@ async function resolveCodexToken(
|
|
|
876
991
|
// token — tagging the new grant would make every legitimate joiner look foreign.
|
|
877
992
|
resolvedGrantFingerprint: refreshGrantFingerprint,
|
|
878
993
|
selfRefreshed: true,
|
|
994
|
+
provenance: "self-refresh",
|
|
879
995
|
};
|
|
880
996
|
});
|
|
881
997
|
/*
|
|
@@ -917,6 +1033,9 @@ async function resolveCodexToken(
|
|
|
917
1033
|
// produced this generation, and a forced caller needs that to know whether the new
|
|
918
1034
|
// credential descends from the one it was holding.
|
|
919
1035
|
...(result.selfRefreshed !== undefined ? { selfRefreshed: result.selfRefreshed } : {}),
|
|
1036
|
+
// Provenance rides out with the rest: a joiner that adopts this result needs the
|
|
1037
|
+
// flight's own classification, not a guess made at the adoption site (#3019).
|
|
1038
|
+
...(result.provenance !== undefined ? { provenance: result.provenance } : {}),
|
|
920
1039
|
...(result.resolvedGrantFingerprint !== undefined
|
|
921
1040
|
? { resolvedGrantFingerprint: result.resolvedGrantFingerprint }
|
|
922
1041
|
: {}),
|
package/src/codex/auth-api.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getCodexAccountCredential,
|
|
11
11
|
getValidCodexToken,
|
|
12
12
|
isCodexAccountGenerationLive,
|
|
13
|
+
forceRefreshCodexPoolToken,
|
|
13
14
|
markCodexAccountValidated,
|
|
14
15
|
readCodexAccountRecord,
|
|
15
16
|
saveCodexAccountCredential,
|
|
@@ -124,6 +125,14 @@ import { tryAcquireNativeMainProfileClaim } from "./native-main-admission";
|
|
|
124
125
|
import { withNativeMainSharedClaim } from "./native-main-claim";
|
|
125
126
|
import { resolveNativeProfileContext } from "./native-profile-store";
|
|
126
127
|
import { NativeProfileError } from "./native-profile-types";
|
|
128
|
+
import { WHAM_REQUEST_TIMEOUT_MS } from "./quota-recovery-timing";
|
|
129
|
+
import {
|
|
130
|
+
claimQuotaRecovery,
|
|
131
|
+
quotaRecoveryTerminalFor,
|
|
132
|
+
releaseQuotaRecovery,
|
|
133
|
+
settleQuotaRecovery,
|
|
134
|
+
settleQuotaRecoveryTerminal,
|
|
135
|
+
} from "./quota-401-recovery";
|
|
127
136
|
|
|
128
137
|
function isNativeMainClaimUnavailable(error: unknown): error is NativeProfileError {
|
|
129
138
|
return error instanceof NativeProfileError
|
|
@@ -766,7 +775,7 @@ async function fetchMainAccountInfoWhileOwned(
|
|
|
766
775
|
try {
|
|
767
776
|
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
768
777
|
headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
|
|
769
|
-
signal: AbortSignal.timeout(
|
|
778
|
+
signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS),
|
|
770
779
|
});
|
|
771
780
|
if (!resp.ok) {
|
|
772
781
|
const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
|
|
@@ -959,6 +968,180 @@ function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: Fresh
|
|
|
959
968
|
}
|
|
960
969
|
}
|
|
961
970
|
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* One refresh-and-replay for a pool account whose WHAM request came back 401 (#3019).
|
|
975
|
+
*
|
|
976
|
+
* The account list used to convert any 401 straight into `needsReauth`, and a bare 401 is
|
|
977
|
+
* exactly what a stale-but-refreshable bearer produces after a plan change — so a healthy
|
|
978
|
+
* credential was thrown away and the operator was told to log in again.
|
|
979
|
+
*
|
|
980
|
+
* Bounded by the recovery store: one attempt per credential lineage. An unbounded retry
|
|
981
|
+
* against an upstream 401 is a self-inflicted credential-stuffing loop, which is why the
|
|
982
|
+
* claim is taken BEFORE the refresh and settled by the flight rather than by this caller.
|
|
983
|
+
*/
|
|
984
|
+
async function recoverPoolQuotaFrom401(ctx: {
|
|
985
|
+
accountId: string;
|
|
986
|
+
existing: StoredAccountQuota | null;
|
|
987
|
+
configuredPlan: string | undefined;
|
|
988
|
+
rejectedAccessToken: string;
|
|
989
|
+
rejectedGeneration: number;
|
|
990
|
+
resp: Response;
|
|
991
|
+
onCredentialGeneration?: (generation: number) => void;
|
|
992
|
+
}): Promise<PoolQuotaResult> {
|
|
993
|
+
const { accountId, existing, configuredPlan, rejectedAccessToken, rejectedGeneration, resp } = ctx;
|
|
994
|
+
|
|
995
|
+
// Structured terminal evidence short-circuits everything: the same allowlist and bounded
|
|
996
|
+
// parser the main account uses, because it is the same endpoint answering.
|
|
997
|
+
if (await isTerminalPoolAuthResponse(resp)) {
|
|
998
|
+
// Durable, not just this response: the account list re-polls, and without a recorded
|
|
999
|
+
// mark the next bare 401 finds nothing terminal and reports the account healthy.
|
|
1000
|
+
//
|
|
1001
|
+
// Scoped to the generation this evidence is ABOUT. An account-wide mark would outlive
|
|
1002
|
+
// the credential it condemned, so a late terminal response arriving after the operator
|
|
1003
|
+
// re-authenticated would quarantine the replacement.
|
|
1004
|
+
markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
|
|
1005
|
+
return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const claim = claimQuotaRecovery(accountId, rejectedGeneration);
|
|
1009
|
+
if (!claim.granted) {
|
|
1010
|
+
// A lineage fenced by a TERMINAL refresh failure stays terminal. Without this, the
|
|
1011
|
+
// budget being used would make the next bare 401 report a dead credential as healthy.
|
|
1012
|
+
if (quotaRecoveryTerminalFor(accountId, rejectedGeneration)) {
|
|
1013
|
+
return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
|
|
1014
|
+
}
|
|
1015
|
+
// Otherwise: this lineage spent its attempt, another caller is mid-refresh, or a
|
|
1016
|
+
// transient failure is backing off. Report transient and let the next poll try —
|
|
1017
|
+
// quarantining here would undo the whole point of the budget.
|
|
1018
|
+
return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
let refreshed: Awaited<ReturnType<typeof forceRefreshCodexPoolToken>>;
|
|
1022
|
+
try {
|
|
1023
|
+
refreshed = await forceRefreshCodexPoolToken(accountId, {
|
|
1024
|
+
rejectedGeneration,
|
|
1025
|
+
rejectedAccessToken,
|
|
1026
|
+
// Settlement rides the flight, not this await: a cancelled caller would otherwise
|
|
1027
|
+
// leave the claim to expire while the shared refresh commits, and the already
|
|
1028
|
+
// refreshed lineage would get a second attempt.
|
|
1029
|
+
onSettled: outcome => {
|
|
1030
|
+
if (outcome.kind === "resolved") {
|
|
1031
|
+
settleQuotaRecovery(accountId, claim.claimId, outcome);
|
|
1032
|
+
} else if (outcome.error instanceof TokenRefreshError && isTerminalRefreshError(outcome.error)) {
|
|
1033
|
+
// A revoked or expired grant does not become valid on the next poll. Releasing it
|
|
1034
|
+
// into backoff would let the following bare 401 find a non-terminal record and
|
|
1035
|
+
// report a dead credential as healthy.
|
|
1036
|
+
settleQuotaRecoveryTerminal(accountId, claim.claimId);
|
|
1037
|
+
} else {
|
|
1038
|
+
releaseQuotaRecovery(accountId, claim.claimId, QUOTA_RECOVERY_BACKOFF_MS);
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
1041
|
+
});
|
|
1042
|
+
} catch (e) {
|
|
1043
|
+
// A refresh that failed terminally is the one case where the credential really is gone.
|
|
1044
|
+
// Everything else is unknown, and unknown is not proof.
|
|
1045
|
+
if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) {
|
|
1046
|
+
markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
|
|
1047
|
+
return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
|
|
1048
|
+
}
|
|
1049
|
+
return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// A byte-identical access token means replaying earns the same 401. Report transient
|
|
1053
|
+
// rather than burning the replay; the fence already moved to the returned generation.
|
|
1054
|
+
if (!refreshed.rotated) {
|
|
1055
|
+
return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// The flight may have moved the generation while this request was in the air. Tell the
|
|
1059
|
+
// coalescing layer where the credential actually is, or a late caller joins on a stale
|
|
1060
|
+
// generation and opens a redundant flight.
|
|
1061
|
+
ctx.onCredentialGeneration?.(refreshed.generation);
|
|
1062
|
+
|
|
1063
|
+
const writerGeneration = captureConfigGeneration();
|
|
1064
|
+
const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
1065
|
+
headers: {
|
|
1066
|
+
Authorization: `Bearer ${refreshed.accessToken}`,
|
|
1067
|
+
"ChatGPT-Account-Id": refreshed.chatgptAccountId,
|
|
1068
|
+
},
|
|
1069
|
+
signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS),
|
|
1070
|
+
});
|
|
1071
|
+
if (!replay.ok) {
|
|
1072
|
+
if (replay.status === 401 && await isTerminalPoolAuthResponse(replay)) {
|
|
1073
|
+
// The refresh already settled this claim non-terminally, so the record alone would
|
|
1074
|
+
// let the next poll call a dead credential healthy. The evidence is about the
|
|
1075
|
+
// REFRESHED credential, which is what the replay used.
|
|
1076
|
+
markAccountNeedsReauth(accountId, writerGeneration, refreshed.generation);
|
|
1077
|
+
return { quota: existing ?? null, needsReauth: true, credentialGeneration: refreshed.generation };
|
|
1078
|
+
}
|
|
1079
|
+
return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
|
|
1080
|
+
}
|
|
1081
|
+
return await commitPoolQuotaResponse(replay, {
|
|
1082
|
+
accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration,
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Backoff after a refresh failure that proved nothing about the credential. */
|
|
1087
|
+
const QUOTA_RECOVERY_BACKOFF_MS = 60_000;
|
|
1088
|
+
|
|
1089
|
+
/** Same allowlist and bounded parser as the main account: it is the same endpoint. */
|
|
1090
|
+
async function isTerminalPoolAuthResponse(resp: Response): Promise<boolean> {
|
|
1091
|
+
// Consume the original rather than a clone. `resp.clone()` tees the body, and the
|
|
1092
|
+
// bounded parser's timeout cancels only its own reader — the unread original branch
|
|
1093
|
+
// keeps buffering. Nothing needs this response afterwards, so there is nothing to tee.
|
|
1094
|
+
const code = await readMainAuthErrorCode(resp);
|
|
1095
|
+
return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** A revoked or expired grant is terminal; an unknown or transport failure is not. */
|
|
1099
|
+
function isTerminalRefreshError(error: TokenRefreshError): boolean {
|
|
1100
|
+
// Read the discriminator, not the message. TokenRefreshError carries `reason`, and
|
|
1101
|
+
// matching on human text would let a durable quarantine decision change the next time
|
|
1102
|
+
// somebody rewords an error string.
|
|
1103
|
+
return error.reason === "revoked" || error.reason === "expired";
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Parse and store a successful WHAM response. Shared by the first attempt and the replay. */
|
|
1107
|
+
async function commitPoolQuotaResponse(
|
|
1108
|
+
resp: Response,
|
|
1109
|
+
ctx: {
|
|
1110
|
+
accountId: string;
|
|
1111
|
+
existing: StoredAccountQuota | null;
|
|
1112
|
+
configuredPlan: string | undefined;
|
|
1113
|
+
generation: number;
|
|
1114
|
+
writerGeneration: number;
|
|
1115
|
+
},
|
|
1116
|
+
): Promise<PoolQuotaResult> {
|
|
1117
|
+
const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx;
|
|
1118
|
+
const data = (await resp.json()) as WhamUsageResponse;
|
|
1119
|
+
const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
|
|
1120
|
+
const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
|
|
1121
|
+
const freshResetCredits = quota?.resetCredits;
|
|
1122
|
+
if (!quota) {
|
|
1123
|
+
return {
|
|
1124
|
+
quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
|
|
1125
|
+
needsReauth: false,
|
|
1126
|
+
credentialGeneration: generation,
|
|
1127
|
+
...(freshPlan !== undefined ? { freshPlan, freshCredentialGeneration: generation } : {}),
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
if (!isCodexAccountGenerationLive(accountId, generation)) {
|
|
1131
|
+
return { quota: null, needsReauth: false, credentialGeneration: generation };
|
|
1132
|
+
}
|
|
1133
|
+
setAccountQuotaFromParsed(accountId, quota, writerGeneration);
|
|
1134
|
+
return {
|
|
1135
|
+
quota: getAccountQuota(accountId),
|
|
1136
|
+
needsReauth: false,
|
|
1137
|
+
credentialGeneration: generation,
|
|
1138
|
+
freshQuota: quota,
|
|
1139
|
+
freshCredentialGeneration: generation,
|
|
1140
|
+
...(freshPlan !== undefined ? { freshPlan } : {}),
|
|
1141
|
+
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
|
|
962
1145
|
async function fetchFreshPoolAccountQuota(
|
|
963
1146
|
accountId: string,
|
|
964
1147
|
existing: StoredAccountQuota | null,
|
|
@@ -976,39 +1159,25 @@ async function fetchFreshPoolAccountQuota(
|
|
|
976
1159
|
signal: AbortSignal.timeout(8000),
|
|
977
1160
|
});
|
|
978
1161
|
if (!resp.ok) {
|
|
979
|
-
|
|
980
|
-
quota: existing ?? null,
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
...(freshPlan !== undefined
|
|
995
|
-
? { freshPlan, freshCredentialGeneration: generation }
|
|
996
|
-
: {}),
|
|
997
|
-
};
|
|
998
|
-
}
|
|
999
|
-
if (!isCodexAccountGenerationLive(accountId, generation)) {
|
|
1000
|
-
return { quota: null, needsReauth: false, credentialGeneration: generation };
|
|
1162
|
+
if (resp.status !== 401) {
|
|
1163
|
+
return { quota: existing ?? null, needsReauth: false, credentialGeneration: generation };
|
|
1164
|
+
}
|
|
1165
|
+
// A bare 401 is what a stale-but-refreshable bearer produces after a plan change, so
|
|
1166
|
+
// quarantining on it tells the operator to re-authenticate an account that was fine
|
|
1167
|
+
// (#3019). Refresh once, replay once, and only then decide.
|
|
1168
|
+
return await recoverPoolQuotaFrom401({
|
|
1169
|
+
accountId,
|
|
1170
|
+
existing,
|
|
1171
|
+
configuredPlan,
|
|
1172
|
+
rejectedAccessToken: accessToken,
|
|
1173
|
+
rejectedGeneration: generation,
|
|
1174
|
+
resp,
|
|
1175
|
+
onCredentialGeneration,
|
|
1176
|
+
});
|
|
1001
1177
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
needsReauth: false,
|
|
1006
|
-
credentialGeneration: generation,
|
|
1007
|
-
freshQuota: quota,
|
|
1008
|
-
freshCredentialGeneration: generation,
|
|
1009
|
-
...(freshPlan !== undefined ? { freshPlan } : {}),
|
|
1010
|
-
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
1011
|
-
};
|
|
1178
|
+
return await commitPoolQuotaResponse(resp, {
|
|
1179
|
+
accountId, existing, configuredPlan, generation, writerGeneration,
|
|
1180
|
+
});
|
|
1012
1181
|
} catch (e) {
|
|
1013
1182
|
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
|
|
1014
1183
|
|| e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
|
|
@@ -90,9 +90,9 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth
|
|
|
90
90
|
: inputs.routingKind === "custom-local" || inputs.routingKind === "unknown"
|
|
91
91
|
? COMMANDS.restoreNative
|
|
92
92
|
: inputs.serviceSupported
|
|
93
|
-
// An already-registered service is refreshed in place: `repair`
|
|
94
|
-
//
|
|
95
|
-
// cannot switch a WinSW install to Task Scheduler the way `install` would. Only a
|
|
93
|
+
// An already-registered service is refreshed in place: `repair` reuses healthy Windows
|
|
94
|
+
// scheduler definitions, while stale ones may be re-registered and require elevation.
|
|
95
|
+
// It still cannot switch a WinSW install to Task Scheduler the way `install` would. Only a
|
|
96
96
|
// genuinely absent (or conflicting, which needs uninstall-then-install) service
|
|
97
97
|
// gets the registering command.
|
|
98
98
|
? (inputs.serviceInstalled && !inputs.serviceConflict ? COMMANDS.repairService : COMMANDS.installService)
|