@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.
- package/dist/accounts.d.ts +59 -2
- package/dist/accounts.js +666 -115
- package/dist/auth.js +3 -0
- package/dist/cachekeep.d.ts +18 -1
- package/dist/cachekeep.js +108 -82
- package/dist/cch.d.ts +11 -1
- package/dist/cch.js +48 -3
- package/dist/claude-code.d.ts +7 -1
- package/dist/claude-code.js +18 -3
- package/dist/claustrum-enrollment-registry.d.ts +17 -0
- package/dist/claustrum-enrollment-registry.js +194 -0
- package/dist/claustrum-enrollment.d.ts +70 -0
- package/dist/claustrum-enrollment.js +485 -0
- package/dist/claustrum-scoped-roster.d.ts +23 -0
- package/dist/claustrum-scoped-roster.js +194 -0
- package/dist/claustrum-scoped-runtime.d.ts +28 -0
- package/dist/claustrum-scoped-runtime.js +230 -0
- package/dist/claustrum-scoped.d.ts +39 -0
- package/dist/claustrum-scoped.js +193 -0
- package/dist/claustrum.d.ts +149 -6
- package/dist/claustrum.js +990 -48
- package/dist/commands/account.d.ts +40 -12
- package/dist/commands/account.js +138 -9
- package/dist/constants.d.ts +3 -2
- package/dist/constants.js +3 -2
- package/dist/custom-headers.d.ts +3 -0
- package/dist/custom-headers.js +102 -0
- package/dist/fast.js +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/logger.js +3 -1
- package/dist/model-remap.d.ts +27 -0
- package/dist/model-remap.js +72 -0
- package/dist/models.d.ts +19 -0
- package/dist/models.js +48 -12
- package/dist/oauth-profile.d.ts +2 -1
- package/dist/oauth-profile.js +6 -0
- package/dist/prime.d.ts +11 -3
- package/dist/prime.js +25 -7
- package/dist/quota-header-feed.d.ts +13 -0
- package/dist/quota-header-feed.js +60 -8
- package/dist/quota-manager.d.ts +15 -1
- package/dist/quota-manager.js +73 -22
- package/dist/relay.d.ts +8 -0
- package/dist/relay.js +72 -12
- package/dist/sticky-routing.d.ts +2 -0
- package/dist/sticky-routing.js +19 -1
- package/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
|
-
|
|
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
|
|
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)
|
|
474
|
+
if (!isRecord(value))
|
|
443
475
|
return undefined;
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
826
|
-
existingEntry.access !==
|
|
931
|
+
incomingForMerge.access &&
|
|
932
|
+
existingEntry.access !== incomingForMerge.access) ||
|
|
827
933
|
(existingEntry.refresh &&
|
|
828
|
-
|
|
829
|
-
existingEntry.refresh !==
|
|
830
|
-
const mergesHeaderQuota = Boolean(!tokenChanged &&
|
|
831
|
-
const effectiveIncoming = mergesHeaderQuota &&
|
|
934
|
+
incomingForMerge.refresh &&
|
|
935
|
+
existingEntry.refresh !== incomingForMerge.refresh));
|
|
936
|
+
const mergesHeaderQuota = Boolean(!tokenChanged && incomingForMerge.quota?.source === 'headers');
|
|
937
|
+
const effectiveIncoming = mergesHeaderQuota && incomingForMerge.quota
|
|
832
938
|
? {
|
|
833
|
-
...
|
|
834
|
-
quota: mergeHeaderQuotaForPersistence(existingEntry.quota,
|
|
835
|
-
}
|
|
836
|
-
:
|
|
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
|
|
963
|
-
return storage
|
|
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
|
-
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
const
|
|
1206
|
-
|
|
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
|
-
|
|
1217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2567
|
+
const quota = storage?.quota?.mainQuota;
|
|
2568
|
+
if (!quota)
|
|
2180
2569
|
return null;
|
|
2181
2570
|
return {
|
|
2182
|
-
quota
|
|
2183
|
-
checkedAt:
|
|
2184
|
-
tokenFingerprint: storage.quota
|
|
2185
|
-
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
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
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
|
-
|
|
2401
|
-
|
|
2402
|
-
return
|
|
2403
|
-
|
|
2404
|
-
|
|
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
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
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
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|