@cortexkit/anthropic-auth-core 1.20.0 → 1.22.0

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