@cortexkit/anthropic-auth-core 1.23.0 → 2.0.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/README.md +2 -0
- package/dist/accounts.d.ts +3 -16
- package/dist/cachekeep.d.ts +9 -0
- package/dist/claude-code.d.ts +16 -0
- package/dist/claustrum-enrollment.d.ts +2 -0
- package/dist/claustrum-scoped-roster.d.ts +4 -0
- package/dist/claustrum-scoped-runtime.d.ts +3 -0
- package/dist/claustrum-scoped.d.ts +14 -0
- package/dist/claustrum.d.ts +0 -209
- package/dist/commands/account.d.ts +2 -3
- package/dist/index.d.ts +44 -1
- package/dist/index.js +29700 -38
- package/dist/internal-types/LICENSE +21 -0
- package/dist/internal-types/commands/builtins.d.ts +71 -0
- package/dist/internal-types/commands/index.d.ts +10 -0
- package/dist/internal-types/commands/menu.d.ts +61 -0
- package/dist/internal-types/commands/model.d.ts +238 -0
- package/dist/internal-types/commands/pi.d.ts +23 -0
- package/dist/internal-types/commands/seam.d.ts +72 -0
- package/dist/internal-types/fs/refresh-file-lock.d.ts +33 -0
- package/dist/internal-types/logger/capture-sink.d.ts +14 -0
- package/dist/internal-types/logger/engine.d.ts +100 -0
- package/dist/internal-types/logger/index.d.ts +6 -0
- package/dist/internal-types/logger/redact.d.ts +11 -0
- package/dist/internal-types/pi-slot/index.d.ts +3 -0
- package/dist/internal-types/pi-slot/slot.d.ts +49 -0
- package/dist/internal-types/quota/codec.d.ts +11 -0
- package/dist/internal-types/quota/format.d.ts +46 -0
- package/dist/internal-types/quota/index.d.ts +10 -0
- package/dist/internal-types/quota/map.d.ts +63 -0
- package/dist/internal-types/quota/merge.d.ts +48 -0
- package/dist/internal-types/quota/projection.d.ts +64 -0
- package/dist/internal-types/store/attribution.d.ts +28 -0
- package/dist/internal-types/store/errors.d.ts +55 -0
- package/dist/internal-types/store/hooks.d.ts +12 -0
- package/dist/internal-types/store/identity.d.ts +39 -0
- package/dist/internal-types/store/index.d.ts +21 -0
- package/dist/internal-types/store/mutate.d.ts +189 -0
- package/dist/internal-types/store/pool.d.ts +196 -0
- package/dist/internal-types/store/provider-state.d.ts +131 -0
- package/dist/internal-types/store/publication.d.ts +47 -0
- package/dist/internal-types/store/pull.d.ts +36 -0
- package/dist/internal-types/store/refresh-lock.d.ts +63 -0
- package/dist/internal-types/store/refresh.d.ts +72 -0
- package/dist/internal-types/store/rows.d.ts +313 -0
- package/dist/internal-types/store/runtime.d.ts +36 -0
- package/dist/internal-types/store/schema.d.ts +443 -0
- package/dist/internal-types/store/settings.d.ts +63 -0
- package/dist/internal-types/store/torn.d.ts +78 -0
- package/dist/migration-source.d.ts +33 -0
- package/dist/models.d.ts +41 -0
- package/dist/native-account-runtime.d.ts +121 -0
- package/dist/native-account-view.d.ts +126 -0
- package/dist/native-command-effects.d.ts +59 -0
- package/dist/native-credential-validation.d.ts +42 -0
- package/dist/native-custody-activation.d.ts +45 -0
- package/dist/native-custody.d.ts +114 -0
- package/dist/native-enrollment-authority.d.ts +2 -0
- package/dist/native-host-auth-read.d.ts +3 -0
- package/dist/native-host-auth-write.d.ts +51 -0
- package/dist/native-host-auth.d.ts +12 -0
- package/dist/native-local-credential-service.d.ts +76 -0
- package/dist/native-local-runtime-readers.d.ts +22 -0
- package/dist/native-lock-observer.d.ts +12 -0
- package/dist/native-menu-executor.d.ts +243 -0
- package/dist/native-menu-model.d.ts +142 -0
- package/dist/native-migration-proof.d.ts +28 -0
- package/dist/native-migration-source.d.ts +47 -0
- package/dist/native-migration.d.ts +66 -0
- package/dist/native-quota-codec.d.ts +32 -0
- package/dist/native-refresh-coordinator.d.ts +240 -0
- package/dist/native-roster-seed.d.ts +115 -0
- package/dist/native-runtime-registry.d.ts +19 -0
- package/dist/native-runtime.d.ts +204 -0
- package/dist/native-transient-account.d.ts +38 -0
- package/dist/native-ui.d.ts +45 -0
- package/dist/native-usage-publication.d.ts +11 -0
- package/dist/native-vault-admission.d.ts +8 -0
- package/dist/native-vault-connection.d.ts +33 -0
- package/dist/native-vault-paths.d.ts +13 -0
- package/dist/native-vault-runtime.d.ts +102 -0
- package/dist/native-vault-seed.d.ts +16 -0
- package/dist/pool-authority.d.ts +159 -0
- package/dist/pool-binding.d.ts +32 -0
- package/dist/pool-paths.d.ts +21 -0
- package/dist/pool-store.d.ts +32 -0
- package/dist/provider-error.d.ts +139 -0
- package/dist/quota-manager.d.ts +18 -7
- package/dist/relay.d.ts +14 -1
- package/dist/request-history.d.ts +34 -0
- package/dist/sse.d.ts +81 -0
- package/dist/sticky-routing.d.ts +27 -0
- package/package.json +9 -4
- package/dist/accounts.js +0 -3881
- package/dist/auth.js +0 -185
- package/dist/cache1h.js +0 -97
- package/dist/cachekeep-registry.js +0 -124
- package/dist/cachekeep.js +0 -604
- package/dist/cch.js +0 -172
- package/dist/claude-code.js +0 -403
- package/dist/claustrum-enrollment-registry.d.ts +0 -17
- package/dist/claustrum-enrollment-registry.js +0 -194
- package/dist/claustrum-enrollment.js +0 -485
- package/dist/claustrum-scoped-roster.js +0 -194
- package/dist/claustrum-scoped-runtime.js +0 -230
- package/dist/claustrum-scoped.js +0 -193
- package/dist/claustrum.js +0 -1471
- package/dist/commands/account.js +0 -368
- package/dist/constants.js +0 -83
- package/dist/custom-headers.js +0 -102
- package/dist/dump.js +0 -616
- package/dist/fast.js +0 -66
- package/dist/json.js +0 -10
- package/dist/killswitch.js +0 -132
- package/dist/logger.js +0 -288
- package/dist/logging.js +0 -59
- package/dist/mid-conversation-output-config.js +0 -89
- package/dist/model-remap.js +0 -72
- package/dist/models.js +0 -177
- package/dist/network-errors.js +0 -27
- package/dist/oauth-profile.js +0 -60
- package/dist/pkce.js +0 -17
- package/dist/prime.js +0 -841
- package/dist/provider.js +0 -0
- package/dist/quota-header-feed.js +0 -331
- package/dist/quota-headers.js +0 -121
- package/dist/quota-manager.js +0 -856
- package/dist/quotas.js +0 -141
- package/dist/relay.js +0 -1303
- package/dist/routing.js +0 -94
- package/dist/start.js +0 -29
- package/dist/sticky-routing.js +0 -541
- package/dist/thinking-binding.js +0 -54
- package/dist/token-fingerprint.js +0 -8
package/dist/accounts.js
DELETED
|
@@ -1,3881 +0,0 @@
|
|
|
1
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { dirname, join, resolve } from 'node:path';
|
|
5
|
-
import { parseRetryAfterHeader, refreshClaudeOAuthToken } from './auth.js';
|
|
6
|
-
import { assertNotCustodyTombstone, CustodyTombstoneRefreshError, custodyTombstoneKey, isCustodyTombstoneValue, } from './claustrum.js';
|
|
7
|
-
import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from './constants.js';
|
|
8
|
-
import { parseJsonRedacted } from './json.js';
|
|
9
|
-
import { log, logger } from './logger.js';
|
|
10
|
-
import { isTransientNetworkError } from './network-errors.js';
|
|
11
|
-
import { tokenFingerprint } from './token-fingerprint.js';
|
|
12
|
-
const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
|
|
13
|
-
const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
|
|
14
|
-
export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
|
|
15
|
-
export const ACCOUNT_STATE_FILE_NAME = 'anthropic-auth-state.json';
|
|
16
|
-
export const QUOTA_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
17
|
-
export const QUOTA_FIELD_NAMES = [
|
|
18
|
-
'five_hour',
|
|
19
|
-
'seven_day',
|
|
20
|
-
'scoped',
|
|
21
|
-
'extraUsage',
|
|
22
|
-
'bindingWindow',
|
|
23
|
-
'fallbackAdvised',
|
|
24
|
-
];
|
|
25
|
-
export function hasNoLocalCredential(account) {
|
|
26
|
-
return (account.access == null &&
|
|
27
|
-
(account.refresh == null || account.refresh === ''));
|
|
28
|
-
}
|
|
29
|
-
export function isOAuthAccount(account) {
|
|
30
|
-
return account.type === 'oauth';
|
|
31
|
-
}
|
|
32
|
-
export function isApiKeyAccount(account) {
|
|
33
|
-
return account.type === 'api';
|
|
34
|
-
}
|
|
35
|
-
export function isValidApiBaseURL(value) {
|
|
36
|
-
const raw = value?.trim();
|
|
37
|
-
if (!raw)
|
|
38
|
-
return false;
|
|
39
|
-
try {
|
|
40
|
-
const url = new URL(raw);
|
|
41
|
-
return ((url.protocol === 'http:' || url.protocol === 'https:') &&
|
|
42
|
-
!url.username &&
|
|
43
|
-
!url.password);
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Whether Anthropic OAuth model costs should be zeroed in the provider hook.
|
|
51
|
-
* Defaults to enabled; only an explicit `costZeroing.enabled === false` opts out
|
|
52
|
-
* (to display the provider's real model costs).
|
|
53
|
-
*/
|
|
54
|
-
export function isCostZeroingEnabled(storage) {
|
|
55
|
-
return storage.costZeroing?.enabled !== false;
|
|
56
|
-
}
|
|
57
|
-
const DEFAULT_FALLBACK_ON = [401, 403, 429];
|
|
58
|
-
const MIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240;
|
|
59
|
-
const DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_REFRESH_BEFORE_EXPIRY_MINUTES;
|
|
60
|
-
const DEFAULT_REFRESH_INTERVAL_MINUTES = 10;
|
|
61
|
-
const MIN_REFRESH_RETRY_DELAY_MS = 5 * 60_000;
|
|
62
|
-
const MAX_REFRESH_RETRY_DELAY_MS = 60 * 60_000;
|
|
63
|
-
const NON_TRANSIENT_REFRESH_RETRY_DELAY_MS = 24 * 60 * 60_000;
|
|
64
|
-
const MIN_QUOTA_RETRY_DELAY_MS = 60_000;
|
|
65
|
-
const MAX_QUOTA_RETRY_DELAY_MS = 15 * 60_000;
|
|
66
|
-
const NON_TRANSIENT_QUOTA_RETRY_DELAY_MS = 5 * 60_000;
|
|
67
|
-
const DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES = 5;
|
|
68
|
-
const DEFAULT_MINIMUM_REMAINING = {
|
|
69
|
-
five_hour: 0,
|
|
70
|
-
seven_day: 0,
|
|
71
|
-
};
|
|
72
|
-
const DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA = true;
|
|
73
|
-
export const FALLBACK_BACKGROUND_TICK_MS = 60_000;
|
|
74
|
-
const BACKGROUND_TICK_JITTER_MS = 60_000;
|
|
75
|
-
const FALLBACK_REFRESH_LOCK_TTL_MS = 10 * 60_000;
|
|
76
|
-
const FALLBACK_REFRESH_JOIN_WAIT_MS = 10_000;
|
|
77
|
-
const FALLBACK_REFRESH_JOIN_POLL_MS = 100;
|
|
78
|
-
function getConfigDir() {
|
|
79
|
-
if (process.env.OPENCODE_CONFIG_DIR?.trim()) {
|
|
80
|
-
return process.env.OPENCODE_CONFIG_DIR.trim();
|
|
81
|
-
}
|
|
82
|
-
return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'opencode');
|
|
83
|
-
}
|
|
84
|
-
export function getAccountStoragePath() {
|
|
85
|
-
return (process.env.OPENCODE_ANTHROPIC_AUTH_FILE?.trim() ||
|
|
86
|
-
join(getConfigDir(), ACCOUNT_FILE_NAME));
|
|
87
|
-
}
|
|
88
|
-
export function getAccountStatePath(configPath = getAccountStoragePath()) {
|
|
89
|
-
const explicit = process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE?.trim();
|
|
90
|
-
if (explicit)
|
|
91
|
-
return explicit;
|
|
92
|
-
return configPath.endsWith(ACCOUNT_FILE_NAME)
|
|
93
|
-
? join(dirname(configPath), ACCOUNT_STATE_FILE_NAME)
|
|
94
|
-
: `${configPath}.state.json`;
|
|
95
|
-
}
|
|
96
|
-
function isRecord(value) {
|
|
97
|
-
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
98
|
-
}
|
|
99
|
-
function normalizeAccountBase(value) {
|
|
100
|
-
return {
|
|
101
|
-
id: typeof value.id === 'string' && value.id.trim()
|
|
102
|
-
? value.id.trim()
|
|
103
|
-
: randomUUID(),
|
|
104
|
-
label: typeof value.label === 'string' ? value.label : undefined,
|
|
105
|
-
enabled: typeof value.enabled === 'boolean' ? value.enabled : undefined,
|
|
106
|
-
addedAt: typeof value.addedAt === 'number' ? value.addedAt : undefined,
|
|
107
|
-
lastUsed: typeof value.lastUsed === 'number' ? value.lastUsed : undefined,
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
function normalizeAccount(value) {
|
|
111
|
-
if (!isRecord(value))
|
|
112
|
-
return null;
|
|
113
|
-
if (value.type === 'api') {
|
|
114
|
-
const baseURL = typeof value.baseURL === 'string' ? value.baseURL.trim() : '';
|
|
115
|
-
const apiKey = typeof value.apiKey === 'string' ? value.apiKey.trim() : '';
|
|
116
|
-
if (!isValidApiBaseURL(baseURL))
|
|
117
|
-
return null;
|
|
118
|
-
const authHeader = value.authHeader === 'x-api-key' ? 'x-api-key' : 'authorization-bearer';
|
|
119
|
-
return {
|
|
120
|
-
...normalizeAccountBase(value),
|
|
121
|
-
type: 'api',
|
|
122
|
-
apiKey: apiKey || undefined,
|
|
123
|
-
baseURL,
|
|
124
|
-
authHeader,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
if (value.type !== 'oauth')
|
|
128
|
-
return null;
|
|
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)
|
|
142
|
-
return null;
|
|
143
|
-
return {
|
|
144
|
-
...normalizeAccountBase(value),
|
|
145
|
-
type: 'oauth',
|
|
146
|
-
authLineageId: typeof value.authLineageId === 'string' && value.authLineageId.trim()
|
|
147
|
-
? value.authLineageId
|
|
148
|
-
: undefined,
|
|
149
|
-
anthropicAccountUuid: typeof value.anthropicAccountUuid === 'string' &&
|
|
150
|
-
value.anthropicAccountUuid.trim()
|
|
151
|
-
? value.anthropicAccountUuid.trim()
|
|
152
|
-
: undefined,
|
|
153
|
-
claustrumHandle: typeof value.claustrumHandle === 'string' && value.claustrumHandle.trim()
|
|
154
|
-
? value.claustrumHandle.trim()
|
|
155
|
-
: undefined,
|
|
156
|
-
claustrumScopedCredentialId: typeof value.claustrumScopedCredentialId === 'string' &&
|
|
157
|
-
value.claustrumScopedCredentialId.trim()
|
|
158
|
-
? value.claustrumScopedCredentialId.trim()
|
|
159
|
-
: undefined,
|
|
160
|
-
claustrumScopedState: typeof value.claustrumScopedState === 'string' &&
|
|
161
|
-
value.claustrumScopedState.trim()
|
|
162
|
-
? value.claustrumScopedState.trim()
|
|
163
|
-
: undefined,
|
|
164
|
-
access: typeof value.access === 'string' ? value.access : undefined,
|
|
165
|
-
refresh,
|
|
166
|
-
expires: typeof value.expires === 'number' ? value.expires : undefined,
|
|
167
|
-
lastRefreshedAt: typeof value.lastRefreshedAt === 'number'
|
|
168
|
-
? value.lastRefreshedAt
|
|
169
|
-
: undefined,
|
|
170
|
-
lastRefreshError: normalizeOperationError(value.lastRefreshError),
|
|
171
|
-
lastQuotaRefreshError: normalizeOperationError(value.lastQuotaRefreshError),
|
|
172
|
-
quota: normalizeQuota(value.quota),
|
|
173
|
-
profile: normalizeOAuthAccountProfile(value.profile),
|
|
174
|
-
prime: normalizePrimeUsageCounters(value.prime),
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
function normalizeOAuthAccountProfile(value) {
|
|
178
|
-
if (!isRecord(value))
|
|
179
|
-
return undefined;
|
|
180
|
-
if (typeof value.tier !== 'string' ||
|
|
181
|
-
!value.tier.trim() ||
|
|
182
|
-
typeof value.orgType !== 'string' ||
|
|
183
|
-
!value.orgType.trim() ||
|
|
184
|
-
typeof value.checkedAt !== 'number' ||
|
|
185
|
-
!Number.isFinite(value.checkedAt)) {
|
|
186
|
-
return undefined;
|
|
187
|
-
}
|
|
188
|
-
return {
|
|
189
|
-
tier: value.tier.trim(),
|
|
190
|
-
orgType: value.orgType.trim(),
|
|
191
|
-
checkedAt: value.checkedAt,
|
|
192
|
-
...(typeof value.accountIdentity === 'string' &&
|
|
193
|
-
value.accountIdentity.trim() && {
|
|
194
|
-
accountIdentity: value.accountIdentity.trim(),
|
|
195
|
-
}),
|
|
196
|
-
...(typeof value.providerAccountUuid === 'string' &&
|
|
197
|
-
value.providerAccountUuid.trim() && {
|
|
198
|
-
providerAccountUuid: value.providerAccountUuid.trim(),
|
|
199
|
-
}),
|
|
200
|
-
...(typeof value.tokenFingerprint === 'string' &&
|
|
201
|
-
value.tokenFingerprint.trim() && {
|
|
202
|
-
tokenFingerprint: value.tokenFingerprint.trim(),
|
|
203
|
-
}),
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
function normalizeOperationError(value) {
|
|
207
|
-
if (!isRecord(value))
|
|
208
|
-
return undefined;
|
|
209
|
-
if (typeof value.message !== 'string')
|
|
210
|
-
return undefined;
|
|
211
|
-
const checkedAt = Number(value.checkedAt);
|
|
212
|
-
if (!Number.isFinite(checkedAt))
|
|
213
|
-
return undefined;
|
|
214
|
-
const nextRetryAt = Number(value.nextRetryAt);
|
|
215
|
-
const retryCount = Number(value.retryCount);
|
|
216
|
-
const status = Number(value.status);
|
|
217
|
-
return {
|
|
218
|
-
message: value.message,
|
|
219
|
-
checkedAt,
|
|
220
|
-
nextRetryAt: Number.isFinite(nextRetryAt) ? nextRetryAt : undefined,
|
|
221
|
-
retryCount: Number.isFinite(retryCount) ? retryCount : undefined,
|
|
222
|
-
accountIdentity: typeof value.accountIdentity === 'string' && value.accountIdentity.trim()
|
|
223
|
-
? value.accountIdentity.trim()
|
|
224
|
-
: undefined,
|
|
225
|
-
tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : undefined,
|
|
226
|
-
refreshTokenFingerprint: typeof value.refreshTokenFingerprint === 'string' &&
|
|
227
|
-
value.refreshTokenFingerprint.trim()
|
|
228
|
-
? value.refreshTokenFingerprint.trim()
|
|
229
|
-
: undefined,
|
|
230
|
-
// Preserve the dead-token discriminators across save/load. Without these,
|
|
231
|
-
// a retry-exhausted transient (permanent=false, 24h backoff) would lose its
|
|
232
|
-
// flag on reload and the 24h-delay heuristic would wrongly re-classify it
|
|
233
|
-
// permanent → false "needs re-login" nag.
|
|
234
|
-
status: Number.isFinite(status) ? status : undefined,
|
|
235
|
-
permanent: typeof value.permanent === 'boolean' ? value.permanent : undefined,
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
function normalizeQuotaWindow(value) {
|
|
239
|
-
if (!isRecord(value))
|
|
240
|
-
return undefined;
|
|
241
|
-
const usedPercent = Number(value.usedPercent);
|
|
242
|
-
const remainingPercent = Number(value.remainingPercent);
|
|
243
|
-
const checkedAt = Number(value.checkedAt);
|
|
244
|
-
if (!Number.isFinite(usedPercent) ||
|
|
245
|
-
!Number.isFinite(remainingPercent) ||
|
|
246
|
-
!Number.isFinite(checkedAt)) {
|
|
247
|
-
return undefined;
|
|
248
|
-
}
|
|
249
|
-
return {
|
|
250
|
-
usedPercent,
|
|
251
|
-
remainingPercent,
|
|
252
|
-
checkedAt,
|
|
253
|
-
resetsAt: typeof value.resetsAt === 'string' ? value.resetsAt : undefined,
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
function normalizePrimeUsageCounters(value) {
|
|
257
|
-
if (!isRecord(value))
|
|
258
|
-
return undefined;
|
|
259
|
-
const count = Number(value.count);
|
|
260
|
-
const inputTokens = Number(value.inputTokens);
|
|
261
|
-
const outputTokens = Number(value.outputTokens);
|
|
262
|
-
const since = Number(value.since);
|
|
263
|
-
if (![count, inputTokens, outputTokens, since].every(Number.isFinite) ||
|
|
264
|
-
count < 0 ||
|
|
265
|
-
inputTokens < 0 ||
|
|
266
|
-
outputTokens < 0 ||
|
|
267
|
-
since < 0) {
|
|
268
|
-
return undefined;
|
|
269
|
-
}
|
|
270
|
-
return {
|
|
271
|
-
count: Math.floor(count),
|
|
272
|
-
inputTokens: Math.floor(inputTokens),
|
|
273
|
-
outputTokens: Math.floor(outputTokens),
|
|
274
|
-
since: Math.floor(since),
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
function normalizeQuota(value) {
|
|
278
|
-
if (!isRecord(value))
|
|
279
|
-
return undefined;
|
|
280
|
-
const quota = {};
|
|
281
|
-
for (const key of ['five_hour', 'seven_day']) {
|
|
282
|
-
const normalized = normalizeQuotaWindow(value[key]);
|
|
283
|
-
if (normalized)
|
|
284
|
-
quota[key] = normalized;
|
|
285
|
-
}
|
|
286
|
-
// Persist a top-level snapshot checkedAt through normalize so the
|
|
287
|
-
// mergeAccountRuntimeState freshness comparison stays meaningful when the
|
|
288
|
-
// snapshot has no per-window checkedAt (e.g. {scoped:[]}). Pre-feature
|
|
289
|
-
// inputs without this key are unaffected — only on-disk snapshots that
|
|
290
|
-
// already carry it reach this branch.
|
|
291
|
-
if (typeof value.checkedAt === 'number' && Number.isFinite(value.checkedAt)) {
|
|
292
|
-
quota.checkedAt = value.checkedAt;
|
|
293
|
-
}
|
|
294
|
-
if (typeof value.accountIdentity === 'string' &&
|
|
295
|
-
value.accountIdentity.trim()) {
|
|
296
|
-
quota.accountIdentity = value.accountIdentity.trim();
|
|
297
|
-
}
|
|
298
|
-
if (Array.isArray(value.scoped)) {
|
|
299
|
-
const scoped = value.scoped
|
|
300
|
-
.map((entry) => {
|
|
301
|
-
if (!isRecord(entry))
|
|
302
|
-
return undefined;
|
|
303
|
-
const window = normalizeQuotaWindow(entry);
|
|
304
|
-
if (!window)
|
|
305
|
-
return undefined;
|
|
306
|
-
if (typeof entry.id !== 'string' || !entry.id.trim())
|
|
307
|
-
return undefined;
|
|
308
|
-
if (typeof entry.title !== 'string' || !entry.title.trim()) {
|
|
309
|
-
return undefined;
|
|
310
|
-
}
|
|
311
|
-
if (typeof entry.modelName !== 'string' || !entry.modelName.trim()) {
|
|
312
|
-
return undefined;
|
|
313
|
-
}
|
|
314
|
-
const modelId = typeof entry.modelId === 'string' && entry.modelId.trim()
|
|
315
|
-
? entry.modelId.trim()
|
|
316
|
-
: undefined;
|
|
317
|
-
return {
|
|
318
|
-
...window,
|
|
319
|
-
id: entry.id.trim(),
|
|
320
|
-
title: entry.title.trim(),
|
|
321
|
-
...(modelId && { modelId }),
|
|
322
|
-
modelName: entry.modelName.trim(),
|
|
323
|
-
};
|
|
324
|
-
})
|
|
325
|
-
.filter((entry) => entry != null);
|
|
326
|
-
// Preserve empty `[]` so a downstream reader can distinguish "scoped
|
|
327
|
-
// owned by anthropic-auth, none visible" from "no scoped data on this
|
|
328
|
-
// snapshot". Pre-feature inputs without a `scoped` key are not affected
|
|
329
|
-
// — only inputs that already carried an array reach this line.
|
|
330
|
-
quota.scoped = scoped;
|
|
331
|
-
}
|
|
332
|
-
if (isRecord(value.extraUsage)) {
|
|
333
|
-
const used = normalizeQuotaMoney(value.extraUsage.used);
|
|
334
|
-
const limit = normalizeQuotaMoney(value.extraUsage.limit);
|
|
335
|
-
if (used && limit && typeof value.extraUsage.exhausted === 'boolean') {
|
|
336
|
-
quota.extraUsage = {
|
|
337
|
-
used,
|
|
338
|
-
limit,
|
|
339
|
-
...(typeof value.extraUsage.utilizationPercent === 'number' &&
|
|
340
|
-
Number.isFinite(value.extraUsage.utilizationPercent) && {
|
|
341
|
-
utilizationPercent: value.extraUsage.utilizationPercent,
|
|
342
|
-
}),
|
|
343
|
-
...(typeof value.extraUsage.severity === 'string' && {
|
|
344
|
-
severity: value.extraUsage.severity,
|
|
345
|
-
}),
|
|
346
|
-
exhausted: value.extraUsage.exhausted,
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
if (typeof value.bindingWindow === 'string' && value.bindingWindow.trim()) {
|
|
351
|
-
quota.bindingWindow = value.bindingWindow.trim();
|
|
352
|
-
}
|
|
353
|
-
if (value.bindingWindowSource === 'poll' ||
|
|
354
|
-
value.bindingWindowSource === 'headers') {
|
|
355
|
-
quota.bindingWindowSource = value.bindingWindowSource;
|
|
356
|
-
}
|
|
357
|
-
if (typeof value.fallbackAdvised === 'boolean') {
|
|
358
|
-
quota.fallbackAdvised = value.fallbackAdvised;
|
|
359
|
-
}
|
|
360
|
-
if (value.source === 'poll' || value.source === 'headers') {
|
|
361
|
-
quota.source = value.source;
|
|
362
|
-
}
|
|
363
|
-
if (isRecord(value.fieldSources)) {
|
|
364
|
-
const fieldSources = {};
|
|
365
|
-
for (const field of QUOTA_FIELD_NAMES) {
|
|
366
|
-
if (quota[field] === undefined)
|
|
367
|
-
continue;
|
|
368
|
-
const source = value.fieldSources[field];
|
|
369
|
-
if (source === 'poll' || source === 'headers') {
|
|
370
|
-
fieldSources[field] = source;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
if (Object.keys(fieldSources).length > 0) {
|
|
374
|
-
quota.fieldSources = fieldSources;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
return Object.keys(quota).length ? quota : undefined;
|
|
378
|
-
}
|
|
379
|
-
function normalizeQuotaMoney(value) {
|
|
380
|
-
if (!isRecord(value))
|
|
381
|
-
return undefined;
|
|
382
|
-
if (typeof value.amountMinor !== 'number' ||
|
|
383
|
-
!Number.isFinite(value.amountMinor) ||
|
|
384
|
-
typeof value.currency !== 'string' ||
|
|
385
|
-
!value.currency.trim() ||
|
|
386
|
-
typeof value.exponent !== 'number' ||
|
|
387
|
-
!Number.isFinite(value.exponent)) {
|
|
388
|
-
return undefined;
|
|
389
|
-
}
|
|
390
|
-
return {
|
|
391
|
-
amountMinor: value.amountMinor,
|
|
392
|
-
currency: value.currency.trim(),
|
|
393
|
-
exponent: value.exponent,
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
// Fresh empty storage shell — main OpenCode OAuth account, no fallback
|
|
397
|
-
// accounts. Returns a new object each call so mutating callers don't alias.
|
|
398
|
-
export function createEmptyStorage() {
|
|
399
|
-
return {
|
|
400
|
-
version: 1,
|
|
401
|
-
main: { type: 'opencode', provider: 'anthropic' },
|
|
402
|
-
accounts: [],
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
function normalizeStorage(value) {
|
|
406
|
-
if (!isRecord(value) || !Array.isArray(value.accounts))
|
|
407
|
-
return null;
|
|
408
|
-
return {
|
|
409
|
-
version: 1,
|
|
410
|
-
mainAccountId: typeof value.mainAccountId === 'string' && value.mainAccountId.trim()
|
|
411
|
-
? value.mainAccountId.trim()
|
|
412
|
-
: undefined,
|
|
413
|
-
main: {
|
|
414
|
-
type: 'opencode',
|
|
415
|
-
provider: 'anthropic',
|
|
416
|
-
profile: normalizeOAuthAccountProfile(isRecord(value.main) ? value.main.profile : undefined),
|
|
417
|
-
},
|
|
418
|
-
routing: isRecord(value.routing) ? value.routing : undefined,
|
|
419
|
-
fallbackOn: Array.isArray(value.fallbackOn)
|
|
420
|
-
? value.fallbackOn.filter((status) => Number.isInteger(status))
|
|
421
|
-
: undefined,
|
|
422
|
-
refresh: isRecord(value.refresh) ? value.refresh : undefined,
|
|
423
|
-
quota: isRecord(value.quota) ? value.quota : undefined,
|
|
424
|
-
quotaHeaderFeed: isRecord(value.quotaHeaderFeed)
|
|
425
|
-
? value.quotaHeaderFeed
|
|
426
|
-
: undefined,
|
|
427
|
-
claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined,
|
|
428
|
-
dump: isRecord(value.dump) ? value.dump : undefined,
|
|
429
|
-
claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
|
|
430
|
-
thinkingBinding: isRecord(value.thinkingBinding)
|
|
431
|
-
? value.thinkingBinding
|
|
432
|
-
: undefined,
|
|
433
|
-
costZeroing: isRecord(value.costZeroing) ? value.costZeroing : undefined,
|
|
434
|
-
cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
|
|
435
|
-
relay: isRecord(value.relay) ? value.relay : undefined,
|
|
436
|
-
claustrum: normalizeClaustrumConfig(value.claustrum),
|
|
437
|
-
logging: isRecord(value.logging) ? value.logging : undefined,
|
|
438
|
-
killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
|
|
439
|
-
prime: (() => {
|
|
440
|
-
if (!isRecord(value.prime))
|
|
441
|
-
return undefined;
|
|
442
|
-
const enabled = typeof value.prime.enabled === 'boolean'
|
|
443
|
-
? value.prime.enabled
|
|
444
|
-
: undefined;
|
|
445
|
-
const main = normalizePrimeUsageCounters(value.prime.main);
|
|
446
|
-
const mainAuthLineageId = typeof value.prime.mainAuthLineageId === 'string' &&
|
|
447
|
-
value.prime.mainAuthLineageId.trim()
|
|
448
|
-
? value.prime.mainAuthLineageId
|
|
449
|
-
: undefined;
|
|
450
|
-
const mainAuthLineageRefreshTokenFingerprint = typeof value.prime.mainAuthLineageRefreshTokenFingerprint ===
|
|
451
|
-
'string' && value.prime.mainAuthLineageRefreshTokenFingerprint.trim()
|
|
452
|
-
? value.prime.mainAuthLineageRefreshTokenFingerprint
|
|
453
|
-
: undefined;
|
|
454
|
-
if (enabled === undefined &&
|
|
455
|
-
!main &&
|
|
456
|
-
!mainAuthLineageId &&
|
|
457
|
-
!mainAuthLineageRefreshTokenFingerprint)
|
|
458
|
-
return undefined;
|
|
459
|
-
return {
|
|
460
|
-
...(enabled !== undefined && { enabled }),
|
|
461
|
-
...(main && { main }),
|
|
462
|
-
...(mainAuthLineageId && { mainAuthLineageId }),
|
|
463
|
-
...(mainAuthLineageRefreshTokenFingerprint && {
|
|
464
|
-
mainAuthLineageRefreshTokenFingerprint,
|
|
465
|
-
}),
|
|
466
|
-
};
|
|
467
|
-
})(),
|
|
468
|
-
accounts: value.accounts
|
|
469
|
-
.map(normalizeAccount)
|
|
470
|
-
.filter((account) => account != null),
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
function normalizeClaustrumConfig(value) {
|
|
474
|
-
if (!isRecord(value))
|
|
475
|
-
return undefined;
|
|
476
|
-
const mode = value.mode === 'local' || value.mode === 'claustrum'
|
|
477
|
-
? value.mode
|
|
478
|
-
: undefined;
|
|
479
|
-
const handlesFile = typeof value.handlesFile === 'string' && value.handlesFile.trim()
|
|
480
|
-
? value.handlesFile.trim()
|
|
481
|
-
: undefined;
|
|
482
|
-
const primary = isRecord(value.primaryAccount)
|
|
483
|
-
? value.primaryAccount
|
|
484
|
-
: undefined;
|
|
485
|
-
const primaryAccount = primary &&
|
|
486
|
-
typeof primary.credentialId === 'string' &&
|
|
487
|
-
primary.credentialId.trim() &&
|
|
488
|
-
typeof primary.accountId === 'string' &&
|
|
489
|
-
primary.accountId.trim() &&
|
|
490
|
-
typeof primary.state === 'string'
|
|
491
|
-
? {
|
|
492
|
-
credentialId: primary.credentialId.trim(),
|
|
493
|
-
accountId: primary.accountId.trim(),
|
|
494
|
-
state: primary.state,
|
|
495
|
-
}
|
|
496
|
-
: undefined;
|
|
497
|
-
const scopedRoster = value.scopedRoster === true;
|
|
498
|
-
const disabledAccountIdentities = Array.isArray(value.disabledAccountIdentities)
|
|
499
|
-
? [
|
|
500
|
-
...new Set(value.disabledAccountIdentities
|
|
501
|
-
.filter((id) => typeof id === 'string' && id.trim().length > 0)
|
|
502
|
-
.map((id) => id.trim())),
|
|
503
|
-
]
|
|
504
|
-
: [];
|
|
505
|
-
const accounts = isRecord(value.accounts)
|
|
506
|
-
? Object.fromEntries(Object.entries(value.accounts).flatMap(([id, entry]) => {
|
|
507
|
-
if (!isRecord(entry))
|
|
508
|
-
return [];
|
|
509
|
-
return [
|
|
510
|
-
[
|
|
511
|
-
id,
|
|
512
|
-
{
|
|
513
|
-
...(typeof entry.enabled === 'boolean' && {
|
|
514
|
-
enabled: entry.enabled,
|
|
515
|
-
}),
|
|
516
|
-
},
|
|
517
|
-
],
|
|
518
|
-
];
|
|
519
|
-
}))
|
|
520
|
-
: undefined;
|
|
521
|
-
if (!mode &&
|
|
522
|
-
!handlesFile &&
|
|
523
|
-
!scopedRoster &&
|
|
524
|
-
disabledAccountIdentities.length === 0 &&
|
|
525
|
-
(!accounts || Object.keys(accounts).length === 0)) {
|
|
526
|
-
return undefined;
|
|
527
|
-
}
|
|
528
|
-
return {
|
|
529
|
-
...(mode && { mode }),
|
|
530
|
-
...(scopedRoster && { scopedRoster: true }),
|
|
531
|
-
...(primaryAccount && { primaryAccount }),
|
|
532
|
-
...(disabledAccountIdentities.length > 0 && { disabledAccountIdentities }),
|
|
533
|
-
...(handlesFile && { handlesFile }),
|
|
534
|
-
...(accounts && Object.keys(accounts).length > 0 && { accounts }),
|
|
535
|
-
};
|
|
536
|
-
}
|
|
537
|
-
async function readJsonIfPresent(path) {
|
|
538
|
-
try {
|
|
539
|
-
return {
|
|
540
|
-
exists: true,
|
|
541
|
-
value: parseJsonRedacted(await readFile(path, 'utf8')),
|
|
542
|
-
};
|
|
543
|
-
}
|
|
544
|
-
catch (error) {
|
|
545
|
-
if (error.code === 'ENOENT') {
|
|
546
|
-
return { exists: false, value: null };
|
|
547
|
-
}
|
|
548
|
-
const cause = error instanceof Error ? error.message : String(error);
|
|
549
|
-
throw new Error(`account store at ${path} is corrupt or unreadable (${cause}) — fix or remove it`);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
function objectWithDefinedEntries(value) {
|
|
553
|
-
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
554
|
-
}
|
|
555
|
-
function numericField(value) {
|
|
556
|
-
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
557
|
-
}
|
|
558
|
-
// A clear is a monotonic tombstone; older or absent writes must not reopen backoff.
|
|
559
|
-
export function mergeMainQuotaErrorClearedAt(existing, incoming) {
|
|
560
|
-
if (incoming === undefined || !Number.isFinite(incoming))
|
|
561
|
-
return existing;
|
|
562
|
-
if (existing === undefined || !Number.isFinite(existing))
|
|
563
|
-
return incoming;
|
|
564
|
-
return Math.max(existing, incoming);
|
|
565
|
-
}
|
|
566
|
-
export function mergeMainRefreshErrorClearedAt(existing, incoming) {
|
|
567
|
-
if (incoming === undefined || !Number.isFinite(incoming))
|
|
568
|
-
return existing;
|
|
569
|
-
if (existing === undefined || !Number.isFinite(existing))
|
|
570
|
-
return incoming;
|
|
571
|
-
return Math.max(existing, incoming);
|
|
572
|
-
}
|
|
573
|
-
function accountCredentialTimestamp(value) {
|
|
574
|
-
return Math.max(numericField(value.lastRefreshedAt), numericField(value.lastUsed), numericField(value.addedAt));
|
|
575
|
-
}
|
|
576
|
-
function legacyConfigCredentialsAreNewer(account, stateAccount) {
|
|
577
|
-
if (account.type !== 'oauth')
|
|
578
|
-
return false;
|
|
579
|
-
if (typeof account.refresh !== 'string' || !account.refresh.trim()) {
|
|
580
|
-
return false;
|
|
581
|
-
}
|
|
582
|
-
const tokenChanged = Boolean((typeof account.access === 'string' &&
|
|
583
|
-
typeof stateAccount.access === 'string' &&
|
|
584
|
-
account.access !== stateAccount.access) ||
|
|
585
|
-
(typeof account.refresh === 'string' &&
|
|
586
|
-
typeof stateAccount.refresh === 'string' &&
|
|
587
|
-
account.refresh !== stateAccount.refresh));
|
|
588
|
-
if (!tokenChanged)
|
|
589
|
-
return false;
|
|
590
|
-
return (accountCredentialTimestamp(account) >
|
|
591
|
-
accountCredentialTimestamp(stateAccount));
|
|
592
|
-
}
|
|
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
|
-
}
|
|
611
|
-
if (legacyConfigCredentialsAreNewer(account, stateAccount)) {
|
|
612
|
-
const merged = { ...stateAccount, ...account };
|
|
613
|
-
const configTimestamp = accountCredentialTimestamp(account);
|
|
614
|
-
if (configTimestamp > numericField(merged.lastRefreshedAt)) {
|
|
615
|
-
merged.lastRefreshedAt = configTimestamp;
|
|
616
|
-
}
|
|
617
|
-
delete merged.quota;
|
|
618
|
-
delete merged.lastRefreshError;
|
|
619
|
-
delete merged.lastQuotaRefreshError;
|
|
620
|
-
return merged;
|
|
621
|
-
}
|
|
622
|
-
return { ...account, ...stateAccount };
|
|
623
|
-
}
|
|
624
|
-
function configAccountHasInvalidShape(account) {
|
|
625
|
-
if (account.type !== 'api' && account.type !== 'oauth')
|
|
626
|
-
return true;
|
|
627
|
-
if (account.type === 'api' &&
|
|
628
|
-
'baseURL' in account &&
|
|
629
|
-
(typeof account.baseURL !== 'string' || !isValidApiBaseURL(account.baseURL))) {
|
|
630
|
-
return true;
|
|
631
|
-
}
|
|
632
|
-
if (account.type === 'oauth' &&
|
|
633
|
-
'refresh' in account &&
|
|
634
|
-
(typeof account.refresh !== 'string' || !account.refresh.trim())) {
|
|
635
|
-
return true;
|
|
636
|
-
}
|
|
637
|
-
return false;
|
|
638
|
-
}
|
|
639
|
-
function mergeConfigAndState(configValue, stateValue) {
|
|
640
|
-
if (!isRecord(configValue))
|
|
641
|
-
return configValue;
|
|
642
|
-
const state = isRecord(stateValue) ? stateValue : {};
|
|
643
|
-
const mainState = isRecord(state.main) ? state.main : undefined;
|
|
644
|
-
const stateAccounts = isRecord(state.accounts) ? state.accounts : {};
|
|
645
|
-
const quotaConfig = isRecord(configValue.quota) ? configValue.quota : {};
|
|
646
|
-
const refreshConfig = isRecord(configValue.refresh) ? configValue.refresh : {};
|
|
647
|
-
const mainQuotaSource = mainState ?? quotaConfig;
|
|
648
|
-
const mainRefreshSource = mainState ?? refreshConfig;
|
|
649
|
-
const mainQuotaErrorClearedAt = typeof mainState?.quotaErrorClearedAt === 'number' &&
|
|
650
|
-
Number.isFinite(mainState.quotaErrorClearedAt) &&
|
|
651
|
-
mainState.quotaErrorClearedAt >= 0
|
|
652
|
-
? mainState.quotaErrorClearedAt
|
|
653
|
-
: undefined;
|
|
654
|
-
const mainRefreshErrorClearedAt = typeof mainState?.refreshErrorClearedAt === 'number' &&
|
|
655
|
-
Number.isFinite(mainState.refreshErrorClearedAt) &&
|
|
656
|
-
mainState.refreshErrorClearedAt >= 0
|
|
657
|
-
? mainState.refreshErrorClearedAt
|
|
658
|
-
: undefined;
|
|
659
|
-
const configQuotaError = quotaConfig.mainLastQuotaApiError;
|
|
660
|
-
const mainLastQuotaApiError = mainState?.lastQuotaApiError ??
|
|
661
|
-
(mainQuotaErrorClearedAt !== undefined &&
|
|
662
|
-
isRecord(configQuotaError) &&
|
|
663
|
-
typeof configQuotaError.checkedAt === 'number' &&
|
|
664
|
-
configQuotaError.checkedAt <= mainQuotaErrorClearedAt
|
|
665
|
-
? undefined
|
|
666
|
-
: configQuotaError);
|
|
667
|
-
const configRefreshError = refreshConfig.mainLastRefreshError;
|
|
668
|
-
const mainLastRefreshError = mainState?.lastRefreshError ??
|
|
669
|
-
(mainRefreshErrorClearedAt !== undefined &&
|
|
670
|
-
isRecord(configRefreshError) &&
|
|
671
|
-
typeof configRefreshError.checkedAt === 'number' &&
|
|
672
|
-
configRefreshError.checkedAt <= mainRefreshErrorClearedAt
|
|
673
|
-
? undefined
|
|
674
|
-
: configRefreshError);
|
|
675
|
-
const accounts = Array.isArray(configValue.accounts)
|
|
676
|
-
? configValue.accounts.map((account) => {
|
|
677
|
-
if (!isRecord(account))
|
|
678
|
-
return account;
|
|
679
|
-
const rawId = typeof account.id === 'string' ? account.id : undefined;
|
|
680
|
-
const stateValue = rawId
|
|
681
|
-
? (stateAccounts[rawId] ?? stateAccounts[rawId.trim()])
|
|
682
|
-
: undefined;
|
|
683
|
-
const stateAccount = isRecord(stateValue)
|
|
684
|
-
? stateValue
|
|
685
|
-
: {};
|
|
686
|
-
return mergeConfigAccountAndState(account, stateAccount);
|
|
687
|
-
})
|
|
688
|
-
: [];
|
|
689
|
-
return {
|
|
690
|
-
...configValue,
|
|
691
|
-
main: {
|
|
692
|
-
type: 'opencode',
|
|
693
|
-
provider: 'anthropic',
|
|
694
|
-
profile: normalizeOAuthAccountProfile(mainState?.profile),
|
|
695
|
-
},
|
|
696
|
-
refresh: objectWithDefinedEntries({
|
|
697
|
-
...refreshConfig,
|
|
698
|
-
mainLastRefreshError,
|
|
699
|
-
mainRefreshErrorClearedAt: mainRefreshSource.refreshErrorClearedAt,
|
|
700
|
-
mainRefreshLeaseId: mainRefreshSource.refreshLeaseId,
|
|
701
|
-
mainRefreshLeaseUntil: mainRefreshSource.refreshLeaseUntil,
|
|
702
|
-
mainRefreshLeaseTokenHash: mainRefreshSource.refreshLeaseTokenHash,
|
|
703
|
-
}),
|
|
704
|
-
quota: objectWithDefinedEntries({
|
|
705
|
-
...quotaConfig,
|
|
706
|
-
mainQuota: mainQuotaSource.quota,
|
|
707
|
-
mainQuotaCheckedAt: mainQuotaSource.quotaCheckedAt,
|
|
708
|
-
mainQuotaToken: mainQuotaSource.quotaToken,
|
|
709
|
-
mainLastQuotaApiError,
|
|
710
|
-
mainQuotaErrorGeneration: mainQuotaSource.quotaErrorGeneration,
|
|
711
|
-
mainQuotaErrorClearedAt: mainQuotaSource.quotaErrorClearedAt,
|
|
712
|
-
}),
|
|
713
|
-
// Carry the main-side prime counters from the state file back into the
|
|
714
|
-
// merged storage so a subsequent read sees the cumulative counters. The
|
|
715
|
-
// `enabled` flag stays sourced from the config side; main-side counters
|
|
716
|
-
// live exclusively on the state file.
|
|
717
|
-
prime: (() => {
|
|
718
|
-
const configPrime = isRecord(configValue.prime)
|
|
719
|
-
? configValue.prime
|
|
720
|
-
: undefined;
|
|
721
|
-
const mainCounters = normalizePrimeUsageCounters(mainState?.prime);
|
|
722
|
-
const mainAuthLineageId = typeof mainState?.primeAuthLineageId === 'string' &&
|
|
723
|
-
mainState.primeAuthLineageId.trim()
|
|
724
|
-
? mainState.primeAuthLineageId
|
|
725
|
-
: undefined;
|
|
726
|
-
const mainAuthLineageRefreshTokenFingerprint = typeof mainState?.primeAuthLineageRefreshTokenFingerprint ===
|
|
727
|
-
'string' && mainState.primeAuthLineageRefreshTokenFingerprint.trim()
|
|
728
|
-
? mainState.primeAuthLineageRefreshTokenFingerprint
|
|
729
|
-
: undefined;
|
|
730
|
-
if (!configPrime &&
|
|
731
|
-
!mainCounters &&
|
|
732
|
-
!mainAuthLineageId &&
|
|
733
|
-
!mainAuthLineageRefreshTokenFingerprint)
|
|
734
|
-
return undefined;
|
|
735
|
-
return {
|
|
736
|
-
...(configPrime &&
|
|
737
|
-
typeof configPrime.enabled === 'boolean' && {
|
|
738
|
-
enabled: configPrime.enabled,
|
|
739
|
-
}),
|
|
740
|
-
...(mainCounters && { main: mainCounters }),
|
|
741
|
-
...(mainAuthLineageId && { mainAuthLineageId }),
|
|
742
|
-
...(mainAuthLineageRefreshTokenFingerprint && {
|
|
743
|
-
mainAuthLineageRefreshTokenFingerprint,
|
|
744
|
-
}),
|
|
745
|
-
};
|
|
746
|
-
})(),
|
|
747
|
-
accounts,
|
|
748
|
-
};
|
|
749
|
-
}
|
|
750
|
-
export async function loadAccounts(path = getAccountStoragePath()) {
|
|
751
|
-
const config = await readJsonIfPresent(path);
|
|
752
|
-
const state = await readJsonIfPresent(getAccountStatePath(path));
|
|
753
|
-
// Runtime-only flows (main-OAuth refresh with no fallback accounts) write the
|
|
754
|
-
// state file but never the config file, so the store is absent only when
|
|
755
|
-
// neither exists. Synthesize an empty config to merge state into otherwise.
|
|
756
|
-
if (!config.exists && !state.exists)
|
|
757
|
-
return null;
|
|
758
|
-
const configValue = config.exists ? config.value : createEmptyStorage();
|
|
759
|
-
return normalizeStorage(mergeConfigAndState(configValue, state.value));
|
|
760
|
-
}
|
|
761
|
-
async function loadExistingTopLevelFields(path) {
|
|
762
|
-
const existing = await readJsonIfPresent(path);
|
|
763
|
-
return isRecord(existing.value) ? existing.value : {};
|
|
764
|
-
}
|
|
765
|
-
function omitUndefinedTopLevel(value) {
|
|
766
|
-
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
767
|
-
}
|
|
768
|
-
function accountConfig(account) {
|
|
769
|
-
return objectWithDefinedEntries({
|
|
770
|
-
id: account.id,
|
|
771
|
-
label: account.label,
|
|
772
|
-
type: account.type,
|
|
773
|
-
enabled: account.enabled,
|
|
774
|
-
addedAt: account.addedAt,
|
|
775
|
-
baseURL: account.type === 'api' ? account.baseURL : undefined,
|
|
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,
|
|
783
|
-
});
|
|
784
|
-
}
|
|
785
|
-
function accountRuntimeState(account) {
|
|
786
|
-
if (account.type === 'api') {
|
|
787
|
-
return objectWithDefinedEntries({
|
|
788
|
-
apiKey: account.apiKey,
|
|
789
|
-
lastUsed: account.lastUsed,
|
|
790
|
-
});
|
|
791
|
-
}
|
|
792
|
-
return objectWithDefinedEntries({
|
|
793
|
-
authLineageId: account.authLineageId,
|
|
794
|
-
anthropicAccountUuid: account.anthropicAccountUuid,
|
|
795
|
-
claustrumHandle: account.claustrumHandle,
|
|
796
|
-
claustrumScopedCredentialId: account.claustrumScopedCredentialId,
|
|
797
|
-
claustrumScopedState: account.claustrumScopedState,
|
|
798
|
-
access: account.access,
|
|
799
|
-
refresh: account.refresh,
|
|
800
|
-
expires: account.expires,
|
|
801
|
-
lastUsed: account.lastUsed,
|
|
802
|
-
lastRefreshedAt: account.lastRefreshedAt,
|
|
803
|
-
lastRefreshError: account.lastRefreshError,
|
|
804
|
-
lastQuotaRefreshError: account.lastQuotaRefreshError,
|
|
805
|
-
quota: account.quota,
|
|
806
|
-
profile: account.profile,
|
|
807
|
-
prime: account.prime,
|
|
808
|
-
});
|
|
809
|
-
}
|
|
810
|
-
/** Returns the latest timestamp carried by any quota snapshot window. */
|
|
811
|
-
export function quotaSnapshotCheckedAt(quota) {
|
|
812
|
-
return Math.max(quota?.five_hour?.checkedAt ?? 0, quota?.seven_day?.checkedAt ?? 0, ...(quota?.scoped?.map((window) => window.checkedAt) ?? []), quota?.checkedAt ?? 0);
|
|
813
|
-
}
|
|
814
|
-
function quotaSourcePrecedence(quota) {
|
|
815
|
-
if (quota?.source === 'poll')
|
|
816
|
-
return 2;
|
|
817
|
-
if (quota?.source === 'headers')
|
|
818
|
-
return 1;
|
|
819
|
-
return 0;
|
|
820
|
-
}
|
|
821
|
-
function mergeHeaderScopedQuota(existing, incoming) {
|
|
822
|
-
if (!('scoped' in existing))
|
|
823
|
-
return incoming.scoped;
|
|
824
|
-
if (!Array.isArray(existing.scoped) || existing.scoped.length === 0) {
|
|
825
|
-
return existing.scoped;
|
|
826
|
-
}
|
|
827
|
-
if (!Array.isArray(incoming.scoped) || incoming.scoped.length === 0) {
|
|
828
|
-
return existing.scoped;
|
|
829
|
-
}
|
|
830
|
-
const merged = new Map(incoming.scoped.map((window) => [window.id, window]));
|
|
831
|
-
for (const window of existing.scoped) {
|
|
832
|
-
const candidate = merged.get(window.id);
|
|
833
|
-
if (!candidate || window.checkedAt >= candidate.checkedAt) {
|
|
834
|
-
merged.set(window.id, window);
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
return [...merged.values()];
|
|
838
|
-
}
|
|
839
|
-
function mergeHeaderOwnedWindow(existingSnapshot, incomingSnapshot, key) {
|
|
840
|
-
const existing = existingSnapshot[key];
|
|
841
|
-
const incoming = incomingSnapshot[key];
|
|
842
|
-
if (!incoming)
|
|
843
|
-
return existing;
|
|
844
|
-
if (!existing)
|
|
845
|
-
return incoming;
|
|
846
|
-
if (incoming.checkedAt > existing.checkedAt)
|
|
847
|
-
return incoming;
|
|
848
|
-
if (incoming.checkedAt < existing.checkedAt)
|
|
849
|
-
return existing;
|
|
850
|
-
return quotaSourcePrecedence(existingSnapshot) >
|
|
851
|
-
quotaSourcePrecedence(incomingSnapshot)
|
|
852
|
-
? existing
|
|
853
|
-
: incoming;
|
|
854
|
-
}
|
|
855
|
-
export function quotaFieldSource(snapshot, field) {
|
|
856
|
-
if (!snapshot || snapshot[field] === undefined)
|
|
857
|
-
return undefined;
|
|
858
|
-
return (snapshot.fieldSources?.[field] ??
|
|
859
|
-
(snapshot.source === 'poll' || snapshot.source === 'headers'
|
|
860
|
-
? snapshot.source
|
|
861
|
-
: undefined));
|
|
862
|
-
}
|
|
863
|
-
function fieldSourcesForMergedQuota(existing, incoming, merged) {
|
|
864
|
-
const fieldSources = {};
|
|
865
|
-
for (const field of QUOTA_FIELD_NAMES) {
|
|
866
|
-
if (merged[field] === undefined)
|
|
867
|
-
continue;
|
|
868
|
-
if (field === 'scoped' || field === 'extraUsage') {
|
|
869
|
-
fieldSources[field] = 'poll';
|
|
870
|
-
continue;
|
|
871
|
-
}
|
|
872
|
-
if (field === 'bindingWindow' &&
|
|
873
|
-
existing.bindingWindowSource === 'poll' &&
|
|
874
|
-
existing.bindingWindow === merged.bindingWindow) {
|
|
875
|
-
fieldSources[field] = 'poll';
|
|
876
|
-
continue;
|
|
877
|
-
}
|
|
878
|
-
if (field === 'fallbackAdvised' &&
|
|
879
|
-
incoming.fieldSources?.fallbackAdvised === undefined) {
|
|
880
|
-
const source = quotaFieldSource(existing, field);
|
|
881
|
-
if (source)
|
|
882
|
-
fieldSources[field] = source;
|
|
883
|
-
continue;
|
|
884
|
-
}
|
|
885
|
-
const source = incoming[field] === merged[field]
|
|
886
|
-
? quotaFieldSource(incoming, field)
|
|
887
|
-
: existing[field] === merged[field]
|
|
888
|
-
? quotaFieldSource(existing, field)
|
|
889
|
-
: (quotaFieldSource(existing, field) ??
|
|
890
|
-
quotaFieldSource(incoming, field));
|
|
891
|
-
if (source)
|
|
892
|
-
fieldSources[field] = source;
|
|
893
|
-
}
|
|
894
|
-
return Object.keys(fieldSources).length > 0 ? fieldSources : undefined;
|
|
895
|
-
}
|
|
896
|
-
export function mergeHeaderQuotaForPersistence(existing, incoming) {
|
|
897
|
-
if (!existing || incoming.source !== 'headers')
|
|
898
|
-
return incoming;
|
|
899
|
-
const preservePollBinding = existing.bindingWindowSource === 'poll';
|
|
900
|
-
const merged = {
|
|
901
|
-
...existing,
|
|
902
|
-
...incoming,
|
|
903
|
-
five_hour: mergeHeaderOwnedWindow(existing, incoming, 'five_hour'),
|
|
904
|
-
seven_day: mergeHeaderOwnedWindow(existing, incoming, 'seven_day'),
|
|
905
|
-
scoped: mergeHeaderScopedQuota(existing, incoming),
|
|
906
|
-
extraUsage: existing.extraUsage ?? incoming.extraUsage,
|
|
907
|
-
fallbackAdvised: incoming.fieldSources?.fallbackAdvised !== undefined
|
|
908
|
-
? incoming.fallbackAdvised
|
|
909
|
-
: (existing.fallbackAdvised ?? incoming.fallbackAdvised),
|
|
910
|
-
bindingWindow: preservePollBinding
|
|
911
|
-
? existing.bindingWindow
|
|
912
|
-
: (incoming.bindingWindow ?? existing.bindingWindow),
|
|
913
|
-
bindingWindowSource: preservePollBinding
|
|
914
|
-
? 'poll'
|
|
915
|
-
: (incoming.bindingWindowSource ?? existing.bindingWindowSource),
|
|
916
|
-
};
|
|
917
|
-
const fieldSources = fieldSourcesForMergedQuota(existing, incoming, merged);
|
|
918
|
-
return {
|
|
919
|
-
...merged,
|
|
920
|
-
...(fieldSources && { fieldSources }),
|
|
921
|
-
};
|
|
922
|
-
}
|
|
923
|
-
function mergeAccountRuntimeState(existing, incoming, setClaustrumHandle = false) {
|
|
924
|
-
if (!isRecord(existing))
|
|
925
|
-
return incoming;
|
|
926
|
-
const incomingForMerge = { ...incoming };
|
|
927
|
-
if (!setClaustrumHandle)
|
|
928
|
-
delete incomingForMerge.claustrumHandle;
|
|
929
|
-
const existingEntry = existing;
|
|
930
|
-
const tokenChanged = Boolean((existingEntry.access &&
|
|
931
|
-
incomingForMerge.access &&
|
|
932
|
-
existingEntry.access !== incomingForMerge.access) ||
|
|
933
|
-
(existingEntry.refresh &&
|
|
934
|
-
incomingForMerge.refresh &&
|
|
935
|
-
existingEntry.refresh !== incomingForMerge.refresh));
|
|
936
|
-
const mergesHeaderQuota = Boolean(!tokenChanged && incomingForMerge.quota?.source === 'headers');
|
|
937
|
-
const effectiveIncoming = mergesHeaderQuota && incomingForMerge.quota
|
|
938
|
-
? {
|
|
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
|
-
}
|
|
954
|
-
const preferredRefreshError = (() => {
|
|
955
|
-
const existingError = existingEntry.lastRefreshError;
|
|
956
|
-
const incomingError = effectiveIncoming.lastRefreshError;
|
|
957
|
-
if (!existingError)
|
|
958
|
-
return incomingError;
|
|
959
|
-
if (incomingError) {
|
|
960
|
-
return incomingError.checkedAt >= existingError.checkedAt
|
|
961
|
-
? incomingError
|
|
962
|
-
: existingError;
|
|
963
|
-
}
|
|
964
|
-
return (effectiveIncoming.lastRefreshedAt ?? 0) >
|
|
965
|
-
(existingEntry.lastRefreshedAt ?? 0)
|
|
966
|
-
? undefined
|
|
967
|
-
: existingError;
|
|
968
|
-
})();
|
|
969
|
-
const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota);
|
|
970
|
-
const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(effectiveIncoming.quota);
|
|
971
|
-
const existingQuotaWinsEqualTimestamp = Boolean(existingQuotaCheckedAt === incomingQuotaCheckedAt &&
|
|
972
|
-
quotaSourcePrecedence(existingEntry.quota) >
|
|
973
|
-
quotaSourcePrecedence(effectiveIncoming.quota));
|
|
974
|
-
if (!mergesHeaderQuota &&
|
|
975
|
-
(existingQuotaCheckedAt > incomingQuotaCheckedAt ||
|
|
976
|
-
existingQuotaWinsEqualTimestamp)) {
|
|
977
|
-
const existingRefreshAt = existingEntry.lastRefreshedAt ?? 0;
|
|
978
|
-
const incomingRefreshAt = effectiveIncoming.lastRefreshedAt ?? 0;
|
|
979
|
-
if (tokenChanged && incomingRefreshAt <= existingRefreshAt) {
|
|
980
|
-
const merged = { ...existingEntry };
|
|
981
|
-
if (typeof effectiveIncoming.lastUsed === 'number' &&
|
|
982
|
-
(!(typeof existingEntry.lastUsed === 'number') ||
|
|
983
|
-
effectiveIncoming.lastUsed > existingEntry.lastUsed)) {
|
|
984
|
-
merged.lastUsed = effectiveIncoming.lastUsed;
|
|
985
|
-
}
|
|
986
|
-
return merged;
|
|
987
|
-
}
|
|
988
|
-
const merged = {
|
|
989
|
-
...existingEntry,
|
|
990
|
-
...effectiveIncoming,
|
|
991
|
-
};
|
|
992
|
-
if (tokenChanged) {
|
|
993
|
-
if (!('profile' in effectiveIncoming))
|
|
994
|
-
delete merged.profile;
|
|
995
|
-
if (effectiveIncoming.quota?.source) {
|
|
996
|
-
merged.quota = effectiveIncoming.quota;
|
|
997
|
-
}
|
|
998
|
-
else {
|
|
999
|
-
delete merged.quota;
|
|
1000
|
-
}
|
|
1001
|
-
if (!('lastQuotaRefreshError' in effectiveIncoming)) {
|
|
1002
|
-
delete merged.lastQuotaRefreshError;
|
|
1003
|
-
}
|
|
1004
|
-
merged.lastRefreshError = preferredRefreshError;
|
|
1005
|
-
return merged;
|
|
1006
|
-
}
|
|
1007
|
-
return {
|
|
1008
|
-
...merged,
|
|
1009
|
-
quota: existingEntry.quota,
|
|
1010
|
-
lastQuotaRefreshError: existingEntry.lastQuotaRefreshError,
|
|
1011
|
-
lastRefreshError: preferredRefreshError,
|
|
1012
|
-
};
|
|
1013
|
-
}
|
|
1014
|
-
const merged = {
|
|
1015
|
-
...existingEntry,
|
|
1016
|
-
...effectiveIncoming,
|
|
1017
|
-
};
|
|
1018
|
-
if (tokenChanged) {
|
|
1019
|
-
if (!('profile' in effectiveIncoming))
|
|
1020
|
-
delete merged.profile;
|
|
1021
|
-
if (!effectiveIncoming.quota?.source)
|
|
1022
|
-
delete merged.quota;
|
|
1023
|
-
}
|
|
1024
|
-
if (!('lastQuotaRefreshError' in effectiveIncoming)) {
|
|
1025
|
-
delete merged.lastQuotaRefreshError;
|
|
1026
|
-
}
|
|
1027
|
-
merged.lastRefreshError = preferredRefreshError;
|
|
1028
|
-
return merged;
|
|
1029
|
-
}
|
|
1030
|
-
function configFromStorage(storage) {
|
|
1031
|
-
const refresh = storage.refresh
|
|
1032
|
-
? objectWithDefinedEntries({
|
|
1033
|
-
enabled: storage.refresh.enabled,
|
|
1034
|
-
intervalMinutes: storage.refresh.intervalMinutes,
|
|
1035
|
-
refreshBeforeExpiryMinutes: storage.refresh.refreshBeforeExpiryMinutes,
|
|
1036
|
-
})
|
|
1037
|
-
: undefined;
|
|
1038
|
-
const quota = storage.quota
|
|
1039
|
-
? objectWithDefinedEntries({
|
|
1040
|
-
enabled: storage.quota.enabled,
|
|
1041
|
-
checkIntervalMinutes: storage.quota.checkIntervalMinutes,
|
|
1042
|
-
refreshEveryNRequests: storage.quota.refreshEveryNRequests,
|
|
1043
|
-
minimumRemaining: storage.quota.minimumRemaining,
|
|
1044
|
-
failClosedOnUnknownQuota: storage.quota.failClosedOnUnknownQuota,
|
|
1045
|
-
showToasts: storage.quota.showToasts,
|
|
1046
|
-
})
|
|
1047
|
-
: undefined;
|
|
1048
|
-
return omitUndefinedTopLevel({
|
|
1049
|
-
version: 1,
|
|
1050
|
-
mainAccountId: storage.mainAccountId,
|
|
1051
|
-
main: { type: 'opencode', provider: 'anthropic' },
|
|
1052
|
-
routing: storage.routing,
|
|
1053
|
-
fallbackOn: storage.fallbackOn,
|
|
1054
|
-
refresh,
|
|
1055
|
-
quota,
|
|
1056
|
-
quotaHeaderFeed: storage.quotaHeaderFeed,
|
|
1057
|
-
claudeCache: storage.claudeCache,
|
|
1058
|
-
dump: storage.dump,
|
|
1059
|
-
logging: storage.logging,
|
|
1060
|
-
claudeFast: storage.claudeFast,
|
|
1061
|
-
thinkingBinding: storage.thinkingBinding,
|
|
1062
|
-
costZeroing: storage.costZeroing,
|
|
1063
|
-
cacheKeep: storage.cacheKeep,
|
|
1064
|
-
relay: storage.relay,
|
|
1065
|
-
claustrum: storage.claustrum,
|
|
1066
|
-
killswitch: storage.killswitch,
|
|
1067
|
-
prime: (() => {
|
|
1068
|
-
// Config side carries ONLY the `enabled` flag — runtime counters and
|
|
1069
|
-
// lineage bindings stay on the state file. Write `enabled` whenever it was explicitly set so a
|
|
1070
|
-
// toggle off persists `{ enabled: false }` and is visible to a stale
|
|
1071
|
-
// reader that only inspects the config.
|
|
1072
|
-
if (typeof storage.prime?.enabled !== 'boolean')
|
|
1073
|
-
return undefined;
|
|
1074
|
-
return { enabled: storage.prime.enabled };
|
|
1075
|
-
})(),
|
|
1076
|
-
accounts: storage.accounts.map(accountConfig),
|
|
1077
|
-
});
|
|
1078
|
-
}
|
|
1079
|
-
export function getClaustrumMode(storage) {
|
|
1080
|
-
return storage?.claustrum?.mode === 'claustrum' ? 'claustrum' : 'local';
|
|
1081
|
-
}
|
|
1082
|
-
export function isOAuthAccountVaultOwned(storage, account, binding) {
|
|
1083
|
-
return (getClaustrumMode(storage) === 'claustrum' &&
|
|
1084
|
-
isOAuthAccount(account) &&
|
|
1085
|
-
account.enabled !== false &&
|
|
1086
|
-
// Source is provenance, not authorization; the resolver owns the binding decision.
|
|
1087
|
-
(binding?.status === 'resolved' ||
|
|
1088
|
-
Boolean(storage?.claustrum?.scopedRoster && account.claustrumScopedCredentialId)));
|
|
1089
|
-
}
|
|
1090
|
-
export async function setClaustrumModePersistent(mode, path = getAccountStoragePath()) {
|
|
1091
|
-
return enqueueSave(async () => {
|
|
1092
|
-
const lock = await acquireAccountConfigWriteLock(path);
|
|
1093
|
-
try {
|
|
1094
|
-
const existing = await loadAccounts(path);
|
|
1095
|
-
const storage = existing ?? createEmptyStorage();
|
|
1096
|
-
if (existing && getClaustrumMode(storage) === mode)
|
|
1097
|
-
return 'unchanged';
|
|
1098
|
-
storage.claustrum = { ...storage.claustrum, mode };
|
|
1099
|
-
await saveAccountsWithConfigLock(storage, path, {
|
|
1100
|
-
[WRITE_CLAUSTRUM_MODE]: true,
|
|
1101
|
-
});
|
|
1102
|
-
return 'changed';
|
|
1103
|
-
}
|
|
1104
|
-
finally {
|
|
1105
|
-
await lock.release();
|
|
1106
|
-
}
|
|
1107
|
-
});
|
|
1108
|
-
}
|
|
1109
|
-
export async function clearClaustrumHandlePersistent(input) {
|
|
1110
|
-
const path = input.path ?? getAccountStoragePath();
|
|
1111
|
-
return enqueueSave(async () => {
|
|
1112
|
-
const configLock = await acquireAccountConfigWriteLock(path);
|
|
1113
|
-
try {
|
|
1114
|
-
const stateLock = await acquireAccountStateWriteLock(path);
|
|
1115
|
-
try {
|
|
1116
|
-
const storage = await loadAccounts(path);
|
|
1117
|
-
if (!storage)
|
|
1118
|
-
return 'missing';
|
|
1119
|
-
const statePath = getAccountStatePath(path);
|
|
1120
|
-
const state = (await readJsonIfPresent(statePath)).value;
|
|
1121
|
-
const stateAccounts = isRecord(state) && isRecord(state.accounts)
|
|
1122
|
-
? state.accounts
|
|
1123
|
-
: undefined;
|
|
1124
|
-
const matchingStateKeys = Object.keys(stateAccounts ?? {}).filter((key) => key.trim() === input.id.trim());
|
|
1125
|
-
const account = storage.accounts.find((candidate) => candidate.id === input.id);
|
|
1126
|
-
if (!account) {
|
|
1127
|
-
if (matchingStateKeys.length === 0)
|
|
1128
|
-
return 'missing';
|
|
1129
|
-
for (const key of matchingStateKeys) {
|
|
1130
|
-
const stateAccount = stateAccounts?.[key];
|
|
1131
|
-
if (isRecord(stateAccount))
|
|
1132
|
-
delete stateAccount.claustrumHandle;
|
|
1133
|
-
}
|
|
1134
|
-
await writeJsonAtomic(statePath, pruneUndefined(state));
|
|
1135
|
-
return 'updated';
|
|
1136
|
-
}
|
|
1137
|
-
if (!isOAuthAccount(account))
|
|
1138
|
-
return 'ineligible';
|
|
1139
|
-
if (!account.claustrumHandle)
|
|
1140
|
-
return 'updated';
|
|
1141
|
-
delete account.claustrumHandle;
|
|
1142
|
-
const existing = await loadExistingTopLevelFields(path);
|
|
1143
|
-
await writeJsonAtomic(path, {
|
|
1144
|
-
...existing,
|
|
1145
|
-
...configFromStorage(storage),
|
|
1146
|
-
});
|
|
1147
|
-
if (matchingStateKeys.length > 0) {
|
|
1148
|
-
for (const key of matchingStateKeys) {
|
|
1149
|
-
const stateAccount = stateAccounts?.[key];
|
|
1150
|
-
if (isRecord(stateAccount))
|
|
1151
|
-
delete stateAccount.claustrumHandle;
|
|
1152
|
-
}
|
|
1153
|
-
await writeJsonAtomic(statePath, pruneUndefined(state));
|
|
1154
|
-
}
|
|
1155
|
-
return 'updated';
|
|
1156
|
-
}
|
|
1157
|
-
finally {
|
|
1158
|
-
await stateLock.release();
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
finally {
|
|
1162
|
-
await configLock.release();
|
|
1163
|
-
}
|
|
1164
|
-
});
|
|
1165
|
-
}
|
|
1166
|
-
// ---------------------------------------------------------------------------
|
|
1167
|
-
// In-process save mutex — serializes all account-store writes so concurrent
|
|
1168
|
-
// read-modify-write callers (background timers that call saveAccountState with
|
|
1169
|
-
// different section flags) don't lose each other's updates (#9).
|
|
1170
|
-
// ---------------------------------------------------------------------------
|
|
1171
|
-
let saveChain = Promise.resolve();
|
|
1172
|
-
function enqueueSave(fn) {
|
|
1173
|
-
return new Promise((resolve, reject) => {
|
|
1174
|
-
saveChain = saveChain.then(() => fn().then(resolve, reject), () => fn().then(resolve, reject));
|
|
1175
|
-
});
|
|
1176
|
-
}
|
|
1177
|
-
async function writeJsonAtomic(path, value) {
|
|
1178
|
-
await mkdir(dirname(path), { recursive: true });
|
|
1179
|
-
const tempPath = `${path}.${randomUUID()}.tmp`;
|
|
1180
|
-
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
1181
|
-
encoding: 'utf8',
|
|
1182
|
-
mode: 0o600,
|
|
1183
|
-
});
|
|
1184
|
-
try {
|
|
1185
|
-
await rename(tempPath, path);
|
|
1186
|
-
}
|
|
1187
|
-
catch (error) {
|
|
1188
|
-
await rm(tempPath, { force: true }).catch(() => { });
|
|
1189
|
-
throw error;
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
const WRITE_CLAUSTRUM_MODE = Symbol('writeClaustrumMode');
|
|
1193
|
-
const MUTATE_ACCOUNT_ROSTER = Symbol('mutateAccountRoster');
|
|
1194
|
-
function sameAccountIdentity(left, right) {
|
|
1195
|
-
return (left.id === right.id ||
|
|
1196
|
-
Boolean(left.label && right.label && left.label === right.label));
|
|
1197
|
-
}
|
|
1198
|
-
function mergeAccountsForSave(existing, incoming, options) {
|
|
1199
|
-
const removedIds = new Set(options.removedAccountIds ?? []);
|
|
1200
|
-
const current = existing.filter((account) => !removedIds.has(account.id));
|
|
1201
|
-
const next = incoming.filter((account) => !removedIds.has(account.id));
|
|
1202
|
-
const missing = current.filter((account) => !next.some((candidate) => sameAccountIdentity(candidate, account)));
|
|
1203
|
-
if (!missing.length)
|
|
1204
|
-
return [...next];
|
|
1205
|
-
if (options.preserveExistingAccountOrder === false) {
|
|
1206
|
-
return [...next, ...missing];
|
|
1207
|
-
}
|
|
1208
|
-
const usedIncoming = new Set();
|
|
1209
|
-
const merged = current.map((account) => {
|
|
1210
|
-
const index = next.findIndex((candidate, candidateIndex) => !usedIncoming.has(candidateIndex) &&
|
|
1211
|
-
sameAccountIdentity(candidate, account));
|
|
1212
|
-
const candidate = next[index];
|
|
1213
|
-
if (!candidate)
|
|
1214
|
-
return account;
|
|
1215
|
-
usedIncoming.add(index);
|
|
1216
|
-
return candidate;
|
|
1217
|
-
});
|
|
1218
|
-
for (let index = 0; index < next.length; index++) {
|
|
1219
|
-
const candidate = next[index];
|
|
1220
|
-
if (!usedIncoming.has(index) && candidate)
|
|
1221
|
-
merged.push(candidate);
|
|
1222
|
-
}
|
|
1223
|
-
return merged;
|
|
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
|
-
}
|
|
1240
|
-
const ACCOUNT_CONFIG_LOCK_TTL_MS = 10_000;
|
|
1241
|
-
const ACCOUNT_CONFIG_LOCK_WAIT_MS = 12_000;
|
|
1242
|
-
const ACCOUNT_STATE_LOCK_TTL_MS = 10_000;
|
|
1243
|
-
const ACCOUNT_STATE_LOCK_WAIT_MS = 12_000;
|
|
1244
|
-
async function acquireAccountWriteLock(input) {
|
|
1245
|
-
const { path, name, ttlMs, waitMs, description } = input;
|
|
1246
|
-
await mkdir(dirname(path), { recursive: true });
|
|
1247
|
-
const deadline = Date.now() + waitMs;
|
|
1248
|
-
while (true) {
|
|
1249
|
-
const lock = await acquireRefreshFileLock({
|
|
1250
|
-
name,
|
|
1251
|
-
ttlMs,
|
|
1252
|
-
path,
|
|
1253
|
-
renew: true,
|
|
1254
|
-
});
|
|
1255
|
-
if (lock)
|
|
1256
|
-
return lock;
|
|
1257
|
-
if (Date.now() >= deadline) {
|
|
1258
|
-
throw new Error(`Timed out waiting for the account ${description} lock`);
|
|
1259
|
-
}
|
|
1260
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
function acquireAccountConfigWriteLock(path) {
|
|
1264
|
-
return acquireAccountWriteLock({
|
|
1265
|
-
path,
|
|
1266
|
-
name: 'config-write',
|
|
1267
|
-
ttlMs: ACCOUNT_CONFIG_LOCK_TTL_MS,
|
|
1268
|
-
waitMs: ACCOUNT_CONFIG_LOCK_WAIT_MS,
|
|
1269
|
-
description: 'configuration write',
|
|
1270
|
-
});
|
|
1271
|
-
}
|
|
1272
|
-
function acquireAccountStateWriteLock(path) {
|
|
1273
|
-
return acquireAccountWriteLock({
|
|
1274
|
-
path,
|
|
1275
|
-
name: 'state-write',
|
|
1276
|
-
ttlMs: ACCOUNT_STATE_LOCK_TTL_MS,
|
|
1277
|
-
waitMs: ACCOUNT_STATE_LOCK_WAIT_MS,
|
|
1278
|
-
description: 'state write',
|
|
1279
|
-
});
|
|
1280
|
-
}
|
|
1281
|
-
export function saveAccounts(storage, path = getAccountStoragePath(), options = {}) {
|
|
1282
|
-
const resolvedPath = path;
|
|
1283
|
-
return enqueueSave(() => saveAccountsLocked(storage, resolvedPath, options));
|
|
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
|
-
}
|
|
1311
|
-
export async function getOrCreateMainAccountId(path = getAccountStoragePath(), createId = randomUUID) {
|
|
1312
|
-
return enqueueSave(async () => {
|
|
1313
|
-
const lock = await acquireAccountConfigWriteLock(path);
|
|
1314
|
-
try {
|
|
1315
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
1316
|
-
if (storage.mainAccountId)
|
|
1317
|
-
return storage.mainAccountId;
|
|
1318
|
-
const mainAccountId = createId();
|
|
1319
|
-
const nextStorage = { ...storage, mainAccountId };
|
|
1320
|
-
const existing = await loadExistingTopLevelFields(path);
|
|
1321
|
-
await writeJsonAtomic(path, {
|
|
1322
|
-
...existing,
|
|
1323
|
-
...configFromStorage(nextStorage),
|
|
1324
|
-
});
|
|
1325
|
-
return mainAccountId;
|
|
1326
|
-
}
|
|
1327
|
-
finally {
|
|
1328
|
-
await lock.release();
|
|
1329
|
-
}
|
|
1330
|
-
});
|
|
1331
|
-
}
|
|
1332
|
-
async function saveAccountsLocked(storage, path, options) {
|
|
1333
|
-
const lock = await acquireAccountConfigWriteLock(path);
|
|
1334
|
-
try {
|
|
1335
|
-
await saveAccountsWithConfigLock(storage, path, options);
|
|
1336
|
-
}
|
|
1337
|
-
finally {
|
|
1338
|
-
await lock.release();
|
|
1339
|
-
}
|
|
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
|
-
}
|
|
1394
|
-
function applyMainProfileStatePatch(state, storage) {
|
|
1395
|
-
state.main = state.main ?? {};
|
|
1396
|
-
state.main.profile = storage.main?.profile;
|
|
1397
|
-
}
|
|
1398
|
-
function applyMainQuotaStatePatch(state, storage) {
|
|
1399
|
-
state.main = state.main ?? {};
|
|
1400
|
-
const incomingError = storage.quota?.mainLastQuotaApiError;
|
|
1401
|
-
const incomingGeneration = typeof storage.quota?.mainQuotaErrorGeneration === 'number' &&
|
|
1402
|
-
Number.isSafeInteger(storage.quota.mainQuotaErrorGeneration) &&
|
|
1403
|
-
storage.quota.mainQuotaErrorGeneration >= 0
|
|
1404
|
-
? storage.quota.mainQuotaErrorGeneration
|
|
1405
|
-
: undefined;
|
|
1406
|
-
const incomingClearedAt = typeof storage.quota?.mainQuotaErrorClearedAt === 'number' &&
|
|
1407
|
-
Number.isFinite(storage.quota.mainQuotaErrorClearedAt) &&
|
|
1408
|
-
storage.quota.mainQuotaErrorClearedAt >= 0
|
|
1409
|
-
? storage.quota.mainQuotaErrorClearedAt
|
|
1410
|
-
: undefined;
|
|
1411
|
-
const existingGeneration = typeof state.main.quotaErrorGeneration === 'number' &&
|
|
1412
|
-
Number.isSafeInteger(state.main.quotaErrorGeneration) &&
|
|
1413
|
-
state.main.quotaErrorGeneration >= 0
|
|
1414
|
-
? state.main.quotaErrorGeneration
|
|
1415
|
-
: 0;
|
|
1416
|
-
const existingErrorObservedAt = typeof state.main.lastQuotaApiError?.checkedAt === 'number' &&
|
|
1417
|
-
Number.isFinite(state.main.lastQuotaApiError.checkedAt) &&
|
|
1418
|
-
state.main.lastQuotaApiError.checkedAt >= 0
|
|
1419
|
-
? state.main.lastQuotaApiError.checkedAt
|
|
1420
|
-
: undefined;
|
|
1421
|
-
const existingClearedAt = typeof state.main.quotaErrorClearedAt === 'number' &&
|
|
1422
|
-
Number.isFinite(state.main.quotaErrorClearedAt) &&
|
|
1423
|
-
state.main.quotaErrorClearedAt >= 0
|
|
1424
|
-
? state.main.quotaErrorClearedAt
|
|
1425
|
-
: undefined;
|
|
1426
|
-
const existingObservedAt = Math.max(existingErrorObservedAt ?? 0, existingClearedAt ?? 0);
|
|
1427
|
-
const incomingErrorObservedAt = typeof incomingError?.checkedAt === 'number' &&
|
|
1428
|
-
Number.isFinite(incomingError.checkedAt) &&
|
|
1429
|
-
incomingError.checkedAt >= 0
|
|
1430
|
-
? incomingError.checkedAt
|
|
1431
|
-
: undefined;
|
|
1432
|
-
// Error transitions are ordered by observation time, not write order. All
|
|
1433
|
-
// instances share one host clock, so an error observed before a clear cannot
|
|
1434
|
-
// resurrect that clear even when both processes allocated the same generation.
|
|
1435
|
-
if (incomingError === undefined &&
|
|
1436
|
-
incomingClearedAt !== undefined &&
|
|
1437
|
-
incomingClearedAt >= existingObservedAt) {
|
|
1438
|
-
state.main.lastQuotaApiError = undefined;
|
|
1439
|
-
state.main.quotaErrorClearedAt = mergeMainQuotaErrorClearedAt(existingClearedAt, incomingClearedAt);
|
|
1440
|
-
if (incomingGeneration !== undefined &&
|
|
1441
|
-
incomingGeneration >= existingGeneration) {
|
|
1442
|
-
state.main.quotaErrorGeneration = incomingGeneration;
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
else if (incomingError &&
|
|
1446
|
-
typeof incomingError.checkedAt === 'number' &&
|
|
1447
|
-
Number.isFinite(incomingError.checkedAt) &&
|
|
1448
|
-
incomingError.checkedAt > existingObservedAt) {
|
|
1449
|
-
const acceptsByObservation = incomingErrorObservedAt !== undefined &&
|
|
1450
|
-
incomingErrorObservedAt > existingObservedAt;
|
|
1451
|
-
const acceptsEqualObservation = incomingErrorObservedAt !== undefined &&
|
|
1452
|
-
incomingErrorObservedAt === existingObservedAt &&
|
|
1453
|
-
existingErrorObservedAt !== undefined &&
|
|
1454
|
-
existingClearedAt === undefined &&
|
|
1455
|
-
incomingGeneration !== undefined &&
|
|
1456
|
-
incomingGeneration >= existingGeneration;
|
|
1457
|
-
const acceptsLegacyGeneration = incomingErrorObservedAt === undefined &&
|
|
1458
|
-
incomingClearedAt === undefined &&
|
|
1459
|
-
incomingGeneration !== undefined &&
|
|
1460
|
-
incomingGeneration >= existingGeneration &&
|
|
1461
|
-
existingClearedAt === undefined;
|
|
1462
|
-
if (acceptsByObservation ||
|
|
1463
|
-
acceptsEqualObservation ||
|
|
1464
|
-
acceptsLegacyGeneration) {
|
|
1465
|
-
state.main.lastQuotaApiError = incomingError;
|
|
1466
|
-
if (incomingGeneration !== undefined) {
|
|
1467
|
-
state.main.quotaErrorGeneration = Math.max(existingGeneration, incomingGeneration);
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
const incomingQuota = storage.quota?.mainQuota;
|
|
1472
|
-
const sameToken = Boolean(state.main.quotaToken &&
|
|
1473
|
-
storage.quota?.mainQuotaToken &&
|
|
1474
|
-
state.main.quotaToken === storage.quota.mainQuotaToken);
|
|
1475
|
-
const effectiveIncomingQuota = sameToken && incomingQuota?.source === 'headers'
|
|
1476
|
-
? mergeHeaderQuotaForPersistence(state.main.quota, incomingQuota)
|
|
1477
|
-
: incomingQuota;
|
|
1478
|
-
const mergesHeaderQuota = Boolean(sameToken && incomingQuota?.source === 'headers');
|
|
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);
|
|
1484
|
-
if (!mergesHeaderQuota &&
|
|
1485
|
-
(existingCheckedAt > incomingCheckedAt ||
|
|
1486
|
-
(existingCheckedAt === incomingCheckedAt &&
|
|
1487
|
-
quotaSourcePrecedence(state.main.quota) >
|
|
1488
|
-
quotaSourcePrecedence(effectiveIncomingQuota)))) {
|
|
1489
|
-
return;
|
|
1490
|
-
}
|
|
1491
|
-
state.main.quota = effectiveIncomingQuota;
|
|
1492
|
-
const boundCheckedAt = quotaSnapshotCheckedAt(effectiveIncomingQuota);
|
|
1493
|
-
state.main.quotaCheckedAt = boundCheckedAt > 0 ? boundCheckedAt : undefined;
|
|
1494
|
-
state.main.quotaToken = storage.quota?.mainQuotaToken;
|
|
1495
|
-
}
|
|
1496
|
-
function applyMainRefreshStatePatch(state, storage) {
|
|
1497
|
-
state.main = state.main ?? {};
|
|
1498
|
-
const incomingError = storage.refresh?.mainLastRefreshError;
|
|
1499
|
-
const incomingClearedAt = storage.refresh?.mainRefreshErrorClearedAt;
|
|
1500
|
-
const existingErrorObservedAt = state.main.lastRefreshError?.checkedAt;
|
|
1501
|
-
const existingClearedAt = state.main.refreshErrorClearedAt;
|
|
1502
|
-
const existingObservedAt = Math.max(existingErrorObservedAt ?? 0, existingClearedAt ?? 0);
|
|
1503
|
-
if (incomingError === undefined &&
|
|
1504
|
-
incomingClearedAt !== undefined &&
|
|
1505
|
-
incomingClearedAt >= existingObservedAt) {
|
|
1506
|
-
state.main.lastRefreshError = undefined;
|
|
1507
|
-
state.main.refreshErrorClearedAt = mergeMainRefreshErrorClearedAt(existingClearedAt, incomingClearedAt);
|
|
1508
|
-
}
|
|
1509
|
-
else if (incomingError &&
|
|
1510
|
-
typeof incomingError.checkedAt === 'number' &&
|
|
1511
|
-
Number.isFinite(incomingError.checkedAt) &&
|
|
1512
|
-
incomingError.checkedAt > existingObservedAt) {
|
|
1513
|
-
state.main.lastRefreshError = incomingError;
|
|
1514
|
-
}
|
|
1515
|
-
state.main.refreshLeaseId = storage.refresh?.mainRefreshLeaseId;
|
|
1516
|
-
state.main.refreshLeaseUntil = storage.refresh?.mainRefreshLeaseUntil;
|
|
1517
|
-
state.main.refreshLeaseTokenHash = storage.refresh?.mainRefreshLeaseTokenHash;
|
|
1518
|
-
}
|
|
1519
|
-
function applyMainPrimeStatePatch(state, storage) {
|
|
1520
|
-
const incoming = storage.prime?.main;
|
|
1521
|
-
const incomingAuthLineageId = storage.prime?.mainAuthLineageId;
|
|
1522
|
-
const incomingAuthLineageRefreshTokenFingerprint = storage.prime?.mainAuthLineageRefreshTokenFingerprint;
|
|
1523
|
-
if (!incoming &&
|
|
1524
|
-
!incomingAuthLineageId &&
|
|
1525
|
-
!incomingAuthLineageRefreshTokenFingerprint)
|
|
1526
|
-
return;
|
|
1527
|
-
state.main = state.main ?? {};
|
|
1528
|
-
// Last-writer-wins on prime counters — the only writer is the prime manager
|
|
1529
|
-
// itself, monotonically accumulating per success, so there is no race for an
|
|
1530
|
-
// older write to overwrite a newer one within this process. Across processes
|
|
1531
|
-
// the cross-process claim marker (#1247) keeps the fire exclusive.
|
|
1532
|
-
if (incoming)
|
|
1533
|
-
state.main.prime = incoming;
|
|
1534
|
-
if (incomingAuthLineageId) {
|
|
1535
|
-
state.main.primeAuthLineageId = incomingAuthLineageId;
|
|
1536
|
-
}
|
|
1537
|
-
if (incomingAuthLineageRefreshTokenFingerprint) {
|
|
1538
|
-
state.main.primeAuthLineageRefreshTokenFingerprint =
|
|
1539
|
-
incomingAuthLineageRefreshTokenFingerprint;
|
|
1540
|
-
}
|
|
1541
|
-
}
|
|
1542
|
-
function pruneUndefined(value) {
|
|
1543
|
-
if (Array.isArray(value))
|
|
1544
|
-
return value.map(pruneUndefined);
|
|
1545
|
-
if (!isRecord(value))
|
|
1546
|
-
return value;
|
|
1547
|
-
return Object.fromEntries(Object.entries(value)
|
|
1548
|
-
.filter(([, entry]) => entry !== undefined)
|
|
1549
|
-
.map(([key, entry]) => [key, pruneUndefined(entry)]));
|
|
1550
|
-
}
|
|
1551
|
-
export function saveAccountState(storage, path = getAccountStoragePath(), scope = {
|
|
1552
|
-
mainProfile: true,
|
|
1553
|
-
mainQuota: true,
|
|
1554
|
-
mainRefresh: true,
|
|
1555
|
-
accounts: true,
|
|
1556
|
-
}) {
|
|
1557
|
-
const resolvedPath = path;
|
|
1558
|
-
return enqueueSave(async () => {
|
|
1559
|
-
const lock = await acquireAccountStateWriteLock(resolvedPath);
|
|
1560
|
-
try {
|
|
1561
|
-
await saveAccountStateUnlocked(storage, resolvedPath, scope);
|
|
1562
|
-
}
|
|
1563
|
-
finally {
|
|
1564
|
-
await lock.release();
|
|
1565
|
-
}
|
|
1566
|
-
});
|
|
1567
|
-
}
|
|
1568
|
-
export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
|
|
1569
|
-
const resolvedPath = path;
|
|
1570
|
-
return enqueueSave(async () => {
|
|
1571
|
-
const configLock = await acquireAccountConfigWriteLock(resolvedPath);
|
|
1572
|
-
try {
|
|
1573
|
-
const stateLock = await acquireAccountStateWriteLock(resolvedPath);
|
|
1574
|
-
try {
|
|
1575
|
-
const current = await loadAccounts(resolvedPath);
|
|
1576
|
-
const statePath = getAccountStatePath(resolvedPath);
|
|
1577
|
-
const existing = (await readJsonIfPresent(statePath)).value;
|
|
1578
|
-
const next = isRecord(existing)
|
|
1579
|
-
? { ...existing, version: 1 }
|
|
1580
|
-
: { version: 1 };
|
|
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
|
-
}
|
|
1598
|
-
if (accountId === 'main') {
|
|
1599
|
-
next.main = { ...(next.main ?? {}) };
|
|
1600
|
-
const existingProfile = normalizeOAuthAccountProfile(next.main.profile);
|
|
1601
|
-
if (!profile) {
|
|
1602
|
-
if (existingProfile &&
|
|
1603
|
-
(accountIdentity === undefined ||
|
|
1604
|
-
existingProfile.accountIdentity === undefined ||
|
|
1605
|
-
existingProfile.accountIdentity === accountIdentity)) {
|
|
1606
|
-
return false;
|
|
1607
|
-
}
|
|
1608
|
-
next.main.profile = undefined;
|
|
1609
|
-
return await writeJsonAtomic(statePath, pruneUndefined(next)).then(() => true);
|
|
1610
|
-
}
|
|
1611
|
-
if (profile.accountIdentity !== undefined &&
|
|
1612
|
-
profile.accountIdentity !== accountIdentity) {
|
|
1613
|
-
return false;
|
|
1614
|
-
}
|
|
1615
|
-
const persistedProfile = accountIdentity === undefined ||
|
|
1616
|
-
profile.accountIdentity === accountIdentity
|
|
1617
|
-
? profile
|
|
1618
|
-
: { ...profile, accountIdentity };
|
|
1619
|
-
if (existingProfile &&
|
|
1620
|
-
(accountIdentity === undefined ||
|
|
1621
|
-
existingProfile.accountIdentity === undefined ||
|
|
1622
|
-
existingProfile.accountIdentity === accountIdentity) &&
|
|
1623
|
-
existingProfile.checkedAt > persistedProfile.checkedAt) {
|
|
1624
|
-
return false;
|
|
1625
|
-
}
|
|
1626
|
-
next.main.profile = persistedProfile;
|
|
1627
|
-
}
|
|
1628
|
-
else {
|
|
1629
|
-
const account = current?.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
|
|
1630
|
-
if (!account)
|
|
1631
|
-
return false;
|
|
1632
|
-
next.accounts = {
|
|
1633
|
-
...(isRecord(next.accounts) ? next.accounts : {}),
|
|
1634
|
-
};
|
|
1635
|
-
const existingEntry = isRecord(next.accounts[accountId])
|
|
1636
|
-
? { ...next.accounts[accountId] }
|
|
1637
|
-
: {};
|
|
1638
|
-
const existingProfile = normalizeOAuthAccountProfile(existingEntry.profile);
|
|
1639
|
-
if (!profile) {
|
|
1640
|
-
if (existingProfile &&
|
|
1641
|
-
(accountIdentity === undefined ||
|
|
1642
|
-
existingProfile.accountIdentity === undefined ||
|
|
1643
|
-
existingProfile.accountIdentity === accountIdentity)) {
|
|
1644
|
-
return false;
|
|
1645
|
-
}
|
|
1646
|
-
existingEntry.profile = undefined;
|
|
1647
|
-
}
|
|
1648
|
-
else {
|
|
1649
|
-
if (profile.accountIdentity !== undefined &&
|
|
1650
|
-
profile.accountIdentity !== accountIdentity) {
|
|
1651
|
-
return false;
|
|
1652
|
-
}
|
|
1653
|
-
const persistedProfile = accountIdentity === undefined ||
|
|
1654
|
-
profile.accountIdentity === accountIdentity
|
|
1655
|
-
? profile
|
|
1656
|
-
: { ...profile, accountIdentity };
|
|
1657
|
-
if (existingProfile &&
|
|
1658
|
-
(accountIdentity === undefined ||
|
|
1659
|
-
existingProfile.accountIdentity === undefined ||
|
|
1660
|
-
existingProfile.accountIdentity === accountIdentity) &&
|
|
1661
|
-
existingProfile.checkedAt > persistedProfile.checkedAt) {
|
|
1662
|
-
return false;
|
|
1663
|
-
}
|
|
1664
|
-
existingEntry.profile = persistedProfile;
|
|
1665
|
-
}
|
|
1666
|
-
next.accounts[accountId] = existingEntry;
|
|
1667
|
-
}
|
|
1668
|
-
await writeJsonAtomic(statePath, pruneUndefined(next));
|
|
1669
|
-
return true;
|
|
1670
|
-
}
|
|
1671
|
-
finally {
|
|
1672
|
-
await stateLock.release();
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
finally {
|
|
1676
|
-
await configLock.release();
|
|
1677
|
-
}
|
|
1678
|
-
});
|
|
1679
|
-
}
|
|
1680
|
-
export function clearClaustrumRefreshErrorPersistent(accountId, handle, path = getAccountStoragePath()) {
|
|
1681
|
-
return enqueueSave(async () => {
|
|
1682
|
-
const configLock = await acquireAccountConfigWriteLock(path);
|
|
1683
|
-
try {
|
|
1684
|
-
const stateLock = await acquireAccountStateWriteLock(path);
|
|
1685
|
-
try {
|
|
1686
|
-
// Re-read under both locks so this field-scoped clear cannot write a stale credential or config snapshot.
|
|
1687
|
-
const storage = await loadAccounts(path);
|
|
1688
|
-
const account = storage?.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
|
|
1689
|
-
if (!storage ||
|
|
1690
|
-
!account ||
|
|
1691
|
-
account.claustrumHandle !== handle ||
|
|
1692
|
-
!account.lastRefreshError) {
|
|
1693
|
-
return false;
|
|
1694
|
-
}
|
|
1695
|
-
account.lastRefreshError = undefined;
|
|
1696
|
-
await saveAccountStateUnlocked(storage, path, { accounts: [accountId] });
|
|
1697
|
-
const statePath = getAccountStatePath(path);
|
|
1698
|
-
const state = (await readJsonIfPresent(statePath)).value;
|
|
1699
|
-
if (isRecord(state) && isRecord(state.accounts)) {
|
|
1700
|
-
const entry = state.accounts[accountId];
|
|
1701
|
-
if (isRecord(entry)) {
|
|
1702
|
-
delete entry.lastRefreshError;
|
|
1703
|
-
await writeJsonAtomic(statePath, pruneUndefined(state));
|
|
1704
|
-
}
|
|
1705
|
-
}
|
|
1706
|
-
return true;
|
|
1707
|
-
}
|
|
1708
|
-
finally {
|
|
1709
|
-
await stateLock.release();
|
|
1710
|
-
}
|
|
1711
|
-
}
|
|
1712
|
-
finally {
|
|
1713
|
-
await configLock.release();
|
|
1714
|
-
}
|
|
1715
|
-
});
|
|
1716
|
-
}
|
|
1717
|
-
function fenceScopedPrimaryState(state, primaryId) {
|
|
1718
|
-
if (!isRecord(state.main))
|
|
1719
|
-
state.main = {};
|
|
1720
|
-
const main = state.main;
|
|
1721
|
-
if (!primaryId || main.scopedAccountIdentity !== primaryId) {
|
|
1722
|
-
delete main.prime;
|
|
1723
|
-
delete main.primeAuthLineageId;
|
|
1724
|
-
delete main.primeAuthLineageRefreshTokenFingerprint;
|
|
1725
|
-
delete main.quotaErrorGeneration;
|
|
1726
|
-
delete main.quotaErrorClearedAt;
|
|
1727
|
-
}
|
|
1728
|
-
main.scopedAccountIdentity = primaryId;
|
|
1729
|
-
if (!primaryId || main.quota?.accountIdentity !== primaryId) {
|
|
1730
|
-
delete main.quota;
|
|
1731
|
-
delete main.quotaCheckedAt;
|
|
1732
|
-
delete main.quotaToken;
|
|
1733
|
-
}
|
|
1734
|
-
if (!primaryId || main.lastQuotaApiError?.accountIdentity !== primaryId)
|
|
1735
|
-
delete main.lastQuotaApiError;
|
|
1736
|
-
if (!primaryId ||
|
|
1737
|
-
(main.profile?.providerAccountUuid ?? main.profile?.accountIdentity) !==
|
|
1738
|
-
primaryId) {
|
|
1739
|
-
delete main.profile;
|
|
1740
|
-
delete main.profileToken;
|
|
1741
|
-
}
|
|
1742
|
-
delete main.lastRefreshError;
|
|
1743
|
-
delete main.refreshErrorClearedAt;
|
|
1744
|
-
delete main.refreshLeaseId;
|
|
1745
|
-
delete main.refreshLeaseUntil;
|
|
1746
|
-
delete main.refreshLeaseTokenHash;
|
|
1747
|
-
}
|
|
1748
|
-
async function saveAccountStateUnlocked(storage, path, scope) {
|
|
1749
|
-
const statePath = getAccountStatePath(path);
|
|
1750
|
-
const existing = (await readJsonIfPresent(statePath)).value;
|
|
1751
|
-
const next = isRecord(existing)
|
|
1752
|
-
? { ...existing, version: 1 }
|
|
1753
|
-
: { version: 1 };
|
|
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))))
|
|
1769
|
-
applyMainProfileStatePatch(next, storage);
|
|
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)
|
|
1781
|
-
applyMainRefreshStatePatch(next, storage);
|
|
1782
|
-
if (scope.mainPrime && (!scoped || samePrimary))
|
|
1783
|
-
applyMainPrimeStatePatch(next, storage);
|
|
1784
|
-
if (scope.accounts) {
|
|
1785
|
-
const ids = scope.accounts === true ? null : new Set(scope.accounts);
|
|
1786
|
-
const setClaustrumHandleAccountIds = new Set(scope.setClaustrumHandleAccountIds ?? []);
|
|
1787
|
-
const config = persistedConfig;
|
|
1788
|
-
const configAccounts = isRecord(config) && Array.isArray(config.accounts)
|
|
1789
|
-
? new Map(config.accounts
|
|
1790
|
-
.filter(isRecord)
|
|
1791
|
-
.map((entry) => [String(entry.id ?? '').trim(), entry]))
|
|
1792
|
-
: new Map();
|
|
1793
|
-
const configuredIds = (() => {
|
|
1794
|
-
if (!isRecord(config) || !Array.isArray(config.accounts))
|
|
1795
|
-
return null;
|
|
1796
|
-
if (config.accounts.length === 0)
|
|
1797
|
-
return new Set();
|
|
1798
|
-
const stateAccounts = isRecord(next.accounts) ? next.accounts : {};
|
|
1799
|
-
const incomingAccounts = Object.fromEntries(storage.accounts.map((account) => [
|
|
1800
|
-
account.id.trim(),
|
|
1801
|
-
accountRuntimeState(account),
|
|
1802
|
-
]));
|
|
1803
|
-
// Keep both forms because legacy state may still use either key while
|
|
1804
|
-
// scoped saves must preserve entries not superseded by an incoming account.
|
|
1805
|
-
const memberships = config.accounts.map((account) => {
|
|
1806
|
-
if (!isRecord(account) || typeof account.id !== 'string')
|
|
1807
|
-
return null;
|
|
1808
|
-
const id = account.id.trim();
|
|
1809
|
-
if (!id)
|
|
1810
|
-
return null;
|
|
1811
|
-
if (configAccountHasInvalidShape(account))
|
|
1812
|
-
return null;
|
|
1813
|
-
const stateValue = stateAccounts[account.id] ?? stateAccounts[id];
|
|
1814
|
-
const stateAccount = isRecord(stateValue)
|
|
1815
|
-
? stateValue
|
|
1816
|
-
: {};
|
|
1817
|
-
const incomingAccount = incomingAccounts[id];
|
|
1818
|
-
return normalizeAccount(mergeConfigAccountAndState(account, incomingAccount ?? stateAccount))
|
|
1819
|
-
? [account.id, id]
|
|
1820
|
-
: null;
|
|
1821
|
-
});
|
|
1822
|
-
// A populated config with any unparseable entry cannot establish safe membership.
|
|
1823
|
-
return memberships.every((membership) => membership !== null)
|
|
1824
|
-
? new Set(memberships.flatMap((membership) => membership ?? []))
|
|
1825
|
-
: null;
|
|
1826
|
-
})();
|
|
1827
|
-
next.accounts = { ...(isRecord(next.accounts) ? next.accounts : {}) };
|
|
1828
|
-
for (const account of storage.accounts) {
|
|
1829
|
-
const accountId = account.id.trim();
|
|
1830
|
-
if (ids && !ids.has(account.id) && !ids.has(accountId))
|
|
1831
|
-
continue;
|
|
1832
|
-
if (configuredIds &&
|
|
1833
|
-
!configuredIds.has(account.id) &&
|
|
1834
|
-
!configuredIds.has(accountId))
|
|
1835
|
-
continue;
|
|
1836
|
-
const legacyKeys = Object.keys(next.accounts).filter((key) => key !== accountId && key.trim() === accountId);
|
|
1837
|
-
const legacyKey = legacyKeys[0];
|
|
1838
|
-
const existingAccount = next.accounts[accountId] ??
|
|
1839
|
-
(legacyKey ? next.accounts[legacyKey] : undefined);
|
|
1840
|
-
for (const key of legacyKeys)
|
|
1841
|
-
delete next.accounts[key];
|
|
1842
|
-
const configured = configAccounts.get(accountId);
|
|
1843
|
-
if (configured &&
|
|
1844
|
-
typeof configured.claustrumScopedCredentialId === 'string' &&
|
|
1845
|
-
typeof configured.anthropicAccountUuid === 'string') {
|
|
1846
|
-
const matches = (value) => isRecord(value) &&
|
|
1847
|
-
value.claustrumScopedCredentialId ===
|
|
1848
|
-
configured.claustrumScopedCredentialId &&
|
|
1849
|
-
value.anthropicAccountUuid === configured.anthropicAccountUuid;
|
|
1850
|
-
const incoming = accountRuntimeState(account);
|
|
1851
|
-
const merged = mergeAccountRuntimeState(matches(existingAccount) ? existingAccount : undefined, matches(incoming) ? incoming : {});
|
|
1852
|
-
// The current config owns identity. Older local writers cannot restore
|
|
1853
|
-
// credentials, handles or another account's observations after cutover.
|
|
1854
|
-
next.accounts[accountId] = {
|
|
1855
|
-
...merged,
|
|
1856
|
-
claustrumScopedCredentialId: configured.claustrumScopedCredentialId,
|
|
1857
|
-
anthropicAccountUuid: configured.anthropicAccountUuid,
|
|
1858
|
-
refresh: '',
|
|
1859
|
-
};
|
|
1860
|
-
const scoped = next.accounts[accountId];
|
|
1861
|
-
delete scoped.access;
|
|
1862
|
-
delete scoped.expires;
|
|
1863
|
-
delete scoped.lastRefreshedAt;
|
|
1864
|
-
delete scoped.lastRefreshError;
|
|
1865
|
-
delete scoped.claustrumHandle;
|
|
1866
|
-
}
|
|
1867
|
-
else {
|
|
1868
|
-
const merged = mergeAccountRuntimeState(existingAccount, accountRuntimeState(account), setClaustrumHandleAccountIds.has(account.id) ||
|
|
1869
|
-
setClaustrumHandleAccountIds.has(accountId));
|
|
1870
|
-
if (configured) {
|
|
1871
|
-
delete merged.claustrumScopedCredentialId;
|
|
1872
|
-
delete merged.claustrumScopedState;
|
|
1873
|
-
}
|
|
1874
|
-
next.accounts[accountId] = merged;
|
|
1875
|
-
}
|
|
1876
|
-
}
|
|
1877
|
-
if (configuredIds) {
|
|
1878
|
-
// Config membership is authoritative for scoped writes too; otherwise a
|
|
1879
|
-
// stale writer can preserve state for an account removed out of band.
|
|
1880
|
-
for (const id of Object.keys(next.accounts)) {
|
|
1881
|
-
if (!configuredIds.has(id))
|
|
1882
|
-
delete next.accounts[id];
|
|
1883
|
-
}
|
|
1884
|
-
}
|
|
1885
|
-
if (ids) {
|
|
1886
|
-
for (const id of ids) {
|
|
1887
|
-
if (!storage.accounts.some((account) => account.id === id)) {
|
|
1888
|
-
delete next.accounts[id];
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
}
|
|
1893
|
-
await writeJsonAtomic(statePath, pruneUndefined(next));
|
|
1894
|
-
}
|
|
1895
|
-
export async function acquireRefreshFileLock(options) {
|
|
1896
|
-
const accountPath = options.path ?? getAccountStoragePath();
|
|
1897
|
-
const lockPath = `${accountPath}.${options.name}.lock`;
|
|
1898
|
-
const legacyOwnerPath = join(lockPath, 'owner.json');
|
|
1899
|
-
const ownerId = randomUUID();
|
|
1900
|
-
const now = options.now ?? Date.now;
|
|
1901
|
-
let renewTimer = null;
|
|
1902
|
-
let released = false;
|
|
1903
|
-
async function readOwner() {
|
|
1904
|
-
try {
|
|
1905
|
-
return JSON.parse(await readFile(lockPath, 'utf8'));
|
|
1906
|
-
}
|
|
1907
|
-
catch (error) {
|
|
1908
|
-
const code = error.code;
|
|
1909
|
-
if (code !== 'EISDIR')
|
|
1910
|
-
throw error;
|
|
1911
|
-
return JSON.parse(await readFile(legacyOwnerPath, 'utf8'));
|
|
1912
|
-
}
|
|
1913
|
-
}
|
|
1914
|
-
async function writeOwner() {
|
|
1915
|
-
await writeFile(lockPath, `${JSON.stringify({ ownerId, expiresAt: now() + options.ttlMs })}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
1916
|
-
}
|
|
1917
|
-
async function tryAcquire() {
|
|
1918
|
-
try {
|
|
1919
|
-
await writeFile(lockPath, `${JSON.stringify({ ownerId, expiresAt: now() + options.ttlMs })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
1920
|
-
return true;
|
|
1921
|
-
}
|
|
1922
|
-
catch (error) {
|
|
1923
|
-
const code = error.code;
|
|
1924
|
-
if (code === 'EEXIST' || code === 'EISDIR')
|
|
1925
|
-
return false;
|
|
1926
|
-
throw error;
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
|
-
function scheduleRenewal() {
|
|
1930
|
-
if (!options.renew || released)
|
|
1931
|
-
return;
|
|
1932
|
-
const intervalMs = options.renewIntervalMs ?? Math.max(1_000, Math.floor(options.ttlMs / 3));
|
|
1933
|
-
renewTimer = setRefreshLockRenewalTimeout(() => {
|
|
1934
|
-
void (async () => {
|
|
1935
|
-
try {
|
|
1936
|
-
const owner = await readOwner();
|
|
1937
|
-
const currentNow = now();
|
|
1938
|
-
if (released ||
|
|
1939
|
-
owner?.ownerId !== ownerId ||
|
|
1940
|
-
Number(owner?.expiresAt) <= currentNow) {
|
|
1941
|
-
return;
|
|
1942
|
-
}
|
|
1943
|
-
await writeOwner();
|
|
1944
|
-
scheduleRenewal();
|
|
1945
|
-
}
|
|
1946
|
-
catch {
|
|
1947
|
-
// If renewal fails, contenders will wait until the last written expiry.
|
|
1948
|
-
}
|
|
1949
|
-
})();
|
|
1950
|
-
}, intervalMs);
|
|
1951
|
-
if ('unref' in renewTimer)
|
|
1952
|
-
renewTimer.unref();
|
|
1953
|
-
}
|
|
1954
|
-
let acquired = await tryAcquire();
|
|
1955
|
-
if (!acquired) {
|
|
1956
|
-
const evictPath = `${lockPath}.evicting`;
|
|
1957
|
-
const evictOwnerPath = join(evictPath, 'owner.json');
|
|
1958
|
-
const evictOwnerId = randomUUID();
|
|
1959
|
-
const EVICT_TTL = 5_000;
|
|
1960
|
-
const MAX_STEAL_ATTEMPTS = 8;
|
|
1961
|
-
async function backoff() {
|
|
1962
|
-
await new Promise((resolve) => setTimeout(resolve, Math.floor(Math.random() * 4)));
|
|
1963
|
-
}
|
|
1964
|
-
async function lockIsLive() {
|
|
1965
|
-
try {
|
|
1966
|
-
const currentOwner = await readOwner();
|
|
1967
|
-
return Number(currentOwner?.expiresAt) > now();
|
|
1968
|
-
}
|
|
1969
|
-
catch {
|
|
1970
|
-
try {
|
|
1971
|
-
const current = await stat(lockPath);
|
|
1972
|
-
return current.mtimeMs + options.ttlMs > now();
|
|
1973
|
-
}
|
|
1974
|
-
catch {
|
|
1975
|
-
// Lock doesn't exist — safe to acquire.
|
|
1976
|
-
return false;
|
|
1977
|
-
}
|
|
1978
|
-
}
|
|
1979
|
-
}
|
|
1980
|
-
async function ownsEvictionMarker() {
|
|
1981
|
-
try {
|
|
1982
|
-
const owner = JSON.parse(await readFile(evictOwnerPath, 'utf8'));
|
|
1983
|
-
return owner?.ownerId === evictOwnerId;
|
|
1984
|
-
}
|
|
1985
|
-
catch {
|
|
1986
|
-
return false;
|
|
1987
|
-
}
|
|
1988
|
-
}
|
|
1989
|
-
async function tryAcquireEvictionMarker() {
|
|
1990
|
-
await mkdir(evictPath);
|
|
1991
|
-
try {
|
|
1992
|
-
await writeFile(evictOwnerPath, `${JSON.stringify({ ownerId: evictOwnerId, createdAt: now() })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
1993
|
-
}
|
|
1994
|
-
catch (error) {
|
|
1995
|
-
const code = error.code;
|
|
1996
|
-
// Another contender can rename the marker directory between mkdir and
|
|
1997
|
-
// this exclusive create. Darwin/Bun reports that lost-parent race as
|
|
1998
|
-
// either ENOENT or EINVAL; both mean this contender lost the marker.
|
|
1999
|
-
if (code === 'ENOENT' || code === 'EINVAL')
|
|
2000
|
-
return false;
|
|
2001
|
-
await releaseEvictionMarker();
|
|
2002
|
-
throw error;
|
|
2003
|
-
}
|
|
2004
|
-
await options.onStep?.('eviction-marker-acquired');
|
|
2005
|
-
return true;
|
|
2006
|
-
}
|
|
2007
|
-
async function releaseEvictionMarker() {
|
|
2008
|
-
if (await ownsEvictionMarker()) {
|
|
2009
|
-
await rm(evictPath, { recursive: true, force: true }).catch(() => { });
|
|
2010
|
-
}
|
|
2011
|
-
}
|
|
2012
|
-
for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) {
|
|
2013
|
-
acquired = await tryAcquire();
|
|
2014
|
-
if (acquired)
|
|
2015
|
-
break;
|
|
2016
|
-
if (await lockIsLive())
|
|
2017
|
-
return null;
|
|
2018
|
-
try {
|
|
2019
|
-
if (!(await tryAcquireEvictionMarker())) {
|
|
2020
|
-
await backoff();
|
|
2021
|
-
continue;
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
catch (evictError) {
|
|
2025
|
-
const code = evictError.code;
|
|
2026
|
-
if (code !== 'EEXIST')
|
|
2027
|
-
throw evictError;
|
|
2028
|
-
let evictStat;
|
|
2029
|
-
try {
|
|
2030
|
-
evictStat = await stat(evictPath);
|
|
2031
|
-
}
|
|
2032
|
-
catch (statError) {
|
|
2033
|
-
if (statError.code === 'ENOENT') {
|
|
2034
|
-
await backoff();
|
|
2035
|
-
continue;
|
|
2036
|
-
}
|
|
2037
|
-
throw statError;
|
|
2038
|
-
}
|
|
2039
|
-
if (evictStat.mtimeMs + EVICT_TTL > now())
|
|
2040
|
-
return null;
|
|
2041
|
-
await options.onStep?.('stale-marker-stat');
|
|
2042
|
-
const claimedPath = `${evictPath}.${randomUUID()}`;
|
|
2043
|
-
try {
|
|
2044
|
-
await rename(evictPath, claimedPath);
|
|
2045
|
-
}
|
|
2046
|
-
catch (renameError) {
|
|
2047
|
-
if (renameError.code === 'ENOENT') {
|
|
2048
|
-
await backoff();
|
|
2049
|
-
continue;
|
|
2050
|
-
}
|
|
2051
|
-
throw renameError;
|
|
2052
|
-
}
|
|
2053
|
-
await options.onStep?.('stale-marker-claimed');
|
|
2054
|
-
await rm(claimedPath, { recursive: true, force: true }).catch(() => { });
|
|
2055
|
-
await backoff();
|
|
2056
|
-
continue;
|
|
2057
|
-
}
|
|
2058
|
-
try {
|
|
2059
|
-
if (await lockIsLive())
|
|
2060
|
-
return null;
|
|
2061
|
-
if (!(await ownsEvictionMarker()))
|
|
2062
|
-
return null;
|
|
2063
|
-
await options.onStep?.('stale-lock-confirmed');
|
|
2064
|
-
if (!(await ownsEvictionMarker()))
|
|
2065
|
-
return null;
|
|
2066
|
-
await rm(lockPath, { recursive: true, force: true }).catch(() => { });
|
|
2067
|
-
if (!(await ownsEvictionMarker()))
|
|
2068
|
-
return null;
|
|
2069
|
-
acquired = await tryAcquire();
|
|
2070
|
-
if (!acquired)
|
|
2071
|
-
return null;
|
|
2072
|
-
if (!(await ownsEvictionMarker())) {
|
|
2073
|
-
await rm(lockPath, { recursive: true, force: true }).catch(() => { });
|
|
2074
|
-
acquired = false;
|
|
2075
|
-
return null;
|
|
2076
|
-
}
|
|
2077
|
-
break;
|
|
2078
|
-
}
|
|
2079
|
-
finally {
|
|
2080
|
-
await releaseEvictionMarker();
|
|
2081
|
-
}
|
|
2082
|
-
}
|
|
2083
|
-
}
|
|
2084
|
-
if (!acquired)
|
|
2085
|
-
return null;
|
|
2086
|
-
scheduleRenewal();
|
|
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
|
-
},
|
|
2094
|
-
release: async () => {
|
|
2095
|
-
released = true;
|
|
2096
|
-
if (renewTimer) {
|
|
2097
|
-
clearRefreshLockRenewalTimeout(renewTimer);
|
|
2098
|
-
renewTimer = null;
|
|
2099
|
-
}
|
|
2100
|
-
try {
|
|
2101
|
-
const owner = await readOwner();
|
|
2102
|
-
if (owner?.ownerId !== ownerId)
|
|
2103
|
-
return;
|
|
2104
|
-
}
|
|
2105
|
-
catch {
|
|
2106
|
-
return;
|
|
2107
|
-
}
|
|
2108
|
-
await rm(lockPath, { recursive: true, force: true }).catch(() => { });
|
|
2109
|
-
},
|
|
2110
|
-
};
|
|
2111
|
-
}
|
|
2112
|
-
export function isCache1hPersistentlyEnabled(storage) {
|
|
2113
|
-
return storage?.claudeCache?.enabled === true;
|
|
2114
|
-
}
|
|
2115
|
-
function normalizeCache1hMode(value) {
|
|
2116
|
-
return typeof value === 'string' &&
|
|
2117
|
-
CACHE_1H_MODES.includes(value)
|
|
2118
|
-
? value
|
|
2119
|
-
: DEFAULT_CACHE_1H_MODE;
|
|
2120
|
-
}
|
|
2121
|
-
export function getCache1hPersistentMode(storage) {
|
|
2122
|
-
return normalizeCache1hMode(storage?.claudeCache?.mode);
|
|
2123
|
-
}
|
|
2124
|
-
export async function setCache1hPersistentEnabled(enabled, mode, path = getAccountStoragePath()) {
|
|
2125
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
2126
|
-
storage.claudeCache = {
|
|
2127
|
-
...(storage.claudeCache ?? {}),
|
|
2128
|
-
enabled,
|
|
2129
|
-
mode: mode ?? getCache1hPersistentMode(storage),
|
|
2130
|
-
};
|
|
2131
|
-
await saveAccounts(storage, path);
|
|
2132
|
-
return storage;
|
|
2133
|
-
}
|
|
2134
|
-
export async function setCache1hPersistentMode(mode, path = getAccountStoragePath()) {
|
|
2135
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
2136
|
-
storage.claudeCache = {
|
|
2137
|
-
...(storage.claudeCache ?? {}),
|
|
2138
|
-
enabled: storage.claudeCache?.enabled === true,
|
|
2139
|
-
mode,
|
|
2140
|
-
};
|
|
2141
|
-
await saveAccounts(storage, path);
|
|
2142
|
-
return storage;
|
|
2143
|
-
}
|
|
2144
|
-
export function isDumpPersistentlyEnabled(storage) {
|
|
2145
|
-
return storage?.dump?.enabled === true;
|
|
2146
|
-
}
|
|
2147
|
-
export async function setDumpPersistentEnabled(enabled, path = getAccountStoragePath()) {
|
|
2148
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
2149
|
-
storage.dump = {
|
|
2150
|
-
...(storage.dump ?? {}),
|
|
2151
|
-
enabled,
|
|
2152
|
-
};
|
|
2153
|
-
await saveAccounts(storage, path);
|
|
2154
|
-
return storage;
|
|
2155
|
-
}
|
|
2156
|
-
export function isFastModePersistentlyEnabled(storage) {
|
|
2157
|
-
return storage?.claudeFast?.enabled === true;
|
|
2158
|
-
}
|
|
2159
|
-
export async function setFastModePersistentEnabled(enabled, path = getAccountStoragePath()) {
|
|
2160
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
2161
|
-
storage.claudeFast = {
|
|
2162
|
-
...(storage.claudeFast ?? {}),
|
|
2163
|
-
enabled,
|
|
2164
|
-
};
|
|
2165
|
-
await saveAccounts(storage, path);
|
|
2166
|
-
return storage;
|
|
2167
|
-
}
|
|
2168
|
-
export async function setCacheKeepPersistentWindow(startHour, endHour, path = getAccountStoragePath()) {
|
|
2169
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
2170
|
-
storage.cacheKeep = {
|
|
2171
|
-
...(storage.cacheKeep ?? {}),
|
|
2172
|
-
enabled: true,
|
|
2173
|
-
always: false,
|
|
2174
|
-
startHour,
|
|
2175
|
-
endHour,
|
|
2176
|
-
};
|
|
2177
|
-
await saveAccounts(storage, path);
|
|
2178
|
-
return storage;
|
|
2179
|
-
}
|
|
2180
|
-
export async function setCacheKeepPersistentAlways(path = getAccountStoragePath()) {
|
|
2181
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
2182
|
-
storage.cacheKeep = {
|
|
2183
|
-
...(storage.cacheKeep ?? {}),
|
|
2184
|
-
enabled: true,
|
|
2185
|
-
always: true,
|
|
2186
|
-
};
|
|
2187
|
-
delete storage.cacheKeep.startHour;
|
|
2188
|
-
delete storage.cacheKeep.endHour;
|
|
2189
|
-
await saveAccounts(storage, path);
|
|
2190
|
-
return storage;
|
|
2191
|
-
}
|
|
2192
|
-
export async function setCacheKeepPersistentEnabled(enabled, path = getAccountStoragePath()) {
|
|
2193
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
2194
|
-
storage.cacheKeep = {
|
|
2195
|
-
...(storage.cacheKeep ?? {}),
|
|
2196
|
-
enabled,
|
|
2197
|
-
};
|
|
2198
|
-
await saveAccounts(storage, path);
|
|
2199
|
-
return storage;
|
|
2200
|
-
}
|
|
2201
|
-
export function isCacheKeepSubagentsEnabled(storage) {
|
|
2202
|
-
return storage?.cacheKeep?.subagents === true;
|
|
2203
|
-
}
|
|
2204
|
-
export async function setCacheKeepSubagentsEnabled(enabled, path = getAccountStoragePath()) {
|
|
2205
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
2206
|
-
storage.cacheKeep = {
|
|
2207
|
-
...(storage.cacheKeep ?? {}),
|
|
2208
|
-
subagents: enabled,
|
|
2209
|
-
};
|
|
2210
|
-
await saveAccounts(storage, path);
|
|
2211
|
-
return storage;
|
|
2212
|
-
}
|
|
2213
|
-
export function isPrimePersistentlyEnabled(storage) {
|
|
2214
|
-
return storage?.prime?.enabled === true;
|
|
2215
|
-
}
|
|
2216
|
-
export async function setPrimePersistentEnabled(enabled, path = getAccountStoragePath()) {
|
|
2217
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
2218
|
-
storage.prime = {
|
|
2219
|
-
...(storage.prime ?? {}),
|
|
2220
|
-
enabled,
|
|
2221
|
-
};
|
|
2222
|
-
await saveAccounts(storage, path);
|
|
2223
|
-
return storage;
|
|
2224
|
-
}
|
|
2225
|
-
/** Return the stable prime marker identity for an OAuth account. */
|
|
2226
|
-
export async function getOrCreatePrimeAuthLineageId(accountId, path = getAccountStoragePath()) {
|
|
2227
|
-
return enqueueSave(async () => {
|
|
2228
|
-
const configLock = await acquireAccountConfigWriteLock(path);
|
|
2229
|
-
try {
|
|
2230
|
-
const stateLock = await acquireAccountStateWriteLock(path);
|
|
2231
|
-
try {
|
|
2232
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
2233
|
-
if (accountId === 'main') {
|
|
2234
|
-
const existing = storage.prime?.mainAuthLineageId;
|
|
2235
|
-
if (existing)
|
|
2236
|
-
return existing;
|
|
2237
|
-
const mainAccountId = storage.mainAccountId ?? randomUUID();
|
|
2238
|
-
if (!storage.mainAccountId) {
|
|
2239
|
-
const existingConfig = await loadExistingTopLevelFields(path);
|
|
2240
|
-
await writeJsonAtomic(path, {
|
|
2241
|
-
...existingConfig,
|
|
2242
|
-
...configFromStorage({ ...storage, mainAccountId }),
|
|
2243
|
-
});
|
|
2244
|
-
storage.mainAccountId = mainAccountId;
|
|
2245
|
-
}
|
|
2246
|
-
storage.prime = {
|
|
2247
|
-
...(storage.prime ?? {}),
|
|
2248
|
-
mainAuthLineageId: mainAccountId,
|
|
2249
|
-
};
|
|
2250
|
-
await saveAccountStateUnlocked(storage, path, { mainPrime: true });
|
|
2251
|
-
return mainAccountId;
|
|
2252
|
-
}
|
|
2253
|
-
const account = storage.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
|
|
2254
|
-
if (!account) {
|
|
2255
|
-
throw new Error(`getOrCreatePrimeAuthLineageId: OAuth account "${accountId}" not found`);
|
|
2256
|
-
}
|
|
2257
|
-
if (account.authLineageId)
|
|
2258
|
-
return account.authLineageId;
|
|
2259
|
-
// Upgrading changes the marker namespace once, so an already-primed
|
|
2260
|
-
// window may fire again; persisting the seed prevents every later token
|
|
2261
|
-
// rotation from repeating that migration cost.
|
|
2262
|
-
const authLineageId = randomUUID();
|
|
2263
|
-
account.authLineageId = authLineageId;
|
|
2264
|
-
await saveAccountStateUnlocked(storage, path, {
|
|
2265
|
-
accounts: [accountId],
|
|
2266
|
-
});
|
|
2267
|
-
return authLineageId;
|
|
2268
|
-
}
|
|
2269
|
-
finally {
|
|
2270
|
-
await stateLock.release();
|
|
2271
|
-
}
|
|
2272
|
-
}
|
|
2273
|
-
finally {
|
|
2274
|
-
await configLock.release();
|
|
2275
|
-
}
|
|
2276
|
-
});
|
|
2277
|
-
}
|
|
2278
|
-
/**
|
|
2279
|
-
* Atomically increment an account's cumulative prime counters and persist via
|
|
2280
|
-
* the scoped runtime-state path. The `main` account lives at state.main.prime;
|
|
2281
|
-
* every other account lives at state.accounts[id].prime. Config-side writes
|
|
2282
|
-
* are intentionally NOT triggered so prime counters cannot leak into
|
|
2283
|
-
* `anthropic-auth.json`. Callers should not depend on this function to mutate
|
|
2284
|
-
* the caller's storage object.
|
|
2285
|
-
*/
|
|
2286
|
-
export async function incrementPrimeUsagePersistent(accountId, usage, path = getAccountStoragePath(), now = Date.now()) {
|
|
2287
|
-
const inputTokens = Number.isFinite(usage?.inputTokens)
|
|
2288
|
-
? Math.max(0, Math.floor(usage.inputTokens))
|
|
2289
|
-
: 0;
|
|
2290
|
-
const outputTokens = Number.isFinite(usage?.outputTokens)
|
|
2291
|
-
? Math.max(0, Math.floor(usage.outputTokens))
|
|
2292
|
-
: 0;
|
|
2293
|
-
return enqueueSave(async () => {
|
|
2294
|
-
// The config-write lock is the repository-wide outer lock for config and
|
|
2295
|
-
// runtime-state RMW operations; taking it before the state write preserves
|
|
2296
|
-
// saveAccountsLocked's config → state ordering across processes.
|
|
2297
|
-
const lock = await acquireAccountConfigWriteLock(path);
|
|
2298
|
-
try {
|
|
2299
|
-
const stateLock = await acquireAccountStateWriteLock(path);
|
|
2300
|
-
try {
|
|
2301
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
2302
|
-
if (accountId === 'main') {
|
|
2303
|
-
const existing = storage.prime?.main;
|
|
2304
|
-
const next = {
|
|
2305
|
-
count: (existing?.count ?? 0) + 1,
|
|
2306
|
-
inputTokens: (existing?.inputTokens ?? 0) + inputTokens,
|
|
2307
|
-
outputTokens: (existing?.outputTokens ?? 0) + outputTokens,
|
|
2308
|
-
since: existing?.since ?? Math.floor(now),
|
|
2309
|
-
};
|
|
2310
|
-
storage.prime = { ...(storage.prime ?? {}), main: next };
|
|
2311
|
-
await saveAccountStateUnlocked(storage, path, { mainPrime: true });
|
|
2312
|
-
return next;
|
|
2313
|
-
}
|
|
2314
|
-
const index = storage.accounts.findIndex((account) => account.id === accountId);
|
|
2315
|
-
if (index < 0) {
|
|
2316
|
-
throw new Error(`incrementPrimeUsagePersistent: account "${accountId}" not found`);
|
|
2317
|
-
}
|
|
2318
|
-
const account = storage.accounts[index];
|
|
2319
|
-
const existing = account.prime;
|
|
2320
|
-
const next = {
|
|
2321
|
-
count: (existing?.count ?? 0) + 1,
|
|
2322
|
-
inputTokens: (existing?.inputTokens ?? 0) + inputTokens,
|
|
2323
|
-
outputTokens: (existing?.outputTokens ?? 0) + outputTokens,
|
|
2324
|
-
since: existing?.since ?? Math.floor(now),
|
|
2325
|
-
};
|
|
2326
|
-
storage.accounts[index] = { ...account, prime: next };
|
|
2327
|
-
await saveAccountStateUnlocked(storage, path, {
|
|
2328
|
-
accounts: [accountId],
|
|
2329
|
-
});
|
|
2330
|
-
return next;
|
|
2331
|
-
}
|
|
2332
|
-
finally {
|
|
2333
|
-
await stateLock.release();
|
|
2334
|
-
}
|
|
2335
|
-
}
|
|
2336
|
-
finally {
|
|
2337
|
-
await lock.release();
|
|
2338
|
-
}
|
|
2339
|
-
});
|
|
2340
|
-
}
|
|
2341
|
-
function getFallbackStatuses(storage) {
|
|
2342
|
-
return storage?.fallbackOn?.length ? storage.fallbackOn : DEFAULT_FALLBACK_ON;
|
|
2343
|
-
}
|
|
2344
|
-
export function shouldFallbackStatus(status, storage) {
|
|
2345
|
-
return getFallbackStatuses(storage).includes(status);
|
|
2346
|
-
}
|
|
2347
|
-
export function getQuotaMinimumRemainingThresholds(storage) {
|
|
2348
|
-
const configured = storage?.quota?.minimumRemaining || {};
|
|
2349
|
-
return {
|
|
2350
|
-
five_hour: configured.five_hour ??
|
|
2351
|
-
configured['5h'] ??
|
|
2352
|
-
DEFAULT_MINIMUM_REMAINING.five_hour,
|
|
2353
|
-
seven_day: configured.seven_day ??
|
|
2354
|
-
configured['1w'] ??
|
|
2355
|
-
DEFAULT_MINIMUM_REMAINING.seven_day,
|
|
2356
|
-
};
|
|
2357
|
-
}
|
|
2358
|
-
function quotaEnabled(storage) {
|
|
2359
|
-
return storage?.quota?.enabled !== false;
|
|
2360
|
-
}
|
|
2361
|
-
function refreshEnabled(storage) {
|
|
2362
|
-
return storage?.refresh?.enabled !== false;
|
|
2363
|
-
}
|
|
2364
|
-
function jitterMs(maxMs) {
|
|
2365
|
-
return Math.floor(Math.random() * Math.max(0, maxMs));
|
|
2366
|
-
}
|
|
2367
|
-
function refreshBeforeExpiryMs(storage) {
|
|
2368
|
-
const minutes = storage?.refresh?.refreshBeforeExpiryMinutes ??
|
|
2369
|
-
DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES;
|
|
2370
|
-
return Math.max(MIN_REFRESH_BEFORE_EXPIRY_MINUTES, minutes) * 60_000;
|
|
2371
|
-
}
|
|
2372
|
-
export function getRefreshBeforeExpiryMs(storage) {
|
|
2373
|
-
return refreshBeforeExpiryMs(storage);
|
|
2374
|
-
}
|
|
2375
|
-
export function getRefreshIntervalMs(storage) {
|
|
2376
|
-
const minutes = storage?.refresh?.intervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
|
|
2377
|
-
return Math.max(1, minutes) * 60_000;
|
|
2378
|
-
}
|
|
2379
|
-
export function hashRefreshToken(refreshToken) {
|
|
2380
|
-
return createHash('sha256').update(refreshToken).digest('hex');
|
|
2381
|
-
}
|
|
2382
|
-
function isTransientRefreshError(error) {
|
|
2383
|
-
const status = error.status;
|
|
2384
|
-
if (typeof status === 'number' && Number.isFinite(status)) {
|
|
2385
|
-
return status === 429 || status >= 500;
|
|
2386
|
-
}
|
|
2387
|
-
return isTransientNetworkError(error);
|
|
2388
|
-
}
|
|
2389
|
-
export function buildRefreshOperationError(input) {
|
|
2390
|
-
const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity &&
|
|
2391
|
-
input.previous?.refreshTokenFingerprint === input.refreshTokenFingerprint
|
|
2392
|
-
? (input.previous?.retryCount ?? 0)
|
|
2393
|
-
: 0;
|
|
2394
|
-
const retryCount = previousRetryCount + 1;
|
|
2395
|
-
const retryAfterFromError = input.error
|
|
2396
|
-
.retryAfter;
|
|
2397
|
-
let delay;
|
|
2398
|
-
if (typeof retryAfterFromError === 'number' && retryAfterFromError > 0) {
|
|
2399
|
-
delay = retryAfterFromError * 1000;
|
|
2400
|
-
}
|
|
2401
|
-
else if (isTransientNetworkError(input.error)) {
|
|
2402
|
-
delay = MIN_REFRESH_RETRY_DELAY_MS;
|
|
2403
|
-
}
|
|
2404
|
-
else if (isTransientRefreshError(input.error)) {
|
|
2405
|
-
delay = Math.min(MAX_REFRESH_RETRY_DELAY_MS, MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6));
|
|
2406
|
-
}
|
|
2407
|
-
else {
|
|
2408
|
-
delay = NON_TRANSIENT_REFRESH_RETRY_DELAY_MS;
|
|
2409
|
-
}
|
|
2410
|
-
const statusFromError = input.error.status;
|
|
2411
|
-
const status = typeof statusFromError === 'number' && Number.isFinite(statusFromError)
|
|
2412
|
-
? statusFromError
|
|
2413
|
-
: undefined;
|
|
2414
|
-
const message = formatErrorMessage(input.error);
|
|
2415
|
-
// A token is permanently dead ONLY on 400 invalid_grant. The OAuth spec allows
|
|
2416
|
-
// other 400s (invalid_client / invalid_request / unsupported_grant_type) that
|
|
2417
|
-
// re-login does NOT fix — those must stay permanent=false so they are not
|
|
2418
|
-
// falsely flagged "needs re-login". Network failures use a fixed five-minute
|
|
2419
|
-
// retry interval so connectivity recovery cannot inherit an hour-long backoff.
|
|
2420
|
-
// ClaudeOAuthRefreshError carries the raw
|
|
2421
|
-
// OAuth body, and its message embeds it (`...: 400 — <body>`), so check both.
|
|
2422
|
-
const body = typeof input.error.body === 'string'
|
|
2423
|
-
? input.error.body
|
|
2424
|
-
: '';
|
|
2425
|
-
const isInvalidGrant = body.includes('invalid_grant') || message.includes('invalid_grant');
|
|
2426
|
-
return {
|
|
2427
|
-
message,
|
|
2428
|
-
checkedAt: input.now,
|
|
2429
|
-
nextRetryAt: input.now + delay,
|
|
2430
|
-
retryCount,
|
|
2431
|
-
accountIdentity: input.accountIdentity,
|
|
2432
|
-
refreshTokenFingerprint: input.refreshTokenFingerprint,
|
|
2433
|
-
status,
|
|
2434
|
-
permanent: status === 400 && isInvalidGrant,
|
|
2435
|
-
};
|
|
2436
|
-
}
|
|
2437
|
-
/**
|
|
2438
|
-
* True when a refresh error means the token is permanently dead and the account
|
|
2439
|
-
* needs a re-login (vs a transient failure that recovers).
|
|
2440
|
-
*
|
|
2441
|
-
* Precedence:
|
|
2442
|
-
* 1. the explicit `permanent` flag (set at construction from 400 invalid_grant)
|
|
2443
|
-
* — the authoritative signal; correctly classifies a retry-exhausted/network
|
|
2444
|
-
* error (long backoff, but NOT dead) as non-permanent;
|
|
2445
|
-
* 2. else the captured HTTP `status` — 400 (for errors built before `permanent`
|
|
2446
|
-
* existed but after `status`);
|
|
2447
|
-
* 3. else the legacy 24h-delay heuristic — back-compat ONLY for errors persisted
|
|
2448
|
-
* before either field existed (e.g. an operator's already-dead token: no
|
|
2449
|
-
* status, ~24h backoff). It still flags those until the next refresh restamps
|
|
2450
|
-
* the error with the explicit field.
|
|
2451
|
-
*/
|
|
2452
|
-
export function isPermanentRefreshError(error) {
|
|
2453
|
-
if (!error)
|
|
2454
|
-
return false;
|
|
2455
|
-
if (typeof error.permanent === 'boolean')
|
|
2456
|
-
return error.permanent;
|
|
2457
|
-
if (typeof error.status === 'number')
|
|
2458
|
-
return error.status === 400;
|
|
2459
|
-
if (isTransientNetworkError(error))
|
|
2460
|
-
return false;
|
|
2461
|
-
if (typeof error.nextRetryAt === 'number') {
|
|
2462
|
-
return (error.nextRetryAt - error.checkedAt >=
|
|
2463
|
-
NON_TRANSIENT_REFRESH_RETRY_DELAY_MS);
|
|
2464
|
-
}
|
|
2465
|
-
return false;
|
|
2466
|
-
}
|
|
2467
|
-
function effectiveRefreshRetryAt(error) {
|
|
2468
|
-
const persistedRetryAt = error.nextRetryAt;
|
|
2469
|
-
if (!persistedRetryAt || !isTransientNetworkError(error)) {
|
|
2470
|
-
return persistedRetryAt;
|
|
2471
|
-
}
|
|
2472
|
-
return Math.min(persistedRetryAt, error.checkedAt + MIN_REFRESH_RETRY_DELAY_MS);
|
|
2473
|
-
}
|
|
2474
|
-
export function refreshBackoffActive(error, accountIdentity, now, currentRefreshTokenFingerprint) {
|
|
2475
|
-
if (!error)
|
|
2476
|
-
return false;
|
|
2477
|
-
const retryAt = effectiveRefreshRetryAt(error);
|
|
2478
|
-
if (!retryAt || retryAt <= now)
|
|
2479
|
-
return false;
|
|
2480
|
-
if (error.refreshTokenFingerprint &&
|
|
2481
|
-
currentRefreshTokenFingerprint &&
|
|
2482
|
-
error.refreshTokenFingerprint !== currentRefreshTokenFingerprint) {
|
|
2483
|
-
return false;
|
|
2484
|
-
}
|
|
2485
|
-
if (!error.accountIdentity)
|
|
2486
|
-
return true;
|
|
2487
|
-
if (!accountIdentity)
|
|
2488
|
-
return true;
|
|
2489
|
-
return error.accountIdentity === accountIdentity;
|
|
2490
|
-
}
|
|
2491
|
-
export function formatRefreshBackoffMessage(error, now) {
|
|
2492
|
-
const seconds = Math.max(1, Math.ceil(((effectiveRefreshRetryAt(error) ?? now) - now) / 1000));
|
|
2493
|
-
return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
|
|
2494
|
-
}
|
|
2495
|
-
function createRefreshBackoffActiveError(error, now) {
|
|
2496
|
-
return Object.assign(new Error(formatRefreshBackoffMessage(error, now)), {
|
|
2497
|
-
refreshBackoffError: error,
|
|
2498
|
-
});
|
|
2499
|
-
}
|
|
2500
|
-
function existingRefreshBackoffError(error) {
|
|
2501
|
-
if (!error || typeof error !== 'object')
|
|
2502
|
-
return undefined;
|
|
2503
|
-
const existing = error
|
|
2504
|
-
.refreshBackoffError;
|
|
2505
|
-
return existing && typeof existing.message === 'string' ? existing : undefined;
|
|
2506
|
-
}
|
|
2507
|
-
export function getFallbackReauthLabels(storage) {
|
|
2508
|
-
if (!storage)
|
|
2509
|
-
return [];
|
|
2510
|
-
return storage.accounts
|
|
2511
|
-
.filter((account) => account.enabled !== false &&
|
|
2512
|
-
isOAuthAccount(account) &&
|
|
2513
|
-
isPermanentRefreshError(account.lastRefreshError))
|
|
2514
|
-
.map((account) => account.label?.trim() || account.id);
|
|
2515
|
-
}
|
|
2516
|
-
export function isQuotaPolicyAuthError(error) {
|
|
2517
|
-
const status = error.status;
|
|
2518
|
-
if (status === 403)
|
|
2519
|
-
return true;
|
|
2520
|
-
return /Claude quota check failed: 403\b/.test(formatErrorMessage(error));
|
|
2521
|
-
}
|
|
2522
|
-
export function buildQuotaOperationError(input) {
|
|
2523
|
-
const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity
|
|
2524
|
-
? (input.previous?.retryCount ?? 0)
|
|
2525
|
-
: 0;
|
|
2526
|
-
const retryCount = previousRetryCount + 1;
|
|
2527
|
-
const delay = isTransientQuotaError(input.error)
|
|
2528
|
-
? Math.min(MAX_QUOTA_RETRY_DELAY_MS, MIN_QUOTA_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6))
|
|
2529
|
-
: NON_TRANSIENT_QUOTA_RETRY_DELAY_MS;
|
|
2530
|
-
return {
|
|
2531
|
-
message: formatErrorMessage(input.error),
|
|
2532
|
-
checkedAt: input.now,
|
|
2533
|
-
nextRetryAt: input.now + delay,
|
|
2534
|
-
retryCount,
|
|
2535
|
-
...(input.accountIdentity !== undefined && {
|
|
2536
|
-
accountIdentity: input.accountIdentity,
|
|
2537
|
-
}),
|
|
2538
|
-
};
|
|
2539
|
-
}
|
|
2540
|
-
export function quotaBackoffActive(error, now) {
|
|
2541
|
-
if (!error?.nextRetryAt || error.nextRetryAt <= now)
|
|
2542
|
-
return false;
|
|
2543
|
-
return true;
|
|
2544
|
-
}
|
|
2545
|
-
export function formatQuotaBackoffMessage(error, now) {
|
|
2546
|
-
const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
|
|
2547
|
-
return `Quota API backed off for ${seconds}s after: ${error.message}`;
|
|
2548
|
-
}
|
|
2549
|
-
export function getQuotaCheckIntervalMs(storage) {
|
|
2550
|
-
const minutes = storage?.quota?.checkIntervalMinutes ?? DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES;
|
|
2551
|
-
return Math.max(1, minutes) * 60_000;
|
|
2552
|
-
}
|
|
2553
|
-
export function getPersistedLogLevel(storage) {
|
|
2554
|
-
return storage?.logging?.level;
|
|
2555
|
-
}
|
|
2556
|
-
export async function setLogLevelPersistent(level, path = getAccountStoragePath()) {
|
|
2557
|
-
const { setLogLevel } = await import('./logger.js');
|
|
2558
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
2559
|
-
storage.logging = {
|
|
2560
|
-
...(storage.logging ?? {}),
|
|
2561
|
-
level,
|
|
2562
|
-
};
|
|
2563
|
-
await saveAccounts(storage, path);
|
|
2564
|
-
setLogLevel(level);
|
|
2565
|
-
}
|
|
2566
|
-
export function getPersistedMainQuota(storage) {
|
|
2567
|
-
const quota = storage?.quota?.mainQuota;
|
|
2568
|
-
if (!quota)
|
|
2569
|
-
return null;
|
|
2570
|
-
return {
|
|
2571
|
-
quota,
|
|
2572
|
-
checkedAt: quotaSnapshotCheckedAt(quota),
|
|
2573
|
-
tokenFingerprint: storage.quota?.mainQuotaToken,
|
|
2574
|
-
accountIdentity: quota.accountIdentity,
|
|
2575
|
-
};
|
|
2576
|
-
}
|
|
2577
|
-
/**
|
|
2578
|
-
* How often (in requests) to force a quota refresh, independent of the timer.
|
|
2579
|
-
* Returns 0 when disabled (default).
|
|
2580
|
-
*/
|
|
2581
|
-
export function getQuotaRefreshEveryNRequests(storage) {
|
|
2582
|
-
const n = storage?.quota?.refreshEveryNRequests;
|
|
2583
|
-
return typeof n === 'number' && Number.isFinite(n) && n > 0
|
|
2584
|
-
? Math.floor(n)
|
|
2585
|
-
: 0;
|
|
2586
|
-
}
|
|
2587
|
-
function failClosedOnUnknownQuota(storage) {
|
|
2588
|
-
return (storage?.quota?.failClosedOnUnknownQuota ??
|
|
2589
|
-
DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA);
|
|
2590
|
-
}
|
|
2591
|
-
function normalizeScopedQuotaModel(value) {
|
|
2592
|
-
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
2593
|
-
}
|
|
2594
|
-
function scopedQuotaModelKey(model) {
|
|
2595
|
-
if (typeof model !== 'string')
|
|
2596
|
-
return null;
|
|
2597
|
-
const normalized = normalizeScopedQuotaModel(model);
|
|
2598
|
-
if (normalized.includes('fable'))
|
|
2599
|
-
return 'fable';
|
|
2600
|
-
if (normalized.includes('mythos'))
|
|
2601
|
-
return 'mythos';
|
|
2602
|
-
return normalized;
|
|
2603
|
-
}
|
|
2604
|
-
export function getScopedQuotaWindowForModel(quota, model) {
|
|
2605
|
-
const key = scopedQuotaModelKey(model);
|
|
2606
|
-
if (!key)
|
|
2607
|
-
return undefined;
|
|
2608
|
-
return quota?.scoped?.find((window) => {
|
|
2609
|
-
const haystack = [window.modelId, window.modelName, window.title]
|
|
2610
|
-
.filter((value) => typeof value === 'string')
|
|
2611
|
-
.map(normalizeScopedQuotaModel)
|
|
2612
|
-
.join(' ');
|
|
2613
|
-
return haystack.includes(key);
|
|
2614
|
-
});
|
|
2615
|
-
}
|
|
2616
|
-
export function quotaSnapshotModelScopeIsExhausted(quota, model) {
|
|
2617
|
-
const window = getScopedQuotaWindowForModel(quota, model);
|
|
2618
|
-
return Boolean(window &&
|
|
2619
|
-
Number.isFinite(window.remainingPercent) &&
|
|
2620
|
-
window.remainingPercent <= 0);
|
|
2621
|
-
}
|
|
2622
|
-
export function quotaSnapshotPassesModelScope(quota, model) {
|
|
2623
|
-
return !quotaSnapshotModelScopeIsExhausted(quota, model);
|
|
2624
|
-
}
|
|
2625
|
-
export function quotaSnapshotPassesPolicy(quota, storage) {
|
|
2626
|
-
if (!quotaEnabled(storage))
|
|
2627
|
-
return true;
|
|
2628
|
-
const thresholds = getQuotaMinimumRemainingThresholds(storage);
|
|
2629
|
-
for (const key of ['five_hour', 'seven_day']) {
|
|
2630
|
-
const window = quota?.[key];
|
|
2631
|
-
if (!window)
|
|
2632
|
-
return !failClosedOnUnknownQuota(storage);
|
|
2633
|
-
if (!Number.isFinite(window.remainingPercent)) {
|
|
2634
|
-
return !failClosedOnUnknownQuota(storage);
|
|
2635
|
-
}
|
|
2636
|
-
if (window.remainingPercent < thresholds[key])
|
|
2637
|
-
return false;
|
|
2638
|
-
}
|
|
2639
|
-
return true;
|
|
2640
|
-
}
|
|
2641
|
-
export function quotaSnapshotHasStandardWindows(quota) {
|
|
2642
|
-
return Boolean(quota?.five_hour && quota.seven_day);
|
|
2643
|
-
}
|
|
2644
|
-
// ---------------------------------------------------------------------------
|
|
2645
|
-
// Killswitch — hard-block requests when remaining quota drops below per-account
|
|
2646
|
-
// thresholds, even if the API would still accept them.
|
|
2647
|
-
// ---------------------------------------------------------------------------
|
|
2648
|
-
export const DEFAULT_KILLSWITCH_THRESHOLDS = {
|
|
2649
|
-
five_hour: 5,
|
|
2650
|
-
seven_day: 10,
|
|
2651
|
-
scoped: 0,
|
|
2652
|
-
};
|
|
2653
|
-
export function normalizeKillswitchThresholds(thresholds) {
|
|
2654
|
-
const fiveHour = thresholds?.five_hour ?? thresholds?.['5h'];
|
|
2655
|
-
const sevenDay = thresholds?.seven_day ?? thresholds?.['1w'];
|
|
2656
|
-
const scoped = thresholds?.scoped;
|
|
2657
|
-
return {
|
|
2658
|
-
five_hour: typeof fiveHour === 'number' && Number.isFinite(fiveHour)
|
|
2659
|
-
? fiveHour
|
|
2660
|
-
: DEFAULT_KILLSWITCH_THRESHOLDS.five_hour,
|
|
2661
|
-
seven_day: typeof sevenDay === 'number' && Number.isFinite(sevenDay)
|
|
2662
|
-
? sevenDay
|
|
2663
|
-
: DEFAULT_KILLSWITCH_THRESHOLDS.seven_day,
|
|
2664
|
-
scoped: typeof scoped === 'number' && Number.isFinite(scoped)
|
|
2665
|
-
? scoped
|
|
2666
|
-
: DEFAULT_KILLSWITCH_THRESHOLDS.scoped,
|
|
2667
|
-
};
|
|
2668
|
-
}
|
|
2669
|
-
export function isKillswitchEnabled(storage) {
|
|
2670
|
-
return storage?.killswitch?.enabled === true;
|
|
2671
|
-
}
|
|
2672
|
-
export function getKillswitchThresholdsForAccount(storage, accountId) {
|
|
2673
|
-
if (!storage?.killswitch)
|
|
2674
|
-
return DEFAULT_KILLSWITCH_THRESHOLDS;
|
|
2675
|
-
if (accountId && storage.killswitch.accounts?.[accountId]) {
|
|
2676
|
-
return normalizeKillswitchThresholds(storage.killswitch.accounts[accountId]);
|
|
2677
|
-
}
|
|
2678
|
-
return normalizeKillswitchThresholds(storage.killswitch.main);
|
|
2679
|
-
}
|
|
2680
|
-
/**
|
|
2681
|
-
* Returns true if the account's quota is above its killswitch threshold.
|
|
2682
|
-
* When killswitch is disabled, always returns true.
|
|
2683
|
-
*
|
|
2684
|
-
* When `modelId` is provided, the per-account `scoped` threshold is also
|
|
2685
|
-
* evaluated against the quota window matching that model — additive to the
|
|
2686
|
-
* 5h/7d check. A model with no matching scoped window is unaffected.
|
|
2687
|
-
*/
|
|
2688
|
-
export function killswitchPassesPolicy(quota, storage, accountId, modelId) {
|
|
2689
|
-
if (!isKillswitchEnabled(storage))
|
|
2690
|
-
return true;
|
|
2691
|
-
const thresholds = getKillswitchThresholdsForAccount(storage, accountId);
|
|
2692
|
-
let sawUnknownWindow = false;
|
|
2693
|
-
for (const key of ['five_hour', 'seven_day']) {
|
|
2694
|
-
const window = quota?.[key];
|
|
2695
|
-
// Defer the unknown-window decision: a quota snapshot can legally carry
|
|
2696
|
-
// only one window, and a present window below its threshold must still
|
|
2697
|
-
// block even if the other window is missing.
|
|
2698
|
-
if (!window) {
|
|
2699
|
-
sawUnknownWindow = true;
|
|
2700
|
-
continue;
|
|
2701
|
-
}
|
|
2702
|
-
if (!Number.isFinite(window.remainingPercent)) {
|
|
2703
|
-
sawUnknownWindow = true;
|
|
2704
|
-
continue;
|
|
2705
|
-
}
|
|
2706
|
-
if (window.remainingPercent < thresholds[key])
|
|
2707
|
-
return false;
|
|
2708
|
-
}
|
|
2709
|
-
// Scoped check is additive to the 5h/7d evaluation above and is an
|
|
2710
|
-
// INDEPENDENT block reason — it must run before the unknown-window
|
|
2711
|
-
// fail-closed decision, so an exhausted scoped window blocks even when
|
|
2712
|
-
// 5h/7d is missing/non-finite (the latter only changes the fall-through
|
|
2713
|
-
// for accounts that did not already block on scoped). A missing scoped
|
|
2714
|
-
// window (no carve-out for this model) is not "unknown quota" — only a
|
|
2715
|
-
// PRESENT window at/below threshold blocks. The comparison is inclusive
|
|
2716
|
-
// (`<=`) so the default 0 fires at exhaustion.
|
|
2717
|
-
if (modelId) {
|
|
2718
|
-
const scopedWindow = getScopedQuotaWindowForModel(quota, modelId);
|
|
2719
|
-
if (scopedWindow &&
|
|
2720
|
-
Number.isFinite(scopedWindow.remainingPercent) &&
|
|
2721
|
-
scopedWindow.remainingPercent <= thresholds.scoped) {
|
|
2722
|
-
return false;
|
|
2723
|
-
}
|
|
2724
|
-
}
|
|
2725
|
-
if (sawUnknownWindow)
|
|
2726
|
-
return !failClosedOnUnknownQuota(storage);
|
|
2727
|
-
return true;
|
|
2728
|
-
}
|
|
2729
|
-
/**
|
|
2730
|
-
* Find the earliest reset time across all accounts' quota windows.
|
|
2731
|
-
* Returns seconds from `now` until that reset, or 300 as a fallback.
|
|
2732
|
-
*
|
|
2733
|
-
* When `scopedModelId` is provided, ONLY the matched scoped window's
|
|
2734
|
-
* `resetsAt` is considered — the 5h/7d resets are intentionally ignored
|
|
2735
|
-
* so the retry hint reflects the weekly reset, not the sooner 5h reset
|
|
2736
|
-
* (which would cause a retry-storm against a block that won't clear for
|
|
2737
|
-
* days). With `scopedModelId` undefined, the 5h/7d behavior is unchanged.
|
|
2738
|
-
*/
|
|
2739
|
-
export function killswitchRetryAfterSeconds(mainQuota, fallbackAccounts, now, scopedModelId) {
|
|
2740
|
-
const resetTimes = [];
|
|
2741
|
-
const allQuotas = [mainQuota, ...fallbackAccounts.map((a) => a.quota)];
|
|
2742
|
-
for (const quota of allQuotas) {
|
|
2743
|
-
if (scopedModelId) {
|
|
2744
|
-
const scopedWindow = getScopedQuotaWindowForModel(quota, scopedModelId);
|
|
2745
|
-
const resetStr = scopedWindow?.resetsAt;
|
|
2746
|
-
if (!resetStr)
|
|
2747
|
-
continue;
|
|
2748
|
-
const resetTime = Date.parse(resetStr);
|
|
2749
|
-
if (Number.isFinite(resetTime) && resetTime > now) {
|
|
2750
|
-
resetTimes.push(resetTime);
|
|
2751
|
-
}
|
|
2752
|
-
}
|
|
2753
|
-
else {
|
|
2754
|
-
for (const key of ['five_hour', 'seven_day']) {
|
|
2755
|
-
const resetStr = quota?.[key]?.resetsAt;
|
|
2756
|
-
if (!resetStr)
|
|
2757
|
-
continue;
|
|
2758
|
-
const resetTime = Date.parse(resetStr);
|
|
2759
|
-
if (Number.isFinite(resetTime) && resetTime > now) {
|
|
2760
|
-
resetTimes.push(resetTime);
|
|
2761
|
-
}
|
|
2762
|
-
}
|
|
2763
|
-
}
|
|
2764
|
-
}
|
|
2765
|
-
if (!resetTimes.length)
|
|
2766
|
-
return 300;
|
|
2767
|
-
return Math.max(1, Math.ceil((Math.min(...resetTimes) - now) / 1000)) + 60;
|
|
2768
|
-
}
|
|
2769
|
-
export function getKillswitchConfig(storage) {
|
|
2770
|
-
return storage?.killswitch ?? { enabled: false };
|
|
2771
|
-
}
|
|
2772
|
-
export async function setKillswitchPersistent(config, path = getAccountStoragePath()) {
|
|
2773
|
-
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
2774
|
-
storage.killswitch = config;
|
|
2775
|
-
await saveAccounts(storage, path);
|
|
2776
|
-
return storage;
|
|
2777
|
-
}
|
|
2778
|
-
export async function removeAccountPersistent(id, path = getAccountStoragePath()) {
|
|
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
|
-
});
|
|
2795
|
-
}
|
|
2796
|
-
export async function reorderAccountsPersistent(orderedIds, path = getAccountStoragePath()) {
|
|
2797
|
-
await mutateAccountsPersistent(path, (storage) => {
|
|
2798
|
-
reorderAccounts(storage, orderedIds);
|
|
2799
|
-
return {
|
|
2800
|
-
storage,
|
|
2801
|
-
result: undefined,
|
|
2802
|
-
options: { preserveExistingAccountOrder: false },
|
|
2803
|
-
};
|
|
2804
|
-
});
|
|
2805
|
-
}
|
|
2806
|
-
export async function setAccountEnabledPersistent(id, enabled, path = getAccountStoragePath()) {
|
|
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
|
-
});
|
|
2827
|
-
}
|
|
2828
|
-
export async function addAccountPersistent(account, path = getAccountStoragePath()) {
|
|
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
|
-
});
|
|
2857
|
-
}
|
|
2858
|
-
export function getQuotaNextRefreshAt(quota, storage, now) {
|
|
2859
|
-
const intervalMs = getQuotaCheckIntervalMs(storage);
|
|
2860
|
-
if (!quotaEnabled(storage))
|
|
2861
|
-
return now + intervalMs;
|
|
2862
|
-
const windowFreshnessDeadline = Math.min(...[
|
|
2863
|
-
...['five_hour', 'seven_day'].map((key) => quota?.[key]?.checkedAt),
|
|
2864
|
-
...(quota?.scoped ?? []).map((window) => window.checkedAt),
|
|
2865
|
-
]
|
|
2866
|
-
.filter((checkedAt) => Number.isFinite(checkedAt))
|
|
2867
|
-
.map((checkedAt) => checkedAt + intervalMs));
|
|
2868
|
-
const capAtOldestWindow = (candidate) => Number.isFinite(windowFreshnessDeadline)
|
|
2869
|
-
? Math.min(candidate, windowFreshnessDeadline)
|
|
2870
|
-
: candidate;
|
|
2871
|
-
const thresholds = getQuotaMinimumRemainingThresholds(storage);
|
|
2872
|
-
const blockedResetTimes = [];
|
|
2873
|
-
for (const key of ['five_hour', 'seven_day']) {
|
|
2874
|
-
const window = quota?.[key];
|
|
2875
|
-
if (!window)
|
|
2876
|
-
return capAtOldestWindow(now + intervalMs);
|
|
2877
|
-
if (window.remainingPercent >= thresholds[key])
|
|
2878
|
-
continue;
|
|
2879
|
-
const resetTime = window.resetsAt ? Date.parse(window.resetsAt) : Number.NaN;
|
|
2880
|
-
if (!Number.isFinite(resetTime) || resetTime <= now) {
|
|
2881
|
-
return capAtOldestWindow(now + intervalMs);
|
|
2882
|
-
}
|
|
2883
|
-
blockedResetTimes.push(resetTime);
|
|
2884
|
-
}
|
|
2885
|
-
if (!blockedResetTimes.length)
|
|
2886
|
-
return capAtOldestWindow(now + intervalMs);
|
|
2887
|
-
return capAtOldestWindow(Math.min(...blockedResetTimes) + 60_000);
|
|
2888
|
-
}
|
|
2889
|
-
function tokenNeedsRefresh(account, storage, now) {
|
|
2890
|
-
return (!account.access ||
|
|
2891
|
-
!account.expires ||
|
|
2892
|
-
account.expires - now <= refreshBeforeExpiryMs(storage));
|
|
2893
|
-
}
|
|
2894
|
-
function quotaSnapshotIsFresh(quota, storage, now) {
|
|
2895
|
-
if (!quotaEnabled(storage))
|
|
2896
|
-
return true;
|
|
2897
|
-
const maxAge = getQuotaCheckIntervalMs(storage);
|
|
2898
|
-
return ['five_hour', 'seven_day'].every((key) => {
|
|
2899
|
-
const window = quota?.[key];
|
|
2900
|
-
return Boolean(window && now - window.checkedAt < maxAge);
|
|
2901
|
-
});
|
|
2902
|
-
}
|
|
2903
|
-
function quotaIsStale(account, storage, now, modelId) {
|
|
2904
|
-
if (!quotaSnapshotIsFresh(account.quota, storage, now))
|
|
2905
|
-
return true;
|
|
2906
|
-
const scoped = getScopedQuotaWindowForModel(account.quota, modelId);
|
|
2907
|
-
return Boolean(scoped && now - scoped.checkedAt >= getQuotaCheckIntervalMs(storage));
|
|
2908
|
-
}
|
|
2909
|
-
function cachedQuotaWindowStillRelevant(window, now) {
|
|
2910
|
-
if (!window)
|
|
2911
|
-
return false;
|
|
2912
|
-
if (!window.resetsAt)
|
|
2913
|
-
return true;
|
|
2914
|
-
const resetTime = Date.parse(window.resetsAt);
|
|
2915
|
-
return !Number.isFinite(resetTime) || resetTime > now;
|
|
2916
|
-
}
|
|
2917
|
-
function cachedQuotaSnapshotStillRelevant(quota, now) {
|
|
2918
|
-
return ['five_hour', 'seven_day'].every((key) => cachedQuotaWindowStillRelevant(quota?.[key], now));
|
|
2919
|
-
}
|
|
2920
|
-
function isTransientQuotaError(error) {
|
|
2921
|
-
const status = error.status;
|
|
2922
|
-
if (typeof status === 'number' && Number.isFinite(status)) {
|
|
2923
|
-
if (status === 429 || status >= 500)
|
|
2924
|
-
return true;
|
|
2925
|
-
}
|
|
2926
|
-
const formattedMessage = formatErrorMessage(error);
|
|
2927
|
-
if (/Claude quota check failed: (429|5\d\d)\b/.test(formattedMessage)) {
|
|
2928
|
-
return true;
|
|
2929
|
-
}
|
|
2930
|
-
if (formattedMessage.includes('Quota refresh is already in progress')) {
|
|
2931
|
-
return true;
|
|
2932
|
-
}
|
|
2933
|
-
return isTransientNetworkError(error);
|
|
2934
|
-
}
|
|
2935
|
-
function canUseCachedQuotaAfterRefreshError(account, storage, error, now) {
|
|
2936
|
-
return (Boolean(account.access && account.expires && account.expires > now) &&
|
|
2937
|
-
isTransientQuotaError(error) &&
|
|
2938
|
-
quotaSnapshotPassesPolicy(account.quota, storage) &&
|
|
2939
|
-
cachedQuotaSnapshotStillRelevant(account.quota, now));
|
|
2940
|
-
}
|
|
2941
|
-
function clampPercent(value) {
|
|
2942
|
-
if (!Number.isFinite(value))
|
|
2943
|
-
return 0;
|
|
2944
|
-
if (value < 0)
|
|
2945
|
-
return 0;
|
|
2946
|
-
if (value > 100)
|
|
2947
|
-
return 100;
|
|
2948
|
-
return value;
|
|
2949
|
-
}
|
|
2950
|
-
function nonEmptyString(value) {
|
|
2951
|
-
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
2952
|
-
}
|
|
2953
|
-
function slugForQuotaIdentity(value) {
|
|
2954
|
-
return value
|
|
2955
|
-
.toLowerCase()
|
|
2956
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
2957
|
-
.replace(/^-+|-+$/g, '');
|
|
2958
|
-
}
|
|
2959
|
-
function mapScopedWeeklyLimits(limits, checkedAt) {
|
|
2960
|
-
if (!Array.isArray(limits))
|
|
2961
|
-
return [];
|
|
2962
|
-
const seen = new Set();
|
|
2963
|
-
const scoped = [];
|
|
2964
|
-
for (const limit of limits) {
|
|
2965
|
-
if (limit?.kind !== 'weekly_scoped' || limit.group !== 'weekly')
|
|
2966
|
-
continue;
|
|
2967
|
-
if (typeof limit.percent !== 'number' || !Number.isFinite(limit.percent)) {
|
|
2968
|
-
continue;
|
|
2969
|
-
}
|
|
2970
|
-
const modelName = nonEmptyString(limit.scope?.model?.display_name);
|
|
2971
|
-
if (!modelName)
|
|
2972
|
-
continue;
|
|
2973
|
-
const identity = nonEmptyString(limit.scope?.model?.id) ?? modelName;
|
|
2974
|
-
const slug = slugForQuotaIdentity(identity);
|
|
2975
|
-
if (!slug)
|
|
2976
|
-
continue;
|
|
2977
|
-
const id = `claude-weekly-scoped-${slug}`;
|
|
2978
|
-
if (seen.has(id))
|
|
2979
|
-
continue;
|
|
2980
|
-
seen.add(id);
|
|
2981
|
-
const usedPercent = clampPercent(limit.percent);
|
|
2982
|
-
const modelId = nonEmptyString(limit.scope?.model?.id);
|
|
2983
|
-
scoped.push({
|
|
2984
|
-
id,
|
|
2985
|
-
title: `${modelName} only`,
|
|
2986
|
-
...(modelId && { modelId }),
|
|
2987
|
-
modelName,
|
|
2988
|
-
usedPercent,
|
|
2989
|
-
remainingPercent: clampPercent(100 - usedPercent),
|
|
2990
|
-
resetsAt: limit.resets_at,
|
|
2991
|
-
checkedAt,
|
|
2992
|
-
});
|
|
2993
|
-
}
|
|
2994
|
-
return scoped;
|
|
2995
|
-
}
|
|
2996
|
-
function mapExtraUsage(usage) {
|
|
2997
|
-
if (usage.extra_usage?.is_enabled !== true)
|
|
2998
|
-
return undefined;
|
|
2999
|
-
const usedAmount = usage.extra_usage.used_credits;
|
|
3000
|
-
const limitAmount = usage.extra_usage.monthly_limit;
|
|
3001
|
-
if (typeof usedAmount !== 'number' ||
|
|
3002
|
-
!Number.isFinite(usedAmount) ||
|
|
3003
|
-
typeof limitAmount !== 'number' ||
|
|
3004
|
-
!Number.isFinite(limitAmount)) {
|
|
3005
|
-
return undefined;
|
|
3006
|
-
}
|
|
3007
|
-
const rawCurrency = usage.spend?.limit?.currency;
|
|
3008
|
-
const currency = rawCurrency == null ? 'USD' : nonEmptyString(rawCurrency);
|
|
3009
|
-
const rawExponent = usage.spend?.limit?.exponent;
|
|
3010
|
-
const moneyExponent = rawExponent == null ? 2 : rawExponent;
|
|
3011
|
-
if (!currency ||
|
|
3012
|
-
!/^[A-Za-z]{3}$/.test(currency) ||
|
|
3013
|
-
!Number.isInteger(moneyExponent) ||
|
|
3014
|
-
moneyExponent < 0 ||
|
|
3015
|
-
moneyExponent > 20) {
|
|
3016
|
-
return undefined;
|
|
3017
|
-
}
|
|
3018
|
-
return {
|
|
3019
|
-
used: { amountMinor: usedAmount, currency, exponent: moneyExponent },
|
|
3020
|
-
limit: { amountMinor: limitAmount, currency, exponent: moneyExponent },
|
|
3021
|
-
...(typeof usage.extra_usage.utilization === 'number' &&
|
|
3022
|
-
Number.isFinite(usage.extra_usage.utilization) && {
|
|
3023
|
-
utilizationPercent: usage.extra_usage.utilization,
|
|
3024
|
-
}),
|
|
3025
|
-
...(nonEmptyString(usage.spend?.severity) && {
|
|
3026
|
-
severity: nonEmptyString(usage.spend?.severity),
|
|
3027
|
-
}),
|
|
3028
|
-
exhausted: usedAmount >= limitAmount,
|
|
3029
|
-
};
|
|
3030
|
-
}
|
|
3031
|
-
function mapBindingWindow(limits) {
|
|
3032
|
-
if (!Array.isArray(limits))
|
|
3033
|
-
return undefined;
|
|
3034
|
-
const active = limits.find((limit) => limit?.is_active === true);
|
|
3035
|
-
if (!active)
|
|
3036
|
-
return undefined;
|
|
3037
|
-
if (active.kind === 'session')
|
|
3038
|
-
return 'five_hour';
|
|
3039
|
-
if (active.kind === 'weekly_all')
|
|
3040
|
-
return 'seven_day';
|
|
3041
|
-
if (active.kind !== 'weekly_scoped' || active.group !== 'weekly') {
|
|
3042
|
-
return undefined;
|
|
3043
|
-
}
|
|
3044
|
-
const modelName = nonEmptyString(active.scope?.model?.display_name);
|
|
3045
|
-
if (!modelName)
|
|
3046
|
-
return undefined;
|
|
3047
|
-
const identity = nonEmptyString(active.scope?.model?.id) ?? modelName;
|
|
3048
|
-
const slug = slugForQuotaIdentity(identity);
|
|
3049
|
-
return slug ? `claude-weekly-scoped-${slug}` : undefined;
|
|
3050
|
-
}
|
|
3051
|
-
function mapUsageWindow(window, checkedAt) {
|
|
3052
|
-
if (typeof window?.utilization !== 'number')
|
|
3053
|
-
return undefined;
|
|
3054
|
-
if (!Number.isFinite(window.utilization))
|
|
3055
|
-
return undefined;
|
|
3056
|
-
const usedPercent = clampPercent(window.utilization);
|
|
3057
|
-
return {
|
|
3058
|
-
usedPercent,
|
|
3059
|
-
remainingPercent: clampPercent(100 - usedPercent),
|
|
3060
|
-
resetsAt: window.resets_at,
|
|
3061
|
-
checkedAt,
|
|
3062
|
-
};
|
|
3063
|
-
}
|
|
3064
|
-
export async function fetchOAuthQuotaSnapshot(input) {
|
|
3065
|
-
assertNotCustodyTombstone(input.accessToken, 'anthropic');
|
|
3066
|
-
const fetchImpl = input.fetchImpl ?? fetch;
|
|
3067
|
-
const response = await fetchImpl(QUOTA_URL, {
|
|
3068
|
-
method: 'GET',
|
|
3069
|
-
headers: {
|
|
3070
|
-
Authorization: `Bearer ${input.accessToken}`,
|
|
3071
|
-
Accept: 'application/json',
|
|
3072
|
-
'Content-Type': 'application/json',
|
|
3073
|
-
'anthropic-beta': 'oauth-2025-04-20',
|
|
3074
|
-
'User-Agent': `claude-code/${CLAUDE_CODE_VERSION}`,
|
|
3075
|
-
},
|
|
3076
|
-
});
|
|
3077
|
-
if (!response.ok) {
|
|
3078
|
-
const body = await response.text().catch(() => '');
|
|
3079
|
-
const error = Object.assign(new Error(`Claude quota check failed: ${response.status} — ${body}`), {
|
|
3080
|
-
status: response.status,
|
|
3081
|
-
retryAfter: parseRetryAfterHeader(response.headers.get('Retry-After')),
|
|
3082
|
-
});
|
|
3083
|
-
throw error;
|
|
3084
|
-
}
|
|
3085
|
-
const checkedAt = input.now?.() ?? Date.now();
|
|
3086
|
-
const usage = (await response.json());
|
|
3087
|
-
const bindingWindow = mapBindingWindow(usage.limits);
|
|
3088
|
-
const snapshot = {
|
|
3089
|
-
five_hour: mapUsageWindow(usage.five_hour, checkedAt),
|
|
3090
|
-
seven_day: mapUsageWindow(usage.seven_day, checkedAt),
|
|
3091
|
-
scoped: mapScopedWeeklyLimits(usage.limits, checkedAt),
|
|
3092
|
-
extraUsage: mapExtraUsage(usage),
|
|
3093
|
-
...(bindingWindow && {
|
|
3094
|
-
bindingWindow,
|
|
3095
|
-
bindingWindowSource: 'poll',
|
|
3096
|
-
}),
|
|
3097
|
-
source: 'poll',
|
|
3098
|
-
checkedAt,
|
|
3099
|
-
};
|
|
3100
|
-
const fieldSources = {
|
|
3101
|
-
...(snapshot.five_hour && { five_hour: 'poll' }),
|
|
3102
|
-
...(snapshot.seven_day && { seven_day: 'poll' }),
|
|
3103
|
-
...(snapshot.scoped && { scoped: 'poll' }),
|
|
3104
|
-
...(snapshot.extraUsage && { extraUsage: 'poll' }),
|
|
3105
|
-
...(snapshot.bindingWindow && { bindingWindow: 'poll' }),
|
|
3106
|
-
};
|
|
3107
|
-
return {
|
|
3108
|
-
...snapshot,
|
|
3109
|
-
...(Object.keys(fieldSources).length > 0 && { fieldSources }),
|
|
3110
|
-
};
|
|
3111
|
-
}
|
|
3112
|
-
function updateStoredAccount(storage, account) {
|
|
3113
|
-
const index = storage.accounts.findIndex((candidate) => candidate.id === account.id);
|
|
3114
|
-
if (index >= 0)
|
|
3115
|
-
storage.accounts[index] = account;
|
|
3116
|
-
}
|
|
3117
|
-
export function upsertAccount(storage, account) {
|
|
3118
|
-
const index = storage.accounts.findIndex((candidate) => candidate.id === account.id ||
|
|
3119
|
-
(account.label && candidate.label === account.label));
|
|
3120
|
-
if (index >= 0) {
|
|
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,
|
|
3129
|
-
...account,
|
|
3130
|
-
addedAt: existing.addedAt ?? account.addedAt,
|
|
3131
|
-
...(account.type === 'oauth' && {
|
|
3132
|
-
quota: account.quota,
|
|
3133
|
-
profile: account.profile,
|
|
3134
|
-
lastRefreshedAt: account.lastRefreshedAt,
|
|
3135
|
-
lastRefreshError: account.lastRefreshError,
|
|
3136
|
-
lastQuotaRefreshError: account.lastQuotaRefreshError,
|
|
3137
|
-
}),
|
|
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;
|
|
3144
|
-
return;
|
|
3145
|
-
}
|
|
3146
|
-
storage.accounts.push(account);
|
|
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
|
-
}
|
|
3187
|
-
export function removeAccount(storage, id) {
|
|
3188
|
-
const index = storage.accounts.findIndex((c) => c.id === id);
|
|
3189
|
-
if (index < 0)
|
|
3190
|
-
return false;
|
|
3191
|
-
storage.accounts.splice(index, 1);
|
|
3192
|
-
return true;
|
|
3193
|
-
}
|
|
3194
|
-
export function reorderAccounts(storage, orderedIds) {
|
|
3195
|
-
const orderMap = new Map(orderedIds.map((id, i) => [id, i]));
|
|
3196
|
-
const known = storage.accounts.filter((a) => orderMap.has(a.id));
|
|
3197
|
-
const unknown = storage.accounts.filter((a) => !orderMap.has(a.id));
|
|
3198
|
-
known.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
|
3199
|
-
storage.accounts = [...known, ...unknown];
|
|
3200
|
-
}
|
|
3201
|
-
export function setAccountEnabled(storage, id, enabled) {
|
|
3202
|
-
const account = storage.accounts.find((c) => c.id === id);
|
|
3203
|
-
if (!account)
|
|
3204
|
-
return false;
|
|
3205
|
-
account.enabled = enabled;
|
|
3206
|
-
return true;
|
|
3207
|
-
}
|
|
3208
|
-
function formatErrorMessage(error) {
|
|
3209
|
-
return error instanceof Error ? error.message : String(error);
|
|
3210
|
-
}
|
|
3211
|
-
function recordRefreshError(account, error, now) {
|
|
3212
|
-
if (error instanceof CustodyTombstoneRefreshError)
|
|
3213
|
-
return;
|
|
3214
|
-
const existing = existingRefreshBackoffError(error);
|
|
3215
|
-
if (existing) {
|
|
3216
|
-
account.lastRefreshError = existing;
|
|
3217
|
-
return;
|
|
3218
|
-
}
|
|
3219
|
-
account.lastRefreshError = buildRefreshOperationError({
|
|
3220
|
-
error,
|
|
3221
|
-
now,
|
|
3222
|
-
accountIdentity: account.id,
|
|
3223
|
-
refreshTokenFingerprint: tokenFingerprint(account.refresh),
|
|
3224
|
-
previous: account.lastRefreshError,
|
|
3225
|
-
});
|
|
3226
|
-
}
|
|
3227
|
-
function recordQuotaRefreshError(account, error, now) {
|
|
3228
|
-
if (error instanceof CustodyTombstoneRefreshError)
|
|
3229
|
-
return;
|
|
3230
|
-
if (isQuotaPolicyAuthError(error) || existingRefreshBackoffError(error))
|
|
3231
|
-
return;
|
|
3232
|
-
account.lastQuotaRefreshError = buildQuotaOperationError({
|
|
3233
|
-
error,
|
|
3234
|
-
now,
|
|
3235
|
-
previous: account.lastQuotaRefreshError,
|
|
3236
|
-
});
|
|
3237
|
-
if (error.isRefreshError) {
|
|
3238
|
-
recordRefreshError(account, error, now);
|
|
3239
|
-
}
|
|
3240
|
-
}
|
|
3241
|
-
function fallbackRefreshLockName(accountId) {
|
|
3242
|
-
return `fallback-oauth-refresh-${createHash('sha256')
|
|
3243
|
-
.update(accountId)
|
|
3244
|
-
.digest('hex')
|
|
3245
|
-
.slice(0, 16)}`;
|
|
3246
|
-
}
|
|
3247
|
-
export class FallbackAccountManager {
|
|
3248
|
-
now;
|
|
3249
|
-
fetchImpl;
|
|
3250
|
-
configPath;
|
|
3251
|
-
refreshPromises = new Map();
|
|
3252
|
-
custodyVerificationAccounts = new Set();
|
|
3253
|
-
refreshTimer = null;
|
|
3254
|
-
quotaTimer = null;
|
|
3255
|
-
quotaManager;
|
|
3256
|
-
isFallbackAccountVaultServed;
|
|
3257
|
-
isFallbackAccountVaultEnabled;
|
|
3258
|
-
resolveFallbackAccessToken;
|
|
3259
|
-
onBackgroundRefresh;
|
|
3260
|
-
onFallbackStorageChanged;
|
|
3261
|
-
setIntervalImpl;
|
|
3262
|
-
clearIntervalImpl;
|
|
3263
|
-
constructor(options = {}) {
|
|
3264
|
-
this.now = options.now ?? Date.now;
|
|
3265
|
-
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
3266
|
-
this.configPath = options.configPath ?? getAccountStoragePath();
|
|
3267
|
-
this.quotaManager = options.quotaManager ?? null;
|
|
3268
|
-
this.isFallbackAccountVaultServed =
|
|
3269
|
-
options.isFallbackAccountVaultServed ?? (() => false);
|
|
3270
|
-
this.isFallbackAccountVaultEnabled =
|
|
3271
|
-
options.isFallbackAccountVaultEnabled ?? (() => false);
|
|
3272
|
-
this.resolveFallbackAccessToken =
|
|
3273
|
-
options.resolveFallbackAccessToken ??
|
|
3274
|
-
((account) => {
|
|
3275
|
-
if (!account.access ||
|
|
3276
|
-
account.expires === undefined ||
|
|
3277
|
-
account.expires <= this.now()) {
|
|
3278
|
-
return undefined;
|
|
3279
|
-
}
|
|
3280
|
-
return { token: account.access, source: 'sidecar' };
|
|
3281
|
-
});
|
|
3282
|
-
this.onBackgroundRefresh = options.onBackgroundRefresh;
|
|
3283
|
-
this.onFallbackStorageChanged = options.onFallbackStorageChanged;
|
|
3284
|
-
this.setIntervalImpl = options.setIntervalImpl ?? globalThis.setInterval;
|
|
3285
|
-
this.clearIntervalImpl =
|
|
3286
|
-
options.clearIntervalImpl ?? globalThis.clearInterval;
|
|
3287
|
-
}
|
|
3288
|
-
/**
|
|
3289
|
-
* Seed QuotaManager from persisted account.quota if no cache entry exists
|
|
3290
|
-
* yet. Prevents unnecessary API calls when the on-disk snapshot is fresh.
|
|
3291
|
-
*/
|
|
3292
|
-
seedFallbackQuota(account, storage) {
|
|
3293
|
-
if (!this.quotaManager)
|
|
3294
|
-
return;
|
|
3295
|
-
if (!account.quota)
|
|
3296
|
-
return;
|
|
3297
|
-
const checkedAt = quotaSnapshotCheckedAt(account.quota);
|
|
3298
|
-
if (checkedAt <= 0)
|
|
3299
|
-
return;
|
|
3300
|
-
const existing = this.quotaManager.getFallback(account.id, account);
|
|
3301
|
-
if (existing && existing.checkedAt >= checkedAt)
|
|
3302
|
-
return;
|
|
3303
|
-
const checkInterval = getQuotaCheckIntervalMs(storage);
|
|
3304
|
-
this.quotaManager.setFallback(account.id, {
|
|
3305
|
-
quota: account.quota,
|
|
3306
|
-
refreshAfter: checkedAt + checkInterval,
|
|
3307
|
-
checkedAt,
|
|
3308
|
-
}, account);
|
|
3309
|
-
}
|
|
3310
|
-
async load() {
|
|
3311
|
-
return loadAccounts(this.configPath);
|
|
3312
|
-
}
|
|
3313
|
-
async save(storage, accountIds) {
|
|
3314
|
-
await saveAccountState(storage, this.configPath, {
|
|
3315
|
-
accounts: accountIds ?? true,
|
|
3316
|
-
});
|
|
3317
|
-
}
|
|
3318
|
-
startBackgroundRefresh() {
|
|
3319
|
-
const run = async (initial = false) => {
|
|
3320
|
-
await this.onBackgroundRefresh?.(initial);
|
|
3321
|
-
await this.refreshDueAccounts();
|
|
3322
|
-
await this.refreshQuotaForDueAccounts();
|
|
3323
|
-
};
|
|
3324
|
-
const initialRun = run(true).catch(() => { });
|
|
3325
|
-
if (!this.refreshTimer) {
|
|
3326
|
-
this.refreshTimer = this.setIntervalImpl(() => run().catch(() => { }), FALLBACK_BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS));
|
|
3327
|
-
if ('unref' in this.refreshTimer)
|
|
3328
|
-
this.refreshTimer.unref();
|
|
3329
|
-
}
|
|
3330
|
-
return initialRun;
|
|
3331
|
-
}
|
|
3332
|
-
stopBackgroundRefresh() {
|
|
3333
|
-
if (this.refreshTimer)
|
|
3334
|
-
this.clearIntervalImpl(this.refreshTimer);
|
|
3335
|
-
if (this.quotaTimer)
|
|
3336
|
-
this.clearIntervalImpl(this.quotaTimer);
|
|
3337
|
-
this.refreshTimer = null;
|
|
3338
|
-
this.quotaTimer = null;
|
|
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
|
-
}
|
|
3359
|
-
async getUsableFallbackAccounts(existingStorage, options = {}) {
|
|
3360
|
-
const storage = existingStorage !== undefined ? existingStorage : await this.load();
|
|
3361
|
-
if (!storage)
|
|
3362
|
-
return [];
|
|
3363
|
-
const usable = [];
|
|
3364
|
-
let changed = false;
|
|
3365
|
-
for (const account of storage.accounts) {
|
|
3366
|
-
if (account.enabled === false || !isOAuthAccount(account))
|
|
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
|
-
}
|
|
3378
|
-
let next = account;
|
|
3379
|
-
try {
|
|
3380
|
-
if (tokenNeedsRefresh(next, storage, this.now()) &&
|
|
3381
|
-
!this.isFallbackAccountVaultEnabled(next.id, storage) &&
|
|
3382
|
-
!this.isFallbackAccountVaultServed(next.id, storage)) {
|
|
3383
|
-
const refreshError = next.lastRefreshError;
|
|
3384
|
-
if (refreshError &&
|
|
3385
|
-
refreshBackoffActive(refreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
|
|
3386
|
-
throw createRefreshBackoffActiveError(refreshError, this.now());
|
|
3387
|
-
}
|
|
3388
|
-
next = await this.refreshAccount(next, storage);
|
|
3389
|
-
changed = true;
|
|
3390
|
-
}
|
|
3391
|
-
this.seedFallbackQuota(next, storage);
|
|
3392
|
-
const stale = this.quotaManager
|
|
3393
|
-
? this.quotaManager.isFallbackStale(next.id, next.access, options.modelId, next)
|
|
3394
|
-
: quotaIsStale(next, storage, this.now(), options.modelId);
|
|
3395
|
-
// Skip the request-time refresh when this account's quota API is
|
|
3396
|
-
// backed off (recent 429/5xx). Hitting it again would extend the
|
|
3397
|
-
// backoff; evaluate policy on the cached/seeded quota instead. Mirrors
|
|
3398
|
-
// the background refreshQuotaForDueAccounts() guard.
|
|
3399
|
-
if (stale &&
|
|
3400
|
-
!quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
|
|
3401
|
-
const result = await this.refreshAccountQuota(next, storage);
|
|
3402
|
-
next = result.account;
|
|
3403
|
-
changed ||= result.changed;
|
|
3404
|
-
}
|
|
3405
|
-
// Single source of truth: evaluate quota policy from the unified
|
|
3406
|
-
// QuotaManager cache (the same source as the staleness check above) so
|
|
3407
|
-
// an active-route refresh that updated only the cache is not ignored.
|
|
3408
|
-
if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage, {
|
|
3409
|
-
modelId: options.modelId,
|
|
3410
|
-
}))
|
|
3411
|
-
usable.push(next);
|
|
3412
|
-
}
|
|
3413
|
-
catch (error) {
|
|
3414
|
-
if (canUseCachedQuotaAfterRefreshError(next, storage, error, this.now())) {
|
|
3415
|
-
log('[refresh] fallback quota using cached quota after refresh error', {
|
|
3416
|
-
accountId: next.id,
|
|
3417
|
-
error: formatErrorMessage(error),
|
|
3418
|
-
});
|
|
3419
|
-
if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage, {
|
|
3420
|
-
modelId: options.modelId,
|
|
3421
|
-
})) {
|
|
3422
|
-
usable.push(next);
|
|
3423
|
-
}
|
|
3424
|
-
}
|
|
3425
|
-
else if (!failClosedOnUnknownQuota(storage) &&
|
|
3426
|
-
!refreshBackoffActive(next.lastRefreshError, next.id, this.now(), tokenFingerprint(next.refresh)) &&
|
|
3427
|
-
quotaSnapshotPassesModelScope(next.quota, options.modelId)) {
|
|
3428
|
-
usable.push(next);
|
|
3429
|
-
}
|
|
3430
|
-
}
|
|
3431
|
-
}
|
|
3432
|
-
if (changed)
|
|
3433
|
-
await this.save(storage);
|
|
3434
|
-
return usable;
|
|
3435
|
-
}
|
|
3436
|
-
async markUsed(account) {
|
|
3437
|
-
const storage = await this.load();
|
|
3438
|
-
if (!storage)
|
|
3439
|
-
return;
|
|
3440
|
-
const stored = storage.accounts.find((candidate) => candidate.id === account.id);
|
|
3441
|
-
if (!stored)
|
|
3442
|
-
return;
|
|
3443
|
-
stored.lastUsed = this.now();
|
|
3444
|
-
await this.save(storage);
|
|
3445
|
-
}
|
|
3446
|
-
accountPassesQuotaPolicy(account, storage, options = {}) {
|
|
3447
|
-
return (quotaSnapshotPassesPolicy(account.quota, storage) &&
|
|
3448
|
-
quotaSnapshotPassesModelScope(account.quota, options.modelId));
|
|
3449
|
-
}
|
|
3450
|
-
/**
|
|
3451
|
-
* Return the account with its quota overlaid from the unified QuotaManager
|
|
3452
|
-
* cache (token-bound) when available, so quota-policy decisions use the same
|
|
3453
|
-
* source of truth as the staleness check. Falls back to the stored
|
|
3454
|
-
* account.quota when no manager is wired or the cache has no entry.
|
|
3455
|
-
*/
|
|
3456
|
-
quotaPolicyAccount(account) {
|
|
3457
|
-
if (!this.quotaManager)
|
|
3458
|
-
return account;
|
|
3459
|
-
const cached = this.quotaManager.getFallback(account.id, account)?.quota;
|
|
3460
|
-
return cached ? { ...account, quota: cached } : account;
|
|
3461
|
-
}
|
|
3462
|
-
async refreshDueAccounts() {
|
|
3463
|
-
const storage = await this.load();
|
|
3464
|
-
if (!storage || !refreshEnabled(storage))
|
|
3465
|
-
return;
|
|
3466
|
-
let changed = false;
|
|
3467
|
-
for (const account of storage.accounts) {
|
|
3468
|
-
if (account.enabled === false || !isOAuthAccount(account))
|
|
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
|
-
}
|
|
3476
|
-
if (!tokenNeedsRefresh(account, storage, this.now()) ||
|
|
3477
|
-
this.isFallbackAccountVaultEnabled(account.id, storage) ||
|
|
3478
|
-
this.isFallbackAccountVaultServed(account.id, storage))
|
|
3479
|
-
continue;
|
|
3480
|
-
if (refreshBackoffActive(account.lastRefreshError, account.id, this.now(), tokenFingerprint(account.refresh))) {
|
|
3481
|
-
// Backoff skips are steady-state while a fallback account is waiting for
|
|
3482
|
-
// its next retry. Logging every background tick from every OpenCode
|
|
3483
|
-
// process creates noise without adding new diagnostic signal; the
|
|
3484
|
-
// failure/backoff itself is recorded when the refresh attempt fails and
|
|
3485
|
-
// shown by /claude-quota.
|
|
3486
|
-
continue;
|
|
3487
|
-
}
|
|
3488
|
-
try {
|
|
3489
|
-
log('[refresh] fallback oauth background due', {
|
|
3490
|
-
accountId: account.id,
|
|
3491
|
-
expiresInMs: account.expires
|
|
3492
|
-
? account.expires - this.now()
|
|
3493
|
-
: undefined,
|
|
3494
|
-
});
|
|
3495
|
-
await this.refreshAccount(account, storage);
|
|
3496
|
-
changed = true;
|
|
3497
|
-
}
|
|
3498
|
-
catch (error) {
|
|
3499
|
-
logger.warn('refresh', 'fallback oauth background failed', {
|
|
3500
|
-
accountId: account.id,
|
|
3501
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3502
|
-
});
|
|
3503
|
-
recordRefreshError(account, error, this.now());
|
|
3504
|
-
updateStoredAccount(storage, account);
|
|
3505
|
-
changed = true;
|
|
3506
|
-
// Background refresh must not break the plugin request path.
|
|
3507
|
-
}
|
|
3508
|
-
}
|
|
3509
|
-
if (changed)
|
|
3510
|
-
await this.save(storage);
|
|
3511
|
-
}
|
|
3512
|
-
async refreshQuotaForDueAccounts() {
|
|
3513
|
-
const storage = await this.load();
|
|
3514
|
-
if (!storage || !quotaEnabled(storage))
|
|
3515
|
-
return;
|
|
3516
|
-
let changed = false;
|
|
3517
|
-
for (const account of storage.accounts) {
|
|
3518
|
-
if (account.enabled === false || !isOAuthAccount(account))
|
|
3519
|
-
continue;
|
|
3520
|
-
if (this.custodyVerificationAccounts.has(account.id))
|
|
3521
|
-
continue;
|
|
3522
|
-
let next = account;
|
|
3523
|
-
try {
|
|
3524
|
-
if (tokenNeedsRefresh(next, storage, this.now()) &&
|
|
3525
|
-
!this.isFallbackAccountVaultEnabled(next.id, storage) &&
|
|
3526
|
-
!this.isFallbackAccountVaultServed(next.id, storage)) {
|
|
3527
|
-
if (refreshBackoffActive(next.lastRefreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
|
|
3528
|
-
continue;
|
|
3529
|
-
}
|
|
3530
|
-
next = await this.refreshAccount(next, storage);
|
|
3531
|
-
changed = true;
|
|
3532
|
-
}
|
|
3533
|
-
if (quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
|
|
3534
|
-
continue;
|
|
3535
|
-
}
|
|
3536
|
-
this.seedFallbackQuota(next, storage);
|
|
3537
|
-
// Use QuotaManager staleness when available (shared cache);
|
|
3538
|
-
// fall back to per-account on-disk staleness otherwise.
|
|
3539
|
-
const stale = this.quotaManager
|
|
3540
|
-
? this.quotaManager.isFallbackStale(next.id, next.access)
|
|
3541
|
-
: quotaIsStale(next, storage, this.now());
|
|
3542
|
-
if (!stale)
|
|
3543
|
-
continue;
|
|
3544
|
-
const result = await this.refreshAccountQuota(next, storage);
|
|
3545
|
-
changed ||= result.changed;
|
|
3546
|
-
}
|
|
3547
|
-
catch (error) {
|
|
3548
|
-
recordQuotaRefreshError(account, error, this.now());
|
|
3549
|
-
updateStoredAccount(storage, account);
|
|
3550
|
-
changed = true;
|
|
3551
|
-
// Quota probes are advisory; failed probes fail closed at selection time.
|
|
3552
|
-
}
|
|
3553
|
-
}
|
|
3554
|
-
if (changed) {
|
|
3555
|
-
await this.save(storage);
|
|
3556
|
-
this.onFallbackStorageChanged?.();
|
|
3557
|
-
}
|
|
3558
|
-
}
|
|
3559
|
-
async refreshQuotaForAllAccounts(options = {}) {
|
|
3560
|
-
const storage = await this.load();
|
|
3561
|
-
const errors = [];
|
|
3562
|
-
if (!storage || !quotaEnabled(storage))
|
|
3563
|
-
return { storage, errors };
|
|
3564
|
-
const force = options.force ?? false;
|
|
3565
|
-
let changed = false;
|
|
3566
|
-
for (const account of storage.accounts) {
|
|
3567
|
-
if (account.enabled === false || !isOAuthAccount(account))
|
|
3568
|
-
continue;
|
|
3569
|
-
let next = account;
|
|
3570
|
-
try {
|
|
3571
|
-
if (tokenNeedsRefresh(next, storage, this.now()) &&
|
|
3572
|
-
!this.isFallbackAccountVaultServed(next.id, storage) &&
|
|
3573
|
-
!this.isFallbackAccountVaultEnabled(next.id, storage)) {
|
|
3574
|
-
const refreshError = next.lastRefreshError;
|
|
3575
|
-
if (refreshError &&
|
|
3576
|
-
refreshBackoffActive(refreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
|
|
3577
|
-
throw createRefreshBackoffActiveError(refreshError, this.now());
|
|
3578
|
-
}
|
|
3579
|
-
next = await this.refreshAccount(next, storage);
|
|
3580
|
-
changed = true;
|
|
3581
|
-
}
|
|
3582
|
-
// force (manual /claude-quota) bypasses the staleness skip to fetch
|
|
3583
|
-
// fresh numbers on demand. refreshAccountQuota still respects 429
|
|
3584
|
-
// backoff via QuotaManager.refreshFallback.
|
|
3585
|
-
if (!force && !quotaIsStale(next, storage, this.now())) {
|
|
3586
|
-
if (next.lastQuotaRefreshError) {
|
|
3587
|
-
next.lastQuotaRefreshError = undefined;
|
|
3588
|
-
updateStoredAccount(storage, next);
|
|
3589
|
-
changed = true;
|
|
3590
|
-
}
|
|
3591
|
-
continue;
|
|
3592
|
-
}
|
|
3593
|
-
const result = await this.refreshAccountQuota(next, storage);
|
|
3594
|
-
changed ||= result.changed;
|
|
3595
|
-
}
|
|
3596
|
-
catch (error) {
|
|
3597
|
-
recordQuotaRefreshError(account, error, this.now());
|
|
3598
|
-
updateStoredAccount(storage, account);
|
|
3599
|
-
changed = true;
|
|
3600
|
-
errors.push({
|
|
3601
|
-
accountId: account.id,
|
|
3602
|
-
message: formatErrorMessage(error),
|
|
3603
|
-
});
|
|
3604
|
-
}
|
|
3605
|
-
}
|
|
3606
|
-
if (changed)
|
|
3607
|
-
await this.save(storage);
|
|
3608
|
-
return { storage, errors };
|
|
3609
|
-
}
|
|
3610
|
-
async refreshAccount(account, storage, options = {}) {
|
|
3611
|
-
if (this.isFallbackAccountVaultServed(account.id, storage) ||
|
|
3612
|
-
this.isFallbackAccountVaultEnabled(account.id, storage))
|
|
3613
|
-
return account;
|
|
3614
|
-
const existing = this.refreshPromises.get(account.id);
|
|
3615
|
-
if (existing) {
|
|
3616
|
-
const refreshed = await existing;
|
|
3617
|
-
updateStoredAccount(storage, refreshed);
|
|
3618
|
-
return refreshed;
|
|
3619
|
-
}
|
|
3620
|
-
const promise = this.refreshAccountNow(account, storage, options).finally(() => {
|
|
3621
|
-
this.refreshPromises.delete(account.id);
|
|
3622
|
-
});
|
|
3623
|
-
this.refreshPromises.set(account.id, promise);
|
|
3624
|
-
try {
|
|
3625
|
-
const refreshed = await promise;
|
|
3626
|
-
updateStoredAccount(storage, refreshed);
|
|
3627
|
-
return refreshed;
|
|
3628
|
-
}
|
|
3629
|
-
catch (error) {
|
|
3630
|
-
if (options.persistError) {
|
|
3631
|
-
recordRefreshError(account, error, this.now());
|
|
3632
|
-
updateStoredAccount(storage, account);
|
|
3633
|
-
await this.save(storage);
|
|
3634
|
-
}
|
|
3635
|
-
throw error;
|
|
3636
|
-
}
|
|
3637
|
-
}
|
|
3638
|
-
async waitForConcurrentFallbackRefresh(account, storage, previous, options) {
|
|
3639
|
-
const deadline = Date.now() + FALLBACK_REFRESH_JOIN_WAIT_MS;
|
|
3640
|
-
while (Date.now() < deadline) {
|
|
3641
|
-
await new Promise((resolve) => setTimeout(resolve, FALLBACK_REFRESH_JOIN_POLL_MS));
|
|
3642
|
-
const latestStorage = await this.load();
|
|
3643
|
-
const latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id && isOAuthAccount(candidate));
|
|
3644
|
-
if (!latestAccount)
|
|
3645
|
-
continue;
|
|
3646
|
-
const changed = latestAccount.access !== previous.access ||
|
|
3647
|
-
latestAccount.refresh !== previous.refresh ||
|
|
3648
|
-
(latestAccount.expires ?? 0) > (previous.expires ?? 0) + 60_000;
|
|
3649
|
-
if (changed &&
|
|
3650
|
-
(options.force ||
|
|
3651
|
-
!tokenNeedsRefresh(latestAccount, latestStorage, this.now()))) {
|
|
3652
|
-
updateStoredAccount(storage, latestAccount);
|
|
3653
|
-
log('[refresh] fallback oauth joined concurrent refresh', {
|
|
3654
|
-
accountId: latestAccount.id,
|
|
3655
|
-
expiresInMs: latestAccount.expires
|
|
3656
|
-
? latestAccount.expires - this.now()
|
|
3657
|
-
: undefined,
|
|
3658
|
-
});
|
|
3659
|
-
return latestAccount;
|
|
3660
|
-
}
|
|
3661
|
-
const refreshError = latestAccount.lastRefreshError;
|
|
3662
|
-
if (refreshError &&
|
|
3663
|
-
refreshBackoffActive(refreshError, latestAccount.id, this.now(), tokenFingerprint(latestAccount.refresh))) {
|
|
3664
|
-
updateStoredAccount(storage, latestAccount);
|
|
3665
|
-
throw createRefreshBackoffActiveError(refreshError, this.now());
|
|
3666
|
-
}
|
|
3667
|
-
}
|
|
3668
|
-
return null;
|
|
3669
|
-
}
|
|
3670
|
-
async refreshAccountNow(account, storage, options) {
|
|
3671
|
-
let latestStorage = await this.load();
|
|
3672
|
-
let latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id && isOAuthAccount(candidate));
|
|
3673
|
-
if (latestAccount &&
|
|
3674
|
-
!options.force &&
|
|
3675
|
-
!tokenNeedsRefresh(latestAccount, latestStorage, this.now())) {
|
|
3676
|
-
updateStoredAccount(storage, latestAccount);
|
|
3677
|
-
return latestAccount;
|
|
3678
|
-
}
|
|
3679
|
-
let sourceAccount = latestAccount ?? account;
|
|
3680
|
-
const fileLock = await acquireRefreshFileLock({
|
|
3681
|
-
name: fallbackRefreshLockName(sourceAccount.id),
|
|
3682
|
-
ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS,
|
|
3683
|
-
path: this.configPath,
|
|
3684
|
-
now: this.now,
|
|
3685
|
-
renew: true,
|
|
3686
|
-
});
|
|
3687
|
-
if (!fileLock) {
|
|
3688
|
-
log('[refresh] fallback oauth refresh skipped file lock', {
|
|
3689
|
-
accountId: sourceAccount.id,
|
|
3690
|
-
});
|
|
3691
|
-
const concurrent = await this.waitForConcurrentFallbackRefresh(account, storage, sourceAccount, options);
|
|
3692
|
-
if (concurrent)
|
|
3693
|
-
return concurrent;
|
|
3694
|
-
throw new Error('Fallback OAuth refresh is already in progress');
|
|
3695
|
-
}
|
|
3696
|
-
try {
|
|
3697
|
-
latestStorage = await this.load();
|
|
3698
|
-
latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id && isOAuthAccount(candidate));
|
|
3699
|
-
if (latestAccount &&
|
|
3700
|
-
!options.force &&
|
|
3701
|
-
!tokenNeedsRefresh(latestAccount, latestStorage, this.now())) {
|
|
3702
|
-
updateStoredAccount(storage, latestAccount);
|
|
3703
|
-
return latestAccount;
|
|
3704
|
-
}
|
|
3705
|
-
sourceAccount = latestAccount ?? sourceAccount;
|
|
3706
|
-
const refreshToken = sourceAccount.refresh;
|
|
3707
|
-
log('[refresh] fallback oauth refresh request start', {
|
|
3708
|
-
accountId: sourceAccount.id,
|
|
3709
|
-
force: options.force === true,
|
|
3710
|
-
expiresInMs: sourceAccount.expires
|
|
3711
|
-
? sourceAccount.expires - this.now()
|
|
3712
|
-
: undefined,
|
|
3713
|
-
});
|
|
3714
|
-
const refreshed = await refreshClaudeOAuthToken({
|
|
3715
|
-
refreshToken,
|
|
3716
|
-
authLineageId: sourceAccount.authLineageId,
|
|
3717
|
-
fetchImpl: this.fetchImpl,
|
|
3718
|
-
now: this.now,
|
|
3719
|
-
});
|
|
3720
|
-
sourceAccount.access = refreshed.access;
|
|
3721
|
-
sourceAccount.refresh = refreshed.refresh;
|
|
3722
|
-
sourceAccount.authLineageId =
|
|
3723
|
-
refreshed.authLineageId ?? sourceAccount.authLineageId;
|
|
3724
|
-
sourceAccount.expires = refreshed.expires;
|
|
3725
|
-
sourceAccount.lastRefreshedAt =
|
|
3726
|
-
refreshed.expires - refreshed.expiresIn * 1000;
|
|
3727
|
-
sourceAccount.lastRefreshError = undefined;
|
|
3728
|
-
updateStoredAccount(storage, sourceAccount);
|
|
3729
|
-
await this.save(storage);
|
|
3730
|
-
log('[refresh] fallback oauth refresh succeeded', {
|
|
3731
|
-
accountId: sourceAccount.id,
|
|
3732
|
-
expiresInMs: sourceAccount.expires
|
|
3733
|
-
? sourceAccount.expires - this.now()
|
|
3734
|
-
: undefined,
|
|
3735
|
-
});
|
|
3736
|
-
return sourceAccount;
|
|
3737
|
-
}
|
|
3738
|
-
finally {
|
|
3739
|
-
await fileLock.release();
|
|
3740
|
-
}
|
|
3741
|
-
}
|
|
3742
|
-
async refreshAccountQuota(account, storage) {
|
|
3743
|
-
const initialQuotaState = JSON.stringify([
|
|
3744
|
-
account.quota,
|
|
3745
|
-
account.lastQuotaRefreshError,
|
|
3746
|
-
]);
|
|
3747
|
-
let changed = false;
|
|
3748
|
-
let target = account;
|
|
3749
|
-
const vaultEnabled = this.isFallbackAccountVaultEnabled(target.id, storage);
|
|
3750
|
-
let access = this.resolveFallbackAccessToken(target, storage);
|
|
3751
|
-
if (!access && !vaultEnabled) {
|
|
3752
|
-
target = await this.refreshAccount(account, storage, { force: true });
|
|
3753
|
-
changed = true;
|
|
3754
|
-
access = this.resolveFallbackAccessToken(target, storage);
|
|
3755
|
-
}
|
|
3756
|
-
const delegatedQuotaAuthorization = storage?.claustrum?.scopedRoster === true &&
|
|
3757
|
-
this.quotaManager?.canFetchWithoutAccessToken() === true;
|
|
3758
|
-
if (!access && !delegatedQuotaAuthorization) {
|
|
3759
|
-
log('[quota] fallback quota poll skipped: no usable credential', {
|
|
3760
|
-
accountId: target.id,
|
|
3761
|
-
});
|
|
3762
|
-
return {
|
|
3763
|
-
account: target,
|
|
3764
|
-
fetched: false,
|
|
3765
|
-
changed,
|
|
3766
|
-
};
|
|
3767
|
-
}
|
|
3768
|
-
let quotaPollLock = await acquireRefreshFileLock({
|
|
3769
|
-
name: fallbackRefreshLockName(target.id),
|
|
3770
|
-
ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS,
|
|
3771
|
-
path: this.configPath,
|
|
3772
|
-
now: this.now,
|
|
3773
|
-
});
|
|
3774
|
-
if (!quotaPollLock) {
|
|
3775
|
-
log('[quota] fallback quota poll skipped refresh lock', {
|
|
3776
|
-
accountId: target.id,
|
|
3777
|
-
});
|
|
3778
|
-
return { account: target, fetched: false, changed };
|
|
3779
|
-
}
|
|
3780
|
-
await using _quotaPollLock = {
|
|
3781
|
-
[Symbol.asyncDispose]: async () => quotaPollLock?.release(),
|
|
3782
|
-
};
|
|
3783
|
-
// Unify on the shared QuotaManager when present: it adds inflight
|
|
3784
|
-
// deduplication and 429 backoff gating around the same quota API. Fall back
|
|
3785
|
-
// to a direct fetch only when no QuotaManager is wired (e.g. in isolation).
|
|
3786
|
-
const fetchSnapshot = (accessToken) => this.quotaManager
|
|
3787
|
-
? this.quotaManager.refreshFallbackWithMetadata(target.id, accessToken, target)
|
|
3788
|
-
: fetchOAuthQuotaSnapshot({
|
|
3789
|
-
accessToken,
|
|
3790
|
-
fetchImpl: this.fetchImpl,
|
|
3791
|
-
now: this.now,
|
|
3792
|
-
}).then((quota) => ({ quota, fetched: true }));
|
|
3793
|
-
const fetchStartedAt = this.now();
|
|
3794
|
-
let fetched = false;
|
|
3795
|
-
try {
|
|
3796
|
-
const result = await fetchSnapshot(access?.token ?? '');
|
|
3797
|
-
target.quota = result.quota;
|
|
3798
|
-
fetched = result.fetched;
|
|
3799
|
-
}
|
|
3800
|
-
catch (error) {
|
|
3801
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
3802
|
-
if (!message.includes('Claude quota check failed: 401') ||
|
|
3803
|
-
vaultEnabled ||
|
|
3804
|
-
access?.source !== 'sidecar') {
|
|
3805
|
-
throw error;
|
|
3806
|
-
}
|
|
3807
|
-
await quotaPollLock.release();
|
|
3808
|
-
quotaPollLock = null;
|
|
3809
|
-
target = await this.refreshAccount(account, storage, {
|
|
3810
|
-
force: true,
|
|
3811
|
-
});
|
|
3812
|
-
changed = true;
|
|
3813
|
-
access = this.resolveFallbackAccessToken(target, storage);
|
|
3814
|
-
if (!access) {
|
|
3815
|
-
log('[quota] fallback quota poll skipped after refresh: no usable credential', {
|
|
3816
|
-
accountId: target.id,
|
|
3817
|
-
});
|
|
3818
|
-
return {
|
|
3819
|
-
account: target,
|
|
3820
|
-
fetched: false,
|
|
3821
|
-
changed,
|
|
3822
|
-
};
|
|
3823
|
-
}
|
|
3824
|
-
quotaPollLock = await acquireRefreshFileLock({
|
|
3825
|
-
name: fallbackRefreshLockName(target.id),
|
|
3826
|
-
ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS,
|
|
3827
|
-
path: this.configPath,
|
|
3828
|
-
now: this.now,
|
|
3829
|
-
});
|
|
3830
|
-
if (!quotaPollLock) {
|
|
3831
|
-
log('[quota] fallback quota poll skipped refresh lock', {
|
|
3832
|
-
accountId: target.id,
|
|
3833
|
-
});
|
|
3834
|
-
return { account: target, fetched: false, changed };
|
|
3835
|
-
}
|
|
3836
|
-
// 401 does not arm QuotaManager backoff, so this retry proceeds.
|
|
3837
|
-
const result = await fetchSnapshot(access.token);
|
|
3838
|
-
target.quota = result.quota;
|
|
3839
|
-
fetched = result.fetched;
|
|
3840
|
-
}
|
|
3841
|
-
const latestStorage = await this.load();
|
|
3842
|
-
const latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === target.id && isOAuthAccount(candidate));
|
|
3843
|
-
if (latestStorage &&
|
|
3844
|
-
latestAccount &&
|
|
3845
|
-
latestAccount.access !== target.access) {
|
|
3846
|
-
this.seedFallbackQuota(latestAccount, latestStorage);
|
|
3847
|
-
updateStoredAccount(storage, latestAccount);
|
|
3848
|
-
return { account: latestAccount, fetched: false, changed: false };
|
|
3849
|
-
}
|
|
3850
|
-
if (latestStorage &&
|
|
3851
|
-
latestAccount &&
|
|
3852
|
-
latestAccount.access === target.access &&
|
|
3853
|
-
latestAccount.quota &&
|
|
3854
|
-
quotaSnapshotCheckedAt(latestAccount.quota) >= fetchStartedAt &&
|
|
3855
|
-
quotaSnapshotIsFresh(latestAccount.quota, latestStorage, this.now())) {
|
|
3856
|
-
this.seedFallbackQuota(latestAccount, latestStorage);
|
|
3857
|
-
updateStoredAccount(storage, latestAccount);
|
|
3858
|
-
return { account: latestAccount, fetched, changed: false };
|
|
3859
|
-
}
|
|
3860
|
-
target.lastQuotaRefreshError = undefined;
|
|
3861
|
-
updateStoredAccount(storage, target);
|
|
3862
|
-
// Sync to shared QuotaManager so all consumers see the same cache. The
|
|
3863
|
-
// refreshFallback path already cached the snapshot; re-set here so
|
|
3864
|
-
// refreshAfter reflects this storage's check interval consistently.
|
|
3865
|
-
if (this.quotaManager && target.quota) {
|
|
3866
|
-
const now = this.now();
|
|
3867
|
-
this.quotaManager.setFallback(target.id, {
|
|
3868
|
-
quota: target.quota,
|
|
3869
|
-
refreshAfter: now + getQuotaCheckIntervalMs(storage),
|
|
3870
|
-
checkedAt: now,
|
|
3871
|
-
}, target);
|
|
3872
|
-
}
|
|
3873
|
-
return {
|
|
3874
|
-
account: target,
|
|
3875
|
-
fetched,
|
|
3876
|
-
changed: changed ||
|
|
3877
|
-
JSON.stringify([target.quota, target.lastQuotaRefreshError]) !==
|
|
3878
|
-
initialQuotaState,
|
|
3879
|
-
};
|
|
3880
|
-
}
|
|
3881
|
-
}
|