@cortexkit/anthropic-auth-core 1.21.0 → 1.23.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 +100 -3
- package/dist/accounts.js +1099 -169
- package/dist/auth.js +6 -10
- package/dist/cachekeep.d.ts +18 -1
- package/dist/cachekeep.js +108 -82
- package/dist/cch.d.ts +23 -9
- package/dist/cch.js +104 -21
- package/dist/claude-code.d.ts +8 -3
- package/dist/claude-code.js +24 -7
- package/dist/claustrum-enrollment-registry.d.ts +17 -0
- package/dist/claustrum-enrollment-registry.js +194 -0
- package/dist/claustrum-enrollment.d.ts +70 -0
- package/dist/claustrum-enrollment.js +485 -0
- package/dist/claustrum-scoped-roster.d.ts +23 -0
- package/dist/claustrum-scoped-roster.js +194 -0
- package/dist/claustrum-scoped-runtime.d.ts +28 -0
- package/dist/claustrum-scoped-runtime.js +230 -0
- package/dist/claustrum-scoped.d.ts +39 -0
- package/dist/claustrum-scoped.js +193 -0
- package/dist/claustrum.d.ts +252 -0
- package/dist/claustrum.js +1471 -0
- package/dist/commands/account.d.ts +43 -11
- package/dist/commands/account.js +147 -4
- package/dist/constants.d.ts +5 -5
- package/dist/constants.js +5 -5
- package/dist/custom-headers.d.ts +3 -0
- package/dist/custom-headers.js +102 -0
- package/dist/dump.js +2 -1
- package/dist/fast.js +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/json.d.ts +1 -0
- package/dist/json.js +10 -0
- package/dist/logger.js +3 -1
- package/dist/mid-conversation-output-config.d.ts +13 -0
- package/dist/mid-conversation-output-config.js +89 -0
- package/dist/model-remap.d.ts +27 -0
- package/dist/model-remap.js +72 -0
- package/dist/models.d.ts +46 -1
- package/dist/models.js +87 -8
- 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 +6 -0
- package/dist/prime.d.ts +11 -3
- package/dist/prime.js +25 -7
- package/dist/quota-header-feed.d.ts +32 -7
- package/dist/quota-header-feed.js +93 -10
- package/dist/quota-headers.js +59 -6
- package/dist/quota-manager.d.ts +20 -2
- package/dist/quota-manager.js +101 -24
- package/dist/relay.d.ts +8 -0
- package/dist/relay.js +72 -12
- package/dist/sticky-routing.d.ts +2 -0
- package/dist/sticky-routing.js +19 -1
- package/dist/thinking-binding.d.ts +7 -0
- package/dist/thinking-binding.js +54 -0
- package/package.json +4 -1
package/dist/accounts.js
CHANGED
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { dirname, join } from 'node:path';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { parseRetryAfterHeader, refreshClaudeOAuthToken } from './auth.js';
|
|
6
|
+
import { assertNotCustodyTombstone, CustodyTombstoneRefreshError, custodyTombstoneKey, isCustodyTombstoneValue, } 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
|
+
];
|
|
25
|
+
export function hasNoLocalCredential(account) {
|
|
26
|
+
return (account.access == null &&
|
|
27
|
+
(account.refresh == null || account.refresh === ''));
|
|
28
|
+
}
|
|
13
29
|
export function isOAuthAccount(account) {
|
|
14
30
|
return account.type === 'oauth';
|
|
15
31
|
}
|
|
@@ -54,7 +70,7 @@ const DEFAULT_MINIMUM_REMAINING = {
|
|
|
54
70
|
seven_day: 0,
|
|
55
71
|
};
|
|
56
72
|
const DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA = true;
|
|
57
|
-
const
|
|
73
|
+
export const FALLBACK_BACKGROUND_TICK_MS = 60_000;
|
|
58
74
|
const BACKGROUND_TICK_JITTER_MS = 60_000;
|
|
59
75
|
const FALLBACK_REFRESH_LOCK_TTL_MS = 10 * 60_000;
|
|
60
76
|
const FALLBACK_REFRESH_JOIN_WAIT_MS = 10_000;
|
|
@@ -110,7 +126,19 @@ function normalizeAccount(value) {
|
|
|
110
126
|
}
|
|
111
127
|
if (value.type !== 'oauth')
|
|
112
128
|
return null;
|
|
113
|
-
|
|
129
|
+
const refresh = typeof value.refresh === 'string' ? value.refresh : '';
|
|
130
|
+
const rosterOnly = value.enabled === true &&
|
|
131
|
+
typeof value.id === 'string' &&
|
|
132
|
+
Boolean(value.id.trim()) &&
|
|
133
|
+
typeof value.label === 'string' &&
|
|
134
|
+
Boolean(value.label.trim()) &&
|
|
135
|
+
hasNoLocalCredential(value);
|
|
136
|
+
const scopedRosterOnly = typeof value.claustrumScopedCredentialId === 'string' &&
|
|
137
|
+
Boolean(value.claustrumScopedCredentialId.trim()) &&
|
|
138
|
+
typeof value.anthropicAccountUuid === 'string' &&
|
|
139
|
+
Boolean(value.anthropicAccountUuid.trim()) &&
|
|
140
|
+
hasNoLocalCredential(value);
|
|
141
|
+
if (!refresh.trim() && !rosterOnly && !scopedRosterOnly)
|
|
114
142
|
return null;
|
|
115
143
|
return {
|
|
116
144
|
...normalizeAccountBase(value),
|
|
@@ -118,8 +146,23 @@ function normalizeAccount(value) {
|
|
|
118
146
|
authLineageId: typeof value.authLineageId === 'string' && value.authLineageId.trim()
|
|
119
147
|
? value.authLineageId
|
|
120
148
|
: undefined,
|
|
149
|
+
anthropicAccountUuid: typeof value.anthropicAccountUuid === 'string' &&
|
|
150
|
+
value.anthropicAccountUuid.trim()
|
|
151
|
+
? value.anthropicAccountUuid.trim()
|
|
152
|
+
: undefined,
|
|
153
|
+
claustrumHandle: typeof value.claustrumHandle === 'string' && value.claustrumHandle.trim()
|
|
154
|
+
? value.claustrumHandle.trim()
|
|
155
|
+
: undefined,
|
|
156
|
+
claustrumScopedCredentialId: typeof value.claustrumScopedCredentialId === 'string' &&
|
|
157
|
+
value.claustrumScopedCredentialId.trim()
|
|
158
|
+
? value.claustrumScopedCredentialId.trim()
|
|
159
|
+
: undefined,
|
|
160
|
+
claustrumScopedState: typeof value.claustrumScopedState === 'string' &&
|
|
161
|
+
value.claustrumScopedState.trim()
|
|
162
|
+
? value.claustrumScopedState.trim()
|
|
163
|
+
: undefined,
|
|
121
164
|
access: typeof value.access === 'string' ? value.access : undefined,
|
|
122
|
-
refresh
|
|
165
|
+
refresh,
|
|
123
166
|
expires: typeof value.expires === 'number' ? value.expires : undefined,
|
|
124
167
|
lastRefreshedAt: typeof value.lastRefreshedAt === 'number'
|
|
125
168
|
? value.lastRefreshedAt
|
|
@@ -150,6 +193,10 @@ function normalizeOAuthAccountProfile(value) {
|
|
|
150
193
|
value.accountIdentity.trim() && {
|
|
151
194
|
accountIdentity: value.accountIdentity.trim(),
|
|
152
195
|
}),
|
|
196
|
+
...(typeof value.providerAccountUuid === 'string' &&
|
|
197
|
+
value.providerAccountUuid.trim() && {
|
|
198
|
+
providerAccountUuid: value.providerAccountUuid.trim(),
|
|
199
|
+
}),
|
|
153
200
|
...(typeof value.tokenFingerprint === 'string' &&
|
|
154
201
|
value.tokenFingerprint.trim() && {
|
|
155
202
|
tokenFingerprint: value.tokenFingerprint.trim(),
|
|
@@ -176,6 +223,10 @@ function normalizeOperationError(value) {
|
|
|
176
223
|
? value.accountIdentity.trim()
|
|
177
224
|
: undefined,
|
|
178
225
|
tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : undefined,
|
|
226
|
+
refreshTokenFingerprint: typeof value.refreshTokenFingerprint === 'string' &&
|
|
227
|
+
value.refreshTokenFingerprint.trim()
|
|
228
|
+
? value.refreshTokenFingerprint.trim()
|
|
229
|
+
: undefined,
|
|
179
230
|
// Preserve the dead-token discriminators across save/load. Without these,
|
|
180
231
|
// a retry-exhausted transient (permanent=false, 24h backoff) would lose its
|
|
181
232
|
// flag on reload and the 24h-delay heuristic would wrongly re-classify it
|
|
@@ -309,6 +360,20 @@ function normalizeQuota(value) {
|
|
|
309
360
|
if (value.source === 'poll' || value.source === 'headers') {
|
|
310
361
|
quota.source = value.source;
|
|
311
362
|
}
|
|
363
|
+
if (isRecord(value.fieldSources)) {
|
|
364
|
+
const fieldSources = {};
|
|
365
|
+
for (const field of QUOTA_FIELD_NAMES) {
|
|
366
|
+
if (quota[field] === undefined)
|
|
367
|
+
continue;
|
|
368
|
+
const source = value.fieldSources[field];
|
|
369
|
+
if (source === 'poll' || source === 'headers') {
|
|
370
|
+
fieldSources[field] = source;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (Object.keys(fieldSources).length > 0) {
|
|
374
|
+
quota.fieldSources = fieldSources;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
312
377
|
return Object.keys(quota).length ? quota : undefined;
|
|
313
378
|
}
|
|
314
379
|
function normalizeQuotaMoney(value) {
|
|
@@ -362,9 +427,13 @@ function normalizeStorage(value) {
|
|
|
362
427
|
claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined,
|
|
363
428
|
dump: isRecord(value.dump) ? value.dump : undefined,
|
|
364
429
|
claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
|
|
430
|
+
thinkingBinding: isRecord(value.thinkingBinding)
|
|
431
|
+
? value.thinkingBinding
|
|
432
|
+
: undefined,
|
|
365
433
|
costZeroing: isRecord(value.costZeroing) ? value.costZeroing : undefined,
|
|
366
434
|
cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
|
|
367
435
|
relay: isRecord(value.relay) ? value.relay : undefined,
|
|
436
|
+
claustrum: normalizeClaustrumConfig(value.claustrum),
|
|
368
437
|
logging: isRecord(value.logging) ? value.logging : undefined,
|
|
369
438
|
killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
|
|
370
439
|
prime: (() => {
|
|
@@ -401,9 +470,76 @@ function normalizeStorage(value) {
|
|
|
401
470
|
.filter((account) => account != null),
|
|
402
471
|
};
|
|
403
472
|
}
|
|
473
|
+
function normalizeClaustrumConfig(value) {
|
|
474
|
+
if (!isRecord(value))
|
|
475
|
+
return undefined;
|
|
476
|
+
const mode = value.mode === 'local' || value.mode === 'claustrum'
|
|
477
|
+
? value.mode
|
|
478
|
+
: undefined;
|
|
479
|
+
const handlesFile = typeof value.handlesFile === 'string' && value.handlesFile.trim()
|
|
480
|
+
? value.handlesFile.trim()
|
|
481
|
+
: undefined;
|
|
482
|
+
const primary = isRecord(value.primaryAccount)
|
|
483
|
+
? value.primaryAccount
|
|
484
|
+
: undefined;
|
|
485
|
+
const primaryAccount = primary &&
|
|
486
|
+
typeof primary.credentialId === 'string' &&
|
|
487
|
+
primary.credentialId.trim() &&
|
|
488
|
+
typeof primary.accountId === 'string' &&
|
|
489
|
+
primary.accountId.trim() &&
|
|
490
|
+
typeof primary.state === 'string'
|
|
491
|
+
? {
|
|
492
|
+
credentialId: primary.credentialId.trim(),
|
|
493
|
+
accountId: primary.accountId.trim(),
|
|
494
|
+
state: primary.state,
|
|
495
|
+
}
|
|
496
|
+
: undefined;
|
|
497
|
+
const scopedRoster = value.scopedRoster === true;
|
|
498
|
+
const disabledAccountIdentities = Array.isArray(value.disabledAccountIdentities)
|
|
499
|
+
? [
|
|
500
|
+
...new Set(value.disabledAccountIdentities
|
|
501
|
+
.filter((id) => typeof id === 'string' && id.trim().length > 0)
|
|
502
|
+
.map((id) => id.trim())),
|
|
503
|
+
]
|
|
504
|
+
: [];
|
|
505
|
+
const accounts = isRecord(value.accounts)
|
|
506
|
+
? Object.fromEntries(Object.entries(value.accounts).flatMap(([id, entry]) => {
|
|
507
|
+
if (!isRecord(entry))
|
|
508
|
+
return [];
|
|
509
|
+
return [
|
|
510
|
+
[
|
|
511
|
+
id,
|
|
512
|
+
{
|
|
513
|
+
...(typeof entry.enabled === 'boolean' && {
|
|
514
|
+
enabled: entry.enabled,
|
|
515
|
+
}),
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
];
|
|
519
|
+
}))
|
|
520
|
+
: undefined;
|
|
521
|
+
if (!mode &&
|
|
522
|
+
!handlesFile &&
|
|
523
|
+
!scopedRoster &&
|
|
524
|
+
disabledAccountIdentities.length === 0 &&
|
|
525
|
+
(!accounts || Object.keys(accounts).length === 0)) {
|
|
526
|
+
return undefined;
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
...(mode && { mode }),
|
|
530
|
+
...(scopedRoster && { scopedRoster: true }),
|
|
531
|
+
...(primaryAccount && { primaryAccount }),
|
|
532
|
+
...(disabledAccountIdentities.length > 0 && { disabledAccountIdentities }),
|
|
533
|
+
...(handlesFile && { handlesFile }),
|
|
534
|
+
...(accounts && Object.keys(accounts).length > 0 && { accounts }),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
404
537
|
async function readJsonIfPresent(path) {
|
|
405
538
|
try {
|
|
406
|
-
return {
|
|
539
|
+
return {
|
|
540
|
+
exists: true,
|
|
541
|
+
value: parseJsonRedacted(await readFile(path, 'utf8')),
|
|
542
|
+
};
|
|
407
543
|
}
|
|
408
544
|
catch (error) {
|
|
409
545
|
if (error.code === 'ENOENT') {
|
|
@@ -427,6 +563,13 @@ export function mergeMainQuotaErrorClearedAt(existing, incoming) {
|
|
|
427
563
|
return incoming;
|
|
428
564
|
return Math.max(existing, incoming);
|
|
429
565
|
}
|
|
566
|
+
export function mergeMainRefreshErrorClearedAt(existing, incoming) {
|
|
567
|
+
if (incoming === undefined || !Number.isFinite(incoming))
|
|
568
|
+
return existing;
|
|
569
|
+
if (existing === undefined || !Number.isFinite(existing))
|
|
570
|
+
return incoming;
|
|
571
|
+
return Math.max(existing, incoming);
|
|
572
|
+
}
|
|
430
573
|
function accountCredentialTimestamp(value) {
|
|
431
574
|
return Math.max(numericField(value.lastRefreshedAt), numericField(value.lastUsed), numericField(value.addedAt));
|
|
432
575
|
}
|
|
@@ -448,6 +591,23 @@ function legacyConfigCredentialsAreNewer(account, stateAccount) {
|
|
|
448
591
|
accountCredentialTimestamp(stateAccount));
|
|
449
592
|
}
|
|
450
593
|
function mergeConfigAccountAndState(account, stateAccount) {
|
|
594
|
+
if (typeof account.claustrumScopedCredentialId === 'string' &&
|
|
595
|
+
typeof account.anthropicAccountUuid === 'string') {
|
|
596
|
+
const sameBinding = stateAccount.claustrumScopedCredentialId ===
|
|
597
|
+
account.claustrumScopedCredentialId &&
|
|
598
|
+
stateAccount.anthropicAccountUuid === account.anthropicAccountUuid;
|
|
599
|
+
const scoped = {
|
|
600
|
+
...(sameBinding ? stateAccount : {}),
|
|
601
|
+
...account,
|
|
602
|
+
refresh: '',
|
|
603
|
+
};
|
|
604
|
+
delete scoped.access;
|
|
605
|
+
delete scoped.expires;
|
|
606
|
+
delete scoped.lastRefreshedAt;
|
|
607
|
+
delete scoped.lastRefreshError;
|
|
608
|
+
delete scoped.claustrumHandle;
|
|
609
|
+
return scoped;
|
|
610
|
+
}
|
|
451
611
|
if (legacyConfigCredentialsAreNewer(account, stateAccount)) {
|
|
452
612
|
const merged = { ...stateAccount, ...account };
|
|
453
613
|
const configTimestamp = accountCredentialTimestamp(account);
|
|
@@ -461,6 +621,21 @@ function mergeConfigAccountAndState(account, stateAccount) {
|
|
|
461
621
|
}
|
|
462
622
|
return { ...account, ...stateAccount };
|
|
463
623
|
}
|
|
624
|
+
function configAccountHasInvalidShape(account) {
|
|
625
|
+
if (account.type !== 'api' && account.type !== 'oauth')
|
|
626
|
+
return true;
|
|
627
|
+
if (account.type === 'api' &&
|
|
628
|
+
'baseURL' in account &&
|
|
629
|
+
(typeof account.baseURL !== 'string' || !isValidApiBaseURL(account.baseURL))) {
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
if (account.type === 'oauth' &&
|
|
633
|
+
'refresh' in account &&
|
|
634
|
+
(typeof account.refresh !== 'string' || !account.refresh.trim())) {
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
464
639
|
function mergeConfigAndState(configValue, stateValue) {
|
|
465
640
|
if (!isRecord(configValue))
|
|
466
641
|
return configValue;
|
|
@@ -476,6 +651,11 @@ function mergeConfigAndState(configValue, stateValue) {
|
|
|
476
651
|
mainState.quotaErrorClearedAt >= 0
|
|
477
652
|
? mainState.quotaErrorClearedAt
|
|
478
653
|
: undefined;
|
|
654
|
+
const mainRefreshErrorClearedAt = typeof mainState?.refreshErrorClearedAt === 'number' &&
|
|
655
|
+
Number.isFinite(mainState.refreshErrorClearedAt) &&
|
|
656
|
+
mainState.refreshErrorClearedAt >= 0
|
|
657
|
+
? mainState.refreshErrorClearedAt
|
|
658
|
+
: undefined;
|
|
479
659
|
const configQuotaError = quotaConfig.mainLastQuotaApiError;
|
|
480
660
|
const mainLastQuotaApiError = mainState?.lastQuotaApiError ??
|
|
481
661
|
(mainQuotaErrorClearedAt !== undefined &&
|
|
@@ -484,12 +664,24 @@ function mergeConfigAndState(configValue, stateValue) {
|
|
|
484
664
|
configQuotaError.checkedAt <= mainQuotaErrorClearedAt
|
|
485
665
|
? undefined
|
|
486
666
|
: configQuotaError);
|
|
667
|
+
const configRefreshError = refreshConfig.mainLastRefreshError;
|
|
668
|
+
const mainLastRefreshError = mainState?.lastRefreshError ??
|
|
669
|
+
(mainRefreshErrorClearedAt !== undefined &&
|
|
670
|
+
isRecord(configRefreshError) &&
|
|
671
|
+
typeof configRefreshError.checkedAt === 'number' &&
|
|
672
|
+
configRefreshError.checkedAt <= mainRefreshErrorClearedAt
|
|
673
|
+
? undefined
|
|
674
|
+
: configRefreshError);
|
|
487
675
|
const accounts = Array.isArray(configValue.accounts)
|
|
488
676
|
? configValue.accounts.map((account) => {
|
|
489
677
|
if (!isRecord(account))
|
|
490
678
|
return account;
|
|
491
|
-
const
|
|
492
|
-
|
|
679
|
+
const rawId = typeof account.id === 'string' ? account.id : undefined;
|
|
680
|
+
const stateValue = rawId
|
|
681
|
+
? (stateAccounts[rawId] ?? stateAccounts[rawId.trim()])
|
|
682
|
+
: undefined;
|
|
683
|
+
const stateAccount = isRecord(stateValue)
|
|
684
|
+
? stateValue
|
|
493
685
|
: {};
|
|
494
686
|
return mergeConfigAccountAndState(account, stateAccount);
|
|
495
687
|
})
|
|
@@ -503,7 +695,8 @@ function mergeConfigAndState(configValue, stateValue) {
|
|
|
503
695
|
},
|
|
504
696
|
refresh: objectWithDefinedEntries({
|
|
505
697
|
...refreshConfig,
|
|
506
|
-
mainLastRefreshError
|
|
698
|
+
mainLastRefreshError,
|
|
699
|
+
mainRefreshErrorClearedAt: mainRefreshSource.refreshErrorClearedAt,
|
|
507
700
|
mainRefreshLeaseId: mainRefreshSource.refreshLeaseId,
|
|
508
701
|
mainRefreshLeaseUntil: mainRefreshSource.refreshLeaseUntil,
|
|
509
702
|
mainRefreshLeaseTokenHash: mainRefreshSource.refreshLeaseTokenHash,
|
|
@@ -581,6 +774,12 @@ function accountConfig(account) {
|
|
|
581
774
|
addedAt: account.addedAt,
|
|
582
775
|
baseURL: account.type === 'api' ? account.baseURL : undefined,
|
|
583
776
|
authHeader: account.type === 'api' ? account.authHeader : undefined,
|
|
777
|
+
claustrumScopedCredentialId: account.type === 'oauth'
|
|
778
|
+
? account.claustrumScopedCredentialId
|
|
779
|
+
: undefined,
|
|
780
|
+
anthropicAccountUuid: account.type === 'oauth' && account.claustrumScopedCredentialId
|
|
781
|
+
? account.anthropicAccountUuid
|
|
782
|
+
: undefined,
|
|
584
783
|
});
|
|
585
784
|
}
|
|
586
785
|
function accountRuntimeState(account) {
|
|
@@ -592,6 +791,10 @@ function accountRuntimeState(account) {
|
|
|
592
791
|
}
|
|
593
792
|
return objectWithDefinedEntries({
|
|
594
793
|
authLineageId: account.authLineageId,
|
|
794
|
+
anthropicAccountUuid: account.anthropicAccountUuid,
|
|
795
|
+
claustrumHandle: account.claustrumHandle,
|
|
796
|
+
claustrumScopedCredentialId: account.claustrumScopedCredentialId,
|
|
797
|
+
claustrumScopedState: account.claustrumScopedState,
|
|
595
798
|
access: account.access,
|
|
596
799
|
refresh: account.refresh,
|
|
597
800
|
expires: account.expires,
|
|
@@ -649,17 +852,61 @@ function mergeHeaderOwnedWindow(existingSnapshot, incomingSnapshot, key) {
|
|
|
649
852
|
? existing
|
|
650
853
|
: incoming;
|
|
651
854
|
}
|
|
855
|
+
export function quotaFieldSource(snapshot, field) {
|
|
856
|
+
if (!snapshot || snapshot[field] === undefined)
|
|
857
|
+
return undefined;
|
|
858
|
+
return (snapshot.fieldSources?.[field] ??
|
|
859
|
+
(snapshot.source === 'poll' || snapshot.source === 'headers'
|
|
860
|
+
? snapshot.source
|
|
861
|
+
: undefined));
|
|
862
|
+
}
|
|
863
|
+
function fieldSourcesForMergedQuota(existing, incoming, merged) {
|
|
864
|
+
const fieldSources = {};
|
|
865
|
+
for (const field of QUOTA_FIELD_NAMES) {
|
|
866
|
+
if (merged[field] === undefined)
|
|
867
|
+
continue;
|
|
868
|
+
if (field === 'scoped' || field === 'extraUsage') {
|
|
869
|
+
fieldSources[field] = 'poll';
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
if (field === 'bindingWindow' &&
|
|
873
|
+
existing.bindingWindowSource === 'poll' &&
|
|
874
|
+
existing.bindingWindow === merged.bindingWindow) {
|
|
875
|
+
fieldSources[field] = 'poll';
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
if (field === 'fallbackAdvised' &&
|
|
879
|
+
incoming.fieldSources?.fallbackAdvised === undefined) {
|
|
880
|
+
const source = quotaFieldSource(existing, field);
|
|
881
|
+
if (source)
|
|
882
|
+
fieldSources[field] = source;
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
const source = incoming[field] === merged[field]
|
|
886
|
+
? quotaFieldSource(incoming, field)
|
|
887
|
+
: existing[field] === merged[field]
|
|
888
|
+
? quotaFieldSource(existing, field)
|
|
889
|
+
: (quotaFieldSource(existing, field) ??
|
|
890
|
+
quotaFieldSource(incoming, field));
|
|
891
|
+
if (source)
|
|
892
|
+
fieldSources[field] = source;
|
|
893
|
+
}
|
|
894
|
+
return Object.keys(fieldSources).length > 0 ? fieldSources : undefined;
|
|
895
|
+
}
|
|
652
896
|
export function mergeHeaderQuotaForPersistence(existing, incoming) {
|
|
653
897
|
if (!existing || incoming.source !== 'headers')
|
|
654
898
|
return incoming;
|
|
655
899
|
const preservePollBinding = existing.bindingWindowSource === 'poll';
|
|
656
|
-
|
|
900
|
+
const merged = {
|
|
657
901
|
...existing,
|
|
658
902
|
...incoming,
|
|
659
903
|
five_hour: mergeHeaderOwnedWindow(existing, incoming, 'five_hour'),
|
|
660
904
|
seven_day: mergeHeaderOwnedWindow(existing, incoming, 'seven_day'),
|
|
661
905
|
scoped: mergeHeaderScopedQuota(existing, incoming),
|
|
662
906
|
extraUsage: existing.extraUsage ?? incoming.extraUsage,
|
|
907
|
+
fallbackAdvised: incoming.fieldSources?.fallbackAdvised !== undefined
|
|
908
|
+
? incoming.fallbackAdvised
|
|
909
|
+
: (existing.fallbackAdvised ?? incoming.fallbackAdvised),
|
|
663
910
|
bindingWindow: preservePollBinding
|
|
664
911
|
? existing.bindingWindow
|
|
665
912
|
: (incoming.bindingWindow ?? existing.bindingWindow),
|
|
@@ -667,24 +914,58 @@ export function mergeHeaderQuotaForPersistence(existing, incoming) {
|
|
|
667
914
|
? 'poll'
|
|
668
915
|
: (incoming.bindingWindowSource ?? existing.bindingWindowSource),
|
|
669
916
|
};
|
|
917
|
+
const fieldSources = fieldSourcesForMergedQuota(existing, incoming, merged);
|
|
918
|
+
return {
|
|
919
|
+
...merged,
|
|
920
|
+
...(fieldSources && { fieldSources }),
|
|
921
|
+
};
|
|
670
922
|
}
|
|
671
|
-
function mergeAccountRuntimeState(existing, incoming) {
|
|
923
|
+
function mergeAccountRuntimeState(existing, incoming, setClaustrumHandle = false) {
|
|
672
924
|
if (!isRecord(existing))
|
|
673
925
|
return incoming;
|
|
926
|
+
const incomingForMerge = { ...incoming };
|
|
927
|
+
if (!setClaustrumHandle)
|
|
928
|
+
delete incomingForMerge.claustrumHandle;
|
|
674
929
|
const existingEntry = existing;
|
|
675
930
|
const tokenChanged = Boolean((existingEntry.access &&
|
|
676
|
-
|
|
677
|
-
existingEntry.access !==
|
|
931
|
+
incomingForMerge.access &&
|
|
932
|
+
existingEntry.access !== incomingForMerge.access) ||
|
|
678
933
|
(existingEntry.refresh &&
|
|
679
|
-
|
|
680
|
-
existingEntry.refresh !==
|
|
681
|
-
const mergesHeaderQuota = Boolean(!tokenChanged &&
|
|
682
|
-
const effectiveIncoming = mergesHeaderQuota &&
|
|
934
|
+
incomingForMerge.refresh &&
|
|
935
|
+
existingEntry.refresh !== incomingForMerge.refresh));
|
|
936
|
+
const mergesHeaderQuota = Boolean(!tokenChanged && incomingForMerge.quota?.source === 'headers');
|
|
937
|
+
const effectiveIncoming = mergesHeaderQuota && incomingForMerge.quota
|
|
683
938
|
? {
|
|
684
|
-
...
|
|
685
|
-
quota: mergeHeaderQuotaForPersistence(existingEntry.quota,
|
|
939
|
+
...incomingForMerge,
|
|
940
|
+
quota: mergeHeaderQuotaForPersistence(existingEntry.quota, incomingForMerge.quota),
|
|
686
941
|
}
|
|
687
|
-
:
|
|
942
|
+
: incomingForMerge;
|
|
943
|
+
if (existingEntry.refresh === custodyTombstoneKey('anthropic') &&
|
|
944
|
+
((typeof effectiveIncoming.access === 'string' &&
|
|
945
|
+
effectiveIncoming.access.length > 0) ||
|
|
946
|
+
(typeof effectiveIncoming.refresh === 'string' &&
|
|
947
|
+
effectiveIncoming.refresh !== custodyTombstoneKey('anthropic'))) &&
|
|
948
|
+
(!effectiveIncoming.authLineageId ||
|
|
949
|
+
effectiveIncoming.authLineageId === existingEntry.authLineageId)) {
|
|
950
|
+
logger.warn('accounts', 'discarded stale credential write over a custody tombstone');
|
|
951
|
+
const { access: _access, refresh: _refresh, expires: _expires, ...safe } = effectiveIncoming;
|
|
952
|
+
return mergeAccountRuntimeState(existingEntry, safe, setClaustrumHandle);
|
|
953
|
+
}
|
|
954
|
+
const preferredRefreshError = (() => {
|
|
955
|
+
const existingError = existingEntry.lastRefreshError;
|
|
956
|
+
const incomingError = effectiveIncoming.lastRefreshError;
|
|
957
|
+
if (!existingError)
|
|
958
|
+
return incomingError;
|
|
959
|
+
if (incomingError) {
|
|
960
|
+
return incomingError.checkedAt >= existingError.checkedAt
|
|
961
|
+
? incomingError
|
|
962
|
+
: existingError;
|
|
963
|
+
}
|
|
964
|
+
return (effectiveIncoming.lastRefreshedAt ?? 0) >
|
|
965
|
+
(existingEntry.lastRefreshedAt ?? 0)
|
|
966
|
+
? undefined
|
|
967
|
+
: existingError;
|
|
968
|
+
})();
|
|
688
969
|
const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota);
|
|
689
970
|
const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(effectiveIncoming.quota);
|
|
690
971
|
const existingQuotaWinsEqualTimestamp = Boolean(existingQuotaCheckedAt === incomingQuotaCheckedAt &&
|
|
@@ -720,15 +1001,14 @@ function mergeAccountRuntimeState(existing, incoming) {
|
|
|
720
1001
|
if (!('lastQuotaRefreshError' in effectiveIncoming)) {
|
|
721
1002
|
delete merged.lastQuotaRefreshError;
|
|
722
1003
|
}
|
|
723
|
-
|
|
724
|
-
delete merged.lastRefreshError;
|
|
725
|
-
}
|
|
1004
|
+
merged.lastRefreshError = preferredRefreshError;
|
|
726
1005
|
return merged;
|
|
727
1006
|
}
|
|
728
1007
|
return {
|
|
729
1008
|
...merged,
|
|
730
1009
|
quota: existingEntry.quota,
|
|
731
1010
|
lastQuotaRefreshError: existingEntry.lastQuotaRefreshError,
|
|
1011
|
+
lastRefreshError: preferredRefreshError,
|
|
732
1012
|
};
|
|
733
1013
|
}
|
|
734
1014
|
const merged = {
|
|
@@ -744,9 +1024,7 @@ function mergeAccountRuntimeState(existing, incoming) {
|
|
|
744
1024
|
if (!('lastQuotaRefreshError' in effectiveIncoming)) {
|
|
745
1025
|
delete merged.lastQuotaRefreshError;
|
|
746
1026
|
}
|
|
747
|
-
|
|
748
|
-
delete merged.lastRefreshError;
|
|
749
|
-
}
|
|
1027
|
+
merged.lastRefreshError = preferredRefreshError;
|
|
750
1028
|
return merged;
|
|
751
1029
|
}
|
|
752
1030
|
function configFromStorage(storage) {
|
|
@@ -780,9 +1058,11 @@ function configFromStorage(storage) {
|
|
|
780
1058
|
dump: storage.dump,
|
|
781
1059
|
logging: storage.logging,
|
|
782
1060
|
claudeFast: storage.claudeFast,
|
|
1061
|
+
thinkingBinding: storage.thinkingBinding,
|
|
783
1062
|
costZeroing: storage.costZeroing,
|
|
784
1063
|
cacheKeep: storage.cacheKeep,
|
|
785
1064
|
relay: storage.relay,
|
|
1065
|
+
claustrum: storage.claustrum,
|
|
786
1066
|
killswitch: storage.killswitch,
|
|
787
1067
|
prime: (() => {
|
|
788
1068
|
// Config side carries ONLY the `enabled` flag — runtime counters and
|
|
@@ -796,6 +1076,93 @@ function configFromStorage(storage) {
|
|
|
796
1076
|
accounts: storage.accounts.map(accountConfig),
|
|
797
1077
|
});
|
|
798
1078
|
}
|
|
1079
|
+
export function getClaustrumMode(storage) {
|
|
1080
|
+
return storage?.claustrum?.mode === 'claustrum' ? 'claustrum' : 'local';
|
|
1081
|
+
}
|
|
1082
|
+
export function isOAuthAccountVaultOwned(storage, account, binding) {
|
|
1083
|
+
return (getClaustrumMode(storage) === 'claustrum' &&
|
|
1084
|
+
isOAuthAccount(account) &&
|
|
1085
|
+
account.enabled !== false &&
|
|
1086
|
+
// Source is provenance, not authorization; the resolver owns the binding decision.
|
|
1087
|
+
(binding?.status === 'resolved' ||
|
|
1088
|
+
Boolean(storage?.claustrum?.scopedRoster && account.claustrumScopedCredentialId)));
|
|
1089
|
+
}
|
|
1090
|
+
export async function setClaustrumModePersistent(mode, path = getAccountStoragePath()) {
|
|
1091
|
+
return enqueueSave(async () => {
|
|
1092
|
+
const lock = await acquireAccountConfigWriteLock(path);
|
|
1093
|
+
try {
|
|
1094
|
+
const existing = await loadAccounts(path);
|
|
1095
|
+
const storage = existing ?? createEmptyStorage();
|
|
1096
|
+
if (existing && getClaustrumMode(storage) === mode)
|
|
1097
|
+
return 'unchanged';
|
|
1098
|
+
storage.claustrum = { ...storage.claustrum, mode };
|
|
1099
|
+
await saveAccountsWithConfigLock(storage, path, {
|
|
1100
|
+
[WRITE_CLAUSTRUM_MODE]: true,
|
|
1101
|
+
});
|
|
1102
|
+
return 'changed';
|
|
1103
|
+
}
|
|
1104
|
+
finally {
|
|
1105
|
+
await lock.release();
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
export async function clearClaustrumHandlePersistent(input) {
|
|
1110
|
+
const path = input.path ?? getAccountStoragePath();
|
|
1111
|
+
return enqueueSave(async () => {
|
|
1112
|
+
const configLock = await acquireAccountConfigWriteLock(path);
|
|
1113
|
+
try {
|
|
1114
|
+
const stateLock = await acquireAccountStateWriteLock(path);
|
|
1115
|
+
try {
|
|
1116
|
+
const storage = await loadAccounts(path);
|
|
1117
|
+
if (!storage)
|
|
1118
|
+
return 'missing';
|
|
1119
|
+
const statePath = getAccountStatePath(path);
|
|
1120
|
+
const state = (await readJsonIfPresent(statePath)).value;
|
|
1121
|
+
const stateAccounts = isRecord(state) && isRecord(state.accounts)
|
|
1122
|
+
? state.accounts
|
|
1123
|
+
: undefined;
|
|
1124
|
+
const matchingStateKeys = Object.keys(stateAccounts ?? {}).filter((key) => key.trim() === input.id.trim());
|
|
1125
|
+
const account = storage.accounts.find((candidate) => candidate.id === input.id);
|
|
1126
|
+
if (!account) {
|
|
1127
|
+
if (matchingStateKeys.length === 0)
|
|
1128
|
+
return 'missing';
|
|
1129
|
+
for (const key of matchingStateKeys) {
|
|
1130
|
+
const stateAccount = stateAccounts?.[key];
|
|
1131
|
+
if (isRecord(stateAccount))
|
|
1132
|
+
delete stateAccount.claustrumHandle;
|
|
1133
|
+
}
|
|
1134
|
+
await writeJsonAtomic(statePath, pruneUndefined(state));
|
|
1135
|
+
return 'updated';
|
|
1136
|
+
}
|
|
1137
|
+
if (!isOAuthAccount(account))
|
|
1138
|
+
return 'ineligible';
|
|
1139
|
+
if (!account.claustrumHandle)
|
|
1140
|
+
return 'updated';
|
|
1141
|
+
delete account.claustrumHandle;
|
|
1142
|
+
const existing = await loadExistingTopLevelFields(path);
|
|
1143
|
+
await writeJsonAtomic(path, {
|
|
1144
|
+
...existing,
|
|
1145
|
+
...configFromStorage(storage),
|
|
1146
|
+
});
|
|
1147
|
+
if (matchingStateKeys.length > 0) {
|
|
1148
|
+
for (const key of matchingStateKeys) {
|
|
1149
|
+
const stateAccount = stateAccounts?.[key];
|
|
1150
|
+
if (isRecord(stateAccount))
|
|
1151
|
+
delete stateAccount.claustrumHandle;
|
|
1152
|
+
}
|
|
1153
|
+
await writeJsonAtomic(statePath, pruneUndefined(state));
|
|
1154
|
+
}
|
|
1155
|
+
return 'updated';
|
|
1156
|
+
}
|
|
1157
|
+
finally {
|
|
1158
|
+
await stateLock.release();
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
finally {
|
|
1162
|
+
await configLock.release();
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
799
1166
|
// ---------------------------------------------------------------------------
|
|
800
1167
|
// In-process save mutex — serializes all account-store writes so concurrent
|
|
801
1168
|
// read-modify-write callers (background timers that call saveAccountState with
|
|
@@ -822,6 +1189,8 @@ async function writeJsonAtomic(path, value) {
|
|
|
822
1189
|
throw error;
|
|
823
1190
|
}
|
|
824
1191
|
}
|
|
1192
|
+
const WRITE_CLAUSTRUM_MODE = Symbol('writeClaustrumMode');
|
|
1193
|
+
const MUTATE_ACCOUNT_ROSTER = Symbol('mutateAccountRoster');
|
|
825
1194
|
function sameAccountIdentity(left, right) {
|
|
826
1195
|
return (left.id === right.id ||
|
|
827
1196
|
Boolean(left.label && right.label && left.label === right.label));
|
|
@@ -853,6 +1222,21 @@ function mergeAccountsForSave(existing, incoming, options) {
|
|
|
853
1222
|
}
|
|
854
1223
|
return merged;
|
|
855
1224
|
}
|
|
1225
|
+
function preserveScopedAccountsOnSave(current, incoming, options) {
|
|
1226
|
+
const apiRoutes = mergeAccountsForSave(current.filter(isApiKeyAccount), incoming.filter(isApiKeyAccount), options);
|
|
1227
|
+
const byId = new Map(apiRoutes.map((account) => [account.id, account]));
|
|
1228
|
+
const result = current.flatMap((account) => {
|
|
1229
|
+
if (isOAuthAccount(account))
|
|
1230
|
+
return [account];
|
|
1231
|
+
const replacement = byId.get(account.id);
|
|
1232
|
+
if (!replacement)
|
|
1233
|
+
return [];
|
|
1234
|
+
byId.delete(account.id);
|
|
1235
|
+
return [replacement];
|
|
1236
|
+
});
|
|
1237
|
+
result.push(...byId.values());
|
|
1238
|
+
return result;
|
|
1239
|
+
}
|
|
856
1240
|
const ACCOUNT_CONFIG_LOCK_TTL_MS = 10_000;
|
|
857
1241
|
const ACCOUNT_CONFIG_LOCK_WAIT_MS = 12_000;
|
|
858
1242
|
const ACCOUNT_STATE_LOCK_TTL_MS = 10_000;
|
|
@@ -898,6 +1282,32 @@ export function saveAccounts(storage, path = getAccountStoragePath(), options =
|
|
|
898
1282
|
const resolvedPath = path;
|
|
899
1283
|
return enqueueSave(() => saveAccountsLocked(storage, resolvedPath, options));
|
|
900
1284
|
}
|
|
1285
|
+
/** Read-modify-write account configuration under the existing config/state lock order.
|
|
1286
|
+
* The callback is synchronous: network discovery must happen under its own outer
|
|
1287
|
+
* lease, never while holding the config lock. Do not call another persistence API
|
|
1288
|
+
* from the callback.
|
|
1289
|
+
*/
|
|
1290
|
+
export function mutateAccountsPersistent(path, mutate, options = {}) {
|
|
1291
|
+
const resolvedPath = resolve(path);
|
|
1292
|
+
return enqueueSave(async () => {
|
|
1293
|
+
const lock = await acquireAccountConfigWriteLock(resolvedPath);
|
|
1294
|
+
try {
|
|
1295
|
+
const current = (await loadAccounts(resolvedPath)) ?? createEmptyStorage();
|
|
1296
|
+
const mutation = mutate(current);
|
|
1297
|
+
if (mutation.save !== false) {
|
|
1298
|
+
await options.assertAuthority?.();
|
|
1299
|
+
await saveAccountsWithConfigLock(mutation.storage, resolvedPath, {
|
|
1300
|
+
...mutation.options,
|
|
1301
|
+
[MUTATE_ACCOUNT_ROSTER]: true,
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
return mutation.result;
|
|
1305
|
+
}
|
|
1306
|
+
finally {
|
|
1307
|
+
await lock.release();
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
901
1311
|
export async function getOrCreateMainAccountId(path = getAccountStoragePath(), createId = randomUUID) {
|
|
902
1312
|
return enqueueSave(async () => {
|
|
903
1313
|
const lock = await acquireAccountConfigWriteLock(path);
|
|
@@ -922,32 +1332,65 @@ export async function getOrCreateMainAccountId(path = getAccountStoragePath(), c
|
|
|
922
1332
|
async function saveAccountsLocked(storage, path, options) {
|
|
923
1333
|
const lock = await acquireAccountConfigWriteLock(path);
|
|
924
1334
|
try {
|
|
925
|
-
|
|
926
|
-
const nextStorage = {
|
|
927
|
-
...storage,
|
|
928
|
-
accounts: mergeAccountsForSave(current?.accounts ?? [], storage.accounts, options),
|
|
929
|
-
};
|
|
930
|
-
const existing = await loadExistingTopLevelFields(path);
|
|
931
|
-
const nextConfig = { ...existing, ...configFromStorage(nextStorage) };
|
|
932
|
-
await writeJsonAtomic(path, nextConfig);
|
|
933
|
-
// Config precedes state everywhere both locks are needed; reversing this
|
|
934
|
-
// order can deadlock profile mutations against full account saves.
|
|
935
|
-
const stateLock = await acquireAccountStateWriteLock(path);
|
|
936
|
-
try {
|
|
937
|
-
await saveAccountStateUnlocked(nextStorage, path, {
|
|
938
|
-
mainQuota: true,
|
|
939
|
-
mainRefresh: true,
|
|
940
|
-
accounts: true,
|
|
941
|
-
});
|
|
942
|
-
}
|
|
943
|
-
finally {
|
|
944
|
-
await stateLock.release();
|
|
945
|
-
}
|
|
1335
|
+
await saveAccountsWithConfigLock(storage, path, options);
|
|
946
1336
|
}
|
|
947
1337
|
finally {
|
|
948
1338
|
await lock.release();
|
|
949
1339
|
}
|
|
950
1340
|
}
|
|
1341
|
+
async function saveAccountsWithConfigLock(storage, path, options) {
|
|
1342
|
+
const current = await loadAccounts(path);
|
|
1343
|
+
// A config-only writer may hold a pre-discovery snapshot. It must not
|
|
1344
|
+
// resurrect departed OAuth members or overwrite scoped exclusions.
|
|
1345
|
+
const preserveScopedRoster = current?.claustrum?.scopedRoster === true && !options[MUTATE_ACCOUNT_ROSTER];
|
|
1346
|
+
const mergeOptions = preserveScopedRoster
|
|
1347
|
+
? {
|
|
1348
|
+
...options,
|
|
1349
|
+
removedAccountIds: options.removedAccountIds?.filter((id) => current?.accounts.some((account) => account.id === id && isApiKeyAccount(account))),
|
|
1350
|
+
}
|
|
1351
|
+
: options;
|
|
1352
|
+
const nextStorage = {
|
|
1353
|
+
...storage,
|
|
1354
|
+
...(storage.claustrum && {
|
|
1355
|
+
claustrum: {
|
|
1356
|
+
...current?.claustrum,
|
|
1357
|
+
...storage.claustrum,
|
|
1358
|
+
...(preserveScopedRoster && current?.claustrum),
|
|
1359
|
+
...(options[WRITE_CLAUSTRUM_MODE]
|
|
1360
|
+
? { mode: storage.claustrum.mode }
|
|
1361
|
+
: current?.claustrum?.mode
|
|
1362
|
+
? { mode: current.claustrum.mode }
|
|
1363
|
+
: {}),
|
|
1364
|
+
},
|
|
1365
|
+
}),
|
|
1366
|
+
...(!storage.claustrum &&
|
|
1367
|
+
current?.claustrum && {
|
|
1368
|
+
claustrum: current.claustrum,
|
|
1369
|
+
}),
|
|
1370
|
+
accounts: preserveScopedRoster
|
|
1371
|
+
? preserveScopedAccountsOnSave(current?.accounts ?? [], storage.accounts, mergeOptions)
|
|
1372
|
+
: mergeAccountsForSave(current?.accounts ?? [], storage.accounts, mergeOptions),
|
|
1373
|
+
};
|
|
1374
|
+
const existing = await loadExistingTopLevelFields(path);
|
|
1375
|
+
const nextConfig = { ...existing, ...configFromStorage(nextStorage) };
|
|
1376
|
+
if (!nextStorage.claustrum)
|
|
1377
|
+
delete nextConfig.claustrum;
|
|
1378
|
+
await writeJsonAtomic(path, nextConfig);
|
|
1379
|
+
// Config precedes state everywhere both locks are needed; reversing this
|
|
1380
|
+
// order can deadlock profile mutations against full account saves.
|
|
1381
|
+
const stateLock = await acquireAccountStateWriteLock(path);
|
|
1382
|
+
try {
|
|
1383
|
+
await saveAccountStateUnlocked(nextStorage, path, {
|
|
1384
|
+
mainQuota: true,
|
|
1385
|
+
mainRefresh: true,
|
|
1386
|
+
accounts: true,
|
|
1387
|
+
setClaustrumHandleAccountIds: options.setClaustrumHandleAccountIds,
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
finally {
|
|
1391
|
+
await stateLock.release();
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
951
1394
|
function applyMainProfileStatePatch(state, storage) {
|
|
952
1395
|
state.main = state.main ?? {};
|
|
953
1396
|
state.main.profile = storage.main?.profile;
|
|
@@ -999,7 +1442,10 @@ function applyMainQuotaStatePatch(state, storage) {
|
|
|
999
1442
|
state.main.quotaErrorGeneration = incomingGeneration;
|
|
1000
1443
|
}
|
|
1001
1444
|
}
|
|
1002
|
-
else if (incomingError
|
|
1445
|
+
else if (incomingError &&
|
|
1446
|
+
typeof incomingError.checkedAt === 'number' &&
|
|
1447
|
+
Number.isFinite(incomingError.checkedAt) &&
|
|
1448
|
+
incomingError.checkedAt > existingObservedAt) {
|
|
1003
1449
|
const acceptsByObservation = incomingErrorObservedAt !== undefined &&
|
|
1004
1450
|
incomingErrorObservedAt > existingObservedAt;
|
|
1005
1451
|
const acceptsEqualObservation = incomingErrorObservedAt !== undefined &&
|
|
@@ -1030,12 +1476,11 @@ function applyMainQuotaStatePatch(state, storage) {
|
|
|
1030
1476
|
? mergeHeaderQuotaForPersistence(state.main.quota, incomingQuota)
|
|
1031
1477
|
: incomingQuota;
|
|
1032
1478
|
const mergesHeaderQuota = Boolean(sameToken && incomingQuota?.source === 'headers');
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
const
|
|
1037
|
-
|
|
1038
|
-
: quotaSnapshotCheckedAt(effectiveIncomingQuota);
|
|
1479
|
+
// Ordering authority must travel inside the account-bound snapshot. The
|
|
1480
|
+
// legacy top-level quotaCheckedAt/mainQuotaCheckedAt fields carry no account
|
|
1481
|
+
// identity and can form a mixed pair under concurrent cross-account writes.
|
|
1482
|
+
const existingCheckedAt = quotaSnapshotCheckedAt(state.main.quota);
|
|
1483
|
+
const incomingCheckedAt = quotaSnapshotCheckedAt(effectiveIncomingQuota);
|
|
1039
1484
|
if (!mergesHeaderQuota &&
|
|
1040
1485
|
(existingCheckedAt > incomingCheckedAt ||
|
|
1041
1486
|
(existingCheckedAt === incomingCheckedAt &&
|
|
@@ -1044,14 +1489,29 @@ function applyMainQuotaStatePatch(state, storage) {
|
|
|
1044
1489
|
return;
|
|
1045
1490
|
}
|
|
1046
1491
|
state.main.quota = effectiveIncomingQuota;
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
: storage.quota?.mainQuotaCheckedAt;
|
|
1492
|
+
const boundCheckedAt = quotaSnapshotCheckedAt(effectiveIncomingQuota);
|
|
1493
|
+
state.main.quotaCheckedAt = boundCheckedAt > 0 ? boundCheckedAt : undefined;
|
|
1050
1494
|
state.main.quotaToken = storage.quota?.mainQuotaToken;
|
|
1051
1495
|
}
|
|
1052
1496
|
function applyMainRefreshStatePatch(state, storage) {
|
|
1053
1497
|
state.main = state.main ?? {};
|
|
1054
|
-
|
|
1498
|
+
const incomingError = storage.refresh?.mainLastRefreshError;
|
|
1499
|
+
const incomingClearedAt = storage.refresh?.mainRefreshErrorClearedAt;
|
|
1500
|
+
const existingErrorObservedAt = state.main.lastRefreshError?.checkedAt;
|
|
1501
|
+
const existingClearedAt = state.main.refreshErrorClearedAt;
|
|
1502
|
+
const existingObservedAt = Math.max(existingErrorObservedAt ?? 0, existingClearedAt ?? 0);
|
|
1503
|
+
if (incomingError === undefined &&
|
|
1504
|
+
incomingClearedAt !== undefined &&
|
|
1505
|
+
incomingClearedAt >= existingObservedAt) {
|
|
1506
|
+
state.main.lastRefreshError = undefined;
|
|
1507
|
+
state.main.refreshErrorClearedAt = mergeMainRefreshErrorClearedAt(existingClearedAt, incomingClearedAt);
|
|
1508
|
+
}
|
|
1509
|
+
else if (incomingError &&
|
|
1510
|
+
typeof incomingError.checkedAt === 'number' &&
|
|
1511
|
+
Number.isFinite(incomingError.checkedAt) &&
|
|
1512
|
+
incomingError.checkedAt > existingObservedAt) {
|
|
1513
|
+
state.main.lastRefreshError = incomingError;
|
|
1514
|
+
}
|
|
1055
1515
|
state.main.refreshLeaseId = storage.refresh?.mainRefreshLeaseId;
|
|
1056
1516
|
state.main.refreshLeaseUntil = storage.refresh?.mainRefreshLeaseUntil;
|
|
1057
1517
|
state.main.refreshLeaseTokenHash = storage.refresh?.mainRefreshLeaseTokenHash;
|
|
@@ -1119,6 +1579,22 @@ export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
|
|
|
1119
1579
|
? { ...existing, version: 1 }
|
|
1120
1580
|
: { version: 1 };
|
|
1121
1581
|
const { accountId, accountIdentity, profile } = input;
|
|
1582
|
+
if (current?.claustrum?.scopedRoster &&
|
|
1583
|
+
getClaustrumMode(current) === 'claustrum') {
|
|
1584
|
+
const account = current.accounts.find((entry) => entry.id === accountId);
|
|
1585
|
+
const expected = accountId === 'main'
|
|
1586
|
+
? current.claustrum.primaryAccount?.accountId
|
|
1587
|
+
: account && isOAuthAccount(account)
|
|
1588
|
+
? account.anthropicAccountUuid
|
|
1589
|
+
: undefined;
|
|
1590
|
+
if (!expected ||
|
|
1591
|
+
accountIdentity !== expected ||
|
|
1592
|
+
(profile?.accountIdentity !== undefined &&
|
|
1593
|
+
profile.accountIdentity !== expected) ||
|
|
1594
|
+
(profile?.providerAccountUuid !== undefined &&
|
|
1595
|
+
profile.providerAccountUuid !== expected))
|
|
1596
|
+
return false;
|
|
1597
|
+
}
|
|
1122
1598
|
if (accountId === 'main') {
|
|
1123
1599
|
next.main = { ...(next.main ?? {}) };
|
|
1124
1600
|
const existingProfile = normalizeOAuthAccountProfile(next.main.profile);
|
|
@@ -1201,27 +1677,210 @@ export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
|
|
|
1201
1677
|
}
|
|
1202
1678
|
});
|
|
1203
1679
|
}
|
|
1680
|
+
export function clearClaustrumRefreshErrorPersistent(accountId, handle, path = getAccountStoragePath()) {
|
|
1681
|
+
return enqueueSave(async () => {
|
|
1682
|
+
const configLock = await acquireAccountConfigWriteLock(path);
|
|
1683
|
+
try {
|
|
1684
|
+
const stateLock = await acquireAccountStateWriteLock(path);
|
|
1685
|
+
try {
|
|
1686
|
+
// Re-read under both locks so this field-scoped clear cannot write a stale credential or config snapshot.
|
|
1687
|
+
const storage = await loadAccounts(path);
|
|
1688
|
+
const account = storage?.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
|
|
1689
|
+
if (!storage ||
|
|
1690
|
+
!account ||
|
|
1691
|
+
account.claustrumHandle !== handle ||
|
|
1692
|
+
!account.lastRefreshError) {
|
|
1693
|
+
return false;
|
|
1694
|
+
}
|
|
1695
|
+
account.lastRefreshError = undefined;
|
|
1696
|
+
await saveAccountStateUnlocked(storage, path, { accounts: [accountId] });
|
|
1697
|
+
const statePath = getAccountStatePath(path);
|
|
1698
|
+
const state = (await readJsonIfPresent(statePath)).value;
|
|
1699
|
+
if (isRecord(state) && isRecord(state.accounts)) {
|
|
1700
|
+
const entry = state.accounts[accountId];
|
|
1701
|
+
if (isRecord(entry)) {
|
|
1702
|
+
delete entry.lastRefreshError;
|
|
1703
|
+
await writeJsonAtomic(statePath, pruneUndefined(state));
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
return true;
|
|
1707
|
+
}
|
|
1708
|
+
finally {
|
|
1709
|
+
await stateLock.release();
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
finally {
|
|
1713
|
+
await configLock.release();
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
function fenceScopedPrimaryState(state, primaryId) {
|
|
1718
|
+
if (!isRecord(state.main))
|
|
1719
|
+
state.main = {};
|
|
1720
|
+
const main = state.main;
|
|
1721
|
+
if (!primaryId || main.scopedAccountIdentity !== primaryId) {
|
|
1722
|
+
delete main.prime;
|
|
1723
|
+
delete main.primeAuthLineageId;
|
|
1724
|
+
delete main.primeAuthLineageRefreshTokenFingerprint;
|
|
1725
|
+
delete main.quotaErrorGeneration;
|
|
1726
|
+
delete main.quotaErrorClearedAt;
|
|
1727
|
+
}
|
|
1728
|
+
main.scopedAccountIdentity = primaryId;
|
|
1729
|
+
if (!primaryId || main.quota?.accountIdentity !== primaryId) {
|
|
1730
|
+
delete main.quota;
|
|
1731
|
+
delete main.quotaCheckedAt;
|
|
1732
|
+
delete main.quotaToken;
|
|
1733
|
+
}
|
|
1734
|
+
if (!primaryId || main.lastQuotaApiError?.accountIdentity !== primaryId)
|
|
1735
|
+
delete main.lastQuotaApiError;
|
|
1736
|
+
if (!primaryId ||
|
|
1737
|
+
(main.profile?.providerAccountUuid ?? main.profile?.accountIdentity) !==
|
|
1738
|
+
primaryId) {
|
|
1739
|
+
delete main.profile;
|
|
1740
|
+
delete main.profileToken;
|
|
1741
|
+
}
|
|
1742
|
+
delete main.lastRefreshError;
|
|
1743
|
+
delete main.refreshErrorClearedAt;
|
|
1744
|
+
delete main.refreshLeaseId;
|
|
1745
|
+
delete main.refreshLeaseUntil;
|
|
1746
|
+
delete main.refreshLeaseTokenHash;
|
|
1747
|
+
}
|
|
1204
1748
|
async function saveAccountStateUnlocked(storage, path, scope) {
|
|
1205
1749
|
const statePath = getAccountStatePath(path);
|
|
1206
1750
|
const existing = (await readJsonIfPresent(statePath)).value;
|
|
1207
1751
|
const next = isRecord(existing)
|
|
1208
1752
|
? { ...existing, version: 1 }
|
|
1209
1753
|
: { version: 1 };
|
|
1210
|
-
|
|
1754
|
+
const persistedConfig = (await readJsonIfPresent(path)).value;
|
|
1755
|
+
const authority = isRecord(persistedConfig)
|
|
1756
|
+
? normalizeClaustrumConfig(persistedConfig.claustrum)
|
|
1757
|
+
: undefined;
|
|
1758
|
+
const scoped = authority?.mode === 'claustrum' && authority.scopedRoster === true;
|
|
1759
|
+
const primaryId = authority?.primaryAccount?.accountId;
|
|
1760
|
+
const samePrimary = Boolean(primaryId && storage.claustrum?.primaryAccount?.accountId === primaryId);
|
|
1761
|
+
if (scoped)
|
|
1762
|
+
fenceScopedPrimaryState(next, primaryId);
|
|
1763
|
+
const incomingProfileId = storage.main?.profile?.providerAccountUuid ??
|
|
1764
|
+
storage.main?.profile?.accountIdentity;
|
|
1765
|
+
if (scope.mainProfile &&
|
|
1766
|
+
(!scoped ||
|
|
1767
|
+
(samePrimary &&
|
|
1768
|
+
(!storage.main?.profile || incomingProfileId === primaryId))))
|
|
1211
1769
|
applyMainProfileStatePatch(next, storage);
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1770
|
+
const quotaMatches = samePrimary &&
|
|
1771
|
+
(!storage.quota?.mainQuota ||
|
|
1772
|
+
storage.quota.mainQuota.accountIdentity === primaryId) &&
|
|
1773
|
+
(!storage.quota?.mainLastQuotaApiError ||
|
|
1774
|
+
storage.quota.mainLastQuotaApiError.accountIdentity === primaryId);
|
|
1775
|
+
if (scope.mainQuota && (!scoped || quotaMatches)) {
|
|
1776
|
+
applyMainQuotaStatePatch(next, scoped
|
|
1777
|
+
? { ...storage, quota: { ...storage.quota, mainQuotaToken: primaryId } }
|
|
1778
|
+
: storage);
|
|
1779
|
+
}
|
|
1780
|
+
if (scope.mainRefresh && !scoped)
|
|
1215
1781
|
applyMainRefreshStatePatch(next, storage);
|
|
1216
|
-
if (scope.mainPrime)
|
|
1782
|
+
if (scope.mainPrime && (!scoped || samePrimary))
|
|
1217
1783
|
applyMainPrimeStatePatch(next, storage);
|
|
1218
1784
|
if (scope.accounts) {
|
|
1219
1785
|
const ids = scope.accounts === true ? null : new Set(scope.accounts);
|
|
1786
|
+
const setClaustrumHandleAccountIds = new Set(scope.setClaustrumHandleAccountIds ?? []);
|
|
1787
|
+
const config = persistedConfig;
|
|
1788
|
+
const configAccounts = isRecord(config) && Array.isArray(config.accounts)
|
|
1789
|
+
? new Map(config.accounts
|
|
1790
|
+
.filter(isRecord)
|
|
1791
|
+
.map((entry) => [String(entry.id ?? '').trim(), entry]))
|
|
1792
|
+
: new Map();
|
|
1793
|
+
const configuredIds = (() => {
|
|
1794
|
+
if (!isRecord(config) || !Array.isArray(config.accounts))
|
|
1795
|
+
return null;
|
|
1796
|
+
if (config.accounts.length === 0)
|
|
1797
|
+
return new Set();
|
|
1798
|
+
const stateAccounts = isRecord(next.accounts) ? next.accounts : {};
|
|
1799
|
+
const incomingAccounts = Object.fromEntries(storage.accounts.map((account) => [
|
|
1800
|
+
account.id.trim(),
|
|
1801
|
+
accountRuntimeState(account),
|
|
1802
|
+
]));
|
|
1803
|
+
// Keep both forms because legacy state may still use either key while
|
|
1804
|
+
// scoped saves must preserve entries not superseded by an incoming account.
|
|
1805
|
+
const memberships = config.accounts.map((account) => {
|
|
1806
|
+
if (!isRecord(account) || typeof account.id !== 'string')
|
|
1807
|
+
return null;
|
|
1808
|
+
const id = account.id.trim();
|
|
1809
|
+
if (!id)
|
|
1810
|
+
return null;
|
|
1811
|
+
if (configAccountHasInvalidShape(account))
|
|
1812
|
+
return null;
|
|
1813
|
+
const stateValue = stateAccounts[account.id] ?? stateAccounts[id];
|
|
1814
|
+
const stateAccount = isRecord(stateValue)
|
|
1815
|
+
? stateValue
|
|
1816
|
+
: {};
|
|
1817
|
+
const incomingAccount = incomingAccounts[id];
|
|
1818
|
+
return normalizeAccount(mergeConfigAccountAndState(account, incomingAccount ?? stateAccount))
|
|
1819
|
+
? [account.id, id]
|
|
1820
|
+
: null;
|
|
1821
|
+
});
|
|
1822
|
+
// A populated config with any unparseable entry cannot establish safe membership.
|
|
1823
|
+
return memberships.every((membership) => membership !== null)
|
|
1824
|
+
? new Set(memberships.flatMap((membership) => membership ?? []))
|
|
1825
|
+
: null;
|
|
1826
|
+
})();
|
|
1220
1827
|
next.accounts = { ...(isRecord(next.accounts) ? next.accounts : {}) };
|
|
1221
1828
|
for (const account of storage.accounts) {
|
|
1222
|
-
|
|
1829
|
+
const accountId = account.id.trim();
|
|
1830
|
+
if (ids && !ids.has(account.id) && !ids.has(accountId))
|
|
1223
1831
|
continue;
|
|
1224
|
-
|
|
1832
|
+
if (configuredIds &&
|
|
1833
|
+
!configuredIds.has(account.id) &&
|
|
1834
|
+
!configuredIds.has(accountId))
|
|
1835
|
+
continue;
|
|
1836
|
+
const legacyKeys = Object.keys(next.accounts).filter((key) => key !== accountId && key.trim() === accountId);
|
|
1837
|
+
const legacyKey = legacyKeys[0];
|
|
1838
|
+
const existingAccount = next.accounts[accountId] ??
|
|
1839
|
+
(legacyKey ? next.accounts[legacyKey] : undefined);
|
|
1840
|
+
for (const key of legacyKeys)
|
|
1841
|
+
delete next.accounts[key];
|
|
1842
|
+
const configured = configAccounts.get(accountId);
|
|
1843
|
+
if (configured &&
|
|
1844
|
+
typeof configured.claustrumScopedCredentialId === 'string' &&
|
|
1845
|
+
typeof configured.anthropicAccountUuid === 'string') {
|
|
1846
|
+
const matches = (value) => isRecord(value) &&
|
|
1847
|
+
value.claustrumScopedCredentialId ===
|
|
1848
|
+
configured.claustrumScopedCredentialId &&
|
|
1849
|
+
value.anthropicAccountUuid === configured.anthropicAccountUuid;
|
|
1850
|
+
const incoming = accountRuntimeState(account);
|
|
1851
|
+
const merged = mergeAccountRuntimeState(matches(existingAccount) ? existingAccount : undefined, matches(incoming) ? incoming : {});
|
|
1852
|
+
// The current config owns identity. Older local writers cannot restore
|
|
1853
|
+
// credentials, handles or another account's observations after cutover.
|
|
1854
|
+
next.accounts[accountId] = {
|
|
1855
|
+
...merged,
|
|
1856
|
+
claustrumScopedCredentialId: configured.claustrumScopedCredentialId,
|
|
1857
|
+
anthropicAccountUuid: configured.anthropicAccountUuid,
|
|
1858
|
+
refresh: '',
|
|
1859
|
+
};
|
|
1860
|
+
const scoped = next.accounts[accountId];
|
|
1861
|
+
delete scoped.access;
|
|
1862
|
+
delete scoped.expires;
|
|
1863
|
+
delete scoped.lastRefreshedAt;
|
|
1864
|
+
delete scoped.lastRefreshError;
|
|
1865
|
+
delete scoped.claustrumHandle;
|
|
1866
|
+
}
|
|
1867
|
+
else {
|
|
1868
|
+
const merged = mergeAccountRuntimeState(existingAccount, accountRuntimeState(account), setClaustrumHandleAccountIds.has(account.id) ||
|
|
1869
|
+
setClaustrumHandleAccountIds.has(accountId));
|
|
1870
|
+
if (configured) {
|
|
1871
|
+
delete merged.claustrumScopedCredentialId;
|
|
1872
|
+
delete merged.claustrumScopedState;
|
|
1873
|
+
}
|
|
1874
|
+
next.accounts[accountId] = merged;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
if (configuredIds) {
|
|
1878
|
+
// Config membership is authoritative for scoped writes too; otherwise a
|
|
1879
|
+
// stale writer can preserve state for an account removed out of band.
|
|
1880
|
+
for (const id of Object.keys(next.accounts)) {
|
|
1881
|
+
if (!configuredIds.has(id))
|
|
1882
|
+
delete next.accounts[id];
|
|
1883
|
+
}
|
|
1225
1884
|
}
|
|
1226
1885
|
if (ids) {
|
|
1227
1886
|
for (const id of ids) {
|
|
@@ -1230,19 +1889,6 @@ async function saveAccountStateUnlocked(storage, path, scope) {
|
|
|
1230
1889
|
}
|
|
1231
1890
|
}
|
|
1232
1891
|
}
|
|
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
1892
|
}
|
|
1247
1893
|
await writeJsonAtomic(statePath, pruneUndefined(next));
|
|
1248
1894
|
}
|
|
@@ -1439,6 +2085,12 @@ export async function acquireRefreshFileLock(options) {
|
|
|
1439
2085
|
return null;
|
|
1440
2086
|
scheduleRenewal();
|
|
1441
2087
|
return {
|
|
2088
|
+
assertOwned: async () => {
|
|
2089
|
+
const owner = await readOwner().catch(() => undefined);
|
|
2090
|
+
if (released || owner?.ownerId !== ownerId) {
|
|
2091
|
+
throw new Error('Account file lock ownership was lost');
|
|
2092
|
+
}
|
|
2093
|
+
},
|
|
1442
2094
|
release: async () => {
|
|
1443
2095
|
released = true;
|
|
1444
2096
|
if (renewTimer) {
|
|
@@ -1717,6 +2369,9 @@ function refreshBeforeExpiryMs(storage) {
|
|
|
1717
2369
|
DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES;
|
|
1718
2370
|
return Math.max(MIN_REFRESH_BEFORE_EXPIRY_MINUTES, minutes) * 60_000;
|
|
1719
2371
|
}
|
|
2372
|
+
export function getRefreshBeforeExpiryMs(storage) {
|
|
2373
|
+
return refreshBeforeExpiryMs(storage);
|
|
2374
|
+
}
|
|
1720
2375
|
export function getRefreshIntervalMs(storage) {
|
|
1721
2376
|
const minutes = storage?.refresh?.intervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
|
|
1722
2377
|
return Math.max(1, minutes) * 60_000;
|
|
@@ -1729,17 +2384,11 @@ function isTransientRefreshError(error) {
|
|
|
1729
2384
|
if (typeof status === 'number' && Number.isFinite(status)) {
|
|
1730
2385
|
return status === 429 || status >= 500;
|
|
1731
2386
|
}
|
|
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')));
|
|
2387
|
+
return isTransientNetworkError(error);
|
|
1740
2388
|
}
|
|
1741
2389
|
export function buildRefreshOperationError(input) {
|
|
1742
|
-
const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity
|
|
2390
|
+
const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity &&
|
|
2391
|
+
input.previous?.refreshTokenFingerprint === input.refreshTokenFingerprint
|
|
1743
2392
|
? (input.previous?.retryCount ?? 0)
|
|
1744
2393
|
: 0;
|
|
1745
2394
|
const retryCount = previousRetryCount + 1;
|
|
@@ -1749,6 +2398,9 @@ export function buildRefreshOperationError(input) {
|
|
|
1749
2398
|
if (typeof retryAfterFromError === 'number' && retryAfterFromError > 0) {
|
|
1750
2399
|
delay = retryAfterFromError * 1000;
|
|
1751
2400
|
}
|
|
2401
|
+
else if (isTransientNetworkError(input.error)) {
|
|
2402
|
+
delay = MIN_REFRESH_RETRY_DELAY_MS;
|
|
2403
|
+
}
|
|
1752
2404
|
else if (isTransientRefreshError(input.error)) {
|
|
1753
2405
|
delay = Math.min(MAX_REFRESH_RETRY_DELAY_MS, MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6));
|
|
1754
2406
|
}
|
|
@@ -1762,9 +2414,10 @@ export function buildRefreshOperationError(input) {
|
|
|
1762
2414
|
const message = formatErrorMessage(input.error);
|
|
1763
2415
|
// A token is permanently dead ONLY on 400 invalid_grant. The OAuth spec allows
|
|
1764
2416
|
// other 400s (invalid_client / invalid_request / unsupported_grant_type) that
|
|
1765
|
-
// re-login does NOT fix — those
|
|
1766
|
-
//
|
|
1767
|
-
//
|
|
2417
|
+
// re-login does NOT fix — those must stay permanent=false so they are not
|
|
2418
|
+
// falsely flagged "needs re-login". Network failures use a fixed five-minute
|
|
2419
|
+
// retry interval so connectivity recovery cannot inherit an hour-long backoff.
|
|
2420
|
+
// ClaudeOAuthRefreshError carries the raw
|
|
1768
2421
|
// OAuth body, and its message embeds it (`...: 400 — <body>`), so check both.
|
|
1769
2422
|
const body = typeof input.error.body === 'string'
|
|
1770
2423
|
? input.error.body
|
|
@@ -1776,6 +2429,7 @@ export function buildRefreshOperationError(input) {
|
|
|
1776
2429
|
nextRetryAt: input.now + delay,
|
|
1777
2430
|
retryCount,
|
|
1778
2431
|
accountIdentity: input.accountIdentity,
|
|
2432
|
+
refreshTokenFingerprint: input.refreshTokenFingerprint,
|
|
1779
2433
|
status,
|
|
1780
2434
|
permanent: status === 400 && isInvalidGrant,
|
|
1781
2435
|
};
|
|
@@ -1802,15 +2456,32 @@ export function isPermanentRefreshError(error) {
|
|
|
1802
2456
|
return error.permanent;
|
|
1803
2457
|
if (typeof error.status === 'number')
|
|
1804
2458
|
return error.status === 400;
|
|
2459
|
+
if (isTransientNetworkError(error))
|
|
2460
|
+
return false;
|
|
1805
2461
|
if (typeof error.nextRetryAt === 'number') {
|
|
1806
2462
|
return (error.nextRetryAt - error.checkedAt >=
|
|
1807
2463
|
NON_TRANSIENT_REFRESH_RETRY_DELAY_MS);
|
|
1808
2464
|
}
|
|
1809
2465
|
return false;
|
|
1810
2466
|
}
|
|
1811
|
-
|
|
1812
|
-
|
|
2467
|
+
function effectiveRefreshRetryAt(error) {
|
|
2468
|
+
const persistedRetryAt = error.nextRetryAt;
|
|
2469
|
+
if (!persistedRetryAt || !isTransientNetworkError(error)) {
|
|
2470
|
+
return persistedRetryAt;
|
|
2471
|
+
}
|
|
2472
|
+
return Math.min(persistedRetryAt, error.checkedAt + MIN_REFRESH_RETRY_DELAY_MS);
|
|
2473
|
+
}
|
|
2474
|
+
export function refreshBackoffActive(error, accountIdentity, now, currentRefreshTokenFingerprint) {
|
|
2475
|
+
if (!error)
|
|
2476
|
+
return false;
|
|
2477
|
+
const retryAt = effectiveRefreshRetryAt(error);
|
|
2478
|
+
if (!retryAt || retryAt <= now)
|
|
2479
|
+
return false;
|
|
2480
|
+
if (error.refreshTokenFingerprint &&
|
|
2481
|
+
currentRefreshTokenFingerprint &&
|
|
2482
|
+
error.refreshTokenFingerprint !== currentRefreshTokenFingerprint) {
|
|
1813
2483
|
return false;
|
|
2484
|
+
}
|
|
1814
2485
|
if (!error.accountIdentity)
|
|
1815
2486
|
return true;
|
|
1816
2487
|
if (!accountIdentity)
|
|
@@ -1818,7 +2489,7 @@ export function refreshBackoffActive(error, accountIdentity, now) {
|
|
|
1818
2489
|
return error.accountIdentity === accountIdentity;
|
|
1819
2490
|
}
|
|
1820
2491
|
export function formatRefreshBackoffMessage(error, now) {
|
|
1821
|
-
const seconds = Math.max(1, Math.ceil(((error
|
|
2492
|
+
const seconds = Math.max(1, Math.ceil(((effectiveRefreshRetryAt(error) ?? now) - now) / 1000));
|
|
1822
2493
|
return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
|
|
1823
2494
|
}
|
|
1824
2495
|
function createRefreshBackoffActiveError(error, now) {
|
|
@@ -1893,13 +2564,14 @@ export async function setLogLevelPersistent(level, path = getAccountStoragePath(
|
|
|
1893
2564
|
setLogLevel(level);
|
|
1894
2565
|
}
|
|
1895
2566
|
export function getPersistedMainQuota(storage) {
|
|
1896
|
-
|
|
2567
|
+
const quota = storage?.quota?.mainQuota;
|
|
2568
|
+
if (!quota)
|
|
1897
2569
|
return null;
|
|
1898
2570
|
return {
|
|
1899
|
-
quota
|
|
1900
|
-
checkedAt:
|
|
1901
|
-
tokenFingerprint: storage.quota
|
|
1902
|
-
accountIdentity:
|
|
2571
|
+
quota,
|
|
2572
|
+
checkedAt: quotaSnapshotCheckedAt(quota),
|
|
2573
|
+
tokenFingerprint: storage.quota?.mainQuotaToken,
|
|
2574
|
+
accountIdentity: quota.accountIdentity,
|
|
1903
2575
|
};
|
|
1904
2576
|
}
|
|
1905
2577
|
/**
|
|
@@ -2104,35 +2776,84 @@ export async function setKillswitchPersistent(config, path = getAccountStoragePa
|
|
|
2104
2776
|
return storage;
|
|
2105
2777
|
}
|
|
2106
2778
|
export async function removeAccountPersistent(id, path = getAccountStoragePath()) {
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2779
|
+
return mutateAccountsPersistent(path, (storage) => {
|
|
2780
|
+
const account = storage.accounts.find((entry) => entry.id === id);
|
|
2781
|
+
if (account &&
|
|
2782
|
+
isOAuthAccount(account) &&
|
|
2783
|
+
account.claustrumScopedCredentialId &&
|
|
2784
|
+
getClaustrumMode(storage) === 'claustrum') {
|
|
2785
|
+
throw new Error('Disable this account or remove it from Claustrum; scoped membership is managed by the vault');
|
|
2786
|
+
}
|
|
2787
|
+
const existed = removeAccount(storage, id);
|
|
2788
|
+
return {
|
|
2789
|
+
storage,
|
|
2790
|
+
result: existed,
|
|
2791
|
+
save: existed,
|
|
2792
|
+
options: { removedAccountIds: [id] },
|
|
2793
|
+
};
|
|
2794
|
+
});
|
|
2115
2795
|
}
|
|
2116
2796
|
export async function reorderAccountsPersistent(orderedIds, path = getAccountStoragePath()) {
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
return
|
|
2120
|
-
|
|
2121
|
-
|
|
2797
|
+
await mutateAccountsPersistent(path, (storage) => {
|
|
2798
|
+
reorderAccounts(storage, orderedIds);
|
|
2799
|
+
return {
|
|
2800
|
+
storage,
|
|
2801
|
+
result: undefined,
|
|
2802
|
+
options: { preserveExistingAccountOrder: false },
|
|
2803
|
+
};
|
|
2804
|
+
});
|
|
2122
2805
|
}
|
|
2123
2806
|
export async function setAccountEnabledPersistent(id, enabled, path = getAccountStoragePath()) {
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2807
|
+
return mutateAccountsPersistent(path, (storage) => {
|
|
2808
|
+
const found = setAccountEnabled(storage, id, enabled);
|
|
2809
|
+
const account = storage.accounts.find((candidate) => candidate.id === id);
|
|
2810
|
+
if (account &&
|
|
2811
|
+
isOAuthAccount(account) &&
|
|
2812
|
+
(account.claustrumScopedCredentialId ||
|
|
2813
|
+
storage.claustrum?.scopedRoster) &&
|
|
2814
|
+
account.anthropicAccountUuid) {
|
|
2815
|
+
const disabled = new Set(storage.claustrum?.disabledAccountIdentities ?? []);
|
|
2816
|
+
if (enabled)
|
|
2817
|
+
disabled.delete(account.anthropicAccountUuid);
|
|
2818
|
+
else
|
|
2819
|
+
disabled.add(account.anthropicAccountUuid);
|
|
2820
|
+
storage.claustrum = {
|
|
2821
|
+
...storage.claustrum,
|
|
2822
|
+
disabledAccountIdentities: [...disabled],
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2825
|
+
return { storage, result: found, save: found };
|
|
2826
|
+
});
|
|
2131
2827
|
}
|
|
2132
2828
|
export async function addAccountPersistent(account, path = getAccountStoragePath()) {
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2829
|
+
await mutateAccountsPersistent(path, (storage) => {
|
|
2830
|
+
if (storage.claustrum?.scopedRoster &&
|
|
2831
|
+
getClaustrumMode(storage) === 'claustrum' &&
|
|
2832
|
+
isOAuthAccount(account)) {
|
|
2833
|
+
throw new Error('Claustrum account membership is managed by the vault; add accounts with ck auth login');
|
|
2834
|
+
}
|
|
2835
|
+
upsertAccount(storage, account);
|
|
2836
|
+
if (isOAuthAccount(account) &&
|
|
2837
|
+
getClaustrumMode(storage) === 'local' &&
|
|
2838
|
+
account.refresh &&
|
|
2839
|
+
!isCustodyTombstoneValue(account.refresh)) {
|
|
2840
|
+
const updated = storage.accounts.find((entry) => entry.id === account.id ||
|
|
2841
|
+
(account.label && entry.label === account.label));
|
|
2842
|
+
if (updated && isOAuthAccount(updated)) {
|
|
2843
|
+
delete updated.claustrumScopedCredentialId;
|
|
2844
|
+
delete updated.claustrumScopedState;
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
return {
|
|
2848
|
+
storage,
|
|
2849
|
+
result: undefined,
|
|
2850
|
+
options: {
|
|
2851
|
+
setClaustrumHandleAccountIds: account.type === 'oauth' && account.claustrumHandle
|
|
2852
|
+
? [account.id]
|
|
2853
|
+
: undefined,
|
|
2854
|
+
},
|
|
2855
|
+
};
|
|
2856
|
+
});
|
|
2136
2857
|
}
|
|
2137
2858
|
export function getQuotaNextRefreshAt(quota, storage, now) {
|
|
2138
2859
|
const intervalMs = getQuotaCheckIntervalMs(storage);
|
|
@@ -2209,18 +2930,11 @@ function isTransientQuotaError(error) {
|
|
|
2209
2930
|
if (formattedMessage.includes('Quota refresh is already in progress')) {
|
|
2210
2931
|
return true;
|
|
2211
2932
|
}
|
|
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');
|
|
2933
|
+
return isTransientNetworkError(error);
|
|
2221
2934
|
}
|
|
2222
2935
|
function canUseCachedQuotaAfterRefreshError(account, storage, error, now) {
|
|
2223
|
-
return (
|
|
2936
|
+
return (Boolean(account.access && account.expires && account.expires > now) &&
|
|
2937
|
+
isTransientQuotaError(error) &&
|
|
2224
2938
|
quotaSnapshotPassesPolicy(account.quota, storage) &&
|
|
2225
2939
|
cachedQuotaSnapshotStillRelevant(account.quota, now));
|
|
2226
2940
|
}
|
|
@@ -2348,6 +3062,7 @@ function mapUsageWindow(window, checkedAt) {
|
|
|
2348
3062
|
};
|
|
2349
3063
|
}
|
|
2350
3064
|
export async function fetchOAuthQuotaSnapshot(input) {
|
|
3065
|
+
assertNotCustodyTombstone(input.accessToken, 'anthropic');
|
|
2351
3066
|
const fetchImpl = input.fetchImpl ?? fetch;
|
|
2352
3067
|
const response = await fetchImpl(QUOTA_URL, {
|
|
2353
3068
|
method: 'GET',
|
|
@@ -2370,7 +3085,7 @@ export async function fetchOAuthQuotaSnapshot(input) {
|
|
|
2370
3085
|
const checkedAt = input.now?.() ?? Date.now();
|
|
2371
3086
|
const usage = (await response.json());
|
|
2372
3087
|
const bindingWindow = mapBindingWindow(usage.limits);
|
|
2373
|
-
|
|
3088
|
+
const snapshot = {
|
|
2374
3089
|
five_hour: mapUsageWindow(usage.five_hour, checkedAt),
|
|
2375
3090
|
seven_day: mapUsageWindow(usage.seven_day, checkedAt),
|
|
2376
3091
|
scoped: mapScopedWeeklyLimits(usage.limits, checkedAt),
|
|
@@ -2382,6 +3097,17 @@ export async function fetchOAuthQuotaSnapshot(input) {
|
|
|
2382
3097
|
source: 'poll',
|
|
2383
3098
|
checkedAt,
|
|
2384
3099
|
};
|
|
3100
|
+
const fieldSources = {
|
|
3101
|
+
...(snapshot.five_hour && { five_hour: 'poll' }),
|
|
3102
|
+
...(snapshot.seven_day && { seven_day: 'poll' }),
|
|
3103
|
+
...(snapshot.scoped && { scoped: 'poll' }),
|
|
3104
|
+
...(snapshot.extraUsage && { extraUsage: 'poll' }),
|
|
3105
|
+
...(snapshot.bindingWindow && { bindingWindow: 'poll' }),
|
|
3106
|
+
};
|
|
3107
|
+
return {
|
|
3108
|
+
...snapshot,
|
|
3109
|
+
...(Object.keys(fieldSources).length > 0 && { fieldSources }),
|
|
3110
|
+
};
|
|
2385
3111
|
}
|
|
2386
3112
|
function updateStoredAccount(storage, account) {
|
|
2387
3113
|
const index = storage.accounts.findIndex((candidate) => candidate.id === account.id);
|
|
@@ -2392,10 +3118,16 @@ export function upsertAccount(storage, account) {
|
|
|
2392
3118
|
const index = storage.accounts.findIndex((candidate) => candidate.id === account.id ||
|
|
2393
3119
|
(account.label && candidate.label === account.label));
|
|
2394
3120
|
if (index >= 0) {
|
|
2395
|
-
storage.accounts[index]
|
|
2396
|
-
|
|
3121
|
+
const existing = storage.accounts[index];
|
|
3122
|
+
if (!existing)
|
|
3123
|
+
return;
|
|
3124
|
+
const lineageChanged = existing.type === 'oauth' &&
|
|
3125
|
+
account.type === 'oauth' &&
|
|
3126
|
+
existing.authLineageId !== account.authLineageId;
|
|
3127
|
+
const updated = {
|
|
3128
|
+
...existing,
|
|
2397
3129
|
...account,
|
|
2398
|
-
addedAt:
|
|
3130
|
+
addedAt: existing.addedAt ?? account.addedAt,
|
|
2399
3131
|
...(account.type === 'oauth' && {
|
|
2400
3132
|
quota: account.quota,
|
|
2401
3133
|
profile: account.profile,
|
|
@@ -2404,10 +3136,54 @@ export function upsertAccount(storage, account) {
|
|
|
2404
3136
|
lastQuotaRefreshError: account.lastQuotaRefreshError,
|
|
2405
3137
|
}),
|
|
2406
3138
|
};
|
|
3139
|
+
if (lineageChanged && updated.type === 'oauth') {
|
|
3140
|
+
logger.debug('accounts', 'cleared provider UUID after auth lineage change');
|
|
3141
|
+
delete updated.anthropicAccountUuid;
|
|
3142
|
+
}
|
|
3143
|
+
storage.accounts[index] = updated;
|
|
2407
3144
|
return;
|
|
2408
3145
|
}
|
|
2409
3146
|
storage.accounts.push(account);
|
|
2410
3147
|
}
|
|
3148
|
+
export function persistFallbackQuotaHeaderPersistent(input, path = getAccountStoragePath()) {
|
|
3149
|
+
return enqueueSave(async () => {
|
|
3150
|
+
const configLock = await acquireAccountConfigWriteLock(path);
|
|
3151
|
+
try {
|
|
3152
|
+
const stateLock = await acquireAccountStateWriteLock(path);
|
|
3153
|
+
try {
|
|
3154
|
+
const storage = await loadAccounts(path);
|
|
3155
|
+
const account = storage?.accounts.find((candidate) => candidate.id === input.accountId && isOAuthAccount(candidate));
|
|
3156
|
+
if (!storage ||
|
|
3157
|
+
!account ||
|
|
3158
|
+
account.authLineageId !== input.authLineageId) {
|
|
3159
|
+
return false;
|
|
3160
|
+
}
|
|
3161
|
+
account.quota = {
|
|
3162
|
+
...input.quota,
|
|
3163
|
+
accountIdentity: account.id,
|
|
3164
|
+
};
|
|
3165
|
+
if (input.anthropicAccountUuid !== undefined) {
|
|
3166
|
+
account.anthropicAccountUuid = input.anthropicAccountUuid;
|
|
3167
|
+
}
|
|
3168
|
+
await saveAccountStateUnlocked(storage, path, {
|
|
3169
|
+
accounts: [input.accountId],
|
|
3170
|
+
});
|
|
3171
|
+
return true;
|
|
3172
|
+
}
|
|
3173
|
+
finally {
|
|
3174
|
+
await stateLock.release();
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
finally {
|
|
3178
|
+
await configLock.release();
|
|
3179
|
+
}
|
|
3180
|
+
});
|
|
3181
|
+
}
|
|
3182
|
+
export function fallbackAccountUuidForLineage(account, authLineageId) {
|
|
3183
|
+
if (!account || account.authLineageId !== authLineageId)
|
|
3184
|
+
return null;
|
|
3185
|
+
return account.anthropicAccountUuid ?? null;
|
|
3186
|
+
}
|
|
2411
3187
|
export function removeAccount(storage, id) {
|
|
2412
3188
|
const index = storage.accounts.findIndex((c) => c.id === id);
|
|
2413
3189
|
if (index < 0)
|
|
@@ -2433,6 +3209,8 @@ function formatErrorMessage(error) {
|
|
|
2433
3209
|
return error instanceof Error ? error.message : String(error);
|
|
2434
3210
|
}
|
|
2435
3211
|
function recordRefreshError(account, error, now) {
|
|
3212
|
+
if (error instanceof CustodyTombstoneRefreshError)
|
|
3213
|
+
return;
|
|
2436
3214
|
const existing = existingRefreshBackoffError(error);
|
|
2437
3215
|
if (existing) {
|
|
2438
3216
|
account.lastRefreshError = existing;
|
|
@@ -2442,10 +3220,13 @@ function recordRefreshError(account, error, now) {
|
|
|
2442
3220
|
error,
|
|
2443
3221
|
now,
|
|
2444
3222
|
accountIdentity: account.id,
|
|
3223
|
+
refreshTokenFingerprint: tokenFingerprint(account.refresh),
|
|
2445
3224
|
previous: account.lastRefreshError,
|
|
2446
3225
|
});
|
|
2447
3226
|
}
|
|
2448
3227
|
function recordQuotaRefreshError(account, error, now) {
|
|
3228
|
+
if (error instanceof CustodyTombstoneRefreshError)
|
|
3229
|
+
return;
|
|
2449
3230
|
if (isQuotaPolicyAuthError(error) || existingRefreshBackoffError(error))
|
|
2450
3231
|
return;
|
|
2451
3232
|
account.lastQuotaRefreshError = buildQuotaOperationError({
|
|
@@ -2468,9 +3249,14 @@ export class FallbackAccountManager {
|
|
|
2468
3249
|
fetchImpl;
|
|
2469
3250
|
configPath;
|
|
2470
3251
|
refreshPromises = new Map();
|
|
3252
|
+
custodyVerificationAccounts = new Set();
|
|
2471
3253
|
refreshTimer = null;
|
|
2472
3254
|
quotaTimer = null;
|
|
2473
3255
|
quotaManager;
|
|
3256
|
+
isFallbackAccountVaultServed;
|
|
3257
|
+
isFallbackAccountVaultEnabled;
|
|
3258
|
+
resolveFallbackAccessToken;
|
|
3259
|
+
onBackgroundRefresh;
|
|
2474
3260
|
onFallbackStorageChanged;
|
|
2475
3261
|
setIntervalImpl;
|
|
2476
3262
|
clearIntervalImpl;
|
|
@@ -2479,6 +3265,21 @@ export class FallbackAccountManager {
|
|
|
2479
3265
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
2480
3266
|
this.configPath = options.configPath ?? getAccountStoragePath();
|
|
2481
3267
|
this.quotaManager = options.quotaManager ?? null;
|
|
3268
|
+
this.isFallbackAccountVaultServed =
|
|
3269
|
+
options.isFallbackAccountVaultServed ?? (() => false);
|
|
3270
|
+
this.isFallbackAccountVaultEnabled =
|
|
3271
|
+
options.isFallbackAccountVaultEnabled ?? (() => false);
|
|
3272
|
+
this.resolveFallbackAccessToken =
|
|
3273
|
+
options.resolveFallbackAccessToken ??
|
|
3274
|
+
((account) => {
|
|
3275
|
+
if (!account.access ||
|
|
3276
|
+
account.expires === undefined ||
|
|
3277
|
+
account.expires <= this.now()) {
|
|
3278
|
+
return undefined;
|
|
3279
|
+
}
|
|
3280
|
+
return { token: account.access, source: 'sidecar' };
|
|
3281
|
+
});
|
|
3282
|
+
this.onBackgroundRefresh = options.onBackgroundRefresh;
|
|
2482
3283
|
this.onFallbackStorageChanged = options.onFallbackStorageChanged;
|
|
2483
3284
|
this.setIntervalImpl = options.setIntervalImpl ?? globalThis.setInterval;
|
|
2484
3285
|
this.clearIntervalImpl =
|
|
@@ -2515,18 +3316,18 @@ export class FallbackAccountManager {
|
|
|
2515
3316
|
});
|
|
2516
3317
|
}
|
|
2517
3318
|
startBackgroundRefresh() {
|
|
2518
|
-
const run = async () => {
|
|
3319
|
+
const run = async (initial = false) => {
|
|
3320
|
+
await this.onBackgroundRefresh?.(initial);
|
|
2519
3321
|
await this.refreshDueAccounts();
|
|
2520
3322
|
await this.refreshQuotaForDueAccounts();
|
|
2521
3323
|
};
|
|
2522
|
-
|
|
3324
|
+
const initialRun = run(true).catch(() => { });
|
|
2523
3325
|
if (!this.refreshTimer) {
|
|
2524
|
-
this.refreshTimer = this.setIntervalImpl(() => {
|
|
2525
|
-
void run().catch(() => { });
|
|
2526
|
-
}, BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS));
|
|
3326
|
+
this.refreshTimer = this.setIntervalImpl(() => run().catch(() => { }), FALLBACK_BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS));
|
|
2527
3327
|
if ('unref' in this.refreshTimer)
|
|
2528
3328
|
this.refreshTimer.unref();
|
|
2529
3329
|
}
|
|
3330
|
+
return initialRun;
|
|
2530
3331
|
}
|
|
2531
3332
|
stopBackgroundRefresh() {
|
|
2532
3333
|
if (this.refreshTimer)
|
|
@@ -2536,6 +3337,25 @@ export class FallbackAccountManager {
|
|
|
2536
3337
|
this.refreshTimer = null;
|
|
2537
3338
|
this.quotaTimer = null;
|
|
2538
3339
|
}
|
|
3340
|
+
async withAccountRefreshLock(accountId, fn) {
|
|
3341
|
+
const lock = await acquireRefreshFileLock({
|
|
3342
|
+
name: fallbackRefreshLockName(accountId),
|
|
3343
|
+
ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS,
|
|
3344
|
+
path: this.configPath,
|
|
3345
|
+
now: this.now,
|
|
3346
|
+
renew: true,
|
|
3347
|
+
});
|
|
3348
|
+
if (!lock)
|
|
3349
|
+
throw new Error('Fallback OAuth refresh is already in progress');
|
|
3350
|
+
this.custodyVerificationAccounts.add(accountId);
|
|
3351
|
+
try {
|
|
3352
|
+
return await fn();
|
|
3353
|
+
}
|
|
3354
|
+
finally {
|
|
3355
|
+
this.custodyVerificationAccounts.delete(accountId);
|
|
3356
|
+
await lock.release();
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
2539
3359
|
async getUsableFallbackAccounts(existingStorage, options = {}) {
|
|
2540
3360
|
const storage = existingStorage !== undefined ? existingStorage : await this.load();
|
|
2541
3361
|
if (!storage)
|
|
@@ -2545,12 +3365,24 @@ export class FallbackAccountManager {
|
|
|
2545
3365
|
for (const account of storage.accounts) {
|
|
2546
3366
|
if (account.enabled === false || !isOAuthAccount(account))
|
|
2547
3367
|
continue;
|
|
3368
|
+
if (this.isFallbackAccountVaultEnabled(account.id, storage)) {
|
|
3369
|
+
if (!this.isFallbackAccountVaultServed(account.id, storage))
|
|
3370
|
+
continue;
|
|
3371
|
+
if (hasNoLocalCredential(account) &&
|
|
3372
|
+
!storage.quota?.minimumRemaining &&
|
|
3373
|
+
!isKillswitchEnabled(storage)) {
|
|
3374
|
+
usable.push(account);
|
|
3375
|
+
continue;
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
2548
3378
|
let next = account;
|
|
2549
3379
|
try {
|
|
2550
|
-
if (tokenNeedsRefresh(next, storage, this.now())
|
|
3380
|
+
if (tokenNeedsRefresh(next, storage, this.now()) &&
|
|
3381
|
+
!this.isFallbackAccountVaultEnabled(next.id, storage) &&
|
|
3382
|
+
!this.isFallbackAccountVaultServed(next.id, storage)) {
|
|
2551
3383
|
const refreshError = next.lastRefreshError;
|
|
2552
3384
|
if (refreshError &&
|
|
2553
|
-
refreshBackoffActive(refreshError, next.id, this.now())) {
|
|
3385
|
+
refreshBackoffActive(refreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
|
|
2554
3386
|
throw createRefreshBackoffActiveError(refreshError, this.now());
|
|
2555
3387
|
}
|
|
2556
3388
|
next = await this.refreshAccount(next, storage);
|
|
@@ -2566,8 +3398,9 @@ export class FallbackAccountManager {
|
|
|
2566
3398
|
// the background refreshQuotaForDueAccounts() guard.
|
|
2567
3399
|
if (stale &&
|
|
2568
3400
|
!quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
|
|
2569
|
-
|
|
2570
|
-
|
|
3401
|
+
const result = await this.refreshAccountQuota(next, storage);
|
|
3402
|
+
next = result.account;
|
|
3403
|
+
changed ||= result.changed;
|
|
2571
3404
|
}
|
|
2572
3405
|
// Single source of truth: evaluate quota policy from the unified
|
|
2573
3406
|
// QuotaManager cache (the same source as the staleness check above) so
|
|
@@ -2590,7 +3423,7 @@ export class FallbackAccountManager {
|
|
|
2590
3423
|
}
|
|
2591
3424
|
}
|
|
2592
3425
|
else if (!failClosedOnUnknownQuota(storage) &&
|
|
2593
|
-
!refreshBackoffActive(next.lastRefreshError, next.id, this.now()) &&
|
|
3426
|
+
!refreshBackoffActive(next.lastRefreshError, next.id, this.now(), tokenFingerprint(next.refresh)) &&
|
|
2594
3427
|
quotaSnapshotPassesModelScope(next.quota, options.modelId)) {
|
|
2595
3428
|
usable.push(next);
|
|
2596
3429
|
}
|
|
@@ -2634,9 +3467,17 @@ export class FallbackAccountManager {
|
|
|
2634
3467
|
for (const account of storage.accounts) {
|
|
2635
3468
|
if (account.enabled === false || !isOAuthAccount(account))
|
|
2636
3469
|
continue;
|
|
2637
|
-
if (
|
|
3470
|
+
if (this.custodyVerificationAccounts.has(account.id)) {
|
|
3471
|
+
logger.debug('refresh', 'fallback OAuth background skipped vault-service verification', {
|
|
3472
|
+
accountId: account.id,
|
|
3473
|
+
});
|
|
2638
3474
|
continue;
|
|
2639
|
-
|
|
3475
|
+
}
|
|
3476
|
+
if (!tokenNeedsRefresh(account, storage, this.now()) ||
|
|
3477
|
+
this.isFallbackAccountVaultEnabled(account.id, storage) ||
|
|
3478
|
+
this.isFallbackAccountVaultServed(account.id, storage))
|
|
3479
|
+
continue;
|
|
3480
|
+
if (refreshBackoffActive(account.lastRefreshError, account.id, this.now(), tokenFingerprint(account.refresh))) {
|
|
2640
3481
|
// Backoff skips are steady-state while a fallback account is waiting for
|
|
2641
3482
|
// its next retry. Logging every background tick from every OpenCode
|
|
2642
3483
|
// process creates noise without adding new diagnostic signal; the
|
|
@@ -2676,10 +3517,14 @@ export class FallbackAccountManager {
|
|
|
2676
3517
|
for (const account of storage.accounts) {
|
|
2677
3518
|
if (account.enabled === false || !isOAuthAccount(account))
|
|
2678
3519
|
continue;
|
|
3520
|
+
if (this.custodyVerificationAccounts.has(account.id))
|
|
3521
|
+
continue;
|
|
2679
3522
|
let next = account;
|
|
2680
3523
|
try {
|
|
2681
|
-
if (tokenNeedsRefresh(next, storage, this.now())
|
|
2682
|
-
|
|
3524
|
+
if (tokenNeedsRefresh(next, storage, this.now()) &&
|
|
3525
|
+
!this.isFallbackAccountVaultEnabled(next.id, storage) &&
|
|
3526
|
+
!this.isFallbackAccountVaultServed(next.id, storage)) {
|
|
3527
|
+
if (refreshBackoffActive(next.lastRefreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
|
|
2683
3528
|
continue;
|
|
2684
3529
|
}
|
|
2685
3530
|
next = await this.refreshAccount(next, storage);
|
|
@@ -2696,8 +3541,8 @@ export class FallbackAccountManager {
|
|
|
2696
3541
|
: quotaIsStale(next, storage, this.now());
|
|
2697
3542
|
if (!stale)
|
|
2698
3543
|
continue;
|
|
2699
|
-
await this.refreshAccountQuota(next, storage);
|
|
2700
|
-
changed
|
|
3544
|
+
const result = await this.refreshAccountQuota(next, storage);
|
|
3545
|
+
changed ||= result.changed;
|
|
2701
3546
|
}
|
|
2702
3547
|
catch (error) {
|
|
2703
3548
|
recordQuotaRefreshError(account, error, this.now());
|
|
@@ -2723,10 +3568,12 @@ export class FallbackAccountManager {
|
|
|
2723
3568
|
continue;
|
|
2724
3569
|
let next = account;
|
|
2725
3570
|
try {
|
|
2726
|
-
if (tokenNeedsRefresh(next, storage, this.now())
|
|
3571
|
+
if (tokenNeedsRefresh(next, storage, this.now()) &&
|
|
3572
|
+
!this.isFallbackAccountVaultServed(next.id, storage) &&
|
|
3573
|
+
!this.isFallbackAccountVaultEnabled(next.id, storage)) {
|
|
2727
3574
|
const refreshError = next.lastRefreshError;
|
|
2728
3575
|
if (refreshError &&
|
|
2729
|
-
refreshBackoffActive(refreshError, next.id, this.now())) {
|
|
3576
|
+
refreshBackoffActive(refreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
|
|
2730
3577
|
throw createRefreshBackoffActiveError(refreshError, this.now());
|
|
2731
3578
|
}
|
|
2732
3579
|
next = await this.refreshAccount(next, storage);
|
|
@@ -2743,8 +3590,8 @@ export class FallbackAccountManager {
|
|
|
2743
3590
|
}
|
|
2744
3591
|
continue;
|
|
2745
3592
|
}
|
|
2746
|
-
await this.refreshAccountQuota(next, storage);
|
|
2747
|
-
changed
|
|
3593
|
+
const result = await this.refreshAccountQuota(next, storage);
|
|
3594
|
+
changed ||= result.changed;
|
|
2748
3595
|
}
|
|
2749
3596
|
catch (error) {
|
|
2750
3597
|
recordQuotaRefreshError(account, error, this.now());
|
|
@@ -2761,6 +3608,9 @@ export class FallbackAccountManager {
|
|
|
2761
3608
|
return { storage, errors };
|
|
2762
3609
|
}
|
|
2763
3610
|
async refreshAccount(account, storage, options = {}) {
|
|
3611
|
+
if (this.isFallbackAccountVaultServed(account.id, storage) ||
|
|
3612
|
+
this.isFallbackAccountVaultEnabled(account.id, storage))
|
|
3613
|
+
return account;
|
|
2764
3614
|
const existing = this.refreshPromises.get(account.id);
|
|
2765
3615
|
if (existing) {
|
|
2766
3616
|
const refreshed = await existing;
|
|
@@ -2771,9 +3621,19 @@ export class FallbackAccountManager {
|
|
|
2771
3621
|
this.refreshPromises.delete(account.id);
|
|
2772
3622
|
});
|
|
2773
3623
|
this.refreshPromises.set(account.id, promise);
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
3624
|
+
try {
|
|
3625
|
+
const refreshed = await promise;
|
|
3626
|
+
updateStoredAccount(storage, refreshed);
|
|
3627
|
+
return refreshed;
|
|
3628
|
+
}
|
|
3629
|
+
catch (error) {
|
|
3630
|
+
if (options.persistError) {
|
|
3631
|
+
recordRefreshError(account, error, this.now());
|
|
3632
|
+
updateStoredAccount(storage, account);
|
|
3633
|
+
await this.save(storage);
|
|
3634
|
+
}
|
|
3635
|
+
throw error;
|
|
3636
|
+
}
|
|
2777
3637
|
}
|
|
2778
3638
|
async waitForConcurrentFallbackRefresh(account, storage, previous, options) {
|
|
2779
3639
|
const deadline = Date.now() + FALLBACK_REFRESH_JOIN_WAIT_MS;
|
|
@@ -2800,7 +3660,7 @@ export class FallbackAccountManager {
|
|
|
2800
3660
|
}
|
|
2801
3661
|
const refreshError = latestAccount.lastRefreshError;
|
|
2802
3662
|
if (refreshError &&
|
|
2803
|
-
refreshBackoffActive(refreshError, latestAccount.id, this.now())) {
|
|
3663
|
+
refreshBackoffActive(refreshError, latestAccount.id, this.now(), tokenFingerprint(latestAccount.refresh))) {
|
|
2804
3664
|
updateStoredAccount(storage, latestAccount);
|
|
2805
3665
|
throw createRefreshBackoffActiveError(refreshError, this.now());
|
|
2806
3666
|
}
|
|
@@ -2880,10 +3740,46 @@ export class FallbackAccountManager {
|
|
|
2880
3740
|
}
|
|
2881
3741
|
}
|
|
2882
3742
|
async refreshAccountQuota(account, storage) {
|
|
3743
|
+
const initialQuotaState = JSON.stringify([
|
|
3744
|
+
account.quota,
|
|
3745
|
+
account.lastQuotaRefreshError,
|
|
3746
|
+
]);
|
|
3747
|
+
let changed = false;
|
|
2883
3748
|
let target = account;
|
|
2884
|
-
|
|
2885
|
-
|
|
3749
|
+
const vaultEnabled = this.isFallbackAccountVaultEnabled(target.id, storage);
|
|
3750
|
+
let access = this.resolveFallbackAccessToken(target, storage);
|
|
3751
|
+
if (!access && !vaultEnabled) {
|
|
3752
|
+
target = await this.refreshAccount(account, storage, { force: true });
|
|
3753
|
+
changed = true;
|
|
3754
|
+
access = this.resolveFallbackAccessToken(target, storage);
|
|
3755
|
+
}
|
|
3756
|
+
const delegatedQuotaAuthorization = storage?.claustrum?.scopedRoster === true &&
|
|
3757
|
+
this.quotaManager?.canFetchWithoutAccessToken() === true;
|
|
3758
|
+
if (!access && !delegatedQuotaAuthorization) {
|
|
3759
|
+
log('[quota] fallback quota poll skipped: no usable credential', {
|
|
3760
|
+
accountId: target.id,
|
|
3761
|
+
});
|
|
3762
|
+
return {
|
|
3763
|
+
account: target,
|
|
3764
|
+
fetched: false,
|
|
3765
|
+
changed,
|
|
3766
|
+
};
|
|
3767
|
+
}
|
|
3768
|
+
let quotaPollLock = await acquireRefreshFileLock({
|
|
3769
|
+
name: fallbackRefreshLockName(target.id),
|
|
3770
|
+
ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS,
|
|
3771
|
+
path: this.configPath,
|
|
3772
|
+
now: this.now,
|
|
3773
|
+
});
|
|
3774
|
+
if (!quotaPollLock) {
|
|
3775
|
+
log('[quota] fallback quota poll skipped refresh lock', {
|
|
3776
|
+
accountId: target.id,
|
|
3777
|
+
});
|
|
3778
|
+
return { account: target, fetched: false, changed };
|
|
2886
3779
|
}
|
|
3780
|
+
await using _quotaPollLock = {
|
|
3781
|
+
[Symbol.asyncDispose]: async () => quotaPollLock?.release(),
|
|
3782
|
+
};
|
|
2887
3783
|
// Unify on the shared QuotaManager when present: it adds inflight
|
|
2888
3784
|
// deduplication and 429 backoff gating around the same quota API. Fall back
|
|
2889
3785
|
// to a direct fetch only when no QuotaManager is wired (e.g. in isolation).
|
|
@@ -2897,21 +3793,48 @@ export class FallbackAccountManager {
|
|
|
2897
3793
|
const fetchStartedAt = this.now();
|
|
2898
3794
|
let fetched = false;
|
|
2899
3795
|
try {
|
|
2900
|
-
const result = await fetchSnapshot(
|
|
3796
|
+
const result = await fetchSnapshot(access?.token ?? '');
|
|
2901
3797
|
target.quota = result.quota;
|
|
2902
3798
|
fetched = result.fetched;
|
|
2903
3799
|
}
|
|
2904
3800
|
catch (error) {
|
|
2905
3801
|
const message = error instanceof Error ? error.message : String(error);
|
|
2906
|
-
if (!message.includes('Claude quota check failed: 401')
|
|
3802
|
+
if (!message.includes('Claude quota check failed: 401') ||
|
|
3803
|
+
vaultEnabled ||
|
|
3804
|
+
access?.source !== 'sidecar') {
|
|
2907
3805
|
throw error;
|
|
3806
|
+
}
|
|
3807
|
+
await quotaPollLock.release();
|
|
3808
|
+
quotaPollLock = null;
|
|
2908
3809
|
target = await this.refreshAccount(account, storage, {
|
|
2909
3810
|
force: true,
|
|
2910
3811
|
});
|
|
2911
|
-
|
|
2912
|
-
|
|
3812
|
+
changed = true;
|
|
3813
|
+
access = this.resolveFallbackAccessToken(target, storage);
|
|
3814
|
+
if (!access) {
|
|
3815
|
+
log('[quota] fallback quota poll skipped after refresh: no usable credential', {
|
|
3816
|
+
accountId: target.id,
|
|
3817
|
+
});
|
|
3818
|
+
return {
|
|
3819
|
+
account: target,
|
|
3820
|
+
fetched: false,
|
|
3821
|
+
changed,
|
|
3822
|
+
};
|
|
3823
|
+
}
|
|
3824
|
+
quotaPollLock = await acquireRefreshFileLock({
|
|
3825
|
+
name: fallbackRefreshLockName(target.id),
|
|
3826
|
+
ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS,
|
|
3827
|
+
path: this.configPath,
|
|
3828
|
+
now: this.now,
|
|
3829
|
+
});
|
|
3830
|
+
if (!quotaPollLock) {
|
|
3831
|
+
log('[quota] fallback quota poll skipped refresh lock', {
|
|
3832
|
+
accountId: target.id,
|
|
3833
|
+
});
|
|
3834
|
+
return { account: target, fetched: false, changed };
|
|
3835
|
+
}
|
|
2913
3836
|
// 401 does not arm QuotaManager backoff, so this retry proceeds.
|
|
2914
|
-
const result = await fetchSnapshot(
|
|
3837
|
+
const result = await fetchSnapshot(access.token);
|
|
2915
3838
|
target.quota = result.quota;
|
|
2916
3839
|
fetched = result.fetched;
|
|
2917
3840
|
}
|
|
@@ -2922,16 +3845,17 @@ export class FallbackAccountManager {
|
|
|
2922
3845
|
latestAccount.access !== target.access) {
|
|
2923
3846
|
this.seedFallbackQuota(latestAccount, latestStorage);
|
|
2924
3847
|
updateStoredAccount(storage, latestAccount);
|
|
2925
|
-
return { account: latestAccount, fetched: false };
|
|
3848
|
+
return { account: latestAccount, fetched: false, changed: false };
|
|
2926
3849
|
}
|
|
2927
3850
|
if (latestStorage &&
|
|
2928
|
-
latestAccount
|
|
3851
|
+
latestAccount &&
|
|
3852
|
+
latestAccount.access === target.access &&
|
|
2929
3853
|
latestAccount.quota &&
|
|
2930
3854
|
quotaSnapshotCheckedAt(latestAccount.quota) >= fetchStartedAt &&
|
|
2931
3855
|
quotaSnapshotIsFresh(latestAccount.quota, latestStorage, this.now())) {
|
|
2932
3856
|
this.seedFallbackQuota(latestAccount, latestStorage);
|
|
2933
3857
|
updateStoredAccount(storage, latestAccount);
|
|
2934
|
-
return { account: latestAccount, fetched };
|
|
3858
|
+
return { account: latestAccount, fetched, changed: false };
|
|
2935
3859
|
}
|
|
2936
3860
|
target.lastQuotaRefreshError = undefined;
|
|
2937
3861
|
updateStoredAccount(storage, target);
|
|
@@ -2946,6 +3870,12 @@ export class FallbackAccountManager {
|
|
|
2946
3870
|
checkedAt: now,
|
|
2947
3871
|
}, target);
|
|
2948
3872
|
}
|
|
2949
|
-
return {
|
|
3873
|
+
return {
|
|
3874
|
+
account: target,
|
|
3875
|
+
fetched,
|
|
3876
|
+
changed: changed ||
|
|
3877
|
+
JSON.stringify([target.quota, target.lastQuotaRefreshError]) !==
|
|
3878
|
+
initialQuotaState,
|
|
3879
|
+
};
|
|
2950
3880
|
}
|
|
2951
3881
|
}
|