@cortexkit/anthropic-auth-core 1.22.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/accounts.d.ts +59 -2
  2. package/dist/accounts.js +666 -115
  3. package/dist/auth.js +3 -0
  4. package/dist/cachekeep.d.ts +18 -1
  5. package/dist/cachekeep.js +108 -82
  6. package/dist/cch.d.ts +11 -1
  7. package/dist/cch.js +48 -3
  8. package/dist/claude-code.d.ts +7 -1
  9. package/dist/claude-code.js +18 -3
  10. package/dist/claustrum-enrollment-registry.d.ts +17 -0
  11. package/dist/claustrum-enrollment-registry.js +194 -0
  12. package/dist/claustrum-enrollment.d.ts +70 -0
  13. package/dist/claustrum-enrollment.js +485 -0
  14. package/dist/claustrum-scoped-roster.d.ts +23 -0
  15. package/dist/claustrum-scoped-roster.js +194 -0
  16. package/dist/claustrum-scoped-runtime.d.ts +28 -0
  17. package/dist/claustrum-scoped-runtime.js +230 -0
  18. package/dist/claustrum-scoped.d.ts +39 -0
  19. package/dist/claustrum-scoped.js +193 -0
  20. package/dist/claustrum.d.ts +149 -6
  21. package/dist/claustrum.js +990 -48
  22. package/dist/commands/account.d.ts +40 -12
  23. package/dist/commands/account.js +138 -9
  24. package/dist/constants.d.ts +3 -2
  25. package/dist/constants.js +3 -2
  26. package/dist/custom-headers.d.ts +3 -0
  27. package/dist/custom-headers.js +102 -0
  28. package/dist/fast.js +1 -1
  29. package/dist/index.d.ts +7 -0
  30. package/dist/index.js +7 -0
  31. package/dist/logger.js +3 -1
  32. package/dist/model-remap.d.ts +27 -0
  33. package/dist/model-remap.js +72 -0
  34. package/dist/models.d.ts +19 -0
  35. package/dist/models.js +48 -12
  36. package/dist/oauth-profile.d.ts +2 -1
  37. package/dist/oauth-profile.js +6 -0
  38. package/dist/prime.d.ts +11 -3
  39. package/dist/prime.js +25 -7
  40. package/dist/quota-header-feed.d.ts +13 -0
  41. package/dist/quota-header-feed.js +60 -8
  42. package/dist/quota-manager.d.ts +15 -1
  43. package/dist/quota-manager.js +73 -22
  44. package/dist/relay.d.ts +8 -0
  45. package/dist/relay.js +72 -12
  46. package/dist/sticky-routing.d.ts +2 -0
  47. package/dist/sticky-routing.js +19 -1
  48. package/package.json +3 -2
package/dist/accounts.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
4
+ import { dirname, join, resolve } from 'node:path';
5
5
  import { parseRetryAfterHeader, refreshClaudeOAuthToken } from './auth.js';
6
- import { CustodyTombstoneRefreshError } from './claustrum.js';
6
+ import { assertNotCustodyTombstone, CustodyTombstoneRefreshError, custodyTombstoneKey, isCustodyTombstoneValue, } from './claustrum.js';
7
7
  import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from './constants.js';
8
8
  import { parseJsonRedacted } from './json.js';
9
9
  import { log, logger } from './logger.js';
@@ -22,6 +22,10 @@ export const QUOTA_FIELD_NAMES = [
22
22
  'bindingWindow',
23
23
  'fallbackAdvised',
24
24
  ];
25
+ export function hasNoLocalCredential(account) {
26
+ return (account.access == null &&
27
+ (account.refresh == null || account.refresh === ''));
28
+ }
25
29
  export function isOAuthAccount(account) {
26
30
  return account.type === 'oauth';
27
31
  }
@@ -122,7 +126,19 @@ function normalizeAccount(value) {
122
126
  }
123
127
  if (value.type !== 'oauth')
124
128
  return null;
125
- if (typeof value.refresh !== 'string' || !value.refresh.trim())
129
+ const refresh = typeof value.refresh === 'string' ? value.refresh : '';
130
+ const rosterOnly = value.enabled === true &&
131
+ typeof value.id === 'string' &&
132
+ Boolean(value.id.trim()) &&
133
+ typeof value.label === 'string' &&
134
+ Boolean(value.label.trim()) &&
135
+ hasNoLocalCredential(value);
136
+ const scopedRosterOnly = typeof value.claustrumScopedCredentialId === 'string' &&
137
+ Boolean(value.claustrumScopedCredentialId.trim()) &&
138
+ typeof value.anthropicAccountUuid === 'string' &&
139
+ Boolean(value.anthropicAccountUuid.trim()) &&
140
+ hasNoLocalCredential(value);
141
+ if (!refresh.trim() && !rosterOnly && !scopedRosterOnly)
126
142
  return null;
