@cortexkit/anthropic-auth-core 1.21.0 → 1.22.0

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