@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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/dist/accounts.d.ts +100 -3
  3. package/dist/accounts.js +1099 -169
  4. package/dist/auth.js +6 -10
  5. package/dist/cachekeep.d.ts +18 -1
  6. package/dist/cachekeep.js +108 -82
  7. package/dist/cch.d.ts +23 -9
  8. package/dist/cch.js +104 -21
  9. package/dist/claude-code.d.ts +8 -3
  10. package/dist/claude-code.js +24 -7
  11. package/dist/claustrum-enrollment-registry.d.ts +17 -0
  12. package/dist/claustrum-enrollment-registry.js +194 -0
  13. package/dist/claustrum-enrollment.d.ts +70 -0
  14. package/dist/claustrum-enrollment.js +485 -0
  15. package/dist/claustrum-scoped-roster.d.ts +23 -0
  16. package/dist/claustrum-scoped-roster.js +194 -0
  17. package/dist/claustrum-scoped-runtime.d.ts +28 -0
  18. package/dist/claustrum-scoped-runtime.js +230 -0
  19. package/dist/claustrum-scoped.d.ts +39 -0
  20. package/dist/claustrum-scoped.js +193 -0
  21. package/dist/claustrum.d.ts +252 -0
  22. package/dist/claustrum.js +1471 -0
  23. package/dist/commands/account.d.ts +43 -11
  24. package/dist/commands/account.js +147 -4
  25. package/dist/constants.d.ts +5 -5
  26. package/dist/constants.js +5 -5
  27. package/dist/custom-headers.d.ts +3 -0
  28. package/dist/custom-headers.js +102 -0
  29. package/dist/dump.js +2 -1
  30. package/dist/fast.js +1 -1
  31. package/dist/index.d.ts +11 -0
  32. package/dist/index.js +11 -0
  33. package/dist/json.d.ts +1 -0
  34. package/dist/json.js +10 -0
  35. package/dist/logger.js +3 -1
  36. package/dist/mid-conversation-output-config.d.ts +13 -0
  37. package/dist/mid-conversation-output-config.js +89 -0
  38. package/dist/model-remap.d.ts +27 -0
  39. package/dist/model-remap.js +72 -0
  40. package/dist/models.d.ts +46 -1
  41. package/dist/models.js +87 -8
  42. package/dist/network-errors.d.ts +1 -0
  43. package/dist/network-errors.js +27 -0
  44. package/dist/oauth-profile.d.ts +2 -1
  45. package/dist/oauth-profile.js +6 -0
  46. package/dist/prime.d.ts +11 -3
  47. package/dist/prime.js +25 -7
  48. package/dist/quota-header-feed.d.ts +32 -7
  49. package/dist/quota-header-feed.js +93 -10
  50. package/dist/quota-headers.js +59 -6
  51. package/dist/quota-manager.d.ts +20 -2
  52. package/dist/quota-manager.js +101 -24
  53. package/dist/relay.d.ts +8 -0
  54. package/dist/relay.js +72 -12
  55. package/dist/sticky-routing.d.ts +2 -0
  56. package/dist/sticky-routing.js +19 -1
  57. package/dist/thinking-binding.d.ts +7 -0
  58. package/dist/thinking-binding.js +54 -0
  59. 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 BACKGROUND_TICK_MS = 60_000;
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
- if (typeof value.refresh !== 'string' || !value.refresh.trim())
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: value.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 { exists: true, value: JSON.parse(await readFile(path, 'utf8')) };
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 stateAccount = typeof account.id === 'string' && isRecord(stateAccounts[account.id])
492
- ? stateAccounts[account.id]
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: mainRefreshSource.lastRefreshError,
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
- return {
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
- incoming.access &&
677
- existingEntry.access !== incoming.access) ||
931
+ incomingForMerge.access &&
932
+ existingEntry.access !== incomingForMerge.access) ||
678
933
  (existingEntry.refresh &&
679
- incoming.refresh &&
680
- existingEntry.refresh !== incoming.refresh));
681
- const mergesHeaderQuota = Boolean(!tokenChanged && incoming.quota?.source === 'headers');
682
- const effectiveIncoming = mergesHeaderQuota && incoming.quota
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
- ...incoming,
685
- quota: mergeHeaderQuotaForPersistence(existingEntry.quota, incoming.quota),
939
+ ...incomingForMerge,
940
+ quota: mergeHeaderQuotaForPersistence(existingEntry.quota, incomingForMerge.quota),
686
941
  }
