@cortexkit/anthropic-auth-core 1.20.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 +66 -16
- package/dist/accounts.js +693 -223
- 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 +4 -3
- package/dist/claude-code.js +130 -25
- 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.d.ts +1 -0
- package/dist/dump.js +8 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -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/oauth-profile.d.ts +2 -1
- package/dist/oauth-profile.js +10 -4
- package/dist/quota-header-feed.d.ts +52 -0
- package/dist/quota-header-feed.js +279 -0
- package/dist/quota-headers.js +63 -8
- package/dist/quota-manager.d.ts +41 -28
- package/dist/quota-manager.js +405 -148
- package/dist/sticky-routing.d.ts +14 -2
- package/dist/sticky-routing.js +30 -13
- 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,14 +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';
|
|
8
11
|
import { tokenFingerprint } from './token-fingerprint.js';
|
|
9
12
|
const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
|
|
10
13
|
const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
|
|
11
14
|
export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
|
|
12
15
|
export const ACCOUNT_STATE_FILE_NAME = 'anthropic-auth-state.json';
|
|
13
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
|
+
];
|
|
14
25
|
export function isOAuthAccount(account) {
|
|
15
26
|
return account.type === 'oauth';
|
|
16
27
|
}
|
|
@@ -55,7 +66,7 @@ const DEFAULT_MINIMUM_REMAINING = {
|
|
|
55
66
|
seven_day: 0,
|
|
56
67
|
};
|
|
57
68
|
const DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA = true;
|
|
58
|
-
const
|
|
69
|
+
export const FALLBACK_BACKGROUND_TICK_MS = 60_000;
|
|
59
70
|
const BACKGROUND_TICK_JITTER_MS = 60_000;
|
|
60
71
|
const FALLBACK_REFRESH_LOCK_TTL_MS = 10 * 60_000;
|
|
61
72
|
const FALLBACK_REFRESH_JOIN_WAIT_MS = 10_000;
|
|
@@ -119,6 +130,9 @@ function normalizeAccount(value) {
|
|
|
119
130
|
authLineageId: typeof value.authLineageId === 'string' && value.authLineageId.trim()
|
|
120
131
|
? value.authLineageId
|
|
121
132
|
: undefined,
|
|
133
|
+
claustrumHandle: typeof value.claustrumHandle === 'string' && value.claustrumHandle.trim()
|
|
134
|
+
? value.claustrumHandle.trim()
|
|
135
|
+
: undefined,
|
|
122
136
|
access: typeof value.access === 'string' ? value.access : undefined,
|
|
123
137
|
refresh: value.refresh,
|
|
124
138
|
expires: typeof value.expires === 'number' ? value.expires : undefined,
|
|
@@ -147,6 +161,10 @@ function normalizeOAuthAccountProfile(value) {
|
|
|
147
161
|
tier: value.tier.trim(),
|
|
148
162
|
orgType: value.orgType.trim(),
|
|
149
163
|
checkedAt: value.checkedAt,
|
|
164
|
+
...(typeof value.accountIdentity === 'string' &&
|
|
165
|
+
value.accountIdentity.trim() && {
|
|
166
|
+
accountIdentity: value.accountIdentity.trim(),
|
|
167
|
+
}),
|
|
150
168
|
...(typeof value.tokenFingerprint === 'string' &&
|
|
151
169
|
value.tokenFingerprint.trim() && {
|
|
152
170
|
tokenFingerprint: value.tokenFingerprint.trim(),
|
|
@@ -169,7 +187,14 @@ function normalizeOperationError(value) {
|
|
|
169
187
|
checkedAt,
|
|
170
188
|
nextRetryAt: Number.isFinite(nextRetryAt) ? nextRetryAt : undefined,
|
|
171
189
|
retryCount: Number.isFinite(retryCount) ? retryCount : undefined,
|
|
190
|
+
accountIdentity: typeof value.accountIdentity === 'string' && value.accountIdentity.trim()
|
|
191
|
+
? value.accountIdentity.trim()
|
|
192
|
+
: undefined,
|
|
172
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,
|
|
173
198
|
// Preserve the dead-token discriminators across save/load. Without these,
|
|
174
199
|
// a retry-exhausted transient (permanent=false, 24h backoff) would lose its
|
|
175
200
|
// flag on reload and the 24h-delay heuristic would wrongly re-classify it
|
|
@@ -234,6 +259,10 @@ function normalizeQuota(value) {
|
|
|
234
259
|
if (typeof value.checkedAt === 'number' && Number.isFinite(value.checkedAt)) {
|
|
235
260
|
quota.checkedAt = value.checkedAt;
|
|
236
261
|
}
|
|
262
|
+
if (typeof value.accountIdentity === 'string' &&
|
|
263
|
+
value.accountIdentity.trim()) {
|
|
264
|
+
quota.accountIdentity = value.accountIdentity.trim();
|
|
265
|
+
}
|
|
237
266
|
if (Array.isArray(value.scoped)) {
|
|
238
267
|
const scoped = value.scoped
|
|
239
268
|
.map((entry) => {
|
|
@@ -299,6 +328,20 @@ function normalizeQuota(value) {
|
|
|
299
328
|
if (value.source === 'poll' || value.source === 'headers') {
|
|
300
329
|
quota.source = value.source;
|
|
301
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
|
+
}
|
|
302
345
|
return Object.keys(quota).length ? quota : undefined;
|
|
303
346
|
}
|
|
304
347
|
function normalizeQuotaMoney(value) {
|
|
@@ -332,6 +375,9 @@ function normalizeStorage(value) {
|
|
|
332
375
|
return null;
|
|
333
376
|
return {
|
|
334
377
|
version: 1,
|
|
378
|
+
mainAccountId: typeof value.mainAccountId === 'string' && value.mainAccountId.trim()
|
|
379
|
+
? value.mainAccountId.trim()
|
|
380
|
+
: undefined,
|
|
335
381
|
main: {
|
|
336
382
|
type: 'opencode',
|
|
337
383
|
provider: 'anthropic',
|
|
@@ -343,12 +389,19 @@ function normalizeStorage(value) {
|
|
|
343
389
|
: undefined,
|
|
344
390
|
refresh: isRecord(value.refresh) ? value.refresh : undefined,
|
|
345
391
|
quota: isRecord(value.quota) ? value.quota : undefined,
|
|
392
|
+
quotaHeaderFeed: isRecord(value.quotaHeaderFeed)
|
|
393
|
+
? value.quotaHeaderFeed
|
|
394
|
+
: undefined,
|
|
346
395
|
claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined,
|
|
347
396
|
dump: isRecord(value.dump) ? value.dump : undefined,
|
|
348
397
|
claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
|
|
398
|
+
thinkingBinding: isRecord(value.thinkingBinding)
|
|
399
|
+
? value.thinkingBinding
|
|
400
|
+
: undefined,
|
|
349
401
|
costZeroing: isRecord(value.costZeroing) ? value.costZeroing : undefined,
|
|
350
402
|
cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
|
|
351
403
|
relay: isRecord(value.relay) ? value.relay : undefined,
|
|
404
|
+
claustrum: normalizeClaustrumConfig(value.claustrum),
|
|
352
405
|
logging: isRecord(value.logging) ? value.logging : undefined,
|
|
353
406
|
killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
|
|
354
407
|
prime: (() => {
|
|
@@ -385,9 +438,31 @@ function normalizeStorage(value) {
|
|
|
385
438
|
.filter((account) => account != null),
|
|
386
439
|
};
|
|
387
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
|
+
}
|
|
388
460
|
async function readJsonIfPresent(path) {
|
|
389
461
|
try {
|
|
390
|
-
return {
|
|
462
|
+
return {
|
|
463
|
+
exists: true,
|
|
464
|
+
value: parseJsonRedacted(await readFile(path, 'utf8')),
|
|
465
|
+
};
|
|
391
466
|
}
|
|
392
467
|
catch (error) {
|
|
393
468
|
if (error.code === 'ENOENT') {
|
|
@@ -403,6 +478,21 @@ function objectWithDefinedEntries(value) {
|
|
|
403
478
|
function numericField(value) {
|
|
404
479
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
405
480
|
}
|
|
481
|
+
// A clear is a monotonic tombstone; older or absent writes must not reopen backoff.
|
|
482
|
+
export function mergeMainQuotaErrorClearedAt(existing, incoming) {
|
|
483
|
+
if (incoming === undefined || !Number.isFinite(incoming))
|
|
484
|
+
return existing;
|
|
485
|
+
if (existing === undefined || !Number.isFinite(existing))
|
|
486
|
+
return incoming;
|
|
487
|
+
return Math.max(existing, incoming);
|
|
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
|
+
}
|
|
406
496
|
function accountCredentialTimestamp(value) {
|
|
407
497
|
return Math.max(numericField(value.lastRefreshedAt), numericField(value.lastUsed), numericField(value.addedAt));
|
|
408
498
|
}
|
|
@@ -437,6 +527,21 @@ function mergeConfigAccountAndState(account, stateAccount) {
|
|
|
437
527
|
}
|
|
438
528
|
return { ...account, ...stateAccount };
|
|
439
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
|
+
}
|
|
440
545
|
function mergeConfigAndState(configValue, stateValue) {
|
|
441
546
|
if (!isRecord(configValue))
|
|
442
547
|
return configValue;
|
|
@@ -447,12 +552,42 @@ function mergeConfigAndState(configValue, stateValue) {
|
|
|
447
552
|
const refreshConfig = isRecord(configValue.refresh) ? configValue.refresh : {};
|
|
448
553
|
const mainQuotaSource = mainState ?? quotaConfig;
|
|
449
554
|
const mainRefreshSource = mainState ?? refreshConfig;
|
|
555
|
+
const mainQuotaErrorClearedAt = typeof mainState?.quotaErrorClearedAt === 'number' &&
|
|
556
|
+
Number.isFinite(mainState.quotaErrorClearedAt) &&
|
|
557
|
+
mainState.quotaErrorClearedAt >= 0
|
|
558
|
+
? mainState.quotaErrorClearedAt
|
|
559
|
+
: undefined;
|
|
560
|
+
const mainRefreshErrorClearedAt = typeof mainState?.refreshErrorClearedAt === 'number' &&
|
|
561
|
+
Number.isFinite(mainState.refreshErrorClearedAt) &&
|
|
562
|
+
mainState.refreshErrorClearedAt >= 0
|
|
563
|
+
? mainState.refreshErrorClearedAt
|
|
564
|
+
: undefined;
|
|
565
|
+
const configQuotaError = quotaConfig.mainLastQuotaApiError;
|
|
566
|
+
const mainLastQuotaApiError = mainState?.lastQuotaApiError ??
|
|
567
|
+
(mainQuotaErrorClearedAt !== undefined &&
|
|
568
|
+
isRecord(configQuotaError) &&
|
|
569
|
+
typeof configQuotaError.checkedAt === 'number' &&
|
|
570
|
+
configQuotaError.checkedAt <= mainQuotaErrorClearedAt
|
|
571
|
+
? undefined
|
|
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);
|
|
450
581
|
const accounts = Array.isArray(configValue.accounts)
|
|
451
582
|
? configValue.accounts.map((account) => {
|
|
452
583
|
if (!isRecord(account))
|
|
453
584
|
return account;
|
|
454
|
-
const
|
|
455
|
-
|
|
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
|
|
456
591
|
: {};
|
|
457
592
|
return mergeConfigAccountAndState(account, stateAccount);
|
|
458
593
|
})
|
|
@@ -466,7 +601,8 @@ function mergeConfigAndState(configValue, stateValue) {
|
|
|
466
601
|
},
|
|
467
602
|
refresh: objectWithDefinedEntries({
|
|
468
603
|
...refreshConfig,
|
|
469
|
-
mainLastRefreshError
|
|
604
|
+
mainLastRefreshError,
|
|
605
|
+
mainRefreshErrorClearedAt: mainRefreshSource.refreshErrorClearedAt,
|
|
470
606
|
mainRefreshLeaseId: mainRefreshSource.refreshLeaseId,
|
|
471
607
|
mainRefreshLeaseUntil: mainRefreshSource.refreshLeaseUntil,
|
|
472
608
|
mainRefreshLeaseTokenHash: mainRefreshSource.refreshLeaseTokenHash,
|
|
@@ -476,7 +612,9 @@ function mergeConfigAndState(configValue, stateValue) {
|
|
|
476
612
|
mainQuota: mainQuotaSource.quota,
|
|
477
613
|
mainQuotaCheckedAt: mainQuotaSource.quotaCheckedAt,
|
|
478
614
|
mainQuotaToken: mainQuotaSource.quotaToken,
|
|
479
|
-
mainLastQuotaApiError
|
|
615
|
+
mainLastQuotaApiError,
|
|
616
|
+
mainQuotaErrorGeneration: mainQuotaSource.quotaErrorGeneration,
|
|
617
|
+
mainQuotaErrorClearedAt: mainQuotaSource.quotaErrorClearedAt,
|
|
480
618
|
}),
|
|
481
619
|
// Carry the main-side prime counters from the state file back into the
|
|
482
620
|
// merged storage so a subsequent read sees the cumulative counters. The
|
|
@@ -553,6 +691,7 @@ function accountRuntimeState(account) {
|
|
|
553
691
|
}
|
|
554
692
|
return objectWithDefinedEntries({
|
|
555
693
|
authLineageId: account.authLineageId,
|
|
694
|
+
claustrumHandle: account.claustrumHandle,
|
|
556
695
|
access: account.access,
|
|
557
696
|
refresh: account.refresh,
|
|
558
697
|
expires: account.expires,
|
|
@@ -610,17 +749,61 @@ function mergeHeaderOwnedWindow(existingSnapshot, incomingSnapshot, key) {
|
|
|
610
749
|
? existing
|
|
611
750
|
: incoming;
|
|
612
751
|
}
|
|
613
|
-
function
|
|
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
|
+
}
|
|
793
|
+
export function mergeHeaderQuotaForPersistence(existing, incoming) {
|
|
614
794
|
if (!existing || incoming.source !== 'headers')
|
|
615
795
|
return incoming;
|
|
616
796
|
const preservePollBinding = existing.bindingWindowSource === 'poll';
|
|
617
|
-
|
|
797
|
+
const merged = {
|
|
618
798
|
...existing,
|
|
619
799
|
...incoming,
|
|
620
800
|
five_hour: mergeHeaderOwnedWindow(existing, incoming, 'five_hour'),
|
|
621
801
|
seven_day: mergeHeaderOwnedWindow(existing, incoming, 'seven_day'),
|
|
622
802
|
scoped: mergeHeaderScopedQuota(existing, incoming),
|
|
623
803
|
extraUsage: existing.extraUsage ?? incoming.extraUsage,
|
|
804
|
+
fallbackAdvised: incoming.fieldSources?.fallbackAdvised !== undefined
|
|
805
|
+
? incoming.fallbackAdvised
|
|
806
|
+
: (existing.fallbackAdvised ?? incoming.fallbackAdvised),
|
|
624
807
|
bindingWindow: preservePollBinding
|
|
625
808
|
? existing.bindingWindow
|
|
626
809
|
: (incoming.bindingWindow ?? existing.bindingWindow),
|
|
@@ -628,6 +811,11 @@ function mergeHeaderQuotaForPersistence(existing, incoming) {
|
|
|
628
811
|
? 'poll'
|
|
629
812
|
: (incoming.bindingWindowSource ?? existing.bindingWindowSource),
|
|
630
813
|
};
|
|
814
|
+
const fieldSources = fieldSourcesForMergedQuota(existing, incoming, merged);
|
|
815
|
+
return {
|
|
816
|
+
...merged,
|
|
817
|
+
...(fieldSources && { fieldSources }),
|
|
818
|
+
};
|
|
631
819
|
}
|
|
632
820
|
function mergeAccountRuntimeState(existing, incoming) {
|
|
633
821
|
if (!isRecord(existing))
|
|
@@ -646,6 +834,21 @@ function mergeAccountRuntimeState(existing, incoming) {
|
|
|
646
834
|
quota: mergeHeaderQuotaForPersistence(existingEntry.quota, incoming.quota),
|
|
647
835
|
}
|
|
648
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
|
+
})();
|
|
649
852
|
const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota);
|
|
650
853
|
const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(effectiveIncoming.quota);
|
|
651
854
|
const existingQuotaWinsEqualTimestamp = Boolean(existingQuotaCheckedAt === incomingQuotaCheckedAt &&
|
|
@@ -681,15 +884,14 @@ function mergeAccountRuntimeState(existing, incoming) {
|
|
|
681
884
|
if (!('lastQuotaRefreshError' in effectiveIncoming)) {
|
|
682
885
|
delete merged.lastQuotaRefreshError;
|
|
683
886
|
}
|
|
684
|
-
|
|
685
|
-
delete merged.lastRefreshError;
|
|
686
|
-
}
|
|
887
|
+
merged.lastRefreshError = preferredRefreshError;
|
|
687
888
|
return merged;
|
|
688
889
|
}
|
|
689
890
|
return {
|
|
690
891
|
...merged,
|
|
691
892
|
quota: existingEntry.quota,
|
|
692
893
|
lastQuotaRefreshError: existingEntry.lastQuotaRefreshError,
|
|
894
|
+
lastRefreshError: preferredRefreshError,
|
|
693
895
|
};
|
|
694
896
|
}
|
|
695
897
|
const merged = {
|
|
@@ -705,9 +907,7 @@ function mergeAccountRuntimeState(existing, incoming) {
|
|
|
705
907
|
if (!('lastQuotaRefreshError' in effectiveIncoming)) {
|
|
706
908
|
delete merged.lastQuotaRefreshError;
|
|
707
909
|
}
|
|
708
|
-
|
|
709
|
-
delete merged.lastRefreshError;
|
|
710
|
-
}
|
|
910
|
+
merged.lastRefreshError = preferredRefreshError;
|
|
711
911
|
return merged;
|
|
712
912
|
}
|
|
713
913
|
function configFromStorage(storage) {
|
|
@@ -730,18 +930,22 @@ function configFromStorage(storage) {
|
|
|
730
930
|
: undefined;
|
|
731
931
|
return omitUndefinedTopLevel({
|
|
732
932
|
version: 1,
|
|
933
|
+
mainAccountId: storage.mainAccountId,
|
|
733
934
|
main: { type: 'opencode', provider: 'anthropic' },
|
|
734
935
|
routing: storage.routing,
|
|
735
936
|
fallbackOn: storage.fallbackOn,
|
|
736
937
|
refresh,
|
|
737
938
|
quota,
|
|
939
|
+
quotaHeaderFeed: storage.quotaHeaderFeed,
|
|
738
940
|
claudeCache: storage.claudeCache,
|
|
739
941
|
dump: storage.dump,
|
|
740
942
|
logging: storage.logging,
|
|
741
943
|
claudeFast: storage.claudeFast,
|
|
944
|
+
thinkingBinding: storage.thinkingBinding,
|
|
742
945
|
costZeroing: storage.costZeroing,
|
|
743
946
|
cacheKeep: storage.cacheKeep,
|
|
744
947
|
relay: storage.relay,
|
|
948
|
+
claustrum: storage.claustrum,
|
|
745
949
|
killswitch: storage.killswitch,
|
|
746
950
|
prime: (() => {
|
|
747
951
|
// Config side carries ONLY the `enabled` flag — runtime counters and
|
|
@@ -755,6 +959,9 @@ function configFromStorage(storage) {
|
|
|
755
959
|
accounts: storage.accounts.map(accountConfig),
|
|
756
960
|
});
|
|
757
961
|
}
|
|
962
|
+
export function isClaustrumEnabledForAccount(storage, accountId) {
|
|
963
|
+
return storage.claustrum?.accounts?.[accountId]?.enabled === true;
|
|
964
|
+
}
|
|
758
965
|
// ---------------------------------------------------------------------------
|
|
759
966
|
// In-process save mutex — serializes all account-store writes so concurrent
|
|
760
967
|
// read-modify-write callers (background timers that call saveAccountState with
|
|
@@ -857,6 +1064,27 @@ export function saveAccounts(storage, path = getAccountStoragePath(), options =
|
|
|
857
1064
|
const resolvedPath = path;
|
|
858
1065
|
return enqueueSave(() => saveAccountsLocked(storage, resolvedPath, options));
|
|
859
1066
|
}
|
|
1067
|
+
export async function getOrCreateMainAccountId(path = getAccountStoragePath(), createId = randomUUID) {
|
|
1068
|
+
return enqueueSave(async () => {
|
|
1069
|
+
const lock = await acquireAccountConfigWriteLock(path);
|
|
1070
|
+
try {
|
|
1071
|
+
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
1072
|
+
if (storage.mainAccountId)
|
|
1073
|
+
return storage.mainAccountId;
|
|
1074
|
+
const mainAccountId = createId();
|
|
1075
|
+
const nextStorage = { ...storage, mainAccountId };
|
|
1076
|
+
const existing = await loadExistingTopLevelFields(path);
|
|
1077
|
+
await writeJsonAtomic(path, {
|
|
1078
|
+
...existing,
|
|
1079
|
+
...configFromStorage(nextStorage),
|
|
1080
|
+
});
|
|
1081
|
+
return mainAccountId;
|
|
1082
|
+
}
|
|
1083
|
+
finally {
|
|
1084
|
+
await lock.release();
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
860
1088
|
async function saveAccountsLocked(storage, path, options) {
|
|
861
1089
|
const lock = await acquireAccountConfigWriteLock(path);
|
|
862
1090
|
try {
|
|
@@ -892,6 +1120,77 @@ function applyMainProfileStatePatch(state, storage) {
|
|
|
892
1120
|
}
|
|
893
1121
|
function applyMainQuotaStatePatch(state, storage) {
|
|
894
1122
|
state.main = state.main ?? {};
|
|
1123
|
+
const incomingError = storage.quota?.mainLastQuotaApiError;
|
|
1124
|
+
const incomingGeneration = typeof storage.quota?.mainQuotaErrorGeneration === 'number' &&
|
|
1125
|
+
Number.isSafeInteger(storage.quota.mainQuotaErrorGeneration) &&
|
|
1126
|
+
storage.quota.mainQuotaErrorGeneration >= 0
|
|
1127
|
+
? storage.quota.mainQuotaErrorGeneration
|
|
1128
|
+
: undefined;
|
|
1129
|
+
const incomingClearedAt = typeof storage.quota?.mainQuotaErrorClearedAt === 'number' &&
|
|
1130
|
+
Number.isFinite(storage.quota.mainQuotaErrorClearedAt) &&
|
|
1131
|
+
storage.quota.mainQuotaErrorClearedAt >= 0
|
|
1132
|
+
? storage.quota.mainQuotaErrorClearedAt
|
|
1133
|
+
: undefined;
|
|
1134
|
+
const existingGeneration = typeof state.main.quotaErrorGeneration === 'number' &&
|
|
1135
|
+
Number.isSafeInteger(state.main.quotaErrorGeneration) &&
|
|
1136
|
+
state.main.quotaErrorGeneration >= 0
|
|
1137
|
+
? state.main.quotaErrorGeneration
|
|
1138
|
+
: 0;
|
|
1139
|
+
const existingErrorObservedAt = typeof state.main.lastQuotaApiError?.checkedAt === 'number' &&
|
|
1140
|
+
Number.isFinite(state.main.lastQuotaApiError.checkedAt) &&
|
|
1141
|
+
state.main.lastQuotaApiError.checkedAt >= 0
|
|
1142
|
+
? state.main.lastQuotaApiError.checkedAt
|
|
1143
|
+
: undefined;
|
|
1144
|
+
const existingClearedAt = typeof state.main.quotaErrorClearedAt === 'number' &&
|
|
1145
|
+
Number.isFinite(state.main.quotaErrorClearedAt) &&
|
|
1146
|
+
state.main.quotaErrorClearedAt >= 0
|
|
1147
|
+
? state.main.quotaErrorClearedAt
|
|
1148
|
+
: undefined;
|
|
1149
|
+
const existingObservedAt = Math.max(existingErrorObservedAt ?? 0, existingClearedAt ?? 0);
|
|
1150
|
+
const incomingErrorObservedAt = typeof incomingError?.checkedAt === 'number' &&
|
|
1151
|
+
Number.isFinite(incomingError.checkedAt) &&
|
|
1152
|
+
incomingError.checkedAt >= 0
|
|
1153
|
+
? incomingError.checkedAt
|
|
1154
|
+
: undefined;
|
|
1155
|
+
// Error transitions are ordered by observation time, not write order. All
|
|
1156
|
+
// instances share one host clock, so an error observed before a clear cannot
|
|
1157
|
+
// resurrect that clear even when both processes allocated the same generation.
|
|
1158
|
+
if (incomingError === undefined &&
|
|
1159
|
+
incomingClearedAt !== undefined &&
|
|
1160
|
+
incomingClearedAt >= existingObservedAt) {
|
|
1161
|
+
state.main.lastQuotaApiError = undefined;
|
|
1162
|
+
state.main.quotaErrorClearedAt = mergeMainQuotaErrorClearedAt(existingClearedAt, incomingClearedAt);
|
|
1163
|
+
if (incomingGeneration !== undefined &&
|
|
1164
|
+
incomingGeneration >= existingGeneration) {
|
|
1165
|
+
state.main.quotaErrorGeneration = incomingGeneration;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
else if (incomingError &&
|
|
1169
|
+
typeof incomingError.checkedAt === 'number' &&
|
|
1170
|
+
Number.isFinite(incomingError.checkedAt) &&
|
|
1171
|
+
incomingError.checkedAt > existingObservedAt) {
|
|
1172
|
+
const acceptsByObservation = incomingErrorObservedAt !== undefined &&
|
|
1173
|
+
incomingErrorObservedAt > existingObservedAt;
|
|
1174
|
+
const acceptsEqualObservation = incomingErrorObservedAt !== undefined &&
|
|
1175
|
+
incomingErrorObservedAt === existingObservedAt &&
|
|
1176
|
+
existingErrorObservedAt !== undefined &&
|
|
1177
|
+
existingClearedAt === undefined &&
|
|
1178
|
+
incomingGeneration !== undefined &&
|
|
1179
|
+
incomingGeneration >= existingGeneration;
|
|
1180
|
+
const acceptsLegacyGeneration = incomingErrorObservedAt === undefined &&
|
|
1181
|
+
incomingClearedAt === undefined &&
|
|
1182
|
+
incomingGeneration !== undefined &&
|
|
1183
|
+
incomingGeneration >= existingGeneration &&
|
|
1184
|
+
existingClearedAt === undefined;
|
|
1185
|
+
if (acceptsByObservation ||
|
|
1186
|
+
acceptsEqualObservation ||
|
|
1187
|
+
acceptsLegacyGeneration) {
|
|
1188
|
+
state.main.lastQuotaApiError = incomingError;
|
|
1189
|
+
if (incomingGeneration !== undefined) {
|
|
1190
|
+
state.main.quotaErrorGeneration = Math.max(existingGeneration, incomingGeneration);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
895
1194
|
const incomingQuota = storage.quota?.mainQuota;
|
|
896
1195
|
const sameToken = Boolean(state.main.quotaToken &&
|
|
897
1196
|
storage.quota?.mainQuotaToken &&
|
|
@@ -918,11 +1217,26 @@ function applyMainQuotaStatePatch(state, storage) {
|
|
|
918
1217
|
? Math.max(existingCheckedAt, incomingCheckedAt)
|
|
919
1218
|
: storage.quota?.mainQuotaCheckedAt;
|
|
920
1219
|
state.main.quotaToken = storage.quota?.mainQuotaToken;
|
|
921
|
-
state.main.lastQuotaApiError = storage.quota?.mainLastQuotaApiError;
|
|
922
1220
|
}
|
|
923
1221
|
function applyMainRefreshStatePatch(state, storage) {
|
|
924
1222
|
state.main = state.main ?? {};
|
|
925
|
-
|
|
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
|
+
}
|
|
926
1240
|
state.main.refreshLeaseId = storage.refresh?.mainRefreshLeaseId;
|
|
927
1241
|
state.main.refreshLeaseUntil = storage.refresh?.mainRefreshLeaseUntil;
|
|
928
1242
|
state.main.refreshLeaseTokenHash = storage.refresh?.mainRefreshLeaseTokenHash;
|
|
@@ -989,38 +1303,41 @@ export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
|
|
|
989
1303
|
const next = isRecord(existing)
|
|
990
1304
|
? { ...existing, version: 1 }
|
|
991
1305
|
: { version: 1 };
|
|
992
|
-
const { accountId,
|
|
993
|
-
if (profile?.tokenFingerprint &&
|
|
994
|
-
profile.tokenFingerprint !== expectedTokenFingerprint) {
|
|
995
|
-
return false;
|
|
996
|
-
}
|
|
1306
|
+
const { accountId, accountIdentity, profile } = input;
|
|
997
1307
|
if (accountId === 'main') {
|
|
998
1308
|
next.main = { ...(next.main ?? {}) };
|
|
999
1309
|
const existingProfile = normalizeOAuthAccountProfile(next.main.profile);
|
|
1000
|
-
|
|
1001
|
-
if (!profile &&
|
|
1002
|
-
existingProfile &&
|
|
1003
|
-
persistedToken === expectedTokenFingerprint) {
|
|
1004
|
-
return false;
|
|
1005
|
-
}
|
|
1006
|
-
if (profile) {
|
|
1007
|
-
if (persistedToken && persistedToken !== expectedTokenFingerprint) {
|
|
1008
|
-
return false;
|
|
1009
|
-
}
|
|
1310
|
+
if (!profile) {
|
|
1010
1311
|
if (existingProfile &&
|
|
1011
|
-
|
|
1312
|
+
(accountIdentity === undefined ||
|
|
1313
|
+
existingProfile.accountIdentity === undefined ||
|
|
1314
|
+
existingProfile.accountIdentity === accountIdentity)) {
|
|
1012
1315
|
return false;
|
|
1013
1316
|
}
|
|
1317
|
+
next.main.profile = undefined;
|
|
1318
|
+
return await writeJsonAtomic(statePath, pruneUndefined(next)).then(() => true);
|
|
1014
1319
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1320
|
+
if (profile.accountIdentity !== undefined &&
|
|
1321
|
+
profile.accountIdentity !== accountIdentity) {
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
const persistedProfile = accountIdentity === undefined ||
|
|
1325
|
+
profile.accountIdentity === accountIdentity
|
|
1326
|
+
? profile
|
|
1327
|
+
: { ...profile, accountIdentity };
|
|
1328
|
+
if (existingProfile &&
|
|
1329
|
+
(accountIdentity === undefined ||
|
|
1330
|
+
existingProfile.accountIdentity === undefined ||
|
|
1331
|
+
existingProfile.accountIdentity === accountIdentity) &&
|
|
1332
|
+
existingProfile.checkedAt > persistedProfile.checkedAt) {
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
next.main.profile = persistedProfile;
|
|
1017
1336
|
}
|
|
1018
1337
|
else {
|
|
1019
1338
|
const account = current?.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
|
|
1020
|
-
if (!account
|
|
1021
|
-
tokenFingerprint(account.access) !== expectedTokenFingerprint) {
|
|
1339
|
+
if (!account)
|
|
1022
1340
|
return false;
|
|
1023
|
-
}
|
|
1024
1341
|
next.accounts = {
|
|
1025
1342
|
...(isRecord(next.accounts) ? next.accounts : {}),
|
|
1026
1343
|
};
|
|
@@ -1028,16 +1345,33 @@ export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
|
|
|
1028
1345
|
? { ...next.accounts[accountId] }
|
|
1029
1346
|
: {};
|
|
1030
1347
|
const existingProfile = normalizeOAuthAccountProfile(existingEntry.profile);
|
|
1031
|
-
if (!profile
|
|
1032
|
-
existingProfile
|
|
1033
|
-
|
|
1348
|
+
if (!profile) {
|
|
1349
|
+
if (existingProfile &&
|
|
1350
|
+
(accountIdentity === undefined ||
|
|
1351
|
+
existingProfile.accountIdentity === undefined ||
|
|
1352
|
+
existingProfile.accountIdentity === accountIdentity)) {
|
|
1353
|
+
return false;
|
|
1354
|
+
}
|
|
1355
|
+
existingEntry.profile = undefined;
|
|
1034
1356
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1357
|
+
else {
|
|
1358
|
+
if (profile.accountIdentity !== undefined &&
|
|
1359
|
+
profile.accountIdentity !== accountIdentity) {
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
const persistedProfile = accountIdentity === undefined ||
|
|
1363
|
+
profile.accountIdentity === accountIdentity
|
|
1364
|
+
? profile
|
|
1365
|
+
: { ...profile, accountIdentity };
|
|
1366
|
+
if (existingProfile &&
|
|
1367
|
+
(accountIdentity === undefined ||
|
|
1368
|
+
existingProfile.accountIdentity === undefined ||
|
|
1369
|
+
existingProfile.accountIdentity === accountIdentity) &&
|
|
1370
|
+
existingProfile.checkedAt > persistedProfile.checkedAt) {
|
|
1371
|
+
return false;
|
|
1372
|
+
}
|
|
1373
|
+
existingEntry.profile = persistedProfile;
|
|
1039
1374
|
}
|
|
1040
|
-
existingEntry.profile = profile;
|
|
1041
1375
|
next.accounts[accountId] = existingEntry;
|
|
1042
1376
|
}
|
|
1043
1377
|
await writeJsonAtomic(statePath, pruneUndefined(next));
|
|
@@ -1052,6 +1386,44 @@ export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
|
|
|
1052
1386
|
}
|
|
1053
1387
|
});
|
|
1054
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
|
+
}
|
|
1055
1427
|
async function saveAccountStateUnlocked(storage, path, scope) {
|
|
1056
1428
|
const statePath = getAccountStatePath(path);
|
|
1057
1429
|
const existing = (await readJsonIfPresent(statePath)).value;
|
|
@@ -1068,11 +1440,65 @@ async function saveAccountStateUnlocked(storage, path, scope) {
|
|
|
1068
1440
|
applyMainPrimeStatePatch(next, storage);
|
|
1069
1441
|
if (scope.accounts) {
|
|
1070
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
|
+
})();
|
|
1071
1478
|
next.accounts = { ...(isRecord(next.accounts) ? next.accounts : {}) };
|
|
1072
1479
|
for (const account of storage.accounts) {
|
|
1073
|
-
|
|
1480
|
+
const accountId = account.id.trim();
|
|
1481
|
+
if (ids && !ids.has(account.id) && !ids.has(accountId))
|
|
1074
1482
|
continue;
|
|
1075
|
-
|
|
1483
|
+
if (configuredIds &&
|
|
1484
|
+
!configuredIds.has(account.id) &&
|
|
1485
|
+
!configuredIds.has(accountId))
|
|
1486
|
+
continue;
|
|
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
|
+
}
|
|
1076
1502
|
}
|
|
1077
1503
|
if (ids) {
|
|
1078
1504
|
for (const id of ids) {
|
|
@@ -1081,19 +1507,6 @@ async function saveAccountStateUnlocked(storage, path, scope) {
|
|
|
1081
1507
|
}
|
|
1082
1508
|
}
|
|
1083
1509
|
}
|
|
1084
|
-
else {
|
|
1085
|
-
// Full save: drop any per-account state whose id is no longer present in
|
|
1086
|
-
// storage.accounts. The scoped path above only prunes ids it was asked to
|
|
1087
|
-
// save; on a removal the storage is saved with scope.accounts === true
|
|
1088
|
-
// (ids === null), so without this branch the removed account's runtime
|
|
1089
|
-
// state (quota/lastRefreshError/access/refresh/expires) would be orphaned
|
|
1090
|
-
// in the state file and later merged onto a re-added same-id account.
|
|
1091
|
-
const present = new Set(storage.accounts.map((account) => account.id));
|
|
1092
|
-
for (const id of Object.keys(next.accounts)) {
|
|
1093
|
-
if (!present.has(id))
|
|
1094
|
-
delete next.accounts[id];
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
1510
|
}
|
|
1098
1511
|
await writeJsonAtomic(statePath, pruneUndefined(next));
|
|
1099
1512
|
}
|
|
@@ -1421,12 +1834,8 @@ export async function setPrimePersistentEnabled(enabled, path = getAccountStorag
|
|
|
1421
1834
|
await saveAccounts(storage, path);
|
|
1422
1835
|
return storage;
|
|
1423
1836
|
}
|
|
1424
|
-
/**
|
|
1425
|
-
|
|
1426
|
-
* runtime state when older storage has no lineage yet. Main lookups return
|
|
1427
|
-
* undefined when the live refresh token is unavailable and no lineage exists.
|
|
1428
|
-
*/
|
|
1429
|
-
export async function getOrCreatePrimeAuthLineageId(accountId, path = getAccountStoragePath(), mainRefreshToken) {
|
|
1837
|
+
/** Return the stable prime marker identity for an OAuth account. */
|
|
1838
|
+
export async function getOrCreatePrimeAuthLineageId(accountId, path = getAccountStoragePath()) {
|
|
1430
1839
|
return enqueueSave(async () => {
|
|
1431
1840
|
const configLock = await acquireAccountConfigWriteLock(path);
|
|
1432
1841
|
try {
|
|
@@ -1435,52 +1844,23 @@ export async function getOrCreatePrimeAuthLineageId(accountId, path = getAccount
|
|
|
1435
1844
|
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
1436
1845
|
if (accountId === 'main') {
|
|
1437
1846
|
const existing = storage.prime?.mainAuthLineageId;
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
storage.refresh.mainRefreshLeaseUntil > Date.now()) {
|
|
1449
|
-
// The owner binds before publishing its rotated credential. An
|
|
1450
|
-
// active lease makes either side of that handoff ambiguous, so
|
|
1451
|
-
// suppress this tick instead of classifying it as a re-login.
|
|
1452
|
-
return existing;
|
|
1453
|
-
}
|
|
1454
|
-
if (!boundFingerprint) {
|
|
1455
|
-
// Legacy lineages predate refresh-token binding. The first
|
|
1456
|
-
// observation attaches the credential without changing markers.
|
|
1457
|
-
storage.prime = {
|
|
1458
|
-
...(storage.prime ?? {}),
|
|
1459
|
-
mainAuthLineageRefreshTokenFingerprint: observedFingerprint,
|
|
1460
|
-
};
|
|
1461
|
-
await saveAccountStateUnlocked(storage, path, {
|
|
1462
|
-
mainPrime: true,
|
|
1463
|
-
});
|
|
1464
|
-
return existing;
|
|
1465
|
-
}
|
|
1847
|
+
if (existing)
|
|
1848
|
+
return existing;
|
|
1849
|
+
const mainAccountId = storage.mainAccountId ?? randomUUID();
|
|
1850
|
+
if (!storage.mainAccountId) {
|
|
1851
|
+
const existingConfig = await loadExistingTopLevelFields(path);
|
|
1852
|
+
await writeJsonAtomic(path, {
|
|
1853
|
+
...existingConfig,
|
|
1854
|
+
...configFromStorage({ ...storage, mainAccountId }),
|
|
1855
|
+
});
|
|
1856
|
+
storage.mainAccountId = mainAccountId;
|
|
1466
1857
|
}
|
|
1467
|
-
// Without the live host credential there is no safe way to bind a
|
|
1468
|
-
// new identity. Skip this tick rather than creating an unbound
|
|
1469
|
-
// namespace that could churn or suppress another account.
|
|
1470
|
-
if (!observedFingerprint)
|
|
1471
|
-
return undefined;
|
|
1472
|
-
// A bound mismatch that did not pass through the owned refresh path
|
|
1473
|
-
// is a host credential replacement and needs a fresh marker identity.
|
|
1474
|
-
const authLineageId = randomUUID();
|
|
1475
1858
|
storage.prime = {
|
|
1476
1859
|
...(storage.prime ?? {}),
|
|
1477
|
-
mainAuthLineageId:
|
|
1478
|
-
...(observedFingerprint && {
|
|
1479
|
-
mainAuthLineageRefreshTokenFingerprint: observedFingerprint,
|
|
1480
|
-
}),
|
|
1860
|
+
mainAuthLineageId: mainAccountId,
|
|
1481
1861
|
};
|
|
1482
1862
|
await saveAccountStateUnlocked(storage, path, { mainPrime: true });
|
|
1483
|
-
return
|
|
1863
|
+
return mainAccountId;
|
|
1484
1864
|
}
|
|
1485
1865
|
const account = storage.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
|
|
1486
1866
|
if (!account) {
|
|
@@ -1507,42 +1887,6 @@ export async function getOrCreatePrimeAuthLineageId(accountId, path = getAccount
|
|
|
1507
1887
|
}
|
|
1508
1888
|
});
|
|
1509
1889
|
}
|
|
1510
|
-
/** Advance a main lineage across a refresh performed by this plugin. */
|
|
1511
|
-
export async function continueMainPrimeAuthLineageAfterRefresh(input, path = getAccountStoragePath()) {
|
|
1512
|
-
return enqueueSave(async () => {
|
|
1513
|
-
const configLock = await acquireAccountConfigWriteLock(path);
|
|
1514
|
-
try {
|
|
1515
|
-
const stateLock = await acquireAccountStateWriteLock(path);
|
|
1516
|
-
try {
|
|
1517
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
1518
|
-
const authLineageId = storage.prime?.mainAuthLineageId;
|
|
1519
|
-
if (!authLineageId)
|
|
1520
|
-
return;
|
|
1521
|
-
const previousFingerprint = tokenFingerprint(input.previousRefreshToken);
|
|
1522
|
-
const currentFingerprint = tokenFingerprint(input.currentRefreshToken);
|
|
1523
|
-
const boundFingerprint = storage.prime?.mainAuthLineageRefreshTokenFingerprint;
|
|
1524
|
-
if (boundFingerprint &&
|
|
1525
|
-
boundFingerprint !== previousFingerprint &&
|
|
1526
|
-
boundFingerprint !== currentFingerprint) {
|
|
1527
|
-
// Another process observed a host replacement after this refresh
|
|
1528
|
-
// started; the stale rotation must not rebind that newer lineage.
|
|
1529
|
-
return;
|
|
1530
|
-
}
|
|
1531
|
-
storage.prime = {
|
|
1532
|
-
...(storage.prime ?? {}),
|
|
1533
|
-
mainAuthLineageRefreshTokenFingerprint: currentFingerprint,
|
|
1534
|
-
};
|
|
1535
|
-
await saveAccountStateUnlocked(storage, path, { mainPrime: true });
|
|
1536
|
-
}
|
|
1537
|
-
finally {
|
|
1538
|
-
await stateLock.release();
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
finally {
|
|
1542
|
-
await configLock.release();
|
|
1543
|
-
}
|
|
1544
|
-
});
|
|
1545
|
-
}
|
|
1546
1890
|
/**
|
|
1547
1891
|
* Atomically increment an account's cumulative prime counters and persist via
|
|
1548
1892
|
* the scoped runtime-state path. The `main` account lives at state.main.prime;
|
|
@@ -1637,6 +1981,9 @@ function refreshBeforeExpiryMs(storage) {
|
|
|
1637
1981
|
DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES;
|
|
1638
1982
|
return Math.max(MIN_REFRESH_BEFORE_EXPIRY_MINUTES, minutes) * 60_000;
|
|
1639
1983
|
}
|
|
1984
|
+
export function getRefreshBeforeExpiryMs(storage) {
|
|
1985
|
+
return refreshBeforeExpiryMs(storage);
|
|
1986
|
+
}
|
|
1640
1987
|
export function getRefreshIntervalMs(storage) {
|
|
1641
1988
|
const minutes = storage?.refresh?.intervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
|
|
1642
1989
|
return Math.max(1, minutes) * 60_000;
|
|
@@ -1649,19 +1996,12 @@ function isTransientRefreshError(error) {
|
|
|
1649
1996
|
if (typeof status === 'number' && Number.isFinite(status)) {
|
|
1650
1997
|
return status === 429 || status >= 500;
|
|
1651
1998
|
}
|
|
1652
|
-
|
|
1653
|
-
return false;
|
|
1654
|
-
return (error.message.includes('fetch failed') ||
|
|
1655
|
-
('code' in error &&
|
|
1656
|
-
(error.code === 'ECONNRESET' ||
|
|
1657
|
-
error.code === 'ECONNREFUSED' ||
|
|
1658
|
-
error.code === 'ETIMEDOUT' ||
|
|
1659
|
-
error.code === 'UND_ERR_CONNECT_TIMEOUT')));
|
|
1999
|
+
return isTransientNetworkError(error);
|
|
1660
2000
|
}
|
|
1661
2001
|
export function buildRefreshOperationError(input) {
|
|
1662
|
-
const
|
|
1663
|
-
|
|
1664
|
-
? (input.previous
|
|
2002
|
+
const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity &&
|
|
2003
|
+
input.previous?.refreshTokenFingerprint === input.refreshTokenFingerprint
|
|
2004
|
+
? (input.previous?.retryCount ?? 0)
|
|
1665
2005
|
: 0;
|
|
1666
2006
|
const retryCount = previousRetryCount + 1;
|
|
1667
2007
|
const retryAfterFromError = input.error
|
|
@@ -1670,6 +2010,9 @@ export function buildRefreshOperationError(input) {
|
|
|
1670
2010
|
if (typeof retryAfterFromError === 'number' && retryAfterFromError > 0) {
|
|
1671
2011
|
delay = retryAfterFromError * 1000;
|
|
1672
2012
|
}
|
|
2013
|
+
else if (isTransientNetworkError(input.error)) {
|
|
2014
|
+
delay = MIN_REFRESH_RETRY_DELAY_MS;
|
|
2015
|
+
}
|
|
1673
2016
|
else if (isTransientRefreshError(input.error)) {
|
|
1674
2017
|
delay = Math.min(MAX_REFRESH_RETRY_DELAY_MS, MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6));
|
|
1675
2018
|
}
|
|
@@ -1683,9 +2026,10 @@ export function buildRefreshOperationError(input) {
|
|
|
1683
2026
|
const message = formatErrorMessage(input.error);
|
|
1684
2027
|
// A token is permanently dead ONLY on 400 invalid_grant. The OAuth spec allows
|
|
1685
2028
|
// other 400s (invalid_client / invalid_request / unsupported_grant_type) that
|
|
1686
|
-
// re-login does NOT fix — those
|
|
1687
|
-
//
|
|
1688
|
-
//
|
|
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
|
|
1689
2033
|
// OAuth body, and its message embeds it (`...: 400 — <body>`), so check both.
|
|
1690
2034
|
const body = typeof input.error.body === 'string'
|
|
1691
2035
|
? input.error.body
|
|
@@ -1696,7 +2040,8 @@ export function buildRefreshOperationError(input) {
|
|
|
1696
2040
|
checkedAt: input.now,
|
|
1697
2041
|
nextRetryAt: input.now + delay,
|
|
1698
2042
|
retryCount,
|
|
1699
|
-
|
|
2043
|
+
accountIdentity: input.accountIdentity,
|
|
2044
|
+
refreshTokenFingerprint: input.refreshTokenFingerprint,
|
|
1700
2045
|
status,
|
|
1701
2046
|
permanent: status === 400 && isInvalidGrant,
|
|
1702
2047
|
};
|
|
@@ -1723,21 +2068,40 @@ export function isPermanentRefreshError(error) {
|
|
|
1723
2068
|
return error.permanent;
|
|
1724
2069
|
if (typeof error.status === 'number')
|
|
1725
2070
|
return error.status === 400;
|
|
2071
|
+
if (isTransientNetworkError(error))
|
|
2072
|
+
return false;
|
|
1726
2073
|
if (typeof error.nextRetryAt === 'number') {
|
|
1727
2074
|
return (error.nextRetryAt - error.checkedAt >=
|
|
1728
2075
|
NON_TRANSIENT_REFRESH_RETRY_DELAY_MS);
|
|
1729
2076
|
}
|
|
1730
2077
|
return false;
|
|
1731
2078
|
}
|
|
1732
|
-
|
|
1733
|
-
|
|
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)
|
|
1734
2088
|
return false;
|
|
1735
|
-
|
|
2089
|
+
const retryAt = effectiveRefreshRetryAt(error);
|
|
2090
|
+
if (!retryAt || retryAt <= now)
|
|
2091
|
+
return false;
|
|
2092
|
+
if (error.refreshTokenFingerprint &&
|
|
2093
|
+
currentRefreshTokenFingerprint &&
|
|
2094
|
+
error.refreshTokenFingerprint !== currentRefreshTokenFingerprint) {
|
|
2095
|
+
return false;
|
|
2096
|
+
}
|
|
2097
|
+
if (!error.accountIdentity)
|
|
1736
2098
|
return true;
|
|
1737
|
-
|
|
2099
|
+
if (!accountIdentity)
|
|
2100
|
+
return true;
|
|
2101
|
+
return error.accountIdentity === accountIdentity;
|
|
1738
2102
|
}
|
|
1739
2103
|
export function formatRefreshBackoffMessage(error, now) {
|
|
1740
|
-
const seconds = Math.max(1, Math.ceil(((error
|
|
2104
|
+
const seconds = Math.max(1, Math.ceil(((effectiveRefreshRetryAt(error) ?? now) - now) / 1000));
|
|
1741
2105
|
return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
|
|
1742
2106
|
}
|
|
1743
2107
|
function createRefreshBackoffActiveError(error, now) {
|
|
@@ -1768,7 +2132,9 @@ export function isQuotaPolicyAuthError(error) {
|
|
|
1768
2132
|
return /Claude quota check failed: 403\b/.test(formatErrorMessage(error));
|
|
1769
2133
|
}
|
|
1770
2134
|
export function buildQuotaOperationError(input) {
|
|
1771
|
-
const previousRetryCount = input.previous?.
|
|
2135
|
+
const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity
|
|
2136
|
+
? (input.previous?.retryCount ?? 0)
|
|
2137
|
+
: 0;
|
|
1772
2138
|
const retryCount = previousRetryCount + 1;
|
|
1773
2139
|
const delay = isTransientQuotaError(input.error)
|
|
1774
2140
|
? Math.min(MAX_QUOTA_RETRY_DELAY_MS, MIN_QUOTA_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6))
|
|
@@ -1778,6 +2144,9 @@ export function buildQuotaOperationError(input) {
|
|
|
1778
2144
|
checkedAt: input.now,
|
|
1779
2145
|
nextRetryAt: input.now + delay,
|
|
1780
2146
|
retryCount,
|
|
2147
|
+
...(input.accountIdentity !== undefined && {
|
|
2148
|
+
accountIdentity: input.accountIdentity,
|
|
2149
|
+
}),
|
|
1781
2150
|
};
|
|
1782
2151
|
}
|
|
1783
2152
|
export function quotaBackoffActive(error, now) {
|
|
@@ -1813,6 +2182,7 @@ export function getPersistedMainQuota(storage) {
|
|
|
1813
2182
|
quota: storage.quota.mainQuota,
|
|
1814
2183
|
checkedAt: storage.quota.mainQuotaCheckedAt,
|
|
1815
2184
|
tokenFingerprint: storage.quota.mainQuotaToken,
|
|
2185
|
+
accountIdentity: storage.quota.mainQuota.accountIdentity,
|
|
1816
2186
|
};
|
|
1817
2187
|
}
|
|
1818
2188
|
/**
|
|
@@ -1879,6 +2249,9 @@ export function quotaSnapshotPassesPolicy(quota, storage) {
|
|
|
1879
2249
|
}
|
|
1880
2250
|
return true;
|
|
1881
2251
|
}
|
|
2252
|
+
export function quotaSnapshotHasStandardWindows(quota) {
|
|
2253
|
+
return Boolean(quota?.five_hour && quota.seven_day);
|
|
2254
|
+
}
|
|
1882
2255
|
// ---------------------------------------------------------------------------
|
|
1883
2256
|
// Killswitch — hard-block requests when remaining quota drops below per-account
|
|
1884
2257
|
// thresholds, even if the API would still accept them.
|
|
@@ -2119,18 +2492,11 @@ function isTransientQuotaError(error) {
|
|
|
2119
2492
|
if (formattedMessage.includes('Quota refresh is already in progress')) {
|
|
2120
2493
|
return true;
|
|
2121
2494
|
}
|
|
2122
|
-
|
|
2123
|
-
return false;
|
|
2124
|
-
const message = error.message;
|
|
2125
|
-
const code = error.code;
|
|
2126
|
-
return (message.includes('fetch failed') ||
|
|
2127
|
-
code === 'ECONNRESET' ||
|
|
2128
|
-
code === 'ECONNREFUSED' ||
|
|
2129
|
-
code === 'ETIMEDOUT' ||
|
|
2130
|
-
code === 'UND_ERR_CONNECT_TIMEOUT');
|
|
2495
|
+
return isTransientNetworkError(error);
|
|
2131
2496
|
}
|
|
2132
2497
|
function canUseCachedQuotaAfterRefreshError(account, storage, error, now) {
|
|
2133
|
-
return (
|
|
2498
|
+
return (Boolean(account.access && account.expires && account.expires > now) &&
|
|
2499
|
+
isTransientQuotaError(error) &&
|
|
2134
2500
|
quotaSnapshotPassesPolicy(account.quota, storage) &&
|
|
2135
2501
|
cachedQuotaSnapshotStillRelevant(account.quota, now));
|
|
2136
2502
|
}
|
|
@@ -2280,7 +2646,7 @@ export async function fetchOAuthQuotaSnapshot(input) {
|
|
|
2280
2646
|
const checkedAt = input.now?.() ?? Date.now();
|
|
2281
2647
|
const usage = (await response.json());
|
|
2282
2648
|
const bindingWindow = mapBindingWindow(usage.limits);
|
|
2283
|
-
|
|
2649
|
+
const snapshot = {
|
|
2284
2650
|
five_hour: mapUsageWindow(usage.five_hour, checkedAt),
|
|
2285
2651
|
seven_day: mapUsageWindow(usage.seven_day, checkedAt),
|
|
2286
2652
|
scoped: mapScopedWeeklyLimits(usage.limits, checkedAt),
|
|
@@ -2292,6 +2658,17 @@ export async function fetchOAuthQuotaSnapshot(input) {
|
|
|
2292
2658
|
source: 'poll',
|
|
2293
2659
|
checkedAt,
|
|
2294
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
|
+
};
|
|
2295
2672
|
}
|
|
2296
2673
|
function updateStoredAccount(storage, account) {
|
|
2297
2674
|
const index = storage.accounts.findIndex((candidate) => candidate.id === account.id);
|
|
@@ -2343,6 +2720,8 @@ function formatErrorMessage(error) {
|
|
|
2343
2720
|
return error instanceof Error ? error.message : String(error);
|
|
2344
2721
|
}
|
|
2345
2722
|
function recordRefreshError(account, error, now) {
|
|
2723
|
+
if (error instanceof CustodyTombstoneRefreshError)
|
|
2724
|
+
return;
|
|
2346
2725
|
const existing = existingRefreshBackoffError(error);
|
|
2347
2726
|
if (existing) {
|
|
2348
2727
|
account.lastRefreshError = existing;
|
|
@@ -2351,11 +2730,14 @@ function recordRefreshError(account, error, now) {
|
|
|
2351
2730
|
account.lastRefreshError = buildRefreshOperationError({
|
|
2352
2731
|
error,
|
|
2353
2732
|
now,
|
|
2354
|
-
|
|
2733
|
+
accountIdentity: account.id,
|
|
2734
|
+
refreshTokenFingerprint: tokenFingerprint(account.refresh),
|
|
2355
2735
|
previous: account.lastRefreshError,
|
|
2356
2736
|
});
|
|
2357
2737
|
}
|
|
2358
2738
|
function recordQuotaRefreshError(account, error, now) {
|
|
2739
|
+
if (error instanceof CustodyTombstoneRefreshError)
|
|
2740
|
+
return;
|
|
2359
2741
|
if (isQuotaPolicyAuthError(error) || existingRefreshBackoffError(error))
|
|
2360
2742
|
return;
|
|
2361
2743
|
account.lastQuotaRefreshError = buildQuotaOperationError({
|
|
@@ -2381,6 +2763,10 @@ export class FallbackAccountManager {
|
|
|
2381
2763
|
refreshTimer = null;
|
|
2382
2764
|
quotaTimer = null;
|
|
2383
2765
|
quotaManager;
|
|
2766
|
+
isFallbackAccountVaultServed;
|
|
2767
|
+
isFallbackAccountVaultEnabled;
|
|
2768
|
+
resolveFallbackAccessToken;
|
|
2769
|
+
onBackgroundRefresh;
|
|
2384
2770
|
onFallbackStorageChanged;
|
|
2385
2771
|
setIntervalImpl;
|
|
2386
2772
|
clearIntervalImpl;
|
|
@@ -2389,6 +2775,21 @@ export class FallbackAccountManager {
|
|
|
2389
2775
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
2390
2776
|
this.configPath = options.configPath ?? getAccountStoragePath();
|
|
2391
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;
|
|
2392
2793
|
this.onFallbackStorageChanged = options.onFallbackStorageChanged;
|
|
2393
2794
|
this.setIntervalImpl = options.setIntervalImpl ?? globalThis.setInterval;
|
|
2394
2795
|
this.clearIntervalImpl =
|
|
@@ -2406,7 +2807,7 @@ export class FallbackAccountManager {
|
|
|
2406
2807
|
const checkedAt = quotaSnapshotCheckedAt(account.quota);
|
|
2407
2808
|
if (checkedAt <= 0)
|
|
2408
2809
|
return;
|
|
2409
|
-
const existing = this.quotaManager.getFallback(account.id, account
|
|
2810
|
+
const existing = this.quotaManager.getFallback(account.id, account);
|
|
2410
2811
|
if (existing && existing.checkedAt >= checkedAt)
|
|
2411
2812
|
return;
|
|
2412
2813
|
const checkInterval = getQuotaCheckIntervalMs(storage);
|
|
@@ -2414,7 +2815,7 @@ export class FallbackAccountManager {
|
|
|
2414
2815
|
quota: account.quota,
|
|
2415
2816
|
refreshAfter: checkedAt + checkInterval,
|
|
2416
2817
|
checkedAt,
|
|
2417
|
-
}, account
|
|
2818
|
+
}, account);
|
|
2418
2819
|
}
|
|
2419
2820
|
async load() {
|
|
2420
2821
|
return loadAccounts(this.configPath);
|
|
@@ -2425,18 +2826,18 @@ export class FallbackAccountManager {
|
|
|
2425
2826
|
});
|
|
2426
2827
|
}
|
|
2427
2828
|
startBackgroundRefresh() {
|
|
2428
|
-
const run = async () => {
|
|
2829
|
+
const run = async (initial = false) => {
|
|
2830
|
+
await this.onBackgroundRefresh?.(initial);
|
|
2429
2831
|
await this.refreshDueAccounts();
|
|
2430
2832
|
await this.refreshQuotaForDueAccounts();
|
|
2431
2833
|
};
|
|
2432
|
-
|
|
2834
|
+
const initialRun = run(true).catch(() => { });
|
|
2433
2835
|
if (!this.refreshTimer) {
|
|
2434
|
-
this.refreshTimer = this.setIntervalImpl(() => {
|
|
2435
|
-
void run().catch(() => { });
|
|
2436
|
-
}, BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS));
|
|
2836
|
+
this.refreshTimer = this.setIntervalImpl(() => run().catch(() => { }), FALLBACK_BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS));
|
|
2437
2837
|
if ('unref' in this.refreshTimer)
|
|
2438
2838
|
this.refreshTimer.unref();
|
|
2439
2839
|
}
|
|
2840
|
+
return initialRun;
|
|
2440
2841
|
}
|
|
2441
2842
|
stopBackgroundRefresh() {
|
|
2442
2843
|
if (this.refreshTimer)
|
|
@@ -2455,12 +2856,22 @@ export class FallbackAccountManager {
|
|
|
2455
2856
|
for (const account of storage.accounts) {
|
|
2456
2857
|
if (account.enabled === false || !isOAuthAccount(account))
|
|
2457
2858
|
continue;
|
|
2859
|
+
let next = account;
|
|
2458
2860
|
try {
|
|
2459
|
-
|
|
2460
|
-
|
|
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
|
+
}
|
|
2461
2872
|
const refreshError = next.lastRefreshError;
|
|
2462
2873
|
if (refreshError &&
|
|
2463
|
-
refreshBackoffActive(refreshError, next.
|
|
2874
|
+
refreshBackoffActive(refreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
|
|
2464
2875
|
throw createRefreshBackoffActiveError(refreshError, this.now());
|
|
2465
2876
|
}
|
|
2466
2877
|
next = await this.refreshAccount(next, storage);
|
|
@@ -2468,7 +2879,7 @@ export class FallbackAccountManager {
|
|
|
2468
2879
|
}
|
|
2469
2880
|
this.seedFallbackQuota(next, storage);
|
|
2470
2881
|
const stale = this.quotaManager
|
|
2471
|
-
? this.quotaManager.isFallbackStale(next.id, next.access, options.modelId)
|
|
2882
|
+
? this.quotaManager.isFallbackStale(next.id, next.access, options.modelId, next)
|
|
2472
2883
|
: quotaIsStale(next, storage, this.now(), options.modelId);
|
|
2473
2884
|
// Skip the request-time refresh when this account's quota API is
|
|
2474
2885
|
// backed off (recent 429/5xx). Hitting it again would extend the
|
|
@@ -2476,8 +2887,9 @@ export class FallbackAccountManager {
|
|
|
2476
2887
|
// the background refreshQuotaForDueAccounts() guard.
|
|
2477
2888
|
if (stale &&
|
|
2478
2889
|
!quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
|
|
2479
|
-
|
|
2480
|
-
|
|
2890
|
+
const result = await this.refreshAccountQuota(next, storage);
|
|
2891
|
+
next = result.account;
|
|
2892
|
+
changed ||= result.changed;
|
|
2481
2893
|
}
|
|
2482
2894
|
// Single source of truth: evaluate quota policy from the unified
|
|
2483
2895
|
// QuotaManager cache (the same source as the staleness check above) so
|
|
@@ -2488,20 +2900,21 @@ export class FallbackAccountManager {
|
|
|
2488
2900
|
usable.push(next);
|
|
2489
2901
|
}
|
|
2490
2902
|
catch (error) {
|
|
2491
|
-
if (canUseCachedQuotaAfterRefreshError(
|
|
2903
|
+
if (canUseCachedQuotaAfterRefreshError(next, storage, error, this.now())) {
|
|
2492
2904
|
log('[refresh] fallback quota using cached quota after refresh error', {
|
|
2493
|
-
accountId:
|
|
2905
|
+
accountId: next.id,
|
|
2494
2906
|
error: formatErrorMessage(error),
|
|
2495
2907
|
});
|
|
2496
|
-
if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(
|
|
2908
|
+
if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage, {
|
|
2497
2909
|
modelId: options.modelId,
|
|
2498
2910
|
})) {
|
|
2499
|
-
usable.push(
|
|
2911
|
+
usable.push(next);
|
|
2500
2912
|
}
|
|
2501
2913
|
}
|
|
2502
2914
|
else if (!failClosedOnUnknownQuota(storage) &&
|
|
2503
|
-
|
|
2504
|
-
|
|
2915
|
+
!refreshBackoffActive(next.lastRefreshError, next.id, this.now(), tokenFingerprint(next.refresh)) &&
|
|
2916
|
+
quotaSnapshotPassesModelScope(next.quota, options.modelId)) {
|
|
2917
|
+
usable.push(next);
|
|
2505
2918
|
}
|
|
2506
2919
|
}
|
|
2507
2920
|
}
|
|
@@ -2532,7 +2945,7 @@ export class FallbackAccountManager {
|
|
|
2532
2945
|
quotaPolicyAccount(account) {
|
|
2533
2946
|
if (!this.quotaManager)
|
|
2534
2947
|
return account;
|
|
2535
|
-
const cached = this.quotaManager.getFallback(account.id, account
|
|
2948
|
+
const cached = this.quotaManager.getFallback(account.id, account)?.quota;
|
|
2536
2949
|
return cached ? { ...account, quota: cached } : account;
|
|
2537
2950
|
}
|
|
2538
2951
|
async refreshDueAccounts() {
|
|
@@ -2543,9 +2956,11 @@ export class FallbackAccountManager {
|
|
|
2543
2956
|
for (const account of storage.accounts) {
|
|
2544
2957
|
if (account.enabled === false || !isOAuthAccount(account))
|
|
2545
2958
|
continue;
|
|
2546
|
-
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))
|
|
2547
2962
|
continue;
|
|
2548
|
-
if (refreshBackoffActive(account.lastRefreshError, account.
|
|
2963
|
+
if (refreshBackoffActive(account.lastRefreshError, account.id, this.now(), tokenFingerprint(account.refresh))) {
|
|
2549
2964
|
// Backoff skips are steady-state while a fallback account is waiting for
|
|
2550
2965
|
// its next retry. Logging every background tick from every OpenCode
|
|
2551
2966
|
// process creates noise without adding new diagnostic signal; the
|
|
@@ -2587,8 +3002,10 @@ export class FallbackAccountManager {
|
|
|
2587
3002
|
continue;
|
|
2588
3003
|
let next = account;
|
|
2589
3004
|
try {
|
|
2590
|
-
if (tokenNeedsRefresh(next, storage, this.now())
|
|
2591
|
-
|
|
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))) {
|
|
2592
3009
|
continue;
|
|
2593
3010
|
}
|
|
2594
3011
|
next = await this.refreshAccount(next, storage);
|
|
@@ -2605,8 +3022,8 @@ export class FallbackAccountManager {
|
|
|
2605
3022
|
: quotaIsStale(next, storage, this.now());
|
|
2606
3023
|
if (!stale)
|
|
2607
3024
|
continue;
|
|
2608
|
-
await this.refreshAccountQuota(next, storage);
|
|
2609
|
-
changed
|
|
3025
|
+
const result = await this.refreshAccountQuota(next, storage);
|
|
3026
|
+
changed ||= result.changed;
|
|
2610
3027
|
}
|
|
2611
3028
|
catch (error) {
|
|
2612
3029
|
recordQuotaRefreshError(account, error, this.now());
|
|
@@ -2632,10 +3049,12 @@ export class FallbackAccountManager {
|
|
|
2632
3049
|
continue;
|
|
2633
3050
|
let next = account;
|
|
2634
3051
|
try {
|
|
2635
|
-
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)) {
|
|
2636
3055
|
const refreshError = next.lastRefreshError;
|
|
2637
3056
|
if (refreshError &&
|
|
2638
|
-
refreshBackoffActive(refreshError, next.
|
|
3057
|
+
refreshBackoffActive(refreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
|
|
2639
3058
|
throw createRefreshBackoffActiveError(refreshError, this.now());
|
|
2640
3059
|
}
|
|
2641
3060
|
next = await this.refreshAccount(next, storage);
|
|
@@ -2652,8 +3071,8 @@ export class FallbackAccountManager {
|
|
|
2652
3071
|
}
|
|
2653
3072
|
continue;
|
|
2654
3073
|
}
|
|
2655
|
-
await this.refreshAccountQuota(next, storage);
|
|
2656
|
-
changed
|
|
3074
|
+
const result = await this.refreshAccountQuota(next, storage);
|
|
3075
|
+
changed ||= result.changed;
|
|
2657
3076
|
}
|
|
2658
3077
|
catch (error) {
|
|
2659
3078
|
recordQuotaRefreshError(account, error, this.now());
|
|
@@ -2670,6 +3089,8 @@ export class FallbackAccountManager {
|
|
|
2670
3089
|
return { storage, errors };
|
|
2671
3090
|
}
|
|
2672
3091
|
async refreshAccount(account, storage, options = {}) {
|
|
3092
|
+
if (this.isFallbackAccountVaultServed(account.id, storage))
|
|
3093
|
+
return account;
|
|
2673
3094
|
const existing = this.refreshPromises.get(account.id);
|
|
2674
3095
|
if (existing) {
|
|
2675
3096
|
const refreshed = await existing;
|
|
@@ -2680,9 +3101,19 @@ export class FallbackAccountManager {
|
|
|
2680
3101
|
this.refreshPromises.delete(account.id);
|
|
2681
3102
|
});
|
|
2682
3103
|
this.refreshPromises.set(account.id, promise);
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
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
|
+
}
|
|
2686
3117
|
}
|
|
2687
3118
|
async waitForConcurrentFallbackRefresh(account, storage, previous, options) {
|
|
2688
3119
|
const deadline = Date.now() + FALLBACK_REFRESH_JOIN_WAIT_MS;
|
|
@@ -2709,7 +3140,7 @@ export class FallbackAccountManager {
|
|
|
2709
3140
|
}
|
|
2710
3141
|
const refreshError = latestAccount.lastRefreshError;
|
|
2711
3142
|
if (refreshError &&
|
|
2712
|
-
refreshBackoffActive(refreshError, latestAccount.
|
|
3143
|
+
refreshBackoffActive(refreshError, latestAccount.id, this.now(), tokenFingerprint(latestAccount.refresh))) {
|
|
2713
3144
|
updateStoredAccount(storage, latestAccount);
|
|
2714
3145
|
throw createRefreshBackoffActiveError(refreshError, this.now());
|
|
2715
3146
|
}
|
|
@@ -2789,15 +3220,34 @@ export class FallbackAccountManager {
|
|
|
2789
3220
|
}
|
|
2790
3221
|
}
|
|
2791
3222
|
async refreshAccountQuota(account, storage) {
|
|
3223
|
+
const initialQuotaState = JSON.stringify([
|
|
3224
|
+
account.quota,
|
|
3225
|
+
account.lastQuotaRefreshError,
|
|
3226
|
+
]);
|
|
3227
|
+
let changed = false;
|
|
2792
3228
|
let target = account;
|
|
2793
|
-
|
|
2794
|
-
|
|
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
|
+
};
|
|
2795
3245
|
}
|
|
2796
3246
|
// Unify on the shared QuotaManager when present: it adds inflight
|
|
2797
3247
|
// deduplication and 429 backoff gating around the same quota API. Fall back
|
|
2798
3248
|
// to a direct fetch only when no QuotaManager is wired (e.g. in isolation).
|
|
2799
3249
|
const fetchSnapshot = (accessToken) => this.quotaManager
|
|
2800
|
-
? this.quotaManager.refreshFallbackWithMetadata(target.id, accessToken)
|
|
3250
|
+
? this.quotaManager.refreshFallbackWithMetadata(target.id, accessToken, target)
|
|
2801
3251
|
: fetchOAuthQuotaSnapshot({
|
|
2802
3252
|
accessToken,
|
|
2803
3253
|
fetchImpl: this.fetchImpl,
|
|
@@ -2806,21 +3256,34 @@ export class FallbackAccountManager {
|
|
|
2806
3256
|
const fetchStartedAt = this.now();
|
|
2807
3257
|
let fetched = false;
|
|
2808
3258
|
try {
|
|
2809
|
-
const result = await fetchSnapshot(
|
|
3259
|
+
const result = await fetchSnapshot(access.token);
|
|
2810
3260
|
target.quota = result.quota;
|
|
2811
3261
|
fetched = result.fetched;
|
|
2812
3262
|
}
|
|
2813
3263
|
catch (error) {
|
|
2814
3264
|
const message = error instanceof Error ? error.message : String(error);
|
|
2815
|
-
if (!message.includes('Claude quota check failed: 401')
|
|
3265
|
+
if (!message.includes('Claude quota check failed: 401') ||
|
|
3266
|
+
vaultEnabled ||
|
|
3267
|
+
access.source !== 'sidecar') {
|
|
2816
3268
|
throw error;
|
|
3269
|
+
}
|
|
2817
3270
|
target = await this.refreshAccount(account, storage, {
|
|
2818
3271
|
force: true,
|
|
2819
3272
|
});
|
|
2820
|
-
|
|
2821
|
-
|
|
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
|
+
}
|
|
2822
3285
|
// 401 does not arm QuotaManager backoff, so this retry proceeds.
|
|
2823
|
-
const result = await fetchSnapshot(
|
|
3286
|
+
const result = await fetchSnapshot(access.token);
|
|
2824
3287
|
target.quota = result.quota;
|
|
2825
3288
|
fetched = result.fetched;
|
|
2826
3289
|
}
|
|
@@ -2831,16 +3294,17 @@ export class FallbackAccountManager {
|
|
|
2831
3294
|
latestAccount.access !== target.access) {
|
|
2832
3295
|
this.seedFallbackQuota(latestAccount, latestStorage);
|
|
2833
3296
|
updateStoredAccount(storage, latestAccount);
|
|
2834
|
-
return { account: latestAccount, fetched: false };
|
|
3297
|
+
return { account: latestAccount, fetched: false, changed: false };
|
|
2835
3298
|
}
|
|
2836
3299
|
if (latestStorage &&
|
|
2837
|
-
latestAccount
|
|
3300
|
+
latestAccount &&
|
|
3301
|
+
latestAccount.access === target.access &&
|
|
2838
3302
|
latestAccount.quota &&
|
|
2839
3303
|
quotaSnapshotCheckedAt(latestAccount.quota) >= fetchStartedAt &&
|
|
2840
3304
|
quotaSnapshotIsFresh(latestAccount.quota, latestStorage, this.now())) {
|
|
2841
3305
|
this.seedFallbackQuota(latestAccount, latestStorage);
|
|
2842
3306
|
updateStoredAccount(storage, latestAccount);
|
|
2843
|
-
return { account: latestAccount, fetched };
|
|
3307
|
+
return { account: latestAccount, fetched, changed: false };
|
|
2844
3308
|
}
|
|
2845
3309
|
target.lastQuotaRefreshError = undefined;
|
|
2846
3310
|
updateStoredAccount(storage, target);
|
|
@@ -2853,8 +3317,14 @@ export class FallbackAccountManager {
|
|
|
2853
3317
|
quota: target.quota,
|
|
2854
3318
|
refreshAfter: now + getQuotaCheckIntervalMs(storage),
|
|
2855
3319
|
checkedAt: now,
|
|
2856
|
-
}, target
|
|
3320
|
+
}, target);
|
|
2857
3321
|
}
|
|
2858
|
-
return {
|
|
3322
|
+
return {
|
|
3323
|
+
account: target,
|
|
3324
|
+
fetched,
|
|
3325
|
+
changed: changed ||
|
|
3326
|
+
JSON.stringify([target.quota, target.lastQuotaRefreshError]) !==
|
|
3327
|
+
initialQuotaState,
|
|
3328
|
+
};
|
|
2859
3329
|
}
|
|
2860
3330
|
}
|