Repository navigation
Expand file tree
/
Copy pathaccount-store.ts
More file actions
1539 lines (1450 loc) · 73.3 KB
/
Copy pathaccount-store.ts
File metadata and controls
1539 lines (1450 loc) · 73.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { publishCredentialSync } from "../local-credentials/generation";
import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure, type ChatgptRefreshDiagnosticCode } from "./chatgpt-refresh-failure";
import { readBoundedResponseBody } from "../lib/bounded-body";
export { classifyChatgptRefreshFailure } from "./chatgpt-refresh-failure";
import { readOrcaAuthSource } from "./orca-auth-source";
import { createHash } from "node:crypto";
import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
ConfigMutationLockError,
getConfigDir,
atomicWriteFile,
backupInvalidConfig,
hardenConfigDir,
hardenExistingSecret,
loadConfig,
withConfigMutationLockSync,
} from "../config";
import { notifyCodexStoreLoaded } from "../lib/decision-quota-sink";
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
import type { CodexAccount, CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
import { isValidCodexAccountId } from "./account-id";
import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "./account-label";
import type { PoolQuotaWriter } from "./quota-types";
import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing";
import {
CodexPoolRefreshCooldownError,
clearCodexPoolRefreshFailure,
codexPoolRefreshFence,
isCodexPoolRefreshCooling,
noteCodexPoolRefreshFailure,
} from "./pool-refresh-backoff";
type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
type CodexAccountStore = Record<string, CodexAccountCredentialRecord>;
type RawCodexAccountStore = Record<string, CodexAccountCredentials | CodexAccountCredentialRecord>;
const REFRESH_SKEW_MS = 60_000;
const REFRESH_LOCK_STALE_MS = 60_000;
const REFRESH_LOCK_WAIT_MS = REFRESH_LOCK_STALE_MS + 5_000;
const REFRESH_LOCK_POLL_MS = 50;
function codexAccountsPath(): string {
return join(getConfigDir(), "codex-accounts.json");
}
export function loadCodexAccountStore(): LegacyCodexAccountStore {
const records = loadCodexAccountRecordStore();
const credentials: LegacyCodexAccountStore = {};
for (const [id, record] of Object.entries(records)) {
if (record.deletedAt == null && record.credential) credentials[id] = record.credential;
}
return credentials;
}
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
export function inspectLocalCodexCredentialSlots(raw: Record<string, unknown> | null): { count: number; sourcePaths: string[] } {
if (raw === null) return { count: 0, sourcePaths: [] };
let count = 0;
const sourcePaths: string[] = [];
for (const value of Object.values(raw)) {
let credential: CodexAccountCredentials | undefined;
if (isCredentialRecord(value)) {
credential = value.credential;
if (!credential && value.deletedAt === undefined) throw new Error("Unrecognized Codex credential record");
} else if (isCredential(value)) credential = value;
else throw new Error("Invalid Codex credential record");
if (credential) {
count += 1;
if (credential.sourceAuthPath) sourcePaths.push(credential.sourceAuthPath);
}
}
return { count, sourcePaths };
}
function isCredential(value: unknown): value is CodexAccountCredentials {
const hasNoSource = isObject(value)
&& value.sourceAuthPath === undefined
&& value.sourceSubject === undefined;
const hasCompleteSource = isObject(value)
&& typeof value.sourceAuthPath === "string"
&& value.sourceAuthPath.length > 0
&& typeof value.sourceSubject === "string"
&& value.sourceSubject.length > 0;
return isObject(value)
&& typeof value.accessToken === "string"
&& typeof value.refreshToken === "string"
&& typeof value.expiresAt === "number"
&& typeof value.chatgptAccountId === "string"
&& value.chatgptAccountId.trim().length > 0
&& (hasNoSource || hasCompleteSource);
}
function isCredentialRecord(value: unknown): value is CodexAccountCredentialRecord {
return isObject(value)
&& typeof value.generation === "number"
&& (value.credential === undefined || isCredential(value.credential))
&& (value.refreshGrantFingerprint === undefined || typeof value.refreshGrantFingerprint === "string")
&& (value.deletedAt === undefined || typeof value.deletedAt === "number")
&& (value.replacedAt === undefined || typeof value.replacedAt === "number")
&& (value.lastCodexValidatedAt === undefined || typeof value.lastCodexValidatedAt === "number")
&& (value.lastCodexValidationStatus === undefined || value.lastCodexValidationStatus === "ok" || value.lastCodexValidationStatus === "failed")
&& (value.lastCodexValidationError === undefined || typeof value.lastCodexValidationError === "string")
&& (value.codexValidationPending === undefined || typeof value.codexValidationPending === "boolean")
&& (value.lastCodexValidationTerminal === undefined || typeof value.lastCodexValidationTerminal === "boolean");
}
export function refreshGrantFingerprintForToken(refreshToken: string): string {
return createHash("sha256").update(`codex-refresh-grant:${refreshToken}`).digest("hex");
}
function recordGrantFingerprint(record: CodexAccountCredentialRecord): string | undefined {
if (record.credential?.sourceAuthPath) return undefined;
return record.refreshGrantFingerprint ?? (
record.credential ? refreshGrantFingerprintForToken(record.credential.refreshToken) : undefined
);
}
function normalizeRecord(value: CodexAccountCredentials | CodexAccountCredentialRecord | undefined): CodexAccountCredentialRecord | undefined {
if (!value) return undefined;
if (isCredentialRecord(value)) {
const refreshGrantFingerprint = recordGrantFingerprint(value);
return refreshGrantFingerprint ? { ...value, refreshGrantFingerprint } : value;
}
if (isCredential(value)) {
return {
credential: value,
generation: 0,
refreshGrantFingerprint: refreshGrantFingerprintForToken(value.refreshToken),
};
}
return undefined;
}
/**
* Observe-only read for the JEV quota activation owner: the same normalization as the loader,
* without hardening, invalid-file backup or slot notification. Absent or invalid stores read as empty.
*/
export function peekCodexAccountRecordStore(): Record<string, CodexAccountCredentialRecord> {
const path = codexAccountsPath();
if (!existsSync(path)) return {};
const normalized: Record<string, CodexAccountCredentialRecord> = {};
try {
const raw = JSON.parse(readFileSync(path, "utf-8")) as RawCodexAccountStore;
for (const [id, value] of Object.entries(raw)) {
const record = normalizeRecord(value);
if (record) normalized[id] = record;
}
} catch {
return {};
}
return normalized;
}
/** Read the Codex account record store from disk with hardened permissions, and hand it to the opt-in decision-quota slot (a no-op unless JEV quota signals are active). */
function loadCodexAccountRecordStore(): CodexAccountStore {
const result = loadCodexAccountRecordStoreResult();
return result.status === "ok" ? result.records : {};
}
type CodexAccountRecordStoreResult = { status: "ok"; records: CodexAccountStore } | { status: "failed" };
/** Retain read/parse failure separately for consumers that must preserve existing evidence. */
function loadCodexAccountRecordStoreResult(): CodexAccountRecordStoreResult {
const path = codexAccountsPath();
hardenConfigDir();
hardenExistingSecret(path);
const normalized: CodexAccountStore = {};
try {
const raw = JSON.parse(readFileSync(path, "utf-8")) as RawCodexAccountStore;
if (!isObject(raw)) throw new Error("invalid Codex account store shape");
for (const [id, value] of Object.entries(raw)) {
const record = normalizeRecord(value);
if (record) normalized[id] = record;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
notifyCodexStoreLoaded({});
return { status: "ok", records: {} };
}
backupInvalidConfig(path);
notifyCodexStoreLoaded({});
return { status: "failed" };
}
// Outside the parse try: roster publication is advisory and must never route a loaded store into the invalid-config backup path.
notifyCodexStoreLoaded(normalized);
return { status: "ok", records: normalized };
}
/** Atomically write the Codex account store, then hand it to the opt-in decision-quota slot so active advisory evidence tracks the persisted credentials. */
function persist(store: CodexAccountStore): void {
const dir = getConfigDir();
assertNotRealHomeUnderTest(dir);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
publishCredentialSync(() => atomicWriteFile(codexAccountsPath(), JSON.stringify(store, null, 2) + "\n"));
notifyCodexStoreLoaded(store);
}
function persistCredentialMutation(store: CodexAccountStore): void {
persist(store);
advanceCodexCredentialMutationEpoch();
}
/**
* The durable `p<hex6>` label the dashboard and usage log show for this pool account, so a refresh
* failure line can be matched to the account row (#7592). The id hash is only the label of a
* legacy row that never received one. A config read failure must not replace the refresh error
* being reported, so it degrades to that same hash.
*/
function poolAccountLogLabel(id: string): string {
let account: CodexAccount | undefined;
try { account = loadConfig().codexAccounts?.find(row => row.id === id); } catch { account = undefined; }
return account ? codexAccountLogLabel(account) : fallbackCodexAccountLogLabel(id);
}
/**
* Validation metadata that survives a credential write.
*
* `lastCodexValidationTerminal` is deliberately NOT in this list. Every credential write —
* re-login, the CAS refresh commit, same-grant alias propagation — rebuilds the record from this
* pick list, so leaving the marker out is what makes a successful refresh or a re-authentication
* erase a terminal verdict. Both events disprove "the grant was revoked", and a verdict that
* could only ever be set would brand an account dead forever on one spurious `invalid_grant`.
*/
function preservedValidationMetadata(record: CodexAccountCredentialRecord | undefined): Pick<
CodexAccountCredentialRecord,
"lastCodexValidatedAt" | "lastCodexValidationStatus" | "lastCodexValidationError" | "codexValidationPending"
> {
return {
...(record?.codexValidationPending === true ? { codexValidationPending: true } : {}),
...(record?.lastCodexValidatedAt !== undefined ? { lastCodexValidatedAt: record.lastCodexValidatedAt } : {}),
...(record?.lastCodexValidationStatus !== undefined ? { lastCodexValidationStatus: record.lastCodexValidationStatus } : {}),
...(record?.lastCodexValidationError !== undefined ? { lastCodexValidationError: record.lastCodexValidationError } : {}),
};
}
export function getCodexAccountCredential(id: string): CodexAccountCredentials | null {
const record = readCodexAccountRecord(id);
if (!record || record.deletedAt != null) return null;
return record.credential ?? null;
}
export function saveCodexAccountCredential(
id: string,
cred: CodexAccountCredentials,
options: { validationPending?: boolean } = {},
): number {
return withCredentialMutationLockSync(() => {
const store = loadCodexAccountRecordStore();
const current = store[id];
const refreshGrantFingerprint = current?.credential?.refreshToken === cred.refreshToken
? current.refreshGrantFingerprint ?? refreshGrantFingerprintForToken(cred.refreshToken)
: refreshGrantFingerprintForToken(cred.refreshToken);
store[id] = {
credential: cred,
generation: (current?.generation ?? 0) + 1,
refreshGrantFingerprint,
replacedAt: current ? Date.now() : undefined,
quotaHistoryIdentity: crypto.randomUUID(),
...preservedValidationMetadata(current),
...(options.validationPending ? {
codexValidationPending: true,
lastCodexValidatedAt: undefined,
lastCodexValidationStatus: undefined,
lastCodexValidationError: undefined,
} : {}),
};
persistCredentialMutation(store);
return store[id].generation;
});
}
export function markCodexAccountValidated(id: string, atMs: number = Date.now(), generation?: number): void {
withCredentialMutationLockSync(() => {
const store = loadCodexAccountRecordStore();
const current = store[id];
if (!current || current.deletedAt != null || !current.credential) return;
if (current.codexValidationPending && generation === undefined) return;
if (generation !== undefined && current.generation !== generation) return;
store[id] = {
...current,
lastCodexValidatedAt: atMs,
lastCodexValidationStatus: "ok",
lastCodexValidationError: undefined,
codexValidationPending: undefined,
// A completed validation is the direct refutation of a terminal verdict, and this
// spread would otherwise carry the old marker forward.
lastCodexValidationTerminal: undefined,
};
// Becoming routable invalidates credential-derived caches; a timestamp-only
// update on an already validated account preserves the existing epoch policy.
if (current.codexValidationPending) persistCredentialMutation(store);
else persist(store);
});
}
export interface MarkCodexAccountValidationFailedOptions {
/**
* Write only while the stored record is still at this generation.
*
* A validation attempt is not atomic with the store: an operator can re-authenticate the
* account, or another writer can commit a refresh, while a probe is still in flight. Without
* this fence the late failure lands on whatever credential happens to be there and brands a
* freshly installed one dead. Declining to write is the safe direction — the failure cannot be
* attributed to a credential the caller never observed.
*/
expectedGeneration?: number;
/** The grant itself is revoked or expired; only a re-login clears it. */
terminal?: boolean;
}
/** Returns whether the verdict was actually persisted (false when the fence declined it). */
export function markCodexAccountValidationFailed(
id: string,
reason: string,
options: MarkCodexAccountValidationFailedOptions = {},
): boolean {
return withCredentialMutationLockSync(() => {
const store = loadCodexAccountRecordStore();
const current = store[id];
if (!current || current.deletedAt != null || !current.credential) return false;
// Deferred validation is settled only by a caller that names the generation it observed.
// An unfenced write must never resolve a pending account, whichever verdict it carries.
if (current.codexValidationPending && options.expectedGeneration === undefined) return false;
if (options.expectedGeneration !== undefined && current.generation !== options.expectedGeneration) {
return false;
}
store[id] = {
...current,
lastCodexValidationStatus: "failed",
lastCodexValidationError: reason,
// Only ever set here. A transient failure must not clear a terminal marker set earlier,
// and it must not invent one either, so the flag is written only when the caller proves
// the grant is dead.
...(options.terminal ? { lastCodexValidationTerminal: true } : {}),
};
persist(store);
return true;
});
}
export function removeCodexAccountCredential(id: string): void {
tombstoneCodexAccount(id);
}
export function listCodexAccountIds(): string[] {
return Object.keys(loadCodexAccountStore());
}
export function readCodexAccountRecord(id: string): CodexAccountCredentialRecord | null {
return loadCodexAccountRecordStore()[id] ?? null;
}
/**
* One store load, every record, for a caller that resolves MANY ids in a single synchronous pass.
*
* `readCodexAccountRecord` reloads, reparses and renormalizes the whole file per id. That is the
* right shape for one lookup and the wrong shape for a loop: the entitlement denial reader holds
* up to 64 accounts with four client versions each, so scoring one warm flagship request could
* perform up to 256 full-store reads on the request path.
*
* These are the same normalized records `readCodexAccountRecord` hands out, tombstones included,
* so the caller keeps its own `deletedAt` and `generation` checks instead of trusting a filtered
* view. That is the difference from `loadCodexAccountStore`, which drops both and cannot answer a
* question about credential generation.
*/
export function loadCodexAccountRecordSnapshot(): Readonly<Record<string, CodexAccountCredentialRecord>> {
return loadCodexAccountRecordStore();
}
const QUOTA_HISTORY_IDENTITY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
function validQuotaHistoryIdentity(value: unknown): value is string {
return typeof value === "string" && QUOTA_HISTORY_IDENTITY_RE.test(value);
}
type DispatchedPoolCredential = Pick<CodexAccountCredentials, "accessToken" | "chatgptAccountId"> & { generation: number };
function matchesDispatchedPoolCredential(record: CodexAccountCredentialRecord | undefined | null, dispatched: DispatchedPoolCredential): record is CodexAccountCredentialRecord & { credential: CodexAccountCredentials } {
return !!record?.credential && record.deletedAt == null
&& dispatched.accessToken.length > 0 && dispatched.chatgptAccountId.length > 0
&& Number.isSafeInteger(dispatched.generation) && dispatched.generation >= 0
&& record.generation === dispatched.generation
&& record.credential.accessToken === dispatched.accessToken
&& record.credential.chatgptAccountId === dispatched.chatgptAccountId;
}
/** Optional evidence capture; a stale credential or unavailable store never gains a new writer. */
export function capturePoolQuotaWriter(accountId: string, dispatched: DispatchedPoolCredential): PoolQuotaWriter | undefined {
if (!isValidCodexAccountId(accountId)) return undefined;
try {
const current = readCodexAccountRecord(accountId);
if (!matchesDispatchedPoolCredential(current, dispatched)) return undefined;
if (validQuotaHistoryIdentity(current.quotaHistoryIdentity)) {
return { accountId, credentialGeneration: dispatched.generation, historyIdentity: current.quotaHistoryIdentity };
}
return withCredentialMutationLockSync(() => {
const store = loadCodexAccountRecordStore();
const locked = store[accountId];
if (!matchesDispatchedPoolCredential(locked, dispatched)) return undefined;
if (!validQuotaHistoryIdentity(locked.quotaHistoryIdentity)) {
locked.quotaHistoryIdentity = crypto.randomUUID();
// Identity metadata is not a new credential; preserve generation and mutation epoch.
persist(store);
}
return { accountId, credentialGeneration: dispatched.generation, historyIdentity: locked.quotaHistoryIdentity };
});
} catch {
// History is optional evidence. Permission, lock and disk errors cannot fail inference.
return undefined;
}
}
/** Read-only retention identity; unlike capture this never initializes legacy metadata. */
export function poolQuotaHistoryIdentity(accountId: string): string | undefined {
if (!isValidCodexAccountId(accountId)) return undefined;
try {
return poolQuotaHistoryIdentityOf(readCodexAccountRecord(accountId));
} catch {
return undefined;
}
}
/** {@link poolQuotaHistoryIdentity} for a record the caller already loaded, e.g. from one snapshot. */
export function poolQuotaHistoryIdentityOf(record: CodexAccountCredentialRecord | null | undefined): string | undefined {
return record?.credential && record.deletedAt == null && validQuotaHistoryIdentity(record.quotaHistoryIdentity)
? record.quotaHistoryIdentity : undefined;
}
/** One id out of {@link loadCodexAccountRecordSnapshot}; an invalid id or an inherited key is no record. */
export function codexAccountRecordFromSnapshot(records: Readonly<Record<string, CodexAccountCredentialRecord>>, accountId: string): CodexAccountCredentialRecord | null {
return isValidCodexAccountId(accountId) && Object.hasOwn(records, accountId) ? records[accountId]! : null;
}
/** Recheck append admission after upstream I/O; refresh may retire a writer without erasing history. */
export function isPoolQuotaWriterLive(writer: PoolQuotaWriter): boolean {
if (!isValidCodexAccountId(writer.accountId)) return false;
try {
const record = readCodexAccountRecord(writer.accountId);
return !!record?.credential && record.deletedAt == null
&& record.generation === writer.credentialGeneration
&& validQuotaHistoryIdentity(writer.historyIdentity)
&& record.quotaHistoryIdentity === writer.historyIdentity;
} catch {
return false;
}
}
export function isCodexAccountGenerationLive(id: string, generation: number): boolean {
const record = readCodexAccountRecord(id);
return !!record?.credential && record.deletedAt == null && record.generation === generation;
}
/** Callable for existing liveness consumers; stateful consumers can distinguish unknown snapshots. */
export type CodexAccountGenerationLiveCheck = ((id: string, generation: number) => boolean)
& { readonly snapshotStatus: "ok" | "failed" };
/**
* The same verdict as {@link isCodexAccountGenerationLive}, over ONE store load.
*
* `readCodexAccountRecord` is `loadCodexAccountRecordStore()[id]`, so asking it per row
* reloads, reparses and renormalizes the whole file per row — the exact shape
* {@link loadCodexAccountRecordSnapshot} was added to avoid. The denial reader resolves
* several accounts in one synchronous pass on the request path, so it opens one checker and
* closes over the snapshot instead.
*/
export function beginCodexAccountGenerationLiveCheck(): CodexAccountGenerationLiveCheck {
let result: CodexAccountRecordStoreResult;
try { result = loadCodexAccountRecordStoreResult(); }
catch { result = { status: "failed" }; }
const snapshot = result.status === "ok" ? result.records : {};
return Object.assign((id: string, generation: number) => {
const record = snapshot[id];
return !!record?.credential && record.deletedAt == null && record.generation === generation;
}, { snapshotStatus: result.status });
}
export function saveCodexAccountCredentialIfGeneration(
id: string,
generation: number,
cred: CodexAccountCredentials,
): boolean {
return withCredentialMutationLockSync(() => {
const store = loadCodexAccountRecordStore();
const current = store[id];
if (!current || current.generation !== generation || current.deletedAt != null || !current.credential) {
return false;
}
const refreshGrantFingerprint = current.credential.refreshToken === cred.refreshToken
? current.refreshGrantFingerprint ?? refreshGrantFingerprintForToken(cred.refreshToken)
: refreshGrantFingerprintForToken(cred.refreshToken);
store[id] = {
credential: cred,
generation: generation + 1,
refreshGrantFingerprint,
replacedAt: current.replacedAt,
quotaHistoryIdentity: current.credential.chatgptAccountId === cred.chatgptAccountId
? current.quotaHistoryIdentity : crypto.randomUUID(),
...preservedValidationMetadata(current),
};
persistCredentialMutation(store);
return true;
});
}
/**
* Commit a refreshed credential to its owner AND to any record that is provably an untouched
* duplicate of the pre-refresh credential (#2892 gap 3).
*
* A refresh normally rotates the refresh token, and the owner CAS above changes only the owner's
* record. A second non-deleted record holding the same grant that is not participating in the
* flight therefore keeps a refresh token upstream has just rotated away. Its next refresh sends a
* dead grant, and `invalid_grant` classifies as `revoked` — retiring a healthy account because we
* rotated its grant and never told it.
*
* Eligibility is deliberately narrow, and each condition earns its place:
*
* - Same pre-refresh grant fingerprint, access token, AND expiry. Anything else means the alias was
* updated concurrently, and repairing only its grant while keeping its own access token would
* advance a generation without advancing the access-token JWT. `plan-from-token` reads a higher
* generation as proof of a newer JWT (that is how JWT plan claims supersede a WHAM observation),
* so that combination lets a stale JWT overwrite an authoritative plan. It would also hand a live
* forced-refresh joiner back its own 401-rejected bearer: flights are keyed by grant and do not
* record participants, so a scan cannot tell a dormant alias from a joiner, and the recursion's
* freshness shortcut does not re-compare against the rejected token.
* - Same `chatgptAccountId` as the owner. A fingerprint is `sha256` of the refresh token and
* carries no identity claim; no invariant here guarantees one grant cannot span two account ids,
* so identity is compared rather than assumed.
*
* The rotated access token, refresh token, and expiry move together, keeping a generation bump
* meaning what every fence already assumes. `replacedAt` and the validation metadata survive
* because the probe-lease settlement check accepts only an intact `G → G+1` lineage.
*
* One lock acquisition and one `persist` for the owner and every alias: `persist` writes the whole
* store, so a second pass would open a window in which some records hold the dead grant.
*/
export function commitRefreshedCodexCredentialWithAliases(
id: string,
generation: number,
cred: CodexAccountCredentials,
): { committed: boolean; propagatedAliases: { id: string; generation: number }[] } {
return withCredentialMutationLockSync(() => {
const store = loadCodexAccountRecordStore();
const current = store[id];
if (!current || current.generation !== generation || current.deletedAt != null || !current.credential || current.credential.sourceAuthPath) {
return { committed: false, propagatedAliases: [] };
}
const priorCredential = current.credential;
const priorFingerprint = recordGrantFingerprint(current);
const refreshGrantFingerprint = priorCredential.refreshToken === cred.refreshToken
? current.refreshGrantFingerprint ?? refreshGrantFingerprintForToken(cred.refreshToken)
: refreshGrantFingerprintForToken(cred.refreshToken);
store[id] = {
credential: cred,
generation: generation + 1,
refreshGrantFingerprint,
replacedAt: current.replacedAt,
quotaHistoryIdentity: current.credential.chatgptAccountId === cred.chatgptAccountId
? current.quotaHistoryIdentity : crypto.randomUUID(),
...preservedValidationMetadata(current),
};
// Each alias carries its OWN committed generation: aliases need not share one, and the plan
// reconciliation below is generation-fenced, so an id alone would be reconciled at the wrong fence.
const propagatedAliases: { id: string; generation: number }[] = [];
// Nothing to propagate when the grant did not actually rotate: the aliases already hold it.
// An absent owner identity fails closed: two empty strings compare equal but prove nothing about
// which upstream account either record was meant to use, and a matching bearer snapshot only
// shows they copied the same token once. Leave those dormant records alone.
if (
priorFingerprint !== undefined
&& priorCredential.refreshToken !== cred.refreshToken
&& !!priorCredential.chatgptAccountId
&& priorCredential.chatgptAccountId === cred.chatgptAccountId
) {
for (const [aliasId, alias] of Object.entries(store)) {
if (aliasId === id || alias.deletedAt != null || !alias.credential || alias.credential.sourceAuthPath) continue;
if (recordGrantFingerprint(alias) !== priorFingerprint) continue;
if (alias.credential.accessToken !== priorCredential.accessToken) continue;
if (alias.credential.expiresAt !== priorCredential.expiresAt) continue;
if (!alias.credential.chatgptAccountId) continue;
if (alias.credential.chatgptAccountId !== priorCredential.chatgptAccountId) continue;
const aliasGeneration = alias.generation + 1;
store[aliasId] = {
// The alias keeps its OWN chatgptAccountId value, which the guard above proved equal.
credential: { ...cred, chatgptAccountId: alias.credential.chatgptAccountId },
generation: aliasGeneration,
refreshGrantFingerprint,
replacedAt: alias.replacedAt,
quotaHistoryIdentity: alias.quotaHistoryIdentity,
...preservedValidationMetadata(alias),
};
propagatedAliases.push({ id: aliasId, generation: aliasGeneration });
}
}
persistCredentialMutation(store);
return { committed: true, propagatedAliases };
});
}
export function tombstoneCodexAccount(id: string): number {
return withCredentialMutationLockSync(() => {
const store = loadCodexAccountRecordStore();
const current = store[id];
const generation = (current?.generation ?? 0) + 1;
store[id] = { generation, deletedAt: Date.now() };
persistCredentialMutation(store);
return generation;
});
}
const CHATGPT_TOKEN_URL = "https://auth.openai.com/oauth/token";
const CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
export class TokenRefreshError extends Error {
reason: "expired" | "revoked" | "unknown";
constructor(reason: "expired" | "revoked" | "unknown", message: string,
readonly status?: number, readonly code?: ChatgptRefreshDiagnosticCode) {
super(message);
this.name = "TokenRefreshError";
this.reason = reason;
}
}
/**
* The stored record or its refresh-grant fingerprint is gone. Retrying cannot
* conjure a missing credential, so callers must treat this as terminal.
*/
export class CodexCredentialUnavailableError extends Error {
constructor(message = "Codex account credential is unavailable; reauthenticate the account.") {
super(message);
this.name = "CodexCredentialUnavailableError";
}
}
export class CodexCredentialGenerationConflictError extends Error {
constructor(message = "Codex account changed during refresh") {
super(message);
this.name = "CodexCredentialGenerationConflictError";
}
}
export class CodexCredentialRefreshLockTimeoutError extends Error {
constructor(message = "Timed out waiting for Codex account refresh lock") {
super(message);
this.name = "CodexCredentialRefreshLockTimeoutError";
}
}
export class CodexCredentialRefreshBusyError extends Error {
readonly code = "CODEX_REFRESH_BUSY";
readonly retryable = true;
constructor() {
super("Codex credential refresh capacity reached");
this.name = "CodexCredentialRefreshBusyError";
}
}
export class CodexCredentialRefreshStaleError extends Error {
readonly code = "CODEX_REFRESH_STALE";
readonly retryable = true;
constructor() {
super("Codex credential refresh owner became stale");
this.name = "CodexCredentialRefreshStaleError";
}
}
/**
* Terminal means the grant itself is dead, or there is no grant to refresh.
* Token-endpoint 5xx (`unknown`) and a generation CAS loss stay transient
* because those genuinely may clear (#2887).
*/
export function isTerminalCodexPoolRefreshFailure(error: unknown): boolean {
return (error instanceof TokenRefreshError && (error.reason === "revoked" || error.reason === "expired"))
|| error instanceof CodexCredentialUnavailableError;
}
function isOperationalCodexPoolRefreshFailure(error: unknown): boolean {
if (error instanceof CodexPoolRefreshCooldownError) return true;
if (error instanceof CodexCredentialRefreshBusyError) return true;
if (error instanceof CodexCredentialRefreshStaleError) return true;
if (error instanceof CodexCredentialRefreshLockTimeoutError) return true;
return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
}
function classifyCodexPoolRefreshFailureReason(error: unknown): string {
if (error instanceof TokenRefreshError) return error.reason;
if (error instanceof CodexCredentialGenerationConflictError) return "generation_conflict";
return "network";
}
/** Credential writers share the config mutation coordinator; contention is transient, not reauth. */
function withCredentialMutationLockSync<T>(fn: () => T): T {
try {
return withConfigMutationLockSync(fn);
} catch (error) {
if (error instanceof ConfigMutationLockError) throw new CodexCredentialRefreshLockTimeoutError();
throw error;
}
}
type CodexTokenResult = { accessToken: string; chatgptAccountId: string; generation: number };
type CodexRefreshGenerationHandoff = (accountId: string, fromGeneration: number, toGeneration: number) => void;
// var (hoisted, initialized to undefined) rather than const: a registrar reached
// through an import cycle can register while this module body is still evaluating,
// and the lazily created Set must be reachable rather than in the temporal dead zone.
var refreshGenerationHandoffs: Set<CodexRefreshGenerationHandoff> | undefined;
function refreshHandoffs(): Set<CodexRefreshGenerationHandoff> {
return refreshGenerationHandoffs ??= new Set();
}
/** Register process-local state that must follow a credential refresh generation. */
export function registerCodexRefreshGenerationHandoff(handoff: CodexRefreshGenerationHandoff): () => void {
const set = refreshHandoffs();
set.add(handoff);
return () => set.delete(handoff);
}
/**
* Invoke every registered handoff for one committed `fromGeneration` to `toGeneration` move.
*
* Each listener runs independently and a throw is contained: by the time handoffs run the
* rotated credential is already persisted, so a failing listener must not reject the shared
* refresh promise (surviving waiters would see a refresh failure that never happened), must
* not starve the remaining listeners, and must not block plan reconciliation. The warning
* carries no account id or token material — the same scrub refresh error messages get.
*/
function dispatchRefreshGenerationHandoffs(accountId: string, fromGeneration: number, toGeneration: number): void {
for (const handoff of refreshHandoffs()) {
try {
handoff(accountId, fromGeneration, toGeneration);
} catch (error) {
console.warn("[codex-auth] a refresh generation handoff listener failed", error);
}
}
}
type CodexRefreshResult = CodexTokenResult & {
credential?: CodexAccountCredentials;
/**
* Records that adopted this refresh's rotated credential through same-grant propagation, each
* with its own committed generation (#2892 gap 3). Carried on the result so the flight settles
* every plan in one place rather than the commit doing its own (#2933).
*/
propagatedAliases?: { id: string; generation: number }[];
/**
* Grant the returned credential actually belongs to.
*
* Flights are keyed by refresh grant and shared across every account holding that
* grant, but a flight can resolve to a credential from a DIFFERENT grant: the
* owner's credential may be externally replaced while it waits for the file lock,
* and the grant-mismatch branch then hands back that replacement. A joiner that
* only checks its own current grant would CAS-write another account's access and
* refresh tokens onto itself. The result therefore carries its own provenance.
*/
resolvedGrantFingerprint?: string;
/**
* True when this call's own CAS write produced `generation` — the credential is a
* refresh of the one the caller was holding, not somebody else's replacement.
*/
selfRefreshed?: boolean;
/**
* Three-way form of {@link selfRefreshed}, kept alongside it so existing callers are
* unaffected (#3019). `selfRefreshed` is `provenance === "self-refresh"`.
*/
provenance?: CodexRefreshProvenance;
};
/**
* How THIS caller arrived at the credential it is returning (#3019).
*
* `selfRefreshed` is a boolean, and a boolean cannot carry three cases. Its `false` means
* both "somebody else replaced the credential" and "I joined an in-flight refresh of the
* same grant and adopted its result" — and a recovery budget has to treat those opposite
* ways. Joining is the same lineage getting its one refresh; replacement is a NEW lineage
* that has not had one yet, and charging it for somebody else's attempt would deny the
* fresh credential the recovery this exists to grant.
*/
export type CodexRefreshProvenance = "self-refresh" | "joined-lineage" | "external-replacement";
/** Terminal outcome of one forced refresh, as seen by the caller that requested it. */
export type ForcedRefreshOutcome =
| {
kind: "resolved";
provenance: CodexRefreshProvenance;
generation: number;
rotated: boolean;
/** Same-grant records advanced by this refresh, at their committed generations. */
propagatedAliases?: { id: string; generation: number }[];
}
| { kind: "failed"; error: unknown };
const MAX_CODEX_REFRESH_FLIGHTS = 32;
const CODEX_REFRESH_FLIGHT_STALE_MS = 120_000;
interface RefreshFlight {
promise: Promise<CodexRefreshResult>;
startedAt: number;
abort: AbortController;
}
const refreshLocks = new Map<string, RefreshFlight>();
function codexRefreshLockPath(lockKey: string): string {
const digest = createHash("sha256").update(lockKey).digest("hex").slice(0, 32);
return join(getConfigDir(), `codex-refresh-${digest}.lock`);
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) return new Promise(resolve => setTimeout(resolve, ms));
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason);
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
function errCode(err: unknown): string | undefined {
return err && typeof err === "object" && "code" in err ? String((err as { code?: unknown }).code) : undefined;
}
function isRefreshLockStale(path: string): boolean {
try {
hardenExistingSecret(path);
const parsed = JSON.parse(readFileSync(path, "utf-8")) as { acquiredAt?: unknown };
return typeof parsed.acquiredAt !== "number" || Date.now() - parsed.acquiredAt > REFRESH_LOCK_STALE_MS;
} catch {
// The owner creates the file and writes its metadata in two steps, so a live lock is
// briefly unreadable. Age the file itself instead of calling that window stale, which
// let a waiter delete a lock whose owner was still inside its critical section.
try {
return Date.now() - statSync(path).mtimeMs > REFRESH_LOCK_STALE_MS;
} catch {
return false;
}
}
}
function releaseCodexRefreshFileLock(path: string, fd: number): void {
let owned: { dev: bigint; ino: bigint } | null = null;
try {
const info = fstatSync(fd, { bigint: true });
if (info.dev >= 0n && info.ino > 0n) owned = { dev: info.dev, ino: info.ino };
} catch { /* Unknown descriptor identity never authorizes unlink. */ }
try {
withConfigMutationLockSync(() => {
let current: { dev: bigint; ino: bigint } | null = null;
try {
const info = statSync(path, { bigint: true });
if (info.dev >= 0n && info.ino > 0n) current = { dev: info.dev, ino: info.ino };
} catch { /* Keep the lock and the callback outcome when the path probe fails. */ }
if (owned && current && current.dev === owned.dev && current.ino === owned.ino) {
try { unlinkSync(path); } catch (err) {
if (errCode(err) !== "ENOENT") throw err;
}
}
});
} catch (err) {
// Keep the descriptor alive through comparison/unlink so its inode cannot be recycled.
// Unavailable coordination leaves the path without masking the completed refresh.
if (!(err instanceof ConfigMutationLockError)) throw err;
} finally { closeSync(fd); }
}
export async function withCodexRefreshFileLock<T>(lockKey: string, signal: AbortSignal, fn: () => Promise<T>): Promise<T> {
hardenConfigDir();
const dir = getConfigDir();
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
const path = codexRefreshLockPath(lockKey);
const deadline = Date.now() + REFRESH_LOCK_WAIT_MS;
let fd: number | null = null;
while (fd == null) {
if (signal.aborted) throw signal.reason;
try {
// Serialize only metadata operations, never the async refresh callback. Cooperating
// contenders cannot reclaim a successor between stale observation and path mutation.
withConfigMutationLockSync(() => {
try {
fd = openSync(path, "wx", 0o600);
writeFileSync(fd, JSON.stringify({ acquiredAt: Date.now(), pid: process.pid }) + "\n");
} catch (err) {
if (fd != null) {
const failedFd = fd;
fd = null;
try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve write failure. */ }
throw err;
}
if (errCode(err) !== "EEXIST") throw err;
if (isRefreshLockStale(path)) {
try { unlinkSync(path); } catch (unlinkErr) {
if (errCode(unlinkErr) !== "ENOENT") throw unlinkErr;
}
}
}
});
} catch (err) {
// A failed SQLite commit can follow successful file creation; it still owns an fd.
if (fd != null) {
const failedFd = fd;
fd = null;
try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve admission failure. */ }
}
if (!(err instanceof ConfigMutationLockError)) throw err;
}
if (fd != null) break;
if (Date.now() >= deadline) throw new CodexCredentialRefreshLockTimeoutError();
await sleep(REFRESH_LOCK_POLL_MS, signal);
}
try {
return await fn();
} finally {
releaseCodexRefreshFileLock(path, fd);
}
}
function findFreshCredentialForGrant(
refreshGrantFingerprint: string,
excludeId: string,
rejectedAccessToken?: string,
expectedChatgptAccountId?: string,
): CodexAccountCredentials | null {
const now = Date.now();
const records = loadCodexAccountRecordStore();
// Adoption copies another record's access AND refresh tokens onto the caller, so the two records
// must be the same upstream identity. A grant fingerprint is `sha256` of the refresh token and
// carries no identity claim, and nothing here guarantees one grant cannot span two accounts, so
// require both ids to be present and exactly equal rather than inferring identity from the grant.
if (!expectedChatgptAccountId) return null;
for (const [candidateId, candidate] of Object.entries(records)) {
if (candidateId === excludeId || candidate.deletedAt != null || !candidate.credential || candidate.credential.sourceAuthPath) continue;
if (recordGrantFingerprint(candidate) !== refreshGrantFingerprint) continue;
if (!candidate.credential.chatgptAccountId) continue;
if (candidate.credential.chatgptAccountId !== expectedChatgptAccountId) continue;
// A sibling alias can hold a still-unexpired copy of the exact token upstream
// just rejected. Reusing it would bump the generation and replay the identical
// bearer — a second 401 dressed up as recovery.
if (rejectedAccessToken !== undefined && candidate.credential.accessToken === rejectedAccessToken) continue;
if (candidate.credential.expiresAt > now + REFRESH_SKEW_MS) return candidate.credential;
}
return null;
}
async function notePlanFromRefreshedAccessToken(
id: string,
accessToken: string,
generation: number,
): Promise<void> {
try {
const { noteCodexAccountAccessToken } = await import("./plan-from-token");
noteCodexAccountAccessToken(id, accessToken, generation);
} catch {
// Derived plan metadata must not fail credential refresh.
}
}
/**
* A forced refresh raised by a rejected bearer. Carries the generation the 401 was
* observed under so a credential someone else already replaced is never refreshed
* again, and the rejected token so a sibling alias holding that same token cannot
* satisfy the refresh.
*/
type ForcedRefreshFence = { rejectedGeneration: number; rejectedAccessToken: string };
/** True once the stored credential has moved off the generation the 401 belongs to. */
function forcedFenceSuperseded(recordGeneration: number, forced: ForcedRefreshFence | undefined): boolean {
return forced !== undefined && recordGeneration !== forced.rejectedGeneration;
}
/**
* Wait for a SHARED promise while honoring only the calling request's cancellation.
*
* The awaited work is not the caller's to cancel — other requests are waiting on the
* same promise — so an aborted caller stops waiting and the work continues to
* completion for them (#2892 gap 2). The rejection handler prevents an unhandled
* rejection from the promise this caller walked away from.
*/
function awaitOwnCancellation<T>(work: Promise<T>, callerSignal?: AbortSignal): Promise<T> {
if (!callerSignal) return work;
if (callerSignal.aborted) {
work.catch(() => {});
return Promise.reject(callerSignal.reason);
}
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
work.catch(() => {});
reject(callerSignal.reason);
};