687
- : incoming;
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
- if (!('lastRefreshError' in effectiveIncoming)) {
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
- if (!('lastRefreshError' in effectiveIncoming)) {
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
- const current = await loadAccounts(path);
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
- const existingCheckedAt = typeof state.main.quotaCheckedAt === 'number'
1034
- ? state.main.quotaCheckedAt
1035
- : quotaSnapshotCheckedAt(state.main.quota);
1036
- const incomingCheckedAt = typeof storage.quota?.mainQuotaCheckedAt === 'number'
1037
- ? storage.quota.mainQuotaCheckedAt
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
- state.main.quotaCheckedAt = mergesHeaderQuota
1048
- ? Math.max(existingCheckedAt, incomingCheckedAt)
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
- state.main.lastRefreshError = storage.refresh?.mainLastRefreshError;
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
- if (scope.mainProfile)
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
- if (scope.mainQuota)
1213
- applyMainQuotaStatePatch(next, storage);
1214
- if (scope.mainRefresh)
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
- if (ids && !ids.has(account.id))
1829
+ const accountId = account.id.trim();
1830
+ if (ids && !ids.has(account.id) && !ids.has(accountId))
1223
1831
  continue;
1224
- next.accounts[account.id] = mergeAccountRuntimeState(next.accounts[account.id], accountRuntimeState(account));
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
- if (!(error instanceof Error))
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, like a retry-exhausted / network / 429 / 5xx
1766
- // error, get a long backoff but must stay permanent=false so they are not
1767
- // falsely flagged "needs re-login". ClaudeOAuthRefreshError carries the raw
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
- export function refreshBackoffActive(error, accountIdentity, now) {
1812
- if (!error?.nextRetryAt || error.nextRetryAt <= now)
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.nextRetryAt ?? now) - now) / 1000));
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
- if (!storage?.quota?.mainQuota || !storage.quota.mainQuotaCheckedAt)
2567
+ const quota = storage?.quota?.mainQuota;
2568
+ if (!quota)
1897
2569
  return null;
1898
2570
  return {
1899
- quota: storage.quota.mainQuota,
1900
- checkedAt: storage.quota.mainQuotaCheckedAt,
1901
- tokenFingerprint: storage.quota.mainQuotaToken,
1902
- accountIdentity: storage.quota.mainQuota.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
- const storage = await loadAccounts(path);
2108
- if (!storage)
2109
- return false;
2110
- const existed = removeAccount(storage, id);
2111
- if (existed) {
2112
- await saveAccounts(storage, path, { removedAccountIds: [id] });
2113
- }
2114
- return existed;
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
- const storage = await loadAccounts(path);
2118
- if (!storage)
2119
- return;
2120
- reorderAccounts(storage, orderedIds);
2121
- await saveAccounts(storage, path, { preserveExistingAccountOrder: false });
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
- const storage = await loadAccounts(path);
2125
- if (!storage)
2126
- return false;
2127
- const found = setAccountEnabled(storage, id, enabled);
2128
- if (found)
2129
- await saveAccounts(storage, path);
2130
- return found;
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
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
2134
- upsertAccount(storage, account);
2135
- await saveAccounts(storage, path);
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
- if (!(error instanceof Error))
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 (isTransientQuotaError(error) &&
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
- return {
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
- ...storage.accounts[index],
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: storage.accounts[index]?.addedAt ?? account.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
- void run().catch(() => { });
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
- next = (await this.refreshAccountQuota(next, storage)).account;
2570
- changed = true;
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 (!tokenNeedsRefresh(account, storage, this.now()))
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
- if (refreshBackoffActive(account.lastRefreshError, account.id, this.now())) {
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
- if (refreshBackoffActive(next.lastRefreshError, next.id, this.now())) {
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 = true;
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 = true;
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
- const refreshed = await promise;
2775
- updateStoredAccount(storage, refreshed);
2776
- return refreshed;
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
- if (!target.access) {
2885
- throw new Error(`Fallback account ${account.id} has no access token`);
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(target.access);
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
- if (!target.access)
2912
- throw error;
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(target.access);
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?.access === target.access &&
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 { account: target, fetched };
3873
+ return {
3874
+ account: target,
3875
+ fetched,
3876
+ changed: changed ||
3877
+ JSON.stringify([target.quota, target.lastQuotaRefreshError]) !==
3878
+ initialQuotaState,
3879
+ };
2950
3880
  }
2951
3881
  }