@bitkyc08/opencodex 2.38.0 → 2.39.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 +58 -6
- package/gui/dist/assets/index-D-lchsPw.js +112 -0
- package/gui/dist/assets/index-uvENYLin.css +1 -0
- package/gui/dist/index.html +2 -2
- 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/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/orcarouter.svg +175 -0
- package/gui/dist/provider-icons/parallel.svg +13 -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/zenmux.svg +1 -0
- package/package.json +1 -1
- package/src/adapters/cursor/live-models.ts +1 -0
- package/src/adapters/openai-responses.ts +72 -10
- package/src/bridge.ts +15 -15
- package/src/cli/dispatch.ts +54 -18
- package/src/cli/index.ts +420 -30
- package/src/cli/integrations.ts +24 -2
- package/src/cli/uninstall-plan.ts +86 -0
- package/src/codex/account-store.ts +121 -8
- package/src/codex/auth-api.ts +202 -33
- package/src/codex/catalog/provider-fetch.ts +76 -9
- 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/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/routing.ts +68 -16
- package/src/codex/subagent-model-fallback.ts +4 -1
- 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/generated/compatibility-version.json +84 -32
- package/src/integrations/journal.ts +12 -1
- package/src/integrations/writer.ts +82 -7
- package/src/lib/process-control.ts +30 -5
- package/src/lib/state-store-registrations.ts +8 -0
- package/src/oauth/index.ts +220 -13
- package/src/oauth/store.ts +220 -18
- package/src/responses/spill-store.ts +20 -0
- package/src/responses/state.ts +288 -2
- package/src/server/management/integration-routes.ts +36 -5
- package/src/server/management-api.ts +66 -12
- package/src/server/responses/encrypted-payload.ts +18 -1
- package/src/server/stop-teardown.ts +84 -0
- package/src/service.ts +155 -20
- package/src/update/index.ts +36 -4
- 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-C14iCj_Q.js +0 -112
- package/gui/dist/assets/index-D7PIz7_g.css +0 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an uninstall may take shared client config down (#3008).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `handleUninstall` because the rule is a decision, and a decision that
|
|
5
|
+
* only exists inside a long imperative command can only be tested by reading its source —
|
|
6
|
+
* which is how this shipped wrong twice: first trusting a boolean that collapsed "not
|
|
7
|
+
* installed" with "still running", then trusting a missing pid file as proof no proxy was
|
|
8
|
+
* serving.
|
|
9
|
+
*
|
|
10
|
+
* Native Codex and the Grok fence are SHARED. Restoring them while something may still be
|
|
11
|
+
* serving leaves the client and the proxy pointing at each other, so every step that could
|
|
12
|
+
* leave a live proxy behind has to be accounted for first.
|
|
13
|
+
*/
|
|
14
|
+
export type UninstallObservation = {
|
|
15
|
+
/** Detailed service-stop outcome, or null when the step threw. */
|
|
16
|
+
serviceStop: "absent" | "stopped" | "stopped-respawnable" | "failed" | "state-unknown" | null;
|
|
17
|
+
/**
|
|
18
|
+
* Did the proxy step PROVE nothing is serving?
|
|
19
|
+
*
|
|
20
|
+
* A `findLiveProxy` miss is not that proof: it collapses a timeout and a transport
|
|
21
|
+
* failure into the same null as a dead endpoint, so an unresponsive proxy read as absent.
|
|
22
|
+
*/
|
|
23
|
+
proxyProvenDown: boolean;
|
|
24
|
+
/** Service removal outcome, or null when the step threw. */
|
|
25
|
+
serviceRemoval: "absent" | "removed" | "failed" | null;
|
|
26
|
+
/**
|
|
27
|
+
* For a Task Scheduler backend: was the restart window verified AFTER removal?
|
|
28
|
+
*
|
|
29
|
+
* Deleting the registration does not prove an already-running `:loop` wrapper died —
|
|
30
|
+
* killing it is best-effort (#764). `ocx stop` polls across the window; uninstall has
|
|
31
|
+
* to do the same before it may take shared config down.
|
|
32
|
+
*/
|
|
33
|
+
respawnWindowVerified: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function sharedTeardownAuthorized(o: UninstallObservation): boolean {
|
|
37
|
+
if (o.serviceStop === null) return false;
|
|
38
|
+
// "absent" and a clean stop are the only service states that prove nothing is managing
|
|
39
|
+
// the proxy.
|
|
40
|
+
if (o.serviceStop === "failed" || o.serviceStop === "state-unknown") return false;
|
|
41
|
+
// Removing the registration is not the same as proving the running wrapper is gone.
|
|
42
|
+
if (o.serviceStop === "stopped-respawnable" && !o.respawnWindowVerified) return false;
|
|
43
|
+
if (o.serviceRemoval === null || o.serviceRemoval === "failed") return false;
|
|
44
|
+
return o.proxyProvenDown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** An endpoint an uninstall must account for before shared config comes down. */
|
|
48
|
+
export type ProbeEndpoint = { hostname: string; port: number };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Every DISTINCT endpoint this home could be serving on.
|
|
52
|
+
*
|
|
53
|
+
* A runtime record and the configured port can disagree — a stale record pointing at a
|
|
54
|
+
* closed port while the live proxy sits on the configured one. Probing only the runtime
|
|
55
|
+
* candidate then reports "dead" for a port nobody is using and authorizes the teardown
|
|
56
|
+
* (#3008). `findLiveProxy` already probes both; the proof has to cover both too.
|
|
57
|
+
*/
|
|
58
|
+
export function endpointsToProve(
|
|
59
|
+
runtime: { port?: number; hostname?: string } | null,
|
|
60
|
+
config: { port?: number; hostname?: string },
|
|
61
|
+
): ProbeEndpoint[] {
|
|
62
|
+
const out: ProbeEndpoint[] = [];
|
|
63
|
+
const push = (port: number | undefined, hostname: string | undefined) => {
|
|
64
|
+
if (!port || port <= 0 || port > 65535) return;
|
|
65
|
+
const endpoint = { hostname: hostname ?? "127.0.0.1", port };
|
|
66
|
+
if (out.some(e => e.port === endpoint.port && e.hostname === endpoint.hostname)) return;
|
|
67
|
+
out.push(endpoint);
|
|
68
|
+
};
|
|
69
|
+
push(runtime?.port, runtime?.hostname);
|
|
70
|
+
push(typeof config.port === "number" && config.port > 0 ? config.port : 10100, config.hostname);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Proof requires EVERY candidate to be definitively dead.
|
|
76
|
+
*
|
|
77
|
+
* "unknown" is not absence: a listener that accepts connections but withholds /healthz, or
|
|
78
|
+
* one that times out, is exactly the state where restoring shared config is most harmful.
|
|
79
|
+
*/
|
|
80
|
+
export function everyEndpointProvenDown(
|
|
81
|
+
endpoints: readonly ProbeEndpoint[],
|
|
82
|
+
probe: (e: ProbeEndpoint) => "live" | "dead" | "unknown",
|
|
83
|
+
): boolean {
|
|
84
|
+
if (endpoints.length === 0) return false;
|
|
85
|
+
return endpoints.every(e => probe(e) === "dead");
|
|
86
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
14
14
|
import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
|
|
15
15
|
import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
|
|
16
|
+
import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing";
|
|
16
17
|
|
|
17
18
|
type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
|
|
18
19
|
type CodexAccountStore = Record<string, CodexAccountCredentialRecord>;
|
|
@@ -407,7 +408,29 @@ type CodexRefreshResult = CodexTokenResult & {
|
|
|
407
408
|
* refresh of the one the caller was holding, not somebody else's replacement.
|
|
408
409
|
*/
|
|
409
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;
|
|
410
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 };
|
|
411
434
|
const MAX_CODEX_REFRESH_FLIGHTS = 32;
|
|
412
435
|
const CODEX_REFRESH_FLIGHT_STALE_MS = 120_000;
|
|
413
436
|
interface RefreshFlight {
|
|
@@ -587,22 +610,81 @@ function awaitOwnCancellation<T>(work: Promise<T>, callerSignal?: AbortSignal):
|
|
|
587
610
|
*/
|
|
588
611
|
export async function forceRefreshCodexPoolToken(
|
|
589
612
|
id: string,
|
|
590
|
-
options: {
|
|
591
|
-
|
|
592
|
-
|
|
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(
|
|
593
660
|
id,
|
|
594
661
|
{ rejectedGeneration: options.rejectedGeneration, rejectedAccessToken: options.rejectedAccessToken },
|
|
595
|
-
|
|
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,
|
|
596
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 }),
|
|
674
|
+
);
|
|
675
|
+
const result = await awaitOwnCancellation(completion, options.signal);
|
|
676
|
+
const provenance = classify(result);
|
|
677
|
+
const rotated = result.accessToken !== options.rejectedAccessToken;
|
|
597
678
|
return {
|
|
598
679
|
accessToken: result.accessToken,
|
|
599
680
|
chatgptAccountId: result.chatgptAccountId,
|
|
600
681
|
generation: result.generation,
|
|
601
|
-
rotated
|
|
682
|
+
rotated,
|
|
602
683
|
// Only a CAS this call performed itself proves the new credential descends from the
|
|
603
684
|
// rejected one; anything else is somebody else's replacement and must not be treated
|
|
604
685
|
// as this request's own lineage.
|
|
605
|
-
selfRefreshed:
|
|
686
|
+
selfRefreshed: provenance === "self-refresh",
|
|
687
|
+
provenance,
|
|
606
688
|
};
|
|
607
689
|
}
|
|
608
690
|
|
|
@@ -633,7 +715,15 @@ async function resolveCodexToken(
|
|
|
633
715
|
// correct again and refreshing would burn a rotation for nothing.
|
|
634
716
|
const forcedTargetsStoredCredential = forced !== undefined && !forcedFenceSuperseded(record.generation, forced);
|
|
635
717
|
if (cred.expiresAt > Date.now() + REFRESH_SKEW_MS && !forcedTargetsStoredCredential) {
|
|
636
|
-
|
|
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
|
+
};
|
|
637
727
|
}
|
|
638
728
|
|
|
639
729
|
const existing = refreshLocks.get(refreshGrantFingerprint);
|
|
@@ -658,6 +748,14 @@ async function resolveCodexToken(
|
|
|
658
748
|
accessToken: currentCred.accessToken,
|
|
659
749
|
chatgptAccountId: currentCred.chatgptAccountId,
|
|
660
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",
|
|
661
759
|
};
|
|
662
760
|
}
|
|
663
761
|
}
|
|
@@ -693,6 +791,9 @@ async function resolveCodexToken(
|
|
|
693
791
|
accessToken: currentCred.accessToken,
|
|
694
792
|
chatgptAccountId: currentCred.chatgptAccountId,
|
|
695
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",
|
|
696
797
|
};
|
|
697
798
|
}
|
|
698
799
|
if (
|
|
@@ -720,6 +821,7 @@ async function resolveCodexToken(
|
|
|
720
821
|
// This joiner performed its own CAS onto its own record, so the resulting
|
|
721
822
|
// generation is its own lineage even though another caller drove the fetch.
|
|
722
823
|
selfRefreshed: true,
|
|
824
|
+
provenance: "self-refresh",
|
|
723
825
|
resolvedGrantFingerprint: refreshGrantFingerprint,
|
|
724
826
|
};
|
|
725
827
|
}
|
|
@@ -747,7 +849,7 @@ async function resolveCodexToken(
|
|
|
747
849
|
* eviction) and the 30s ceiling remain, because those bound the flight itself.
|
|
748
850
|
*/
|
|
749
851
|
const abort = new AbortController();
|
|
750
|
-
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(
|
|
852
|
+
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(CODEX_REFRESH_FLIGHT_CEILING_MS)]);
|
|
751
853
|
let flight!: RefreshFlight;
|
|
752
854
|
const fetchPromise = withCodexRefreshFileLock(refreshGrantFingerprint, signal, async (): Promise<CodexRefreshResult> => {
|
|
753
855
|
const current = readCodexAccountRecord(id);
|
|
@@ -765,6 +867,9 @@ async function resolveCodexToken(
|
|
|
765
867
|
credential: lockedCred,
|
|
766
868
|
// This credential belongs to a DIFFERENT grant than the flight was opened
|
|
767
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",
|
|
768
873
|
...(lockedRefreshGrantFingerprint !== undefined
|
|
769
874
|
? { resolvedGrantFingerprint: lockedRefreshGrantFingerprint }
|
|
770
875
|
: {}),
|
|
@@ -783,6 +888,9 @@ async function resolveCodexToken(
|
|
|
783
888
|
chatgptAccountId: lockedCred.chatgptAccountId,
|
|
784
889
|
generation: startGeneration,
|
|
785
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",
|
|
786
894
|
resolvedGrantFingerprint: refreshGrantFingerprint,
|
|
787
895
|
};
|
|
788
896
|
}
|
|
@@ -803,6 +911,7 @@ async function resolveCodexToken(
|
|
|
803
911
|
credential: sameGrantFreshCredential,
|
|
804
912
|
resolvedGrantFingerprint: refreshGrantFingerprint,
|
|
805
913
|
selfRefreshed: true,
|
|
914
|
+
provenance: "self-refresh",
|
|
806
915
|
};
|
|
807
916
|
}
|
|
808
917
|
const res = await fetch(CHATGPT_TOKEN_URL, {
|
|
@@ -882,6 +991,7 @@ async function resolveCodexToken(
|
|
|
882
991
|
// token — tagging the new grant would make every legitimate joiner look foreign.
|
|
883
992
|
resolvedGrantFingerprint: refreshGrantFingerprint,
|
|
884
993
|
selfRefreshed: true,
|
|
994
|
+
provenance: "self-refresh",
|
|
885
995
|
};
|
|
886
996
|
});
|
|
887
997
|
/*
|
|
@@ -923,6 +1033,9 @@ async function resolveCodexToken(
|
|
|
923
1033
|
// produced this generation, and a forced caller needs that to know whether the new
|
|
924
1034
|
// credential descends from the one it was holding.
|
|
925
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 } : {}),
|
|
926
1039
|
...(result.resolvedGrantFingerprint !== undefined
|
|
927
1040
|
? { resolvedGrantFingerprint: result.resolvedGrantFingerprint }
|
|
928
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) {
|