127
143
  return {
128
144
  ...normalizeAccountBase(value),
@@ -130,11 +146,23 @@ function normalizeAccount(value) {
130
146
  authLineageId: typeof value.authLineageId === 'string' && value.authLineageId.trim()
131
147
  ? value.authLineageId
132
148
  : undefined,
149
+ anthropicAccountUuid: typeof value.anthropicAccountUuid === 'string' &&
150
+ value.anthropicAccountUuid.trim()
151
+ ? value.anthropicAccountUuid.trim()
152
+ : undefined,
133
153
  claustrumHandle: typeof value.claustrumHandle === 'string' && value.claustrumHandle.trim()
134
154
  ? value.claustrumHandle.trim()
135
155
  : undefined,
156
+ claustrumScopedCredentialId: typeof value.claustrumScopedCredentialId === 'string' &&
157
+ value.claustrumScopedCredentialId.trim()
158
+ ? value.claustrumScopedCredentialId.trim()
159
+ : undefined,
160
+ claustrumScopedState: typeof value.claustrumScopedState === 'string' &&
161
+ value.claustrumScopedState.trim()
162
+ ? value.claustrumScopedState.trim()
163
+ : undefined,
136
164
  access: typeof value.access === 'string' ? value.access : undefined,
137
- refresh: value.refresh,
165
+ refresh,
138
166
  expires: typeof value.expires === 'number' ? value.expires : undefined,
139
167
  lastRefreshedAt: typeof value.lastRefreshedAt === 'number'
140
168
  ? value.lastRefreshedAt
@@ -165,6 +193,10 @@ function normalizeOAuthAccountProfile(value) {
165
193
  value.accountIdentity.trim() && {
166
194
  accountIdentity: value.accountIdentity.trim(),
167
195
  }),
196
+ ...(typeof value.providerAccountUuid === 'string' &&
197
+ value.providerAccountUuid.trim() && {
198
+ providerAccountUuid: value.providerAccountUuid.trim(),
199
+ }),
168
200
  ...(typeof value.tokenFingerprint === 'string' &&
169
201
  value.tokenFingerprint.trim() && {
170
202
  tokenFingerprint: value.tokenFingerprint.trim(),
@@ -439,23 +471,68 @@ function normalizeStorage(value) {
439
471
  };
440
472
  }
441
473
  function normalizeClaustrumConfig(value) {
442
- if (!isRecord(value) || !isRecord(value.accounts))
474
+ if (!isRecord(value))
443
475
  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;
476
+ const mode = value.mode === 'local' || value.mode === 'claustrum'
477
+ ? value.mode
478
+ : undefined;
479
+ const handlesFile = typeof value.handlesFile === 'string' && value.handlesFile.trim()
480
+ ? value.handlesFile.trim()
481
+ : undefined;
482
+ const primary = isRecord(value.primaryAccount)
483
+ ? value.primaryAccount
484
+ : undefined;
485
+ const primaryAccount = primary &&
486
+ typeof primary.credentialId === 'string' &&
487
+ primary.credentialId.trim() &&
488
+ typeof primary.accountId === 'string' &&
489
+ primary.accountId.trim() &&
490
+ typeof primary.state === 'string'
491
+ ? {
492
+ credentialId: primary.credentialId.trim(),
493
+ accountId: primary.accountId.trim(),
494
+ state: primary.state,
495
+ }
496
+ : undefined;
497
+ const scopedRoster = value.scopedRoster === true;
498
+ const disabledAccountIdentities = Array.isArray(value.disabledAccountIdentities)
499
+ ? [
500
+ ...new Set(value.disabledAccountIdentities
501
+ .filter((id) => typeof id === 'string' && id.trim().length > 0)
502
+ .map((id) => id.trim())),
503
+ ]
504
+ : [];
505
+ const accounts = isRecord(value.accounts)
506
+ ? Object.fromEntries(Object.entries(value.accounts).flatMap(([id, entry]) => {
507
+ if (!isRecord(entry))
508
+ return [];
509
+ return [
510
+ [
511
+ id,
512
+ {
513
+ ...(typeof entry.enabled === 'boolean' && {
514
+ enabled: entry.enabled,
515
+ }),
516
+ },
517
+ ],
518
+ ];
519
+ }))
520
+ : undefined;
521
+ if (!mode &&
522
+ !handlesFile &&
523
+ !scopedRoster &&
524
+ disabledAccountIdentities.length === 0 &&
525
+ (!accounts || Object.keys(accounts).length === 0)) {
526
+ return undefined;
527
+ }
528
+ return {
529
+ ...(mode && { mode }),
530
+ ...(scopedRoster && { scopedRoster: true }),
531
+ ...(primaryAccount && { primaryAccount }),
532
+ ...(disabledAccountIdentities.length > 0 && { disabledAccountIdentities }),
533
+ ...(handlesFile && { handlesFile }),
534
+ ...(accounts && Object.keys(accounts).length > 0 && { accounts }),
535
+ };
459
536
  }
460
537
  async function readJsonIfPresent(path) {
461
538
  try {
@@ -514,6 +591,23 @@ function legacyConfigCredentialsAreNewer(account, stateAccount) {
514
591
  accountCredentialTimestamp(stateAccount));
515
592
  }
516
593
  function mergeConfigAccountAndState(account, stateAccount) {
594
+ if (typeof account.claustrumScopedCredentialId === 'string' &&
595
+ typeof account.anthropicAccountUuid === 'string') {
596
+ const sameBinding = stateAccount.claustrumScopedCredentialId ===
597
+ account.claustrumScopedCredentialId &&
598
+ stateAccount.anthropicAccountUuid === account.anthropicAccountUuid;
599
+ const scoped = {
600
+ ...(sameBinding ? stateAccount : {}),
601
+ ...account,
602
+ refresh: '',
603
+ };
604
+ delete scoped.access;
605
+ delete scoped.expires;
606
+ delete scoped.lastRefreshedAt;
607
+ delete scoped.lastRefreshError;
608
+ delete scoped.claustrumHandle;
609
+ return scoped;
610
+ }
517
611
  if (legacyConfigCredentialsAreNewer(account, stateAccount)) {
518
612
  const merged = { ...stateAccount, ...account };
519
613
  const configTimestamp = accountCredentialTimestamp(account);
@@ -680,6 +774,12 @@ function accountConfig(account) {
680
774
  addedAt: account.addedAt,
681
775
  baseURL: account.type === 'api' ? account.baseURL : undefined,
682
776
  authHeader: account.type === 'api' ? account.authHeader : undefined,
777
+ claustrumScopedCredentialId: account.type === 'oauth'
778
+ ? account.claustrumScopedCredentialId
779
+ : undefined,
780
+ anthropicAccountUuid: account.type === 'oauth' && account.claustrumScopedCredentialId
781
+ ? account.anthropicAccountUuid
782
+ : undefined,
683
783
  });
684
784
  }
685
785
  function accountRuntimeState(account) {
@@ -691,7 +791,10 @@ function accountRuntimeState(account) {
691
791
  }
692
792
  return objectWithDefinedEntries({
693
793
  authLineageId: account.authLineageId,
794
+ anthropicAccountUuid: account.anthropicAccountUuid,
694
795
  claustrumHandle: account.claustrumHandle,
796
+ claustrumScopedCredentialId: account.claustrumScopedCredentialId,
797
+ claustrumScopedState: account.claustrumScopedState,
695
798
  access: account.access,
696
799
  refresh: account.refresh,
697
800
  expires: account.expires,
@@ -817,23 +920,37 @@ export function mergeHeaderQuotaForPersistence(existing, incoming) {
817
920
  ...(fieldSources && { fieldSources }),
818
921
  };
819
922
  }
820
- function mergeAccountRuntimeState(existing, incoming) {
923
+ function mergeAccountRuntimeState(existing, incoming, setClaustrumHandle = false) {
821
924
  if (!isRecord(existing))
822
925
  return incoming;
926
+ const incomingForMerge = { ...incoming };
927
+ if (!setClaustrumHandle)
928
+ delete incomingForMerge.claustrumHandle;
823
929
  const existingEntry = existing;
824
930
  const tokenChanged = Boolean((existingEntry.access &&
825
- incoming.access &&
826
- existingEntry.access !== incoming.access) ||
931
+ incomingForMerge.access &&
932
+ existingEntry.access !== incomingForMerge.access) ||
827
933
  (existingEntry.refresh &&
828
- incoming.refresh &&
829
- existingEntry.refresh !== incoming.refresh));
830
- const mergesHeaderQuota = Boolean(!tokenChanged && incoming.quota?.source === 'headers');
831
- const effectiveIncoming = mergesHeaderQuota && incoming.quota
934
+ incomingForMerge.refresh &&
935
+ existingEntry.refresh !== incomingForMerge.refresh));
936
+ const mergesHeaderQuota = Boolean(!tokenChanged && incomingForMerge.quota?.source === 'headers');
937
+ const effectiveIncoming = mergesHeaderQuota && incomingForMerge.quota
832
938
  ? {
833
- ...incoming,
834
- quota: mergeHeaderQuotaForPersistence(existingEntry.quota, incoming.quota),
835
- }
836
- : incoming;
939
+ ...incomingForMerge,
940
+ quota: mergeHeaderQuotaForPersistence(existingEntry.quota, incomingForMerge.quota),
941
+ }
942
+ : incomingForMerge;
943
+ if (existingEntry.refresh === custodyTombstoneKey('anthropic') &&
944
+ ((typeof effectiveIncoming.access === 'string' &&
945
+ effectiveIncoming.access.length > 0) ||
946
+ (typeof effectiveIncoming.refresh === 'string' &&
947
+ effectiveIncoming.refresh !== custodyTombstoneKey('anthropic'))) &&
948
+ (!effectiveIncoming.authLineageId ||
949
+ effectiveIncoming.authLineageId === existingEntry.authLineageId)) {
950
+ logger.warn('accounts', 'discarded stale credential write over a custody tombstone');
951
+ const { access: _access, refresh: _refresh, expires: _expires, ...safe } = effectiveIncoming;
952
+ return mergeAccountRuntimeState(existingEntry, safe, setClaustrumHandle);
953
+ }
837
954
  const preferredRefreshError = (() => {
838
955
  const existingError = existingEntry.lastRefreshError;
839
956
  const incomingError = effectiveIncoming.lastRefreshError;
@@ -959,8 +1076,92 @@ function configFromStorage(storage) {
959
1076
  accounts: storage.accounts.map(accountConfig),
960
1077
  });
961
1078
  }
962
- export function isClaustrumEnabledForAccount(storage, accountId) {
963
- return storage.claustrum?.accounts?.[accountId]?.enabled === true;
1079
+ export function getClaustrumMode(storage) {
1080
+ return storage?.claustrum?.mode === 'claustrum' ? 'claustrum' : 'local';
1081
+ }
1082
+ export function isOAuthAccountVaultOwned(storage, account, binding) {
1083
+ return (getClaustrumMode(storage) === 'claustrum' &&
1084
+ isOAuthAccount(account) &&
1085
+ account.enabled !== false &&
1086
+ // Source is provenance, not authorization; the resolver owns the binding decision.
1087
+ (binding?.status === 'resolved' ||
1088
+ Boolean(storage?.claustrum?.scopedRoster && account.claustrumScopedCredentialId)));
1089
+ }
1090
+ export async function setClaustrumModePersistent(mode, path = getAccountStoragePath()) {
1091
+ return enqueueSave(async () => {
1092
+ const lock = await acquireAccountConfigWriteLock(path);
1093
+ try {
1094
+ const existing = await loadAccounts(path);
1095
+ const storage = existing ?? createEmptyStorage();
1096
+ if (existing && getClaustrumMode(storage) === mode)
1097
+ return 'unchanged';
1098
+ storage.claustrum = { ...storage.claustrum, mode };
1099
+ await saveAccountsWithConfigLock(storage, path, {
1100
+ [WRITE_CLAUSTRUM_MODE]: true,
1101
+ });
1102
+ return 'changed';
1103
+ }
1104
+ finally {
1105
+ await lock.release();
1106
+ }
1107
+ });
1108
+ }
1109
+ export async function clearClaustrumHandlePersistent(input) {
1110
+ const path = input.path ?? getAccountStoragePath();
1111
+ return enqueueSave(async () => {
1112
+ const configLock = await acquireAccountConfigWriteLock(path);
1113
+ try {
1114
+ const stateLock = await acquireAccountStateWriteLock(path);
1115
+ try {
1116
+ const storage = await loadAccounts(path);
1117
+ if (!storage)
1118
+ return 'missing';
1119
+ const statePath = getAccountStatePath(path);
1120
+ const state = (await readJsonIfPresent(statePath)).value;
1121
+ const stateAccounts = isRecord(state) && isRecord(state.accounts)
1122
+ ? state.accounts
1123
+ : undefined;
1124
+ const matchingStateKeys = Object.keys(stateAccounts ?? {}).filter((key) => key.trim() === input.id.trim());
1125
+ const account = storage.accounts.find((candidate) => candidate.id === input.id);
1126
+ if (!account) {
1127
+ if (matchingStateKeys.length === 0)
1128
+ return 'missing';
1129
+ for (const key of matchingStateKeys) {
1130
+ const stateAccount = stateAccounts?.[key];
1131
+ if (isRecord(stateAccount))
1132
+ delete stateAccount.claustrumHandle;
1133
+ }
1134
+ await writeJsonAtomic(statePath, pruneUndefined(state));
1135
+ return 'updated';
1136
+ }
1137
+ if (!isOAuthAccount(account))
1138
+ return 'ineligible';
1139
+ if (!account.claustrumHandle)
1140
+ return 'updated';
1141
+ delete account.claustrumHandle;
1142
+ const existing = await loadExistingTopLevelFields(path);
1143
+ await writeJsonAtomic(path, {
1144
+ ...existing,
1145
+ ...configFromStorage(storage),
1146
+ });
1147
+ if (matchingStateKeys.length > 0) {
1148
+ for (const key of matchingStateKeys) {
1149
+ const stateAccount = stateAccounts?.[key];
1150
+ if (isRecord(stateAccount))
1151
+ delete stateAccount.claustrumHandle;
1152
+ }
1153
+ await writeJsonAtomic(statePath, pruneUndefined(state));
1154
+ }
1155
+ return 'updated';
1156
+ }
1157
+ finally {
1158
+ await stateLock.release();
1159
+ }
1160
+ }
1161
+ finally {
1162
+ await configLock.release();
1163
+ }
1164
+ });
964
1165
  }
965
1166
  // ---------------------------------------------------------------------------
966
1167
  // In-process save mutex — serializes all account-store writes so concurrent
@@ -988,6 +1189,8 @@ async function writeJsonAtomic(path, value) {
988
1189
  throw error;
989
1190
  }
990
1191
  }
1192
+ const WRITE_CLAUSTRUM_MODE = Symbol('writeClaustrumMode');
1193
+ const MUTATE_ACCOUNT_ROSTER = Symbol('mutateAccountRoster');
991
1194
  function sameAccountIdentity(left, right) {
992
1195
  return (left.id === right.id ||
993
1196
  Boolean(left.label && right.label && left.label === right.label));
@@ -1019,6 +1222,21 @@ function mergeAccountsForSave(existing, incoming, options) {
1019
1222
  }
1020
1223
  return merged;
1021
1224
  }
1225
+ function preserveScopedAccountsOnSave(current, incoming, options) {
1226
+ const apiRoutes = mergeAccountsForSave(current.filter(isApiKeyAccount), incoming.filter(isApiKeyAccount), options);
1227
+ const byId = new Map(apiRoutes.map((account) => [account.id, account]));
1228
+ const result = current.flatMap((account) => {
1229
+ if (isOAuthAccount(account))
1230
+ return [account];
1231
+ const replacement = byId.get(account.id);
1232
+ if (!replacement)
1233
+ return [];
1234
+ byId.delete(account.id);
1235
+ return [replacement];
1236
+ });
1237
+ result.push(...byId.values());
1238
+ return result;
1239
+ }
1022
1240
  const ACCOUNT_CONFIG_LOCK_TTL_MS = 10_000;
1023
1241
  const ACCOUNT_CONFIG_LOCK_WAIT_MS = 12_000;
1024
1242
  const ACCOUNT_STATE_LOCK_TTL_MS = 10_000;
@@ -1064,6 +1282,32 @@ export function saveAccounts(storage, path = getAccountStoragePath(), options =
1064
1282
  const resolvedPath = path;
1065
1283
  return enqueueSave(() => saveAccountsLocked(storage, resolvedPath, options));
1066
1284
  }
1285
+ /** Read-modify-write account configuration under the existing config/state lock order.
1286
+ * The callback is synchronous: network discovery must happen under its own outer
1287
+ * lease, never while holding the config lock. Do not call another persistence API
1288
+ * from the callback.
1289
+ */
1290
+ export function mutateAccountsPersistent(path, mutate, options = {}) {
1291
+ const resolvedPath = resolve(path);
1292
+ return enqueueSave(async () => {
1293
+ const lock = await acquireAccountConfigWriteLock(resolvedPath);
1294
+ try {
1295
+ const current = (await loadAccounts(resolvedPath)) ?? createEmptyStorage();
1296
+ const mutation = mutate(current);
1297
+ if (mutation.save !== false) {
1298
+ await options.assertAuthority?.();
1299
+ await saveAccountsWithConfigLock(mutation.storage, resolvedPath, {
1300
+ ...mutation.options,
1301
+ [MUTATE_ACCOUNT_ROSTER]: true,
1302
+ });
1303
+ }
1304
+ return mutation.result;
1305
+ }
1306
+ finally {
1307
+ await lock.release();
1308
+ }
1309
+ });
1310
+ }
1067
1311
  export async function getOrCreateMainAccountId(path = getAccountStoragePath(), createId = randomUUID) {
1068
1312
  return enqueueSave(async () => {
1069
1313
  const lock = await acquireAccountConfigWriteLock(path);
@@ -1088,32 +1332,65 @@ export async function getOrCreateMainAccountId(path = getAccountStoragePath(), c
1088
1332
  async function saveAccountsLocked(storage, path, options) {
1089
1333
  const lock = await acquireAccountConfigWriteLock(path);
1090
1334
  try {
1091
- const current = await loadAccounts(path);
1092
- const nextStorage = {
1093
- ...storage,
1094
- accounts: mergeAccountsForSave(current?.accounts ?? [], storage.accounts, options),
1095
- };
1096
- const existing = await loadExistingTopLevelFields(path);
1097
- const nextConfig = { ...existing, ...configFromStorage(nextStorage) };
1098
- await writeJsonAtomic(path, nextConfig);
1099
- // Config precedes state everywhere both locks are needed; reversing this
1100
- // order can deadlock profile mutations against full account saves.
1101
- const stateLock = await acquireAccountStateWriteLock(path);
1102
- try {
1103
- await saveAccountStateUnlocked(nextStorage, path, {
1104
- mainQuota: true,
1105
- mainRefresh: true,
1106
- accounts: true,
1107
- });
1108
- }
1109
- finally {
1110
- await stateLock.release();
1111
- }
1335
+ await saveAccountsWithConfigLock(storage, path, options);
1112
1336
  }
1113
1337
  finally {
1114
1338
  await lock.release();
1115
1339
  }
1116
1340
  }
1341
+ async function saveAccountsWithConfigLock(storage, path, options) {
1342
+ const current = await loadAccounts(path);
1343
+ // A config-only writer may hold a pre-discovery snapshot. It must not
1344
+ // resurrect departed OAuth members or overwrite scoped exclusions.
1345
+ const preserveScopedRoster = current?.claustrum?.scopedRoster === true && !options[MUTATE_ACCOUNT_ROSTER];
1346
+ const mergeOptions = preserveScopedRoster
1347
+ ? {
1348
+ ...options,
1349
+ removedAccountIds: options.removedAccountIds?.filter((id) => current?.accounts.some((account) => account.id === id && isApiKeyAccount(account))),
1350
+ }
1351
+ : options;
1352
+ const nextStorage = {
1353
+ ...storage,
1354
+ ...(storage.claustrum && {
1355
+ claustrum: {
1356
+ ...current?.claustrum,
1357
+ ...storage.claustrum,
1358
+ ...(preserveScopedRoster && current?.claustrum),
1359
+ ...(options[WRITE_CLAUSTRUM_MODE]
1360
+ ? { mode: storage.claustrum.mode }
1361
+ : current?.claustrum?.mode
1362
+ ? { mode: current.claustrum.mode }
1363
+ : {}),
1364
+ },
1365
+ }),
1366
+ ...(!storage.claustrum &&
1367
+ current?.claustrum && {
1368
+ claustrum: current.claustrum,
1369
+ }),
1370
+ accounts: preserveScopedRoster
1371
+ ? preserveScopedAccountsOnSave(current?.accounts ?? [], storage.accounts, mergeOptions)
1372
+ : mergeAccountsForSave(current?.accounts ?? [], storage.accounts, mergeOptions),
1373
+ };
1374
+ const existing = await loadExistingTopLevelFields(path);
1375
+ const nextConfig = { ...existing, ...configFromStorage(nextStorage) };
1376
+ if (!nextStorage.claustrum)
1377
+ delete nextConfig.claustrum;
1378
+ await writeJsonAtomic(path, nextConfig);
1379
+ // Config precedes state everywhere both locks are needed; reversing this
1380
+ // order can deadlock profile mutations against full account saves.
1381
+ const stateLock = await acquireAccountStateWriteLock(path);
1382
+ try {
1383
+ await saveAccountStateUnlocked(nextStorage, path, {
1384
+ mainQuota: true,
1385
+ mainRefresh: true,
1386
+ accounts: true,
1387
+ setClaustrumHandleAccountIds: options.setClaustrumHandleAccountIds,
1388
+ });
1389
+ }
1390
+ finally {
1391
+ await stateLock.release();
1392
+ }
1393
+ }
1117
1394
  function applyMainProfileStatePatch(state, storage) {
1118
1395
  state.main = state.main ?? {};
1119
1396
  state.main.profile = storage.main?.profile;
@@ -1199,12 +1476,11 @@ function applyMainQuotaStatePatch(state, storage) {
1199
1476
  ? mergeHeaderQuotaForPersistence(state.main.quota, incomingQuota)
1200
1477
  : incomingQuota;
1201
1478
  const mergesHeaderQuota = Boolean(sameToken && incomingQuota?.source === 'headers');
1202
- const existingCheckedAt = typeof state.main.quotaCheckedAt === 'number'
1203
- ? state.main.quotaCheckedAt
1204
- : quotaSnapshotCheckedAt(state.main.quota);
1205
- const incomingCheckedAt = typeof storage.quota?.mainQuotaCheckedAt === 'number'
1206
- ? storage.quota.mainQuotaCheckedAt
1207
- : quotaSnapshotCheckedAt(effectiveIncomingQuota);
1479
+ // Ordering authority must travel inside the account-bound snapshot. The
1480
+ // legacy top-level quotaCheckedAt/mainQuotaCheckedAt fields carry no account
1481
+ // identity and can form a mixed pair under concurrent cross-account writes.
1482
+ const existingCheckedAt = quotaSnapshotCheckedAt(state.main.quota);
1483
+ const incomingCheckedAt = quotaSnapshotCheckedAt(effectiveIncomingQuota);
1208
1484
  if (!mergesHeaderQuota &&
1209
1485
  (existingCheckedAt > incomingCheckedAt ||
1210
1486
  (existingCheckedAt === incomingCheckedAt &&
@@ -1213,9 +1489,8 @@ function applyMainQuotaStatePatch(state, storage) {
1213
1489
  return;
1214
1490
  }
1215
1491
  state.main.quota = effectiveIncomingQuota;
1216
- state.main.quotaCheckedAt = mergesHeaderQuota
1217
- ? Math.max(existingCheckedAt, incomingCheckedAt)
1218
- : storage.quota?.mainQuotaCheckedAt;
1492
+ const boundCheckedAt = quotaSnapshotCheckedAt(effectiveIncomingQuota);
1493
+ state.main.quotaCheckedAt = boundCheckedAt > 0 ? boundCheckedAt : undefined;
1219
1494
  state.main.quotaToken = storage.quota?.mainQuotaToken;
1220
1495
  }
1221
1496
  function applyMainRefreshStatePatch(state, storage) {
@@ -1304,6 +1579,22 @@ export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
1304
1579
  ? { ...existing, version: 1 }
1305
1580
  : { version: 1 };
1306
1581
  const { accountId, accountIdentity, profile } = input;
1582
+ if (current?.claustrum?.scopedRoster &&
1583
+ getClaustrumMode(current) === 'claustrum') {
1584
+ const account = current.accounts.find((entry) => entry.id === accountId);
1585
+ const expected = accountId === 'main'
1586
+ ? current.claustrum.primaryAccount?.accountId
1587
+ : account && isOAuthAccount(account)
1588
+ ? account.anthropicAccountUuid
1589
+ : undefined;
1590
+ if (!expected ||
1591
+ accountIdentity !== expected ||
1592
+ (profile?.accountIdentity !== undefined &&
1593
+ profile.accountIdentity !== expected) ||
1594
+ (profile?.providerAccountUuid !== undefined &&
1595
+ profile.providerAccountUuid !== expected))
1596
+ return false;
1597
+ }
1307
1598
  if (accountId === 'main') {
1308
1599
  next.main = { ...(next.main ?? {}) };
1309
1600
  const existingProfile = normalizeOAuthAccountProfile(next.main.profile);
@@ -1398,7 +1689,6 @@ export function clearClaustrumRefreshErrorPersistent(accountId, handle, path = g
1398
1689
  if (!storage ||
1399
1690
  !account ||
1400
1691
  account.claustrumHandle !== handle ||
1401
- !isClaustrumEnabledForAccount(storage, accountId) ||
1402
1692
  !account.lastRefreshError) {
1403
1693
  return false;
1404
1694
  }
@@ -1424,23 +1714,82 @@ export function clearClaustrumRefreshErrorPersistent(accountId, handle, path = g
1424
1714
  }
1425
1715
  });
1426
1716
  }
1717
+ function fenceScopedPrimaryState(state, primaryId) {
1718
+ if (!isRecord(state.main))
1719
+ state.main = {};
1720
+ const main = state.main;
1721
+ if (!primaryId || main.scopedAccountIdentity !== primaryId) {
1722
+ delete main.prime;
1723
+ delete main.primeAuthLineageId;
1724
+ delete main.primeAuthLineageRefreshTokenFingerprint;
1725
+ delete main.quotaErrorGeneration;
1726
+ delete main.quotaErrorClearedAt;
1727
+ }
1728
+ main.scopedAccountIdentity = primaryId;
1729
+ if (!primaryId || main.quota?.accountIdentity !== primaryId) {
1730
+ delete main.quota;
1731
+ delete main.quotaCheckedAt;
1732
+ delete main.quotaToken;
1733
+ }
1734
+ if (!primaryId || main.lastQuotaApiError?.accountIdentity !== primaryId)
1735
+ delete main.lastQuotaApiError;
1736
+ if (!primaryId ||
1737
+ (main.profile?.providerAccountUuid ?? main.profile?.accountIdentity) !==
1738
+ primaryId) {
1739
+ delete main.profile;
1740
+ delete main.profileToken;
1741
+ }
1742
+ delete main.lastRefreshError;
1743
+ delete main.refreshErrorClearedAt;
1744
+ delete main.refreshLeaseId;
1745
+ delete main.refreshLeaseUntil;
1746
+ delete main.refreshLeaseTokenHash;
1747
+ }
1427
1748
  async function saveAccountStateUnlocked(storage, path, scope) {
1428
1749
  const statePath = getAccountStatePath(path);
1429
1750
  const existing = (await readJsonIfPresent(statePath)).value;
1430
1751
  const next = isRecord(existing)
1431
1752
  ? { ...existing, version: 1 }
1432
1753
  : { version: 1 };
1433
- if (scope.mainProfile)
1754
+ const persistedConfig = (await readJsonIfPresent(path)).value;
1755
+ const authority = isRecord(persistedConfig)
1756
+ ? normalizeClaustrumConfig(persistedConfig.claustrum)
1757
+ : undefined;
1758
+ const scoped = authority?.mode === 'claustrum' && authority.scopedRoster === true;
1759
+ const primaryId = authority?.primaryAccount?.accountId;
1760
+ const samePrimary = Boolean(primaryId && storage.claustrum?.primaryAccount?.accountId === primaryId);
1761
+ if (scoped)
1762
+ fenceScopedPrimaryState(next, primaryId);
1763
+ const incomingProfileId = storage.main?.profile?.providerAccountUuid ??
1764
+ storage.main?.profile?.accountIdentity;
1765
+ if (scope.mainProfile &&
1766
+ (!scoped ||
1767
+ (samePrimary &&
1768
+ (!storage.main?.profile || incomingProfileId === primaryId))))
1434
1769
  applyMainProfileStatePatch(next, storage);
1435
- if (scope.mainQuota)
1436
- applyMainQuotaStatePatch(next, storage);
1437
- if (scope.mainRefresh)
1770
+ const quotaMatches = samePrimary &&
1771
+ (!storage.quota?.mainQuota ||
1772
+ storage.quota.mainQuota.accountIdentity === primaryId) &&
1773
+ (!storage.quota?.mainLastQuotaApiError ||
1774
+ storage.quota.mainLastQuotaApiError.accountIdentity === primaryId);
1775
+ if (scope.mainQuota && (!scoped || quotaMatches)) {
1776
+ applyMainQuotaStatePatch(next, scoped
1777
+ ? { ...storage, quota: { ...storage.quota, mainQuotaToken: primaryId } }
1778
+ : storage);
1779
+ }
1780
+ if (scope.mainRefresh && !scoped)
1438
1781
  applyMainRefreshStatePatch(next, storage);
1439
- if (scope.mainPrime)
1782
+ if (scope.mainPrime && (!scoped || samePrimary))
1440
1783
  applyMainPrimeStatePatch(next, storage);
1441
1784
  if (scope.accounts) {
1442
1785
  const ids = scope.accounts === true ? null : new Set(scope.accounts);
1443
- const config = (await readJsonIfPresent(path)).value;
1786
+ const setClaustrumHandleAccountIds = new Set(scope.setClaustrumHandleAccountIds ?? []);
1787
+ const config = persistedConfig;
1788
+ const configAccounts = isRecord(config) && Array.isArray(config.accounts)
1789
+ ? new Map(config.accounts
1790
+ .filter(isRecord)
1791
+ .map((entry) => [String(entry.id ?? '').trim(), entry]))
1792
+ : new Map();
1444
1793
  const configuredIds = (() => {
1445
1794
  if (!isRecord(config) || !Array.isArray(config.accounts))
1446
1795
  return null;
@@ -1490,7 +1839,40 @@ async function saveAccountStateUnlocked(storage, path, scope) {
1490
1839
  (legacyKey ? next.accounts[legacyKey] : undefined);
1491
1840
  for (const key of legacyKeys)
1492
1841
  delete next.accounts[key];
1493
- next.accounts[accountId] = mergeAccountRuntimeState(existingAccount, accountRuntimeState(account));
1842
+ const configured = configAccounts.get(accountId);
1843
+ if (configured &&
1844
+ typeof configured.claustrumScopedCredentialId === 'string' &&
1845
+ typeof configured.anthropicAccountUuid === 'string') {
1846
+ const matches = (value) => isRecord(value) &&
1847
+ value.claustrumScopedCredentialId ===
1848
+ configured.claustrumScopedCredentialId &&
1849
+ value.anthropicAccountUuid === configured.anthropicAccountUuid;
1850
+ const incoming = accountRuntimeState(account);
1851
+ const merged = mergeAccountRuntimeState(matches(existingAccount) ? existingAccount : undefined, matches(incoming) ? incoming : {});
1852
+ // The current config owns identity. Older local writers cannot restore
1853
+ // credentials, handles or another account's observations after cutover.
1854
+ next.accounts[accountId] = {
1855
+ ...merged,
1856
+ claustrumScopedCredentialId: configured.claustrumScopedCredentialId,
1857
+ anthropicAccountUuid: configured.anthropicAccountUuid,
1858
+ refresh: '',
1859
+ };
1860
+ const scoped = next.accounts[accountId];
1861
+ delete scoped.access;
1862
+ delete scoped.expires;
1863
+ delete scoped.lastRefreshedAt;
1864
+ delete scoped.lastRefreshError;
1865
+ delete scoped.claustrumHandle;
1866
+ }
1867
+ else {
1868
+ const merged = mergeAccountRuntimeState(existingAccount, accountRuntimeState(account), setClaustrumHandleAccountIds.has(account.id) ||
1869
+ setClaustrumHandleAccountIds.has(accountId));
1870
+ if (configured) {
1871
+ delete merged.claustrumScopedCredentialId;
1872
+ delete merged.claustrumScopedState;
1873
+ }
1874
+ next.accounts[accountId] = merged;
1875
+ }
1494
1876
  }
1495
1877
  if (configuredIds) {
1496
1878
  // Config membership is authoritative for scoped writes too; otherwise a
@@ -1703,6 +2085,12 @@ export async function acquireRefreshFileLock(options) {
1703
2085
  return null;
1704
2086
  scheduleRenewal();
1705
2087
  return {
2088
+ assertOwned: async () => {
2089
+ const owner = await readOwner().catch(() => undefined);
2090
+ if (released || owner?.ownerId !== ownerId) {
2091
+ throw new Error('Account file lock ownership was lost');
2092
+ }
2093
+ },
1706
2094
  release: async () => {
1707
2095
  released = true;
1708
2096
  if (renewTimer) {
@@ -2176,13 +2564,14 @@ export async function setLogLevelPersistent(level, path = getAccountStoragePath(
2176
2564
  setLogLevel(level);
2177
2565
  }
2178
2566
  export function getPersistedMainQuota(storage) {
2179
- if (!storage?.quota?.mainQuota || !storage.quota.mainQuotaCheckedAt)
2567
+ const quota = storage?.quota?.mainQuota;
2568
+ if (!quota)
2180
2569
  return null;
2181
2570
  return {
2182
- quota: storage.quota.mainQuota,
2183
- checkedAt: storage.quota.mainQuotaCheckedAt,
2184
- tokenFingerprint: storage.quota.mainQuotaToken,
2185
- accountIdentity: storage.quota.mainQuota.accountIdentity,
2571
+ quota,
2572
+ checkedAt: quotaSnapshotCheckedAt(quota),
2573
+ tokenFingerprint: storage.quota?.mainQuotaToken,
2574
+ accountIdentity: quota.accountIdentity,
2186
2575
  };
2187
2576
  }
2188
2577
  /**
@@ -2387,35 +2776,84 @@ export async function setKillswitchPersistent(config, path = getAccountStoragePa
2387
2776
  return storage;
2388
2777
  }
2389
2778
  export async function removeAccountPersistent(id, path = getAccountStoragePath()) {
2390
- const storage = await loadAccounts(path);
2391
- if (!storage)
2392
- return false;
2393
- const existed = removeAccount(storage, id);
2394
- if (existed) {
2395
- await saveAccounts(storage, path, { removedAccountIds: [id] });
2396
- }
2397
- return existed;
2779
+ return mutateAccountsPersistent(path, (storage) => {
2780
+ const account = storage.accounts.find((entry) => entry.id === id);
2781
+ if (account &&
2782
+ isOAuthAccount(account) &&
2783
+ account.claustrumScopedCredentialId &&
2784
+ getClaustrumMode(storage) === 'claustrum') {
2785
+ throw new Error('Disable this account or remove it from Claustrum; scoped membership is managed by the vault');
2786
+ }
2787
+ const existed = removeAccount(storage, id);
2788
+ return {
2789
+ storage,
2790
+ result: existed,
2791
+ save: existed,
2792
+ options: { removedAccountIds: [id] },
2793
+ };
2794
+ });
2398
2795
  }
2399
2796
  export async function reorderAccountsPersistent(orderedIds, path = getAccountStoragePath()) {
2400
- const storage = await loadAccounts(path);
2401
- if (!storage)
2402
- return;
2403
- reorderAccounts(storage, orderedIds);
2404
- await saveAccounts(storage, path, { preserveExistingAccountOrder: false });
2797
+ await mutateAccountsPersistent(path, (storage) => {
2798
+ reorderAccounts(storage, orderedIds);
2799
+ return {
2800
+ storage,
2801
+ result: undefined,
2802
+ options: { preserveExistingAccountOrder: false },
2803
+ };
2804
+ });
2405
2805
  }
2406
2806
  export async function setAccountEnabledPersistent(id, enabled, path = getAccountStoragePath()) {
2407
- const storage = await loadAccounts(path);
2408
- if (!storage)
2409
- return false;
2410
- const found = setAccountEnabled(storage, id, enabled);
2411
- if (found)
2412
- await saveAccounts(storage, path);
2413
- return found;
2807
+ return mutateAccountsPersistent(path, (storage) => {
2808
+ const found = setAccountEnabled(storage, id, enabled);
2809
+ const account = storage.accounts.find((candidate) => candidate.id === id);
2810
+ if (account &&
2811
+ isOAuthAccount(account) &&
2812
+ (account.claustrumScopedCredentialId ||
2813
+ storage.claustrum?.scopedRoster) &&
2814
+ account.anthropicAccountUuid) {
2815
+ const disabled = new Set(storage.claustrum?.disabledAccountIdentities ?? []);
2816
+ if (enabled)
2817
+ disabled.delete(account.anthropicAccountUuid);
2818
+ else
2819
+ disabled.add(account.anthropicAccountUuid);
2820
+ storage.claustrum = {
2821
+ ...storage.claustrum,
2822
+ disabledAccountIdentities: [...disabled],
2823
+ };
2824
+ }
2825
+ return { storage, result: found, save: found };
2826
+ });
2414
2827
  }
2415
2828
  export async function addAccountPersistent(account, path = getAccountStoragePath()) {
2416
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
2417
- upsertAccount(storage, account);
2418
- await saveAccounts(storage, path);
2829
+ await mutateAccountsPersistent(path, (storage) => {
2830
+ if (storage.claustrum?.scopedRoster &&
2831
+ getClaustrumMode(storage) === 'claustrum' &&
2832
+ isOAuthAccount(account)) {
2833
+ throw new Error('Claustrum account membership is managed by the vault; add accounts with ck auth login');
2834
+ }
2835
+ upsertAccount(storage, account);
2836
+ if (isOAuthAccount(account) &&
2837
+ getClaustrumMode(storage) === 'local' &&
2838
+ account.refresh &&
2839
+ !isCustodyTombstoneValue(account.refresh)) {
2840
+ const updated = storage.accounts.find((entry) => entry.id === account.id ||
2841
+ (account.label && entry.label === account.label));
2842
+ if (updated && isOAuthAccount(updated)) {
2843
+ delete updated.claustrumScopedCredentialId;
2844
+ delete updated.claustrumScopedState;
2845
+ }
2846
+ }
2847
+ return {
2848
+ storage,
2849
+ result: undefined,
2850
+ options: {
2851
+ setClaustrumHandleAccountIds: account.type === 'oauth' && account.claustrumHandle
2852
+ ? [account.id]
2853
+ : undefined,
2854
+ },
2855
+ };
2856
+ });
2419
2857
  }
2420
2858
  export function getQuotaNextRefreshAt(quota, storage, now) {
2421
2859
  const intervalMs = getQuotaCheckIntervalMs(storage);
@@ -2624,6 +3062,7 @@ function mapUsageWindow(window, checkedAt) {
2624
3062
  };
2625
3063
  }
2626
3064
  export async function fetchOAuthQuotaSnapshot(input) {
3065
+ assertNotCustodyTombstone(input.accessToken, 'anthropic');
2627
3066
  const fetchImpl = input.fetchImpl ?? fetch;
2628
3067
  const response = await fetchImpl(QUOTA_URL, {
2629
3068
  method: 'GET',
@@ -2679,10 +3118,16 @@ export function upsertAccount(storage, account) {
2679
3118
  const index = storage.accounts.findIndex((candidate) => candidate.id === account.id ||
2680
3119
  (account.label && candidate.label === account.label));
2681
3120
  if (index >= 0) {
2682
- storage.accounts[index] = {
2683
- ...storage.accounts[index],
3121
+ const existing = storage.accounts[index];
3122
+ if (!existing)
3123
+ return;
3124
+ const lineageChanged = existing.type === 'oauth' &&
3125
+ account.type === 'oauth' &&
3126
+ existing.authLineageId !== account.authLineageId;
3127
+ const updated = {
3128
+ ...existing,
2684
3129
  ...account,
2685
- addedAt: storage.accounts[index]?.addedAt ?? account.addedAt,
3130
+ addedAt: existing.addedAt ?? account.addedAt,
2686
3131
  ...(account.type === 'oauth' && {
2687
3132
  quota: account.quota,
2688
3133
  profile: account.profile,
@@ -2691,10 +3136,54 @@ export function upsertAccount(storage, account) {
2691
3136
  lastQuotaRefreshError: account.lastQuotaRefreshError,
2692
3137
  }),
2693
3138
  };
3139
+ if (lineageChanged && updated.type === 'oauth') {
3140
+ logger.debug('accounts', 'cleared provider UUID after auth lineage change');
3141
+ delete updated.anthropicAccountUuid;
3142
+ }
3143
+ storage.accounts[index] = updated;
2694
3144
  return;
2695
3145
  }
2696
3146
  storage.accounts.push(account);
2697
3147
  }
3148
+ export function persistFallbackQuotaHeaderPersistent(input, path = getAccountStoragePath()) {
3149
+ return enqueueSave(async () => {
3150
+ const configLock = await acquireAccountConfigWriteLock(path);
3151
+ try {
3152
+ const stateLock = await acquireAccountStateWriteLock(path);
3153
+ try {
3154
+ const storage = await loadAccounts(path);
3155
+ const account = storage?.accounts.find((candidate) => candidate.id === input.accountId && isOAuthAccount(candidate));
3156
+ if (!storage ||
3157
+ !account ||
3158
+ account.authLineageId !== input.authLineageId) {
3159
+ return false;
3160
+ }
3161
+ account.quota = {
3162
+ ...input.quota,
3163
+ accountIdentity: account.id,
3164
+ };
3165
+ if (input.anthropicAccountUuid !== undefined) {
3166
+ account.anthropicAccountUuid = input.anthropicAccountUuid;
3167
+ }
3168
+ await saveAccountStateUnlocked(storage, path, {
3169
+ accounts: [input.accountId],
3170
+ });
3171
+ return true;
3172
+ }
3173
+ finally {
3174
+ await stateLock.release();
3175
+ }
3176
+ }
3177
+ finally {
3178
+ await configLock.release();
3179
+ }
3180
+ });
3181
+ }
3182
+ export function fallbackAccountUuidForLineage(account, authLineageId) {
3183
+ if (!account || account.authLineageId !== authLineageId)
3184
+ return null;
3185
+ return account.anthropicAccountUuid ?? null;
3186
+ }
2698
3187
  export function removeAccount(storage, id) {
2699
3188
  const index = storage.accounts.findIndex((c) => c.id === id);
2700
3189
  if (index < 0)
@@ -2760,6 +3249,7 @@ export class FallbackAccountManager {
2760
3249
  fetchImpl;
2761
3250
  configPath;
2762
3251
  refreshPromises = new Map();
3252
+ custodyVerificationAccounts = new Set();
2763
3253
  refreshTimer = null;
2764
3254
  quotaTimer = null;
2765
3255
  quotaManager;
@@ -2847,6 +3337,25 @@ export class FallbackAccountManager {
2847
3337
  this.refreshTimer = null;
2848
3338
  this.quotaTimer = null;
2849
3339
  }
3340
+ async withAccountRefreshLock(accountId, fn) {
3341
+ const lock = await acquireRefreshFileLock({
3342
+ name: fallbackRefreshLockName(accountId),
3343
+ ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS,
3344
+ path: this.configPath,
3345
+ now: this.now,
3346
+ renew: true,
3347
+ });
3348
+ if (!lock)
3349
+ throw new Error('Fallback OAuth refresh is already in progress');
3350
+ this.custodyVerificationAccounts.add(accountId);
3351
+ try {
3352
+ return await fn();
3353
+ }
3354
+ finally {
3355
+ this.custodyVerificationAccounts.delete(accountId);
3356
+ await lock.release();
3357
+ }
3358
+ }
2850
3359
  async getUsableFallbackAccounts(existingStorage, options = {}) {
2851
3360
  const storage = existingStorage !== undefined ? existingStorage : await this.load();
2852
3361
  if (!storage)
@@ -2856,19 +3365,21 @@ export class FallbackAccountManager {
2856
3365
  for (const account of storage.accounts) {
2857
3366
  if (account.enabled === false || !isOAuthAccount(account))
2858
3367
  continue;
3368
+ if (this.isFallbackAccountVaultEnabled(account.id, storage)) {
3369
+ if (!this.isFallbackAccountVaultServed(account.id, storage))
3370
+ continue;
3371
+ if (hasNoLocalCredential(account) &&
3372
+ !storage.quota?.minimumRemaining &&
3373
+ !isKillswitchEnabled(storage)) {
3374
+ usable.push(account);
3375
+ continue;
3376
+ }
3377
+ }
2859
3378
  let next = account;
2860
3379
  try {
2861
3380
  if (tokenNeedsRefresh(next, storage, this.now()) &&
2862
- (!this.isFallbackAccountVaultEnabled(next.id, storage) ||
2863
- next.expires === undefined ||
2864
- next.expires <= this.now()) &&
3381
+ !this.isFallbackAccountVaultEnabled(next.id, storage) &&
2865
3382
  !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
- }
2872
3383
  const refreshError = next.lastRefreshError;
2873
3384
  if (refreshError &&
2874
3385
  refreshBackoffActive(refreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
@@ -2956,6 +3467,12 @@ export class FallbackAccountManager {
2956
3467
  for (const account of storage.accounts) {
2957
3468
  if (account.enabled === false || !isOAuthAccount(account))
2958
3469
  continue;
3470
+ if (this.custodyVerificationAccounts.has(account.id)) {
3471
+ logger.debug('refresh', 'fallback OAuth background skipped vault-service verification', {
3472
+ accountId: account.id,
3473
+ });
3474
+ continue;
3475
+ }
2959
3476
  if (!tokenNeedsRefresh(account, storage, this.now()) ||
2960
3477
  this.isFallbackAccountVaultEnabled(account.id, storage) ||
2961
3478
  this.isFallbackAccountVaultServed(account.id, storage))
@@ -3000,6 +3517,8 @@ export class FallbackAccountManager {
3000
3517
  for (const account of storage.accounts) {
3001
3518
  if (account.enabled === false || !isOAuthAccount(account))
3002
3519
  continue;
3520
+ if (this.custodyVerificationAccounts.has(account.id))
3521
+ continue;
3003
3522
  let next = account;
3004
3523
  try {
3005
3524
  if (tokenNeedsRefresh(next, storage, this.now()) &&
@@ -3089,7 +3608,8 @@ export class FallbackAccountManager {
3089
3608
  return { storage, errors };
3090
3609
  }
3091
3610
  async refreshAccount(account, storage, options = {}) {
3092
- if (this.isFallbackAccountVaultServed(account.id, storage))
3611
+ if (this.isFallbackAccountVaultServed(account.id, storage) ||
3612
+ this.isFallbackAccountVaultEnabled(account.id, storage))
3093
3613
  return account;
3094
3614
  const existing = this.refreshPromises.get(account.id);
3095
3615
  if (existing) {
@@ -3233,7 +3753,9 @@ export class FallbackAccountManager {
3233
3753
  changed = true;
3234
3754
  access = this.resolveFallbackAccessToken(target, storage);
3235
3755
  }
3236
- if (!access) {
3756
+ const delegatedQuotaAuthorization = storage?.claustrum?.scopedRoster === true &&
3757
+ this.quotaManager?.canFetchWithoutAccessToken() === true;
3758
+ if (!access && !delegatedQuotaAuthorization) {
3237
3759
  log('[quota] fallback quota poll skipped: no usable credential', {
3238
3760
  accountId: target.id,
3239
3761
  });
@@ -3243,6 +3765,21 @@ export class FallbackAccountManager {
3243
3765
  changed,
3244
3766
  };
3245
3767
  }
3768
+ let quotaPollLock = await acquireRefreshFileLock({
3769
+ name: fallbackRefreshLockName(target.id),
3770
+ ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS,
3771
+ path: this.configPath,
3772
+ now: this.now,
3773
+ });
3774
+ if (!quotaPollLock) {
3775
+ log('[quota] fallback quota poll skipped refresh lock', {
3776
+ accountId: target.id,
3777
+ });
3778
+ return { account: target, fetched: false, changed };
3779
+ }
3780
+ await using _quotaPollLock = {
3781
+ [Symbol.asyncDispose]: async () => quotaPollLock?.release(),
3782
+ };
3246
3783
  // Unify on the shared QuotaManager when present: it adds inflight
3247
3784
  // deduplication and 429 backoff gating around the same quota API. Fall back
3248
3785
  // to a direct fetch only when no QuotaManager is wired (e.g. in isolation).
@@ -3256,7 +3793,7 @@ export class FallbackAccountManager {
3256
3793
  const fetchStartedAt = this.now();
3257
3794
  let fetched = false;
3258
3795
  try {
3259
- const result = await fetchSnapshot(access.token);
3796
+ const result = await fetchSnapshot(access?.token ?? '');
3260
3797
  target.quota = result.quota;
3261
3798
  fetched = result.fetched;
3262
3799
  }
@@ -3264,9 +3801,11 @@ export class FallbackAccountManager {
3264
3801
  const message = error instanceof Error ? error.message : String(error);
3265
3802
  if (!message.includes('Claude quota check failed: 401') ||
3266
3803
  vaultEnabled ||
3267
- access.source !== 'sidecar') {
3804
+ access?.source !== 'sidecar') {
3268
3805
  throw error;
3269
3806
  }
3807
+ await quotaPollLock.release();
3808
+ quotaPollLock = null;
3270
3809
  target = await this.refreshAccount(account, storage, {
3271
3810
  force: true,
3272
3811
  });
@@ -3282,6 +3821,18 @@ export class FallbackAccountManager {
3282
3821
  changed,
3283
3822
  };
3284
3823
  }
3824
+ quotaPollLock = await acquireRefreshFileLock({
3825
+ name: fallbackRefreshLockName(target.id),
3826
+ ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS,
3827
+ path: this.configPath,
3828
+ now: this.now,
3829
+ });
3830
+ if (!quotaPollLock) {
3831
+ log('[quota] fallback quota poll skipped refresh lock', {
3832
+ accountId: target.id,
3833
+ });
3834
+ return { account: target, fetched: false, changed };
3835
+ }
3285
3836
  // 401 does not arm QuotaManager backoff, so this retry proceeds.
3286
3837
  const result = await fetchSnapshot(access.token);
3287
3838
  target.quota = result.quota;