@cortexkit/anthropic-auth-core 1.21.0 → 1.22.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/README.md +1 -1
- package/dist/accounts.d.ts +43 -3
- package/dist/accounts.js +469 -90
- package/dist/auth.js +3 -10
- package/dist/cch.d.ts +13 -9
- package/dist/cch.js +57 -19
- package/dist/claude-code.d.ts +1 -2
- package/dist/claude-code.js +6 -4
- package/dist/claustrum.d.ts +109 -0
- package/dist/claustrum.js +529 -0
- package/dist/commands/account.d.ts +5 -1
- package/dist/commands/account.js +16 -2
- package/dist/constants.d.ts +4 -5
- package/dist/constants.js +4 -5
- package/dist/dump.js +2 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/json.d.ts +1 -0
- package/dist/json.js +10 -0
- package/dist/mid-conversation-output-config.d.ts +13 -0
- package/dist/mid-conversation-output-config.js +89 -0
- package/dist/models.d.ts +27 -1
- package/dist/models.js +43 -0
- package/dist/network-errors.d.ts +1 -0
- package/dist/network-errors.js +27 -0
- package/dist/quota-header-feed.d.ts +19 -7
- package/dist/quota-header-feed.js +33 -2
- package/dist/quota-headers.js +59 -6
- package/dist/quota-manager.d.ts +5 -1
- package/dist/quota-manager.js +31 -5
- package/dist/thinking-binding.d.ts +7 -0
- package/dist/thinking-binding.js +54 -0
- package/package.json +3 -1
package/dist/accounts.js
CHANGED
|
@@ -3,13 +3,25 @@ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { parseRetryAfterHeader, refreshClaudeOAuthToken } from './auth.js';
|
|
6
|
+
import { CustodyTombstoneRefreshError } from './claustrum.js';
|
|
6
7
|
import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from './constants.js';
|
|
8
|
+
import { parseJsonRedacted } from './json.js';
|
|
7
9
|
import { log, logger } from './logger.js';
|
|
10
|
+
import { isTransientNetworkError } from './network-errors.js';
|
|
11
|
+
import { tokenFingerprint } from './token-fingerprint.js';
|
|
8
12
|
const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
|
|
9
13
|
const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
|
|
10
14
|
export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
|
|
11
15
|
export const ACCOUNT_STATE_FILE_NAME = 'anthropic-auth-state.json';
|
|
12
16
|
export const QUOTA_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
17
|
+
export const QUOTA_FIELD_NAMES = [
|
|
18
|
+
'five_hour',
|
|
19
|
+
'seven_day',
|
|
20
|
+
'scoped',
|
|
21
|
+
'extraUsage',
|
|
22
|
+
'bindingWindow',
|
|
23
|
+
'fallbackAdvised',
|
|
24
|
+
];
|
|
13
25
|
export function isOAuthAccount(account) {
|
|
14
26
|
return account.type === 'oauth';
|
|
15
27
|
}
|
|
@@ -54,7 +66,7 @@ const DEFAULT_MINIMUM_REMAINING = {
|
|
|
54
66
|
seven_day: 0,
|
|
55
67
|
};
|
|
56
68
|
const DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA = true;
|
|
57
|
-
const
|
|
69
|
+
export const FALLBACK_BACKGROUND_TICK_MS = 60_000;
|
|
58
70
|
const BACKGROUND_TICK_JITTER_MS = 60_000;
|
|
59
71
|
const FALLBACK_REFRESH_LOCK_TTL_MS = 10 * 60_000;
|
|
60
72
|
const FALLBACK_REFRESH_JOIN_WAIT_MS = 10_000;
|
|
@@ -118,6 +130,9 @@ function normalizeAccount(value) {
|
|
|
118
130
|
authLineageId: typeof value.authLineageId === 'string' && value.authLineageId.trim()
|
|
119
131
|
? value.authLineageId
|
|
120
132
|
: undefined,
|
|
133
|
+
claustrumHandle: typeof value.claustrumHandle === 'string' && value.claustrumHandle.trim()
|
|
134
|
+
? value.claustrumHandle.trim()
|
|
135
|
+
: undefined,
|
|
121
136
|
access: typeof value.access === 'string' ? value.access : undefined,
|
|
122
137
|
refresh: value.refresh,
|
|
123
138
|
expires: typeof value.expires === 'number' ? value.expires : undefined,
|
|
@@ -176,6 +191,10 @@ function normalizeOperationError(value) {
|
|
|
176
191
|
? value.accountIdentity.trim()
|
|
177
192
|
: undefined,
|
|
178
193
|
tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : undefined,
|
|
194
|
+
refreshTokenFingerprint: typeof value.refreshTokenFingerprint === 'string' &&
|
|
195
|
+
value.refreshTokenFingerprint.trim()
|
|
196
|
+
? value.refreshTokenFingerprint.trim()
|
|
197
|
+
: undefined,
|
|
179
198
|
// Preserve the dead-token discriminators across save/load. Without these,
|
|
180
199
|
// a retry-exhausted transient (permanent=false, 24h backoff) would lose its
|
|
181
200
|
// flag on reload and the 24h-delay heuristic would wrongly re-classify it
|
|
@@ -309,6 +328,20 @@ function normalizeQuota(value) {
|
|
|
309
328
|
if (value.source === 'poll' || value.source === 'headers') {
|
|
310
329
|
quota.source = value.source;
|
|
311
330
|
}
|
|
331
|
+
if (isRecord(value.fieldSources)) {
|
|
332
|
+
const fieldSources = {};
|
|
333
|
+
for (const field of QUOTA_FIELD_NAMES) {
|
|
334
|
+
if (quota[field] === undefined)
|
|
335
|
+
continue;
|
|
336
|
+
const source = value.fieldSources[field];
|
|
337
|
+
if (source === 'poll' || source === 'headers') {
|
|
338
|
+
fieldSources[field] = source;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (Object.keys(fieldSources).length > 0) {
|
|
342
|
+
quota.fieldSources = fieldSources;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
312
345
|
return Object.keys(quota).length ? quota : undefined;
|
|
313
346
|
}
|
|
314
347
|
function normalizeQuotaMoney(value) {
|
|
@@ -362,9 +395,13 @@ function normalizeStorage(value) {
|
|
|
362
395
|
claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined,
|
|
363
396
|
dump: isRecord(value.dump) ? value.dump : undefined,
|
|
364
397
|
claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
|
|
398
|
+
thinkingBinding: isRecord(value.thinkingBinding)
|
|
399
|
+
? value.thinkingBinding
|
|
400
|
+
: undefined,
|
|
365
401
|
costZeroing: isRecord(value.costZeroing) ? value.costZeroing : undefined,
|
|
366
402
|
cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
|
|
367
403
|
relay: isRecord(value.relay) ? value.relay : undefined,
|
|
404
|
+
claustrum: normalizeClaustrumConfig(value.claustrum),
|
|
368
405
|
logging: isRecord(value.logging) ? value.logging : undefined,
|
|
369
406
|
killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
|
|
370
407
|
prime: (() => {
|
|
@@ -401,9 +438,31 @@ function normalizeStorage(value) {
|
|
|
401
438
|
.filter((account) => account != null),
|
|
402
439
|
};
|
|
403
440
|
}
|
|
441
|
+
function normalizeClaustrumConfig(value) {
|
|
442
|
+
if (!isRecord(value) || !isRecord(value.accounts))
|
|
443
|
+
return undefined;
|
|
444
|
+
const accounts = Object.fromEntries(Object.entries(value.accounts).flatMap(([id, entry]) => {
|
|
445
|
+
if (!isRecord(entry))
|
|
446
|
+
return [];
|
|
447
|
+
return [
|
|
448
|
+
[
|
|
449
|
+
id,
|
|
450
|
+
{
|
|
451
|
+
...(typeof entry.enabled === 'boolean' && {
|
|
452
|
+
enabled: entry.enabled,
|
|
453
|
+
}),
|
|
454
|
+
},
|
|
455
|
+
],
|
|
456
|
+
];
|
|
457
|
+
}));
|
|
458
|
+
return Object.keys(accounts).length > 0 ? { accounts } : undefined;
|
|
459
|
+
}
|
|
404
460
|
async function readJsonIfPresent(path) {
|
|
405
461
|
try {
|
|
406
|
-
return {
|
|
462
|
+
return {
|
|
463
|
+
exists: true,
|
|
464
|
+
value: parseJsonRedacted(await readFile(path, 'utf8')),
|
|
465
|
+
};
|
|
407
466
|
}
|
|
408
467
|
catch (error) {
|
|
409
468
|
if (error.code === 'ENOENT') {
|
|
@@ -427,6 +486,13 @@ export function mergeMainQuotaErrorClearedAt(existing, incoming) {
|
|
|
427
486
|
return incoming;
|
|
428
487
|
return Math.max(existing, incoming);
|
|
429
488
|
}
|
|
489
|
+
export function mergeMainRefreshErrorClearedAt(existing, incoming) {
|
|
490
|
+
if (incoming === undefined || !Number.isFinite(incoming))
|
|
491
|
+
return existing;
|
|
492
|
+
if (existing === undefined || !Number.isFinite(existing))
|
|
493
|
+
return incoming;
|
|
494
|
+
return Math.max(existing, incoming);
|
|
495
|
+
}
|
|
430
496
|
function accountCredentialTimestamp(value) {
|
|
431
497
|
return Math.max(numericField(value.lastRefreshedAt), numericField(value.lastUsed), numericField(value.addedAt));
|
|
432
498
|
}
|
|
@@ -461,6 +527,21 @@ function mergeConfigAccountAndState(account, stateAccount) {
|
|
|
461
527
|
}
|
|
462
528
|
return { ...account, ...stateAccount };
|
|
463
529
|
}
|
|
530
|
+
function configAccountHasInvalidShape(account) {
|
|
531
|
+
if (account.type !== 'api' && account.type !== 'oauth')
|
|
532
|
+
return true;
|
|
533
|
+
if (account.type === 'api' &&
|
|
534
|
+
'baseURL' in account &&
|
|
535
|
+
(typeof account.baseURL !== 'string' || !isValidApiBaseURL(account.baseURL))) {
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
if (account.type === 'oauth' &&
|
|
539
|
+
'refresh' in account &&
|
|
540
|
+
(typeof account.refresh !== 'string' || !account.refresh.trim())) {
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
464
545
|
function mergeConfigAndState(configValue, stateValue) {
|
|
465
546
|
if (!isRecord(configValue))
|
|
466
547
|
return configValue;
|
|
@@ -476,6 +557,11 @@ function mergeConfigAndState(configValue, stateValue) {
|
|
|
476
557
|
mainState.quotaErrorClearedAt >= 0
|
|
477
558
|
? mainState.quotaErrorClearedAt
|
|
478
559
|
: undefined;
|
|
560
|
+
const mainRefreshErrorClearedAt = typeof mainState?.refreshErrorClearedAt === 'number' &&
|
|
561
|
+
Number.isFinite(mainState.refreshErrorClearedAt) &&
|
|
562
|
+
mainState.refreshErrorClearedAt >= 0
|
|
563
|
+
? mainState.refreshErrorClearedAt
|
|
564
|
+
: undefined;
|
|
479
565
|
const configQuotaError = quotaConfig.mainLastQuotaApiError;
|
|
480
566
|
const mainLastQuotaApiError = mainState?.lastQuotaApiError ??
|
|
481
567
|
(mainQuotaErrorClearedAt !== undefined &&
|
|
@@ -484,12 +570,24 @@ function mergeConfigAndState(configValue, stateValue) {
|
|
|
484
570
|
configQuotaError.checkedAt <= mainQuotaErrorClearedAt
|
|
485
571
|
? undefined
|
|
486
572
|
: configQuotaError);
|
|
573
|
+
const configRefreshError = refreshConfig.mainLastRefreshError;
|
|
574
|
+
const mainLastRefreshError = mainState?.lastRefreshError ??
|
|
575
|
+
(mainRefreshErrorClearedAt !== undefined &&
|
|
576
|
+
isRecord(configRefreshError) &&
|
|
577
|
+
typeof configRefreshError.checkedAt === 'number' &&
|
|
578
|
+
configRefreshError.checkedAt <= mainRefreshErrorClearedAt
|
|
579
|
+
? undefined
|
|
580
|
+
: configRefreshError);
|
|
487
581
|
const accounts = Array.isArray(configValue.accounts)
|
|
488
582
|
? configValue.accounts.map((account) => {
|
|
489
583
|
if (!isRecord(account))
|
|
490
584
|
return account;
|
|
491
|
-
const
|
|
492
|
-
|
|
585
|
+
const rawId = typeof account.id === 'string' ? account.id : undefined;
|
|
586
|
+
const stateValue = rawId
|
|
587
|
+
? (stateAccounts[rawId] ?? stateAccounts[rawId.trim()])
|
|
588
|
+
: undefined;
|
|
589
|
+
const stateAccount = isRecord(stateValue)
|
|
590
|
+
? stateValue
|
|
493
591
|
: {};
|
|
494
592
|
return mergeConfigAccountAndState(account, stateAccount);
|
|
495
593
|
})
|
|
@@ -503,7 +601,8 @@ function mergeConfigAndState(configValue, stateValue) {
|
|
|
503
601
|
},
|
|
504
602
|
refresh: objectWithDefinedEntries({
|
|
505
603
|
...refreshConfig,
|
|
506
|
-
mainLastRefreshError
|
|
604
|
+
mainLastRefreshError,
|
|
605
|
+
mainRefreshErrorClearedAt: mainRefreshSource.refreshErrorClearedAt,
|
|
507
606
|
mainRefreshLeaseId: mainRefreshSource.refreshLeaseId,
|
|
508
607
|
mainRefreshLeaseUntil: mainRefreshSource.refreshLeaseUntil,
|
|
509
608
|
mainRefreshLeaseTokenHash: mainRefreshSource.refreshLeaseTokenHash,
|
|
@@ -592,6 +691,7 @@ function accountRuntimeState(account) {
|
|
|
592
691
|
}
|
|
593
692
|
return objectWithDefinedEntries({
|
|
594
693
|
authLineageId: account.authLineageId,
|
|
694
|
+
claustrumHandle: account.claustrumHandle,
|
|
595
695
|
access: account.access,
|
|
596
696
|
refresh: account.refresh,
|
|
597
697
|
expires: account.expires,
|
|
@@ -649,17 +749,61 @@ function mergeHeaderOwnedWindow(existingSnapshot, incomingSnapshot, key) {
|
|
|
649
749
|
? existing
|
|
650
750
|
: incoming;
|
|
651
751
|
}
|
|
752
|
+
export function quotaFieldSource(snapshot, field) {
|
|
753
|
+
if (!snapshot || snapshot[field] === undefined)
|
|
754
|
+
return undefined;
|
|
755
|
+
return (snapshot.fieldSources?.[field] ??
|
|
756
|
+
(snapshot.source === 'poll' || snapshot.source === 'headers'
|
|
757
|
+
? snapshot.source
|
|
758
|
+
: undefined));
|
|
759
|
+
}
|
|
760
|
+
function fieldSourcesForMergedQuota(existing, incoming, merged) {
|
|
761
|
+
const fieldSources = {};
|
|
762
|
+
for (const field of QUOTA_FIELD_NAMES) {
|
|
763
|
+
if (merged[field] === undefined)
|
|
764
|
+
continue;
|
|
765
|
+
if (field === 'scoped' || field === 'extraUsage') {
|
|
766
|
+
fieldSources[field] = 'poll';
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (field === 'bindingWindow' &&
|
|
770
|
+
existing.bindingWindowSource === 'poll' &&
|
|
771
|
+
existing.bindingWindow === merged.bindingWindow) {
|
|
772
|
+
fieldSources[field] = 'poll';
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (field === 'fallbackAdvised' &&
|
|
776
|
+
incoming.fieldSources?.fallbackAdvised === undefined) {
|
|
777
|
+
const source = quotaFieldSource(existing, field);
|
|
778
|
+
if (source)
|
|
779
|
+
fieldSources[field] = source;
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
const source = incoming[field] === merged[field]
|
|
783
|
+
? quotaFieldSource(incoming, field)
|
|
784
|
+
: existing[field] === merged[field]
|
|
785
|
+
? quotaFieldSource(existing, field)
|
|
786
|
+
: (quotaFieldSource(existing, field) ??
|
|
787
|
+
quotaFieldSource(incoming, field));
|
|
788
|
+
if (source)
|
|
789
|
+
fieldSources[field] = source;
|
|
790
|
+
}
|
|
791
|
+
return Object.keys(fieldSources).length > 0 ? fieldSources : undefined;
|
|
792
|
+
}
|
|
652
793
|
export function mergeHeaderQuotaForPersistence(existing, incoming) {
|
|
653
794
|
if (!existing || incoming.source !== 'headers')
|
|
654
795
|
return incoming;
|
|
655
796
|
const preservePollBinding = existing.bindingWindowSource === 'poll';
|
|
656
|
-
|
|
797
|
+
const merged = {
|
|
657
798
|
...existing,
|
|
658
799
|
...incoming,
|
|
659
800
|
five_hour: mergeHeaderOwnedWindow(existing, incoming, 'five_hour'),
|
|
660
801
|
seven_day: mergeHeaderOwnedWindow(existing, incoming, 'seven_day'),
|
|
661
802
|
scoped: mergeHeaderScopedQuota(existing, incoming),
|
|
662
803
|
extraUsage: existing.extraUsage ?? incoming.extraUsage,
|
|
804
|
+
fallbackAdvised: incoming.fieldSources?.fallbackAdvised !== undefined
|
|
805
|
+
? incoming.fallbackAdvised
|
|
806
|
+
: (existing.fallbackAdvised ?? incoming.fallbackAdvised),
|
|
663
807
|
bindingWindow: preservePollBinding
|
|
664
808
|
? existing.bindingWindow
|
|
665
809
|
: (incoming.bindingWindow ?? existing.bindingWindow),
|
|
@@ -667,6 +811,11 @@ export function mergeHeaderQuotaForPersistence(existing, incoming) {
|
|
|
667
811
|
? 'poll'
|
|
668
812
|
: (incoming.bindingWindowSource ?? existing.bindingWindowSource),
|
|
669
813
|
};
|
|
814
|
+
const fieldSources = fieldSourcesForMergedQuota(existing, incoming, merged);
|
|
815
|
+
return {
|
|
816
|
+
...merged,
|
|
817
|
+
...(fieldSources && { fieldSources }),
|
|
818
|
+
};
|
|
670
819
|
}
|
|
671
820
|
function mergeAccountRuntimeState(existing, incoming) {
|
|
672
821
|
if (!isRecord(existing))
|
|
@@ -685,6 +834,21 @@ function mergeAccountRuntimeState(existing, incoming) {
|
|
|
685
834
|
quota: mergeHeaderQuotaForPersistence(existingEntry.quota, incoming.quota),
|
|
686
835
|
}
|
|
687
836
|
: incoming;
|
|
837
|
+
const preferredRefreshError = (() => {
|
|
838
|
+
const existingError = existingEntry.lastRefreshError;
|
|
839
|
+
const incomingError = effectiveIncoming.lastRefreshError;
|
|
840
|
+
if (!existingError)
|
|
841
|
+
return incomingError;
|
|
842
|
+
if (incomingError) {
|
|
843
|
+
return incomingError.checkedAt >= existingError.checkedAt
|
|
844
|
+
? incomingError
|
|
845
|
+
: existingError;
|
|
846
|
+
}
|
|
847
|
+
return (effectiveIncoming.lastRefreshedAt ?? 0) >
|
|
848
|
+
(existingEntry.lastRefreshedAt ?? 0)
|
|
849
|
+
? undefined
|
|
850
|
+
: existingError;
|
|
851
|
+
})();
|
|
688
852
|
const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota);
|
|
689
853
|
const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(effectiveIncoming.quota);
|
|
690
854
|
const existingQuotaWinsEqualTimestamp = Boolean(existingQuotaCheckedAt === incomingQuotaCheckedAt &&
|
|
@@ -720,15 +884,14 @@ function mergeAccountRuntimeState(existing, incoming) {
|
|
|
720
884
|
if (!('lastQuotaRefreshError' in effectiveIncoming)) {
|
|
721
885
|
delete merged.lastQuotaRefreshError;
|
|
722
886
|
}
|
|
723
|
-
|
|
724
|
-
delete merged.lastRefreshError;
|
|
725
|
-
}
|
|
887
|
+
merged.lastRefreshError = preferredRefreshError;
|
|
726
888
|
return merged;
|
|
727
889
|
}
|
|
728
890
|
return {
|
|
729
891
|
...merged,
|
|
730
892
|
quota: existingEntry.quota,
|
|
731
893
|
lastQuotaRefreshError: existingEntry.lastQuotaRefreshError,
|
|
894
|
+
lastRefreshError: preferredRefreshError,
|
|
732
895
|
};
|
|
733
896
|
}
|
|
734
897
|
const merged = {
|
|
@@ -744,9 +907,7 @@ function mergeAccountRuntimeState(existing, incoming) {
|
|
|
744
907
|
if (!('lastQuotaRefreshError' in effectiveIncoming)) {
|
|
745
908
|
delete merged.lastQuotaRefreshError;
|
|
746
909
|
}
|
|
747
|
-
|
|
748
|
-
delete merged.lastRefreshError;
|
|
749
|
-
}
|
|
910
|
+
merged.lastRefreshError = preferredRefreshError;
|
|
750
911
|
return merged;
|
|
751
912
|
}
|
|
752
913
|
function configFromStorage(storage) {
|
|
@@ -780,9 +941,11 @@ function configFromStorage(storage) {
|
|
|
780
941
|
dump: storage.dump,
|
|
781
942
|
logging: storage.logging,
|
|
782
943
|
claudeFast: storage.claudeFast,
|
|
944
|
+
thinkingBinding: storage.thinkingBinding,
|
|
783
945
|
costZeroing: storage.costZeroing,
|
|
784
946
|
cacheKeep: storage.cacheKeep,
|
|
785
947
|
relay: storage.relay,
|
|
948
|
+
claustrum: storage.claustrum,
|
|
786
949
|
killswitch: storage.killswitch,
|
|
787
950
|
prime: (() => {
|
|
788
951
|
// Config side carries ONLY the `enabled` flag — runtime counters and
|
|
@@ -796,6 +959,9 @@ function configFromStorage(storage) {
|
|
|
796
959
|
accounts: storage.accounts.map(accountConfig),
|
|
797
960
|
});
|
|
798
961
|
}
|
|
962
|
+
export function isClaustrumEnabledForAccount(storage, accountId) {
|
|
963
|
+
return storage.claustrum?.accounts?.[accountId]?.enabled === true;
|
|
964
|
+
}
|
|
799
965
|
// ---------------------------------------------------------------------------
|
|
800
966
|
// In-process save mutex — serializes all account-store writes so concurrent
|
|
801
967
|
// read-modify-write callers (background timers that call saveAccountState with
|
|
@@ -999,7 +1165,10 @@ function applyMainQuotaStatePatch(state, storage) {
|
|
|
999
1165
|
state.main.quotaErrorGeneration = incomingGeneration;
|
|
1000
1166
|
}
|
|
1001
1167
|
}
|
|
1002
|
-
else if (incomingError
|
|
1168
|
+
else if (incomingError &&
|
|
1169
|
+
typeof incomingError.checkedAt === 'number' &&
|
|
1170
|
+
Number.isFinite(incomingError.checkedAt) &&
|
|
1171
|
+
incomingError.checkedAt > existingObservedAt) {
|
|
1003
1172
|
const acceptsByObservation = incomingErrorObservedAt !== undefined &&
|
|
1004
1173
|
incomingErrorObservedAt > existingObservedAt;
|
|
1005
1174
|
const acceptsEqualObservation = incomingErrorObservedAt !== undefined &&
|
|
@@ -1051,7 +1220,23 @@ function applyMainQuotaStatePatch(state, storage) {
|
|
|
1051
1220
|
}
|
|
1052
1221
|
function applyMainRefreshStatePatch(state, storage) {
|
|
1053
1222
|
state.main = state.main ?? {};
|
|
1054
|
-
|
|
1223
|
+
const incomingError = storage.refresh?.mainLastRefreshError;
|
|
1224
|
+
const incomingClearedAt = storage.refresh?.mainRefreshErrorClearedAt;
|
|
1225
|
+
const existingErrorObservedAt = state.main.lastRefreshError?.checkedAt;
|
|
1226
|
+
const existingClearedAt = state.main.refreshErrorClearedAt;
|
|
1227
|
+
const existingObservedAt = Math.max(existingErrorObservedAt ?? 0, existingClearedAt ?? 0);
|
|
1228
|
+
if (incomingError === undefined &&
|
|
1229
|
+
incomingClearedAt !== undefined &&
|
|
1230
|
+
incomingClearedAt >= existingObservedAt) {
|
|
1231
|
+
state.main.lastRefreshError = undefined;
|
|
1232
|
+
state.main.refreshErrorClearedAt = mergeMainRefreshErrorClearedAt(existingClearedAt, incomingClearedAt);
|
|
1233
|
+
}
|
|
1234
|
+
else if (incomingError &&
|
|
1235
|
+
typeof incomingError.checkedAt === 'number' &&
|
|
1236
|
+
Number.isFinite(incomingError.checkedAt) &&
|
|
1237
|
+
incomingError.checkedAt > existingObservedAt) {
|
|
1238
|
+
state.main.lastRefreshError = incomingError;
|
|
1239
|
+
}
|
|
1055
1240
|
state.main.refreshLeaseId = storage.refresh?.mainRefreshLeaseId;
|
|
1056
1241
|
state.main.refreshLeaseUntil = storage.refresh?.mainRefreshLeaseUntil;
|
|
1057
1242
|
state.main.refreshLeaseTokenHash = storage.refresh?.mainRefreshLeaseTokenHash;
|
|
@@ -1201,6 +1386,44 @@ export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
|
|
|
1201
1386
|
}
|
|
1202
1387
|
});
|
|
1203
1388
|
}
|
|
1389
|
+
export function clearClaustrumRefreshErrorPersistent(accountId, handle, path = getAccountStoragePath()) {
|
|
1390
|
+
return enqueueSave(async () => {
|
|
1391
|
+
const configLock = await acquireAccountConfigWriteLock(path);
|
|
1392
|
+
try {
|
|
1393
|
+
const stateLock = await acquireAccountStateWriteLock(path);
|
|
1394
|
+
try {
|
|
1395
|
+
// Re-read under both locks so this field-scoped clear cannot write a stale credential or config snapshot.
|
|
1396
|
+
const storage = await loadAccounts(path);
|
|
1397
|
+
const account = storage?.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
|
|
1398
|
+
if (!storage ||
|
|
1399
|
+
!account ||
|
|
1400
|
+
account.claustrumHandle !== handle ||
|
|
1401
|
+
!isClaustrumEnabledForAccount(storage, accountId) ||
|
|
1402
|
+
!account.lastRefreshError) {
|
|
1403
|
+
return false;
|
|
1404
|
+
}
|
|
1405
|
+
account.lastRefreshError = undefined;
|
|
1406
|
+
await saveAccountStateUnlocked(storage, path, { accounts: [accountId] });
|
|
1407
|
+
const statePath = getAccountStatePath(path);
|
|
1408
|
+
const state = (await readJsonIfPresent(statePath)).value;
|
|
1409
|
+
if (isRecord(state) && isRecord(state.accounts)) {
|
|
1410
|
+
const entry = state.accounts[accountId];
|
|
1411
|
+
if (isRecord(entry)) {
|
|
1412
|
+
delete entry.lastRefreshError;
|
|
1413
|
+
await writeJsonAtomic(statePath, pruneUndefined(state));
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
return true;
|
|
1417
|
+
}
|
|
1418
|
+
finally {
|
|
1419
|
+
await stateLock.release();
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
finally {
|
|
1423
|
+
await configLock.release();
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1204
1427
|
async function saveAccountStateUnlocked(storage, path, scope) {
|
|
1205
1428
|
const statePath = getAccountStatePath(path);
|
|
1206
1429
|
const existing = (await readJsonIfPresent(statePath)).value;
|
|
@@ -1217,11 +1440,65 @@ async function saveAccountStateUnlocked(storage, path, scope) {
|
|
|
1217
1440
|
applyMainPrimeStatePatch(next, storage);
|
|
1218
1441
|
if (scope.accounts) {
|
|
1219
1442
|
const ids = scope.accounts === true ? null : new Set(scope.accounts);
|
|
1443
|
+
const config = (await readJsonIfPresent(path)).value;
|
|
1444
|
+
const configuredIds = (() => {
|
|
1445
|
+
if (!isRecord(config) || !Array.isArray(config.accounts))
|
|
1446
|
+
return null;
|
|
1447
|
+
if (config.accounts.length === 0)
|
|
1448
|
+
return new Set();
|
|
1449
|
+
const stateAccounts = isRecord(next.accounts) ? next.accounts : {};
|
|
1450
|
+
const incomingAccounts = Object.fromEntries(storage.accounts.map((account) => [
|
|
1451
|
+
account.id.trim(),
|
|
1452
|
+
accountRuntimeState(account),
|
|
1453
|
+
]));
|
|
1454
|
+
// Keep both forms because legacy state may still use either key while
|
|
1455
|
+
// scoped saves must preserve entries not superseded by an incoming account.
|
|
1456
|
+
const memberships = config.accounts.map((account) => {
|
|
1457
|
+
if (!isRecord(account) || typeof account.id !== 'string')
|
|
1458
|
+
return null;
|
|
1459
|
+
const id = account.id.trim();
|
|
1460
|
+
if (!id)
|
|
1461
|
+
return null;
|
|
1462
|
+
if (configAccountHasInvalidShape(account))
|
|
1463
|
+
return null;
|
|
1464
|
+
const stateValue = stateAccounts[account.id] ?? stateAccounts[id];
|
|
1465
|
+
const stateAccount = isRecord(stateValue)
|
|
1466
|
+
? stateValue
|
|
1467
|
+
: {};
|
|
1468
|
+
const incomingAccount = incomingAccounts[id];
|
|
1469
|
+
return normalizeAccount(mergeConfigAccountAndState(account, incomingAccount ?? stateAccount))
|
|
1470
|
+
? [account.id, id]
|
|
1471
|
+
: null;
|
|
1472
|
+
});
|
|
1473
|
+
// A populated config with any unparseable entry cannot establish safe membership.
|
|
1474
|
+
return memberships.every((membership) => membership !== null)
|
|
1475
|
+
? new Set(memberships.flatMap((membership) => membership ?? []))
|
|
1476
|
+
: null;
|
|
1477
|
+
})();
|
|
1220
1478
|
next.accounts = { ...(isRecord(next.accounts) ? next.accounts : {}) };
|
|
1221
1479
|
for (const account of storage.accounts) {
|
|
1222
|
-
|
|
1480
|
+
const accountId = account.id.trim();
|
|
1481
|
+
if (ids && !ids.has(account.id) && !ids.has(accountId))
|
|
1482
|
+
continue;
|
|
1483
|
+
if (configuredIds &&
|
|
1484
|
+
!configuredIds.has(account.id) &&
|
|
1485
|
+
!configuredIds.has(accountId))
|
|
1223
1486
|
continue;
|
|
1224
|
-
|
|
1487
|
+
const legacyKeys = Object.keys(next.accounts).filter((key) => key !== accountId && key.trim() === accountId);
|
|
1488
|
+
const legacyKey = legacyKeys[0];
|
|
1489
|
+
const existingAccount = next.accounts[accountId] ??
|
|
1490
|
+
(legacyKey ? next.accounts[legacyKey] : undefined);
|
|
1491
|
+
for (const key of legacyKeys)
|
|
1492
|
+
delete next.accounts[key];
|
|
1493
|
+
next.accounts[accountId] = mergeAccountRuntimeState(existingAccount, accountRuntimeState(account));
|
|
1494
|
+
}
|
|
1495
|
+
if (configuredIds) {
|
|
1496
|
+
// Config membership is authoritative for scoped writes too; otherwise a
|
|
1497
|
+
// stale writer can preserve state for an account removed out of band.
|
|
1498
|
+
for (const id of Object.keys(next.accounts)) {
|
|
1499
|
+
if (!configuredIds.has(id))
|
|
1500
|
+
delete next.accounts[id];
|
|
1501
|
+
}
|
|
1225
1502
|
}
|
|
1226
1503
|
if (ids) {
|
|
1227
1504
|
for (const id of ids) {
|
|
@@ -1230,19 +1507,6 @@ async function saveAccountStateUnlocked(storage, path, scope) {
|
|
|
1230
1507
|
}
|
|
1231
1508
|
}
|
|
1232
1509
|
}
|
|
1233
|
-
else {
|
|
1234
|
-
// Full save: drop any per-account state whose id is no longer present in
|
|
1235
|
-
// storage.accounts. The scoped path above only prunes ids it was asked to
|
|
1236
|
-
// save; on a removal the storage is saved with scope.accounts === true
|
|
1237
|
-
// (ids === null), so without this branch the removed account's runtime
|
|
1238
|
-
// state (quota/lastRefreshError/access/refresh/expires) would be orphaned
|
|
1239
|
-
// in the state file and later merged onto a re-added same-id account.
|
|
1240
|
-
const present = new Set(storage.accounts.map((account) => account.id));
|
|
1241
|
-
for (const id of Object.keys(next.accounts)) {
|
|
1242
|
-
if (!present.has(id))
|
|
1243
|
-
delete next.accounts[id];
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
1510
|
}
|
|
1247
1511
|
await writeJsonAtomic(statePath, pruneUndefined(next));
|
|
1248
1512
|
}
|
|
@@ -1717,6 +1981,9 @@ function refreshBeforeExpiryMs(storage) {
|
|
|
1717
1981
|
DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES;
|
|
1718
1982
|
return Math.max(MIN_REFRESH_BEFORE_EXPIRY_MINUTES, minutes) * 60_000;
|
|
1719
1983
|
}
|
|
1984
|
+
export function getRefreshBeforeExpiryMs(storage) {
|
|
1985
|
+
return refreshBeforeExpiryMs(storage);
|
|
1986
|
+
}
|
|
1720
1987
|
export function getRefreshIntervalMs(storage) {
|
|
1721
1988
|
const minutes = storage?.refresh?.intervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
|
|
1722
1989
|
return Math.max(1, minutes) * 60_000;
|
|
@@ -1729,17 +1996,11 @@ function isTransientRefreshError(error) {
|
|
|
1729
1996
|
if (typeof status === 'number' && Number.isFinite(status)) {
|
|
1730
1997
|
return status === 429 || status >= 500;
|
|
1731
1998
|
}
|
|
1732
|
-
|
|
1733
|
-
return false;
|
|
1734
|
-
return (error.message.includes('fetch failed') ||
|
|
1735
|
-
('code' in error &&
|
|
1736
|
-
(error.code === 'ECONNRESET' ||
|
|
1737
|
-
error.code === 'ECONNREFUSED' ||
|
|
1738
|
-
error.code === 'ETIMEDOUT' ||
|
|
1739
|
-
error.code === 'UND_ERR_CONNECT_TIMEOUT')));
|
|
1999
|
+
return isTransientNetworkError(error);
|
|
1740
2000
|
}
|
|
1741
2001
|
export function buildRefreshOperationError(input) {
|
|
1742
|
-
const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity
|
|
2002
|
+
const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity &&
|
|
2003
|
+
input.previous?.refreshTokenFingerprint === input.refreshTokenFingerprint
|
|
1743
2004
|
? (input.previous?.retryCount ?? 0)
|
|
1744
2005
|
: 0;
|
|
1745
2006
|
const retryCount = previousRetryCount + 1;
|
|
@@ -1749,6 +2010,9 @@ export function buildRefreshOperationError(input) {
|
|
|
1749
2010
|
if (typeof retryAfterFromError === 'number' && retryAfterFromError > 0) {
|
|
1750
2011
|
delay = retryAfterFromError * 1000;
|
|
1751
2012
|
}
|
|
2013
|
+
else if (isTransientNetworkError(input.error)) {
|
|
2014
|
+
delay = MIN_REFRESH_RETRY_DELAY_MS;
|
|
2015
|
+
}
|
|
1752
2016
|
else if (isTransientRefreshError(input.error)) {
|
|
1753
2017
|
delay = Math.min(MAX_REFRESH_RETRY_DELAY_MS, MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6));
|
|
1754
2018
|
}
|
|
@@ -1762,9 +2026,10 @@ export function buildRefreshOperationError(input) {
|
|
|
1762
2026
|
const message = formatErrorMessage(input.error);
|
|
1763
2027
|
// A token is permanently dead ONLY on 400 invalid_grant. The OAuth spec allows
|
|
1764
2028
|
// other 400s (invalid_client / invalid_request / unsupported_grant_type) that
|
|
1765
|
-
// re-login does NOT fix — those
|
|
1766
|
-
//
|
|
1767
|
-
//
|
|
2029
|
+
// re-login does NOT fix — those must stay permanent=false so they are not
|
|
2030
|
+
// falsely flagged "needs re-login". Network failures use a fixed five-minute
|
|
2031
|
+
// retry interval so connectivity recovery cannot inherit an hour-long backoff.
|
|
2032
|
+
// ClaudeOAuthRefreshError carries the raw
|
|
1768
2033
|
// OAuth body, and its message embeds it (`...: 400 — <body>`), so check both.
|
|
1769
2034
|
const body = typeof input.error.body === 'string'
|
|
1770
2035
|
? input.error.body
|
|
@@ -1776,6 +2041,7 @@ export function buildRefreshOperationError(input) {
|
|
|
1776
2041
|
nextRetryAt: input.now + delay,
|
|
1777
2042
|
retryCount,
|
|
1778
2043
|
accountIdentity: input.accountIdentity,
|
|
2044
|
+
refreshTokenFingerprint: input.refreshTokenFingerprint,
|
|
1779
2045
|
status,
|
|
1780
2046
|
permanent: status === 400 && isInvalidGrant,
|
|
1781
2047
|
};
|
|
@@ -1802,15 +2068,32 @@ export function isPermanentRefreshError(error) {
|
|
|
1802
2068
|
return error.permanent;
|
|
1803
2069
|
if (typeof error.status === 'number')
|
|
1804
2070
|
return error.status === 400;
|
|
2071
|
+
if (isTransientNetworkError(error))
|
|
2072
|
+
return false;
|
|
1805
2073
|
if (typeof error.nextRetryAt === 'number') {
|
|
1806
2074
|
return (error.nextRetryAt - error.checkedAt >=
|
|
1807
2075
|
NON_TRANSIENT_REFRESH_RETRY_DELAY_MS);
|
|
1808
2076
|
}
|
|
1809
2077
|
return false;
|
|
1810
2078
|
}
|
|
1811
|
-
|
|
1812
|
-
|
|
2079
|
+
function effectiveRefreshRetryAt(error) {
|
|
2080
|
+
const persistedRetryAt = error.nextRetryAt;
|
|
2081
|
+
if (!persistedRetryAt || !isTransientNetworkError(error)) {
|
|
2082
|
+
return persistedRetryAt;
|
|
2083
|
+
}
|
|
2084
|
+
return Math.min(persistedRetryAt, error.checkedAt + MIN_REFRESH_RETRY_DELAY_MS);
|
|
2085
|
+
}
|
|
2086
|
+
export function refreshBackoffActive(error, accountIdentity, now, currentRefreshTokenFingerprint) {
|
|
2087
|
+
if (!error)
|
|
2088
|
+
return false;
|
|
2089
|
+
const retryAt = effectiveRefreshRetryAt(error);
|
|
2090
|
+
if (!retryAt || retryAt <= now)
|
|
1813
2091
|
return false;
|
|
2092
|
+
if (error.refreshTokenFingerprint &&
|
|
2093
|
+
currentRefreshTokenFingerprint &&
|
|
2094
|
+
error.refreshTokenFingerprint !== currentRefreshTokenFingerprint) {
|
|
2095
|
+
return false;
|
|
2096
|
+
}
|
|
1814
2097
|
if (!error.accountIdentity)
|
|
1815
2098
|
return true;
|
|
1816
2099
|
if (!accountIdentity)
|
|
@@ -1818,7 +2101,7 @@ export function refreshBackoffActive(error, accountIdentity, now) {
|
|
|
1818
2101
|
return error.accountIdentity === accountIdentity;
|
|
1819
2102
|
}
|
|
1820
2103
|
export function formatRefreshBackoffMessage(error, now) {
|
|
1821
|
-
const seconds = Math.max(1, Math.ceil(((error
|
|
2104
|
+
const seconds = Math.max(1, Math.ceil(((effectiveRefreshRetryAt(error) ?? now) - now) / 1000));
|
|
1822
2105
|
return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
|
|
1823
2106
|
}
|
|
1824
2107
|
function createRefreshBackoffActiveError(error, now) {
|
|
@@ -2209,18 +2492,11 @@ function isTransientQuotaError(error) {
|
|
|
2209
2492
|
if (formattedMessage.includes('Quota refresh is already in progress')) {
|
|
2210
2493
|
return true;
|
|
2211
2494
|
}
|
|
2212
|
-
|
|
2213
|
-
return false;
|
|
2214
|
-
const message = error.message;
|
|
2215
|
-
const code = error.code;
|
|
2216
|
-
return (message.includes('fetch failed') ||
|
|
2217
|
-
code === 'ECONNRESET' ||
|
|
2218
|
-
code === 'ECONNREFUSED' ||
|
|
2219
|
-
code === 'ETIMEDOUT' ||
|
|
2220
|
-
code === 'UND_ERR_CONNECT_TIMEOUT');
|
|
2495
|
+
return isTransientNetworkError(error);
|
|
2221
2496
|
}
|
|
2222
2497
|
function canUseCachedQuotaAfterRefreshError(account, storage, error, now) {
|
|
2223
|
-
return (
|
|
2498
|
+
return (Boolean(account.access && account.expires && account.expires > now) &&
|
|
2499
|
+
isTransientQuotaError(error) &&
|
|
2224
2500
|
quotaSnapshotPassesPolicy(account.quota, storage) &&
|
|
2225
2501
|
cachedQuotaSnapshotStillRelevant(account.quota, now));
|
|
2226
2502
|
}
|
|
@@ -2370,7 +2646,7 @@ export async function fetchOAuthQuotaSnapshot(input) {
|
|
|
2370
2646
|
const checkedAt = input.now?.() ?? Date.now();
|
|
2371
2647
|
const usage = (await response.json());
|
|
2372
2648
|
const bindingWindow = mapBindingWindow(usage.limits);
|
|
2373
|
-
|
|
2649
|
+
const snapshot = {
|
|
2374
2650
|
five_hour: mapUsageWindow(usage.five_hour, checkedAt),
|
|
2375
2651
|
seven_day: mapUsageWindow(usage.seven_day, checkedAt),
|
|
2376
2652
|
scoped: mapScopedWeeklyLimits(usage.limits, checkedAt),
|
|
@@ -2382,6 +2658,17 @@ export async function fetchOAuthQuotaSnapshot(input) {
|
|
|
2382
2658
|
source: 'poll',
|
|
2383
2659
|
checkedAt,
|
|
2384
2660
|
};
|
|
2661
|
+
const fieldSources = {
|
|
2662
|
+
...(snapshot.five_hour && { five_hour: 'poll' }),
|
|
2663
|
+
...(snapshot.seven_day && { seven_day: 'poll' }),
|
|
2664
|
+
...(snapshot.scoped && { scoped: 'poll' }),
|
|
2665
|
+
...(snapshot.extraUsage && { extraUsage: 'poll' }),
|
|
2666
|
+
...(snapshot.bindingWindow && { bindingWindow: 'poll' }),
|
|
2667
|
+
};
|
|
2668
|
+
return {
|
|
2669
|
+
...snapshot,
|
|
2670
|
+
...(Object.keys(fieldSources).length > 0 && { fieldSources }),
|
|
2671
|
+
};
|
|
2385
2672
|
}
|
|
2386
2673
|
function updateStoredAccount(storage, account) {
|
|
2387
2674
|
const index = storage.accounts.findIndex((candidate) => candidate.id === account.id);
|
|
@@ -2433,6 +2720,8 @@ function formatErrorMessage(error) {
|
|
|
2433
2720
|
return error instanceof Error ? error.message : String(error);
|
|
2434
2721
|
}
|
|
2435
2722
|
function recordRefreshError(account, error, now) {
|
|
2723
|
+
if (error instanceof CustodyTombstoneRefreshError)
|
|
2724
|
+
return;
|
|
2436
2725
|
const existing = existingRefreshBackoffError(error);
|
|
2437
2726
|
if (existing) {
|
|
2438
2727
|
account.lastRefreshError = existing;
|
|
@@ -2442,10 +2731,13 @@ function recordRefreshError(account, error, now) {
|
|
|
2442
2731
|
error,
|
|
2443
2732
|
now,
|
|
2444
2733
|
accountIdentity: account.id,
|
|
2734
|
+
refreshTokenFingerprint: tokenFingerprint(account.refresh),
|
|
2445
2735
|
previous: account.lastRefreshError,
|
|
2446
2736
|
});
|
|
2447
2737
|
}
|
|
2448
2738
|
function recordQuotaRefreshError(account, error, now) {
|
|
2739
|
+
if (error instanceof CustodyTombstoneRefreshError)
|
|
2740
|
+
return;
|
|
2449
2741
|
if (isQuotaPolicyAuthError(error) || existingRefreshBackoffError(error))
|
|
2450
2742
|
return;
|
|
2451
2743
|
account.lastQuotaRefreshError = buildQuotaOperationError({
|
|
@@ -2471,6 +2763,10 @@ export class FallbackAccountManager {
|
|
|
2471
2763
|
refreshTimer = null;
|
|
2472
2764
|
quotaTimer = null;
|
|
2473
2765
|
quotaManager;
|
|
2766
|
+
isFallbackAccountVaultServed;
|
|
2767
|
+
isFallbackAccountVaultEnabled;
|
|
2768
|
+
resolveFallbackAccessToken;
|
|
2769
|
+
onBackgroundRefresh;
|
|
2474
2770
|
onFallbackStorageChanged;
|
|
2475
2771
|
setIntervalImpl;
|
|
2476
2772
|
clearIntervalImpl;
|
|
@@ -2479,6 +2775,21 @@ export class FallbackAccountManager {
|
|
|
2479
2775
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
2480
2776
|
this.configPath = options.configPath ?? getAccountStoragePath();
|
|
2481
2777
|
this.quotaManager = options.quotaManager ?? null;
|
|
2778
|
+
this.isFallbackAccountVaultServed =
|
|
2779
|
+
options.isFallbackAccountVaultServed ?? (() => false);
|
|
2780
|
+
this.isFallbackAccountVaultEnabled =
|
|
2781
|
+
options.isFallbackAccountVaultEnabled ?? (() => false);
|
|
2782
|
+
this.resolveFallbackAccessToken =
|
|
2783
|
+
options.resolveFallbackAccessToken ??
|
|
2784
|
+
((account) => {
|
|
2785
|
+
if (!account.access ||
|
|
2786
|
+
account.expires === undefined ||
|
|
2787
|
+
account.expires <= this.now()) {
|
|
2788
|
+
return undefined;
|
|
2789
|
+
}
|
|
2790
|
+
return { token: account.access, source: 'sidecar' };
|
|
2791
|
+
});
|
|
2792
|
+
this.onBackgroundRefresh = options.onBackgroundRefresh;
|
|
2482
2793
|
this.onFallbackStorageChanged = options.onFallbackStorageChanged;
|
|
2483
2794
|
this.setIntervalImpl = options.setIntervalImpl ?? globalThis.setInterval;
|
|
2484
2795
|
this.clearIntervalImpl =
|
|
@@ -2515,18 +2826,18 @@ export class FallbackAccountManager {
|
|
|
2515
2826
|
});
|
|
2516
2827
|
}
|
|
2517
2828
|
startBackgroundRefresh() {
|
|
2518
|
-
const run = async () => {
|
|
2829
|
+
const run = async (initial = false) => {
|
|
2830
|
+
await this.onBackgroundRefresh?.(initial);
|
|
2519
2831
|
await this.refreshDueAccounts();
|
|
2520
2832
|
await this.refreshQuotaForDueAccounts();
|
|
2521
2833
|
};
|
|
2522
|
-
|
|
2834
|
+
const initialRun = run(true).catch(() => { });
|
|
2523
2835
|
if (!this.refreshTimer) {
|
|
2524
|
-
this.refreshTimer = this.setIntervalImpl(() => {
|
|
2525
|
-
void run().catch(() => { });
|
|
2526
|
-
}, BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS));
|
|
2836
|
+
this.refreshTimer = this.setIntervalImpl(() => run().catch(() => { }), FALLBACK_BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS));
|
|
2527
2837
|
if ('unref' in this.refreshTimer)
|
|
2528
2838
|
this.refreshTimer.unref();
|
|
2529
2839
|
}
|
|
2840
|
+
return initialRun;
|
|
2530
2841
|
}
|
|
2531
2842
|
stopBackgroundRefresh() {
|
|
2532
2843
|
if (this.refreshTimer)
|
|
@@ -2547,10 +2858,20 @@ export class FallbackAccountManager {
|
|
|
2547
2858
|
continue;
|
|
2548
2859
|
let next = account;
|
|
2549
2860
|
try {
|
|
2550
|
-
if (tokenNeedsRefresh(next, storage, this.now())
|
|
2861
|
+
if (tokenNeedsRefresh(next, storage, this.now()) &&
|
|
2862
|
+
(!this.isFallbackAccountVaultEnabled(next.id, storage) ||
|
|
2863
|
+
next.expires === undefined ||
|
|
2864
|
+
next.expires <= this.now()) &&
|
|
2865
|
+
!this.isFallbackAccountVaultServed(next.id, storage)) {
|
|
2866
|
+
if (this.isFallbackAccountVaultEnabled(next.id, storage)) {
|
|
2867
|
+
logger.warn('refresh', 'custody override: local fallback refresh', {
|
|
2868
|
+
accountId: next.id,
|
|
2869
|
+
reason: 'vault credential unavailable',
|
|
2870
|
+
});
|
|
2871
|
+
}
|
|
2551
2872
|
const refreshError = next.lastRefreshError;
|
|
2552
2873
|
if (refreshError &&
|
|
2553
|
-
refreshBackoffActive(refreshError, next.id, this.now())) {
|
|
2874
|
+
refreshBackoffActive(refreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
|
|
2554
2875
|
throw createRefreshBackoffActiveError(refreshError, this.now());
|
|
2555
2876
|
}
|
|
2556
2877
|
next = await this.refreshAccount(next, storage);
|
|
@@ -2566,8 +2887,9 @@ export class FallbackAccountManager {
|
|
|
2566
2887
|
// the background refreshQuotaForDueAccounts() guard.
|
|
2567
2888
|
if (stale &&
|
|
2568
2889
|
!quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
|
|
2569
|
-
|
|
2570
|
-
|
|
2890
|
+
const result = await this.refreshAccountQuota(next, storage);
|
|
2891
|
+
next = result.account;
|
|
2892
|
+
changed ||= result.changed;
|
|
2571
2893
|
}
|
|
2572
2894
|
// Single source of truth: evaluate quota policy from the unified
|
|
2573
2895
|
// QuotaManager cache (the same source as the staleness check above) so
|
|
@@ -2590,7 +2912,7 @@ export class FallbackAccountManager {
|
|
|
2590
2912
|
}
|
|
2591
2913
|
}
|
|
2592
2914
|
else if (!failClosedOnUnknownQuota(storage) &&
|
|
2593
|
-
!refreshBackoffActive(next.lastRefreshError, next.id, this.now()) &&
|
|
2915
|
+
!refreshBackoffActive(next.lastRefreshError, next.id, this.now(), tokenFingerprint(next.refresh)) &&
|
|
2594
2916
|
quotaSnapshotPassesModelScope(next.quota, options.modelId)) {
|
|
2595
2917
|
usable.push(next);
|
|
2596
2918
|
}
|
|
@@ -2634,9 +2956,11 @@ export class FallbackAccountManager {
|
|
|
2634
2956
|
for (const account of storage.accounts) {
|
|
2635
2957
|
if (account.enabled === false || !isOAuthAccount(account))
|
|
2636
2958
|
continue;
|
|
2637
|
-
if (!tokenNeedsRefresh(account, storage, this.now())
|
|
2959
|
+
if (!tokenNeedsRefresh(account, storage, this.now()) ||
|
|
2960
|
+
this.isFallbackAccountVaultEnabled(account.id, storage) ||
|
|
2961
|
+
this.isFallbackAccountVaultServed(account.id, storage))
|
|
2638
2962
|
continue;
|
|
2639
|
-
if (refreshBackoffActive(account.lastRefreshError, account.id, this.now())) {
|
|
2963
|
+
if (refreshBackoffActive(account.lastRefreshError, account.id, this.now(), tokenFingerprint(account.refresh))) {
|
|
2640
2964
|
// Backoff skips are steady-state while a fallback account is waiting for
|
|
2641
2965
|
// its next retry. Logging every background tick from every OpenCode
|
|
2642
2966
|
// process creates noise without adding new diagnostic signal; the
|
|
@@ -2678,8 +3002,10 @@ export class FallbackAccountManager {
|
|
|
2678
3002
|
continue;
|
|
2679
3003
|
let next = account;
|
|
2680
3004
|
try {
|
|
2681
|
-
if (tokenNeedsRefresh(next, storage, this.now())
|
|
2682
|
-
|
|
3005
|
+
if (tokenNeedsRefresh(next, storage, this.now()) &&
|
|
3006
|
+
!this.isFallbackAccountVaultEnabled(next.id, storage) &&
|
|
3007
|
+
!this.isFallbackAccountVaultServed(next.id, storage)) {
|
|
3008
|
+
if (refreshBackoffActive(next.lastRefreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
|
|
2683
3009
|
continue;
|
|
2684
3010
|
}
|
|
2685
3011
|
next = await this.refreshAccount(next, storage);
|
|
@@ -2696,8 +3022,8 @@ export class FallbackAccountManager {
|
|
|
2696
3022
|
: quotaIsStale(next, storage, this.now());
|
|
2697
3023
|
if (!stale)
|
|
2698
3024
|
continue;
|
|
2699
|
-
await this.refreshAccountQuota(next, storage);
|
|
2700
|
-
changed
|
|
3025
|
+
const result = await this.refreshAccountQuota(next, storage);
|
|
3026
|
+
changed ||= result.changed;
|
|
2701
3027
|
}
|
|
2702
3028
|
catch (error) {
|
|
2703
3029
|
recordQuotaRefreshError(account, error, this.now());
|
|
@@ -2723,10 +3049,12 @@ export class FallbackAccountManager {
|
|
|
2723
3049
|
continue;
|
|
2724
3050
|
let next = account;
|
|
2725
3051
|
try {
|
|
2726
|
-
if (tokenNeedsRefresh(next, storage, this.now())
|
|
3052
|
+
if (tokenNeedsRefresh(next, storage, this.now()) &&
|
|
3053
|
+
!this.isFallbackAccountVaultServed(next.id, storage) &&
|
|
3054
|
+
!this.isFallbackAccountVaultEnabled(next.id, storage)) {
|
|
2727
3055
|
const refreshError = next.lastRefreshError;
|
|
2728
3056
|
if (refreshError &&
|
|
2729
|
-
refreshBackoffActive(refreshError, next.id, this.now())) {
|
|
3057
|
+
refreshBackoffActive(refreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
|
|
2730
3058
|
throw createRefreshBackoffActiveError(refreshError, this.now());
|
|
2731
3059
|
}
|
|
2732
3060
|
next = await this.refreshAccount(next, storage);
|
|
@@ -2743,8 +3071,8 @@ export class FallbackAccountManager {
|
|
|
2743
3071
|
}
|
|
2744
3072
|
continue;
|
|
2745
3073
|
}
|
|
2746
|
-
await this.refreshAccountQuota(next, storage);
|
|
2747
|
-
changed
|
|
3074
|
+
const result = await this.refreshAccountQuota(next, storage);
|
|
3075
|
+
changed ||= result.changed;
|
|
2748
3076
|
}
|
|
2749
3077
|
catch (error) {
|
|
2750
3078
|
recordQuotaRefreshError(account, error, this.now());
|
|
@@ -2761,6 +3089,8 @@ export class FallbackAccountManager {
|
|
|
2761
3089
|
return { storage, errors };
|
|
2762
3090
|
}
|
|
2763
3091
|
async refreshAccount(account, storage, options = {}) {
|
|
3092
|
+
if (this.isFallbackAccountVaultServed(account.id, storage))
|
|
3093
|
+
return account;
|
|
2764
3094
|
const existing = this.refreshPromises.get(account.id);
|
|
2765
3095
|
if (existing) {
|
|
2766
3096
|
const refreshed = await existing;
|
|
@@ -2771,9 +3101,19 @@ export class FallbackAccountManager {
|
|
|
2771
3101
|
this.refreshPromises.delete(account.id);
|
|
2772
3102
|
});
|
|
2773
3103
|
this.refreshPromises.set(account.id, promise);
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
3104
|
+
try {
|
|
3105
|
+
const refreshed = await promise;
|
|
3106
|
+
updateStoredAccount(storage, refreshed);
|
|
3107
|
+
return refreshed;
|
|
3108
|
+
}
|
|
3109
|
+
catch (error) {
|
|
3110
|
+
if (options.persistError) {
|
|
3111
|
+
recordRefreshError(account, error, this.now());
|
|
3112
|
+
updateStoredAccount(storage, account);
|
|
3113
|
+
await this.save(storage);
|
|
3114
|
+
}
|
|
3115
|
+
throw error;
|
|
3116
|
+
}
|
|
2777
3117
|
}
|
|
2778
3118
|
async waitForConcurrentFallbackRefresh(account, storage, previous, options) {
|
|
2779
3119
|
const deadline = Date.now() + FALLBACK_REFRESH_JOIN_WAIT_MS;
|
|
@@ -2800,7 +3140,7 @@ export class FallbackAccountManager {
|
|
|
2800
3140
|
}
|
|
2801
3141
|
const refreshError = latestAccount.lastRefreshError;
|
|
2802
3142
|
if (refreshError &&
|
|
2803
|
-
refreshBackoffActive(refreshError, latestAccount.id, this.now())) {
|
|
3143
|
+
refreshBackoffActive(refreshError, latestAccount.id, this.now(), tokenFingerprint(latestAccount.refresh))) {
|
|
2804
3144
|
updateStoredAccount(storage, latestAccount);
|
|
2805
3145
|
throw createRefreshBackoffActiveError(refreshError, this.now());
|
|
2806
3146
|
}
|
|
@@ -2880,9 +3220,28 @@ export class FallbackAccountManager {
|
|
|
2880
3220
|
}
|
|
2881
3221
|
}
|
|
2882
3222
|
async refreshAccountQuota(account, storage) {
|
|
3223
|
+
const initialQuotaState = JSON.stringify([
|
|
3224
|
+
account.quota,
|
|
3225
|
+
account.lastQuotaRefreshError,
|
|
3226
|
+
]);
|
|
3227
|
+
let changed = false;
|
|
2883
3228
|
let target = account;
|
|
2884
|
-
|
|
2885
|
-
|
|
3229
|
+
const vaultEnabled = this.isFallbackAccountVaultEnabled(target.id, storage);
|
|
3230
|
+
let access = this.resolveFallbackAccessToken(target, storage);
|
|
3231
|
+
if (!access && !vaultEnabled) {
|
|
3232
|
+
target = await this.refreshAccount(account, storage, { force: true });
|
|
3233
|
+
changed = true;
|
|
3234
|
+
access = this.resolveFallbackAccessToken(target, storage);
|
|
3235
|
+
}
|
|
3236
|
+
if (!access) {
|
|
3237
|
+
log('[quota] fallback quota poll skipped: no usable credential', {
|
|
3238
|
+
accountId: target.id,
|
|
3239
|
+
});
|
|
3240
|
+
return {
|
|
3241
|
+
account: target,
|
|
3242
|
+
fetched: false,
|
|
3243
|
+
changed,
|
|
3244
|
+
};
|
|
2886
3245
|
}
|
|
2887
3246
|
// Unify on the shared QuotaManager when present: it adds inflight
|
|
2888
3247
|
// deduplication and 429 backoff gating around the same quota API. Fall back
|
|
@@ -2897,21 +3256,34 @@ export class FallbackAccountManager {
|
|
|
2897
3256
|
const fetchStartedAt = this.now();
|
|
2898
3257
|
let fetched = false;
|
|
2899
3258
|
try {
|
|
2900
|
-
const result = await fetchSnapshot(
|
|
3259
|
+
const result = await fetchSnapshot(access.token);
|
|
2901
3260
|
target.quota = result.quota;
|
|
2902
3261
|
fetched = result.fetched;
|
|
2903
3262
|
}
|
|
2904
3263
|
catch (error) {
|
|
2905
3264
|
const message = error instanceof Error ? error.message : String(error);
|
|
2906
|
-
if (!message.includes('Claude quota check failed: 401')
|
|
3265
|
+
if (!message.includes('Claude quota check failed: 401') ||
|
|
3266
|
+
vaultEnabled ||
|
|
3267
|
+
access.source !== 'sidecar') {
|
|
2907
3268
|
throw error;
|
|
3269
|
+
}
|
|
2908
3270
|
target = await this.refreshAccount(account, storage, {
|
|
2909
3271
|
force: true,
|
|
2910
3272
|
});
|
|
2911
|
-
|
|
2912
|
-
|
|
3273
|
+
changed = true;
|
|
3274
|
+
access = this.resolveFallbackAccessToken(target, storage);
|
|
3275
|
+
if (!access) {
|
|
3276
|
+
log('[quota] fallback quota poll skipped after refresh: no usable credential', {
|
|
3277
|
+
accountId: target.id,
|
|
3278
|
+
});
|
|
3279
|
+
return {
|
|
3280
|
+
account: target,
|
|
3281
|
+
fetched: false,
|
|
3282
|
+
changed,
|
|
3283
|
+
};
|
|
3284
|
+
}
|
|
2913
3285
|
// 401 does not arm QuotaManager backoff, so this retry proceeds.
|
|
2914
|
-
const result = await fetchSnapshot(
|
|
3286
|
+
const result = await fetchSnapshot(access.token);
|
|
2915
3287
|
target.quota = result.quota;
|
|
2916
3288
|
fetched = result.fetched;
|
|
2917
3289
|
}
|
|
@@ -2922,16 +3294,17 @@ export class FallbackAccountManager {
|
|
|
2922
3294
|
latestAccount.access !== target.access) {
|
|
2923
3295
|
this.seedFallbackQuota(latestAccount, latestStorage);
|
|
2924
3296
|
updateStoredAccount(storage, latestAccount);
|
|
2925
|
-
return { account: latestAccount, fetched: false };
|
|
3297
|
+
return { account: latestAccount, fetched: false, changed: false };
|
|
2926
3298
|
}
|
|
2927
3299
|
if (latestStorage &&
|
|
2928
|
-
latestAccount
|
|
3300
|
+
latestAccount &&
|
|
3301
|
+
latestAccount.access === target.access &&
|
|
2929
3302
|
latestAccount.quota &&
|
|
2930
3303
|
quotaSnapshotCheckedAt(latestAccount.quota) >= fetchStartedAt &&
|
|
2931
3304
|
quotaSnapshotIsFresh(latestAccount.quota, latestStorage, this.now())) {
|
|
2932
3305
|
this.seedFallbackQuota(latestAccount, latestStorage);
|
|
2933
3306
|
updateStoredAccount(storage, latestAccount);
|
|
2934
|
-
return { account: latestAccount, fetched };
|
|
3307
|
+
return { account: latestAccount, fetched, changed: false };
|
|
2935
3308
|
}
|
|
2936
3309
|
target.lastQuotaRefreshError = undefined;
|
|
2937
3310
|
updateStoredAccount(storage, target);
|
|
@@ -2946,6 +3319,12 @@ export class FallbackAccountManager {
|
|
|
2946
3319
|
checkedAt: now,
|
|
2947
3320
|
}, target);
|
|
2948
3321
|
}
|
|
2949
|
-
return {
|
|
3322
|
+
return {
|
|
3323
|
+
account: target,
|
|
3324
|
+
fetched,
|
|
3325
|
+
changed: changed ||
|
|
3326
|
+
JSON.stringify([target.quota, target.lastQuotaRefreshError]) !==
|
|
3327
|
+
initialQuotaState,
|
|
3328
|
+
};
|
|
2950
3329
|
}
|
|
2951
3330
|
}
|