@cortexkit/anthropic-auth-core 1.10.3 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accounts.d.ts +52 -0
- package/dist/accounts.js +392 -94
- package/dist/auth.d.ts +8 -0
- package/dist/auth.js +8 -1
- package/dist/cachekeep.d.ts +3 -1
- package/dist/cachekeep.js +34 -7
- package/dist/commands/account.d.ts +56 -0
- package/dist/commands/account.js +219 -0
- package/dist/dump.js +3 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/logger.d.ts +25 -1
- package/dist/logger.js +238 -18
- package/dist/logging.d.ts +19 -0
- package/dist/logging.js +59 -0
- package/dist/provider.d.ts +19 -0
- package/dist/provider.js +0 -0
- package/dist/quota-manager.d.ts +1 -0
- package/dist/quota-manager.js +16 -7
- package/dist/routing.js +2 -6
- package/package.json +1 -1
package/dist/accounts.js
CHANGED
|
@@ -2,9 +2,9 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { parseRetryAfterHeader, refreshClaudeOAuthToken } from "./auth.js";
|
|
6
6
|
import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from "./constants.js";
|
|
7
|
-
import { log } from "./logger.js";
|
|
7
|
+
import { log, logger } from "./logger.js";
|
|
8
8
|
const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
|
|
9
9
|
const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
|
|
10
10
|
export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
|
|
@@ -136,12 +136,19 @@ function normalizeOperationError(value) {
|
|
|
136
136
|
return undefined;
|
|
137
137
|
const nextRetryAt = Number(value.nextRetryAt);
|
|
138
138
|
const retryCount = Number(value.retryCount);
|
|
139
|
+
const status = Number(value.status);
|
|
139
140
|
return {
|
|
140
141
|
message: value.message,
|
|
141
142
|
checkedAt,
|
|
142
143
|
nextRetryAt: Number.isFinite(nextRetryAt) ? nextRetryAt : undefined,
|
|
143
144
|
retryCount: Number.isFinite(retryCount) ? retryCount : undefined,
|
|
144
145
|
tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : undefined,
|
|
146
|
+
// Preserve the dead-token discriminators across save/load. Without these,
|
|
147
|
+
// a retry-exhausted transient (permanent=false, 24h backoff) would lose its
|
|
148
|
+
// flag on reload and the 24h-delay heuristic would wrongly re-classify it
|
|
149
|
+
// permanent → false "needs re-login" nag.
|
|
150
|
+
status: Number.isFinite(status) ? status : undefined,
|
|
151
|
+
permanent: typeof value.permanent === 'boolean' ? value.permanent : undefined,
|
|
145
152
|
};
|
|
146
153
|
}
|
|
147
154
|
function normalizeQuota(value) {
|
|
@@ -169,6 +176,15 @@ function normalizeQuota(value) {
|
|
|
169
176
|
}
|
|
170
177
|
return Object.keys(quota).length ? quota : undefined;
|
|
171
178
|
}
|
|
179
|
+
// Fresh empty storage shell — main OpenCode OAuth account, no fallback
|
|
180
|
+
// accounts. Returns a new object each call so mutating callers don't alias.
|
|
181
|
+
export function createEmptyStorage() {
|
|
182
|
+
return {
|
|
183
|
+
version: 1,
|
|
184
|
+
main: { type: 'opencode', provider: 'anthropic' },
|
|
185
|
+
accounts: [],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
172
188
|
function normalizeStorage(value) {
|
|
173
189
|
if (!isRecord(value) || !Array.isArray(value.accounts))
|
|
174
190
|
return null;
|
|
@@ -187,6 +203,7 @@ function normalizeStorage(value) {
|
|
|
187
203
|
costZeroing: isRecord(value.costZeroing) ? value.costZeroing : undefined,
|
|
188
204
|
cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
|
|
189
205
|
relay: isRecord(value.relay) ? value.relay : undefined,
|
|
206
|
+
logging: isRecord(value.logging) ? value.logging : undefined,
|
|
190
207
|
killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
|
|
191
208
|
accounts: value.accounts
|
|
192
209
|
.map(normalizeAccount)
|
|
@@ -201,7 +218,8 @@ async function readJsonIfPresent(path) {
|
|
|
201
218
|
if (error.code === 'ENOENT') {
|
|
202
219
|
return { exists: false, value: null };
|
|
203
220
|
}
|
|
204
|
-
|
|
221
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
222
|
+
throw new Error(`account store at ${path} is corrupt or unreadable (${cause}) — fix or remove it`);
|
|
205
223
|
}
|
|
206
224
|
}
|
|
207
225
|
function objectWithDefinedEntries(value) {
|
|
@@ -248,10 +266,14 @@ function mergeConfigAndState(configValue, stateValue) {
|
|
|
248
266
|
}
|
|
249
267
|
export async function loadAccounts(path = getAccountStoragePath()) {
|
|
250
268
|
const config = await readJsonIfPresent(path);
|
|
251
|
-
if (!config.exists)
|
|
252
|
-
return null;
|
|
253
269
|
const state = await readJsonIfPresent(getAccountStatePath(path));
|
|
254
|
-
|
|
270
|
+
// Runtime-only flows (main-OAuth refresh with no fallback accounts) write the
|
|
271
|
+
// state file but never the config file, so the store is absent only when
|
|
272
|
+
// neither exists. Synthesize an empty config to merge state into otherwise.
|
|
273
|
+
if (!config.exists && !state.exists)
|
|
274
|
+
return null;
|
|
275
|
+
const configValue = config.exists ? config.value : createEmptyStorage();
|
|
276
|
+
return normalizeStorage(mergeConfigAndState(configValue, state.value));
|
|
255
277
|
}
|
|
256
278
|
async function loadExistingTopLevelFields(path) {
|
|
257
279
|
const existing = await readJsonIfPresent(path);
|
|
@@ -356,6 +378,7 @@ function configFromStorage(storage) {
|
|
|
356
378
|
quota,
|
|
357
379
|
claudeCache: storage.claudeCache,
|
|
358
380
|
dump: storage.dump,
|
|
381
|
+
logging: storage.logging,
|
|
359
382
|
claudeFast: storage.claudeFast,
|
|
360
383
|
costZeroing: storage.costZeroing,
|
|
361
384
|
cacheKeep: storage.cacheKeep,
|
|
@@ -364,25 +387,16 @@ function configFromStorage(storage) {
|
|
|
364
387
|
accounts: storage.accounts.map(accountConfig),
|
|
365
388
|
});
|
|
366
389
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
quotaToken: storage.quota?.mainQuotaToken,
|
|
378
|
-
lastQuotaApiError: storage.quota?.mainLastQuotaApiError,
|
|
379
|
-
lastRefreshError: storage.refresh?.mainLastRefreshError,
|
|
380
|
-
refreshLeaseId: storage.refresh?.mainRefreshLeaseId,
|
|
381
|
-
refreshLeaseUntil: storage.refresh?.mainRefreshLeaseUntil,
|
|
382
|
-
refreshLeaseTokenHash: storage.refresh?.mainRefreshLeaseTokenHash,
|
|
383
|
-
}),
|
|
384
|
-
accounts,
|
|
385
|
-
};
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// In-process save mutex — serializes all account-store writes so concurrent
|
|
392
|
+
// read-modify-write callers (background timers that call saveAccountState with
|
|
393
|
+
// different section flags) don't lose each other's updates (#9).
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
let saveChain = Promise.resolve();
|
|
396
|
+
function enqueueSave(fn) {
|
|
397
|
+
return new Promise((resolve, reject) => {
|
|
398
|
+
saveChain = saveChain.then(() => fn().then(resolve, reject), () => fn().then(resolve, reject));
|
|
399
|
+
});
|
|
386
400
|
}
|
|
387
401
|
async function writeJsonAtomic(path, value) {
|
|
388
402
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -391,13 +405,27 @@ async function writeJsonAtomic(path, value) {
|
|
|
391
405
|
encoding: 'utf8',
|
|
392
406
|
mode: 0o600,
|
|
393
407
|
});
|
|
394
|
-
|
|
408
|
+
try {
|
|
409
|
+
await rename(tempPath, path);
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
await rm(tempPath, { force: true }).catch(() => { });
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
395
415
|
}
|
|
396
|
-
export
|
|
416
|
+
export function saveAccounts(storage, path = getAccountStoragePath()) {
|
|
417
|
+
const resolvedPath = path;
|
|
418
|
+
return enqueueSave(() => saveAccountsLocked(storage, resolvedPath));
|
|
419
|
+
}
|
|
420
|
+
async function saveAccountsLocked(storage, path) {
|
|
397
421
|
const existing = await loadExistingTopLevelFields(path);
|
|
398
422
|
const nextConfig = { ...existing, ...configFromStorage(storage) };
|
|
399
423
|
await writeJsonAtomic(path, nextConfig);
|
|
400
|
-
await
|
|
424
|
+
await saveAccountStateUnlocked(storage, path, {
|
|
425
|
+
mainQuota: true,
|
|
426
|
+
mainRefresh: true,
|
|
427
|
+
accounts: true,
|
|
428
|
+
});
|
|
401
429
|
}
|
|
402
430
|
function applyMainQuotaStatePatch(state, storage) {
|
|
403
431
|
state.main = state.main ?? {};
|
|
@@ -430,11 +458,15 @@ function pruneUndefined(value) {
|
|
|
430
458
|
.filter(([, entry]) => entry !== undefined)
|
|
431
459
|
.map(([key, entry]) => [key, pruneUndefined(entry)]));
|
|
432
460
|
}
|
|
433
|
-
export
|
|
461
|
+
export function saveAccountState(storage, path = getAccountStoragePath(), scope = {
|
|
434
462
|
mainQuota: true,
|
|
435
463
|
mainRefresh: true,
|
|
436
464
|
accounts: true,
|
|
437
465
|
}) {
|
|
466
|
+
const resolvedPath = path;
|
|
467
|
+
return enqueueSave(() => saveAccountStateUnlocked(storage, resolvedPath, scope));
|
|
468
|
+
}
|
|
469
|
+
async function saveAccountStateUnlocked(storage, path, scope) {
|
|
438
470
|
const statePath = getAccountStatePath(path);
|
|
439
471
|
const existing = (await readJsonIfPresent(statePath)).value;
|
|
440
472
|
const next = isRecord(existing)
|
|
@@ -459,6 +491,19 @@ export async function saveAccountState(storage, path = getAccountStoragePath(),
|
|
|
459
491
|
}
|
|
460
492
|
}
|
|
461
493
|
}
|
|
494
|
+
else {
|
|
495
|
+
// Full save: drop any per-account state whose id is no longer present in
|
|
496
|
+
// storage.accounts. The scoped path above only prunes ids it was asked to
|
|
497
|
+
// save; on a removal the storage is saved with scope.accounts === true
|
|
498
|
+
// (ids === null), so without this branch the removed account's runtime
|
|
499
|
+
// state (quota/lastRefreshError/access/refresh/expires) would be orphaned
|
|
500
|
+
// in the state file and later merged onto a re-added same-id account.
|
|
501
|
+
const present = new Set(storage.accounts.map((account) => account.id));
|
|
502
|
+
for (const id of Object.keys(next.accounts)) {
|
|
503
|
+
if (!present.has(id))
|
|
504
|
+
delete next.accounts[id];
|
|
505
|
+
}
|
|
506
|
+
}
|
|
462
507
|
}
|
|
463
508
|
await writeJsonAtomic(statePath, pruneUndefined(next));
|
|
464
509
|
}
|
|
@@ -521,26 +566,134 @@ export async function acquireRefreshFileLock(options) {
|
|
|
521
566
|
if ('unref' in renewTimer)
|
|
522
567
|
renewTimer.unref();
|
|
523
568
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
569
|
+
let acquired = await tryAcquire();
|
|
570
|
+
if (!acquired) {
|
|
571
|
+
const evictPath = `${lockPath}.evicting`;
|
|
572
|
+
const evictOwnerPath = join(evictPath, 'owner.json');
|
|
573
|
+
const evictOwnerId = randomUUID();
|
|
574
|
+
const EVICT_TTL = 5_000;
|
|
575
|
+
const MAX_STEAL_ATTEMPTS = 8;
|
|
576
|
+
async function backoff() {
|
|
577
|
+
await new Promise((resolve) => setTimeout(resolve, Math.floor(Math.random() * 4)));
|
|
529
578
|
}
|
|
530
|
-
|
|
579
|
+
async function lockIsLive() {
|
|
531
580
|
try {
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
return null;
|
|
581
|
+
const currentOwner = await readOwner();
|
|
582
|
+
return Number(currentOwner?.expiresAt) > now();
|
|
535
583
|
}
|
|
536
584
|
catch {
|
|
585
|
+
try {
|
|
586
|
+
const current = await stat(lockPath);
|
|
587
|
+
return current.mtimeMs + options.ttlMs > now();
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
// Lock doesn't exist — safe to acquire.
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
async function ownsEvictionMarker() {
|
|
596
|
+
try {
|
|
597
|
+
const owner = JSON.parse(await readFile(evictOwnerPath, 'utf8'));
|
|
598
|
+
return owner?.ownerId === evictOwnerId;
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
async function tryAcquireEvictionMarker() {
|
|
605
|
+
await mkdir(evictPath);
|
|
606
|
+
try {
|
|
607
|
+
await writeFile(evictOwnerPath, `${JSON.stringify({ ownerId: evictOwnerId, createdAt: now() })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
if (error.code === 'ENOENT')
|
|
611
|
+
return false;
|
|
612
|
+
await releaseEvictionMarker();
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
await options.onStep?.('eviction-marker-acquired');
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
async function releaseEvictionMarker() {
|
|
619
|
+
if (await ownsEvictionMarker()) {
|
|
620
|
+
await rm(evictPath, { recursive: true, force: true }).catch(() => { });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) {
|
|
624
|
+
acquired = await tryAcquire();
|
|
625
|
+
if (acquired)
|
|
626
|
+
break;
|
|
627
|
+
if (await lockIsLive())
|
|
537
628
|
return null;
|
|
629
|
+
try {
|
|
630
|
+
if (!(await tryAcquireEvictionMarker())) {
|
|
631
|
+
await backoff();
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
catch (evictError) {
|
|
636
|
+
const code = evictError.code;
|
|
637
|
+
if (code !== 'EEXIST')
|
|
638
|
+
throw evictError;
|
|
639
|
+
let evictStat;
|
|
640
|
+
try {
|
|
641
|
+
evictStat = await stat(evictPath);
|
|
642
|
+
}
|
|
643
|
+
catch (statError) {
|
|
644
|
+
if (statError.code === 'ENOENT') {
|
|
645
|
+
await backoff();
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
throw statError;
|
|
649
|
+
}
|
|
650
|
+
if (evictStat.mtimeMs + EVICT_TTL > now())
|
|
651
|
+
return null;
|
|
652
|
+
await options.onStep?.('stale-marker-stat');
|
|
653
|
+
const claimedPath = `${evictPath}.${randomUUID()}`;
|
|
654
|
+
try {
|
|
655
|
+
await rename(evictPath, claimedPath);
|
|
656
|
+
}
|
|
657
|
+
catch (renameError) {
|
|
658
|
+
if (renameError.code === 'ENOENT') {
|
|
659
|
+
await backoff();
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
throw renameError;
|
|
663
|
+
}
|
|
664
|
+
await options.onStep?.('stale-marker-claimed');
|
|
665
|
+
await rm(claimedPath, { recursive: true, force: true }).catch(() => { });
|
|
666
|
+
await backoff();
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
if (await lockIsLive())
|
|
671
|
+
return null;
|
|
672
|
+
if (!(await ownsEvictionMarker()))
|
|
673
|
+
return null;
|
|
674
|
+
await options.onStep?.('stale-lock-confirmed');
|
|
675
|
+
if (!(await ownsEvictionMarker()))
|
|
676
|
+
return null;
|
|
677
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => { });
|
|
678
|
+
if (!(await ownsEvictionMarker()))
|
|
679
|
+
return null;
|
|
680
|
+
acquired = await tryAcquire();
|
|
681
|
+
if (!acquired)
|
|
682
|
+
return null;
|
|
683
|
+
if (!(await ownsEvictionMarker())) {
|
|
684
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => { });
|
|
685
|
+
acquired = false;
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
finally {
|
|
691
|
+
await releaseEvictionMarker();
|
|
538
692
|
}
|
|
539
693
|
}
|
|
540
|
-
await rm(lockPath, { recursive: true, force: true }).catch(() => { });
|
|
541
|
-
if (!(await tryAcquire()))
|
|
542
|
-
return null;
|
|
543
694
|
}
|
|
695
|
+
if (!acquired)
|
|
696
|
+
return null;
|
|
544
697
|
scheduleRenewal();
|
|
545
698
|
return {
|
|
546
699
|
release: async () => {
|
|
@@ -574,11 +727,7 @@ export function getCache1hPersistentMode(storage) {
|
|
|
574
727
|
return normalizeCache1hMode(storage?.claudeCache?.mode);
|
|
575
728
|
}
|
|
576
729
|
export async function setCache1hPersistentEnabled(enabled, mode, path = getAccountStoragePath()) {
|
|
577
|
-
const storage = (await loadAccounts(path)) ??
|
|
578
|
-
version: 1,
|
|
579
|
-
main: { type: 'opencode', provider: 'anthropic' },
|
|
580
|
-
accounts: [],
|
|
581
|
-
};
|
|
730
|
+
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
582
731
|
storage.claudeCache = {
|
|
583
732
|
...(storage.claudeCache ?? {}),
|
|
584
733
|
enabled,
|
|
@@ -588,11 +737,7 @@ export async function setCache1hPersistentEnabled(enabled, mode, path = getAccou
|
|
|
588
737
|
return storage;
|
|
589
738
|
}
|
|
590
739
|
export async function setCache1hPersistentMode(mode, path = getAccountStoragePath()) {
|
|
591
|
-
const storage = (await loadAccounts(path)) ??
|
|
592
|
-
version: 1,
|
|
593
|
-
main: { type: 'opencode', provider: 'anthropic' },
|
|
594
|
-
accounts: [],
|
|
595
|
-
};
|
|
740
|
+
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
596
741
|
storage.claudeCache = {
|
|
597
742
|
...(storage.claudeCache ?? {}),
|
|
598
743
|
enabled: storage.claudeCache?.enabled === true,
|
|
@@ -605,11 +750,7 @@ export function isDumpPersistentlyEnabled(storage) {
|
|
|
605
750
|
return storage?.dump?.enabled === true;
|
|
606
751
|
}
|
|
607
752
|
export async function setDumpPersistentEnabled(enabled, path = getAccountStoragePath()) {
|
|
608
|
-
const storage = (await loadAccounts(path)) ??
|
|
609
|
-
version: 1,
|
|
610
|
-
main: { type: 'opencode', provider: 'anthropic' },
|
|
611
|
-
accounts: [],
|
|
612
|
-
};
|
|
753
|
+
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
613
754
|
storage.dump = {
|
|
614
755
|
...(storage.dump ?? {}),
|
|
615
756
|
enabled,
|
|
@@ -621,11 +762,7 @@ export function isFastModePersistentlyEnabled(storage) {
|
|
|
621
762
|
return storage?.claudeFast?.enabled === true;
|
|
622
763
|
}
|
|
623
764
|
export async function setFastModePersistentEnabled(enabled, path = getAccountStoragePath()) {
|
|
624
|
-
const storage = (await loadAccounts(path)) ??
|
|
625
|
-
version: 1,
|
|
626
|
-
main: { type: 'opencode', provider: 'anthropic' },
|
|
627
|
-
accounts: [],
|
|
628
|
-
};
|
|
765
|
+
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
629
766
|
storage.claudeFast = {
|
|
630
767
|
...(storage.claudeFast ?? {}),
|
|
631
768
|
enabled,
|
|
@@ -634,11 +771,7 @@ export async function setFastModePersistentEnabled(enabled, path = getAccountSto
|
|
|
634
771
|
return storage;
|
|
635
772
|
}
|
|
636
773
|
export async function setCacheKeepPersistentWindow(startHour, endHour, path = getAccountStoragePath()) {
|
|
637
|
-
const storage = (await loadAccounts(path)) ??
|
|
638
|
-
version: 1,
|
|
639
|
-
main: { type: 'opencode', provider: 'anthropic' },
|
|
640
|
-
accounts: [],
|
|
641
|
-
};
|
|
774
|
+
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
642
775
|
storage.cacheKeep = {
|
|
643
776
|
enabled: true,
|
|
644
777
|
startHour,
|
|
@@ -648,11 +781,7 @@ export async function setCacheKeepPersistentWindow(startHour, endHour, path = ge
|
|
|
648
781
|
return storage;
|
|
649
782
|
}
|
|
650
783
|
export async function setCacheKeepPersistentEnabled(enabled, path = getAccountStoragePath()) {
|
|
651
|
-
const storage = (await loadAccounts(path)) ??
|
|
652
|
-
version: 1,
|
|
653
|
-
main: { type: 'opencode', provider: 'anthropic' },
|
|
654
|
-
accounts: [],
|
|
655
|
-
};
|
|
784
|
+
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
656
785
|
storage.cacheKeep = {
|
|
657
786
|
...(storage.cacheKeep ?? {}),
|
|
658
787
|
enabled,
|
|
@@ -660,6 +789,18 @@ export async function setCacheKeepPersistentEnabled(enabled, path = getAccountSt
|
|
|
660
789
|
await saveAccounts(storage, path);
|
|
661
790
|
return storage;
|
|
662
791
|
}
|
|
792
|
+
export function isCacheKeepSubagentsEnabled(storage) {
|
|
793
|
+
return storage?.cacheKeep?.subagents === true;
|
|
794
|
+
}
|
|
795
|
+
export async function setCacheKeepSubagentsEnabled(enabled, path = getAccountStoragePath()) {
|
|
796
|
+
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
797
|
+
storage.cacheKeep = {
|
|
798
|
+
...(storage.cacheKeep ?? {}),
|
|
799
|
+
subagents: enabled,
|
|
800
|
+
};
|
|
801
|
+
await saveAccounts(storage, path);
|
|
802
|
+
return storage;
|
|
803
|
+
}
|
|
663
804
|
function getFallbackStatuses(storage) {
|
|
664
805
|
return storage?.fallbackOn?.length ? storage.fallbackOn : DEFAULT_FALLBACK_ON;
|
|
665
806
|
}
|
|
@@ -699,8 +840,9 @@ export function hashRefreshToken(refreshToken) {
|
|
|
699
840
|
return createHash('sha256').update(refreshToken).digest('hex');
|
|
700
841
|
}
|
|
701
842
|
function isTransientRefreshError(error) {
|
|
702
|
-
|
|
703
|
-
|
|
843
|
+
const status = error.status;
|
|
844
|
+
if (typeof status === 'number' && Number.isFinite(status)) {
|
|
845
|
+
return status === 429 || status >= 500;
|
|
704
846
|
}
|
|
705
847
|
if (!(error instanceof Error))
|
|
706
848
|
return false;
|
|
@@ -717,10 +859,11 @@ export function buildRefreshOperationError(input) {
|
|
|
717
859
|
? (input.previous.retryCount ?? 0)
|
|
718
860
|
: 0;
|
|
719
861
|
const retryCount = previousRetryCount + 1;
|
|
862
|
+
const retryAfterFromError = input.error
|
|
863
|
+
.retryAfter;
|
|
720
864
|
let delay;
|
|
721
|
-
if (
|
|
722
|
-
|
|
723
|
-
delay = input.error.retryAfter * 1000;
|
|
865
|
+
if (typeof retryAfterFromError === 'number' && retryAfterFromError > 0) {
|
|
866
|
+
delay = retryAfterFromError * 1000;
|
|
724
867
|
}
|
|
725
868
|
else if (isTransientRefreshError(input.error)) {
|
|
726
869
|
delay = Math.min(MAX_REFRESH_RETRY_DELAY_MS, MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6));
|
|
@@ -728,14 +871,59 @@ export function buildRefreshOperationError(input) {
|
|
|
728
871
|
else {
|
|
729
872
|
delay = NON_TRANSIENT_REFRESH_RETRY_DELAY_MS;
|
|
730
873
|
}
|
|
874
|
+
const statusFromError = input.error.status;
|
|
875
|
+
const status = typeof statusFromError === 'number' && Number.isFinite(statusFromError)
|
|
876
|
+
? statusFromError
|
|
877
|
+
: undefined;
|
|
878
|
+
const message = formatErrorMessage(input.error);
|
|
879
|
+
// A token is permanently dead ONLY on 400 invalid_grant. The OAuth spec allows
|
|
880
|
+
// other 400s (invalid_client / invalid_request / unsupported_grant_type) that
|
|
881
|
+
// re-login does NOT fix — those, like a retry-exhausted / network / 429 / 5xx
|
|
882
|
+
// error, get a long backoff but must stay permanent=false so they are not
|
|
883
|
+
// falsely flagged "needs re-login". ClaudeOAuthRefreshError carries the raw
|
|
884
|
+
// OAuth body, and its message embeds it (`...: 400 — <body>`), so check both.
|
|
885
|
+
const body = typeof input.error.body === 'string'
|
|
886
|
+
? input.error.body
|
|
887
|
+
: '';
|
|
888
|
+
const isInvalidGrant = body.includes('invalid_grant') || message.includes('invalid_grant');
|
|
731
889
|
return {
|
|
732
|
-
message
|
|
890
|
+
message,
|
|
733
891
|
checkedAt: input.now,
|
|
734
892
|
nextRetryAt: input.now + delay,
|
|
735
893
|
retryCount,
|
|
736
894
|
tokenHash,
|
|
895
|
+
status,
|
|
896
|
+
permanent: status === 400 && isInvalidGrant,
|
|
737
897
|
};
|
|
738
898
|
}
|
|
899
|
+
/**
|
|
900
|
+
* True when a refresh error means the token is permanently dead and the account
|
|
901
|
+
* needs a re-login (vs a transient failure that recovers).
|
|
902
|
+
*
|
|
903
|
+
* Precedence:
|
|
904
|
+
* 1. the explicit `permanent` flag (set at construction from 400 invalid_grant)
|
|
905
|
+
* — the authoritative signal; correctly classifies a retry-exhausted/network
|
|
906
|
+
* error (long backoff, but NOT dead) as non-permanent;
|
|
907
|
+
* 2. else the captured HTTP `status` — 400 (for errors built before `permanent`
|
|
908
|
+
* existed but after `status`);
|
|
909
|
+
* 3. else the legacy 24h-delay heuristic — back-compat ONLY for errors persisted
|
|
910
|
+
* before either field existed (e.g. an operator's already-dead token: no
|
|
911
|
+
* status, ~24h backoff). It still flags those until the next refresh restamps
|
|
912
|
+
* the error with the explicit field.
|
|
913
|
+
*/
|
|
914
|
+
export function isPermanentRefreshError(error) {
|
|
915
|
+
if (!error)
|
|
916
|
+
return false;
|
|
917
|
+
if (typeof error.permanent === 'boolean')
|
|
918
|
+
return error.permanent;
|
|
919
|
+
if (typeof error.status === 'number')
|
|
920
|
+
return error.status === 400;
|
|
921
|
+
if (typeof error.nextRetryAt === 'number') {
|
|
922
|
+
return (error.nextRetryAt - error.checkedAt >=
|
|
923
|
+
NON_TRANSIENT_REFRESH_RETRY_DELAY_MS);
|
|
924
|
+
}
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
739
927
|
export function refreshBackoffActive(error, refreshToken, now) {
|
|
740
928
|
if (!error?.nextRetryAt || error.nextRetryAt <= now)
|
|
741
929
|
return false;
|
|
@@ -747,6 +935,12 @@ export function formatRefreshBackoffMessage(error, now) {
|
|
|
747
935
|
const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
|
|
748
936
|
return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
|
|
749
937
|
}
|
|
938
|
+
export function isQuotaPolicyAuthError(error) {
|
|
939
|
+
const status = error.status;
|
|
940
|
+
if (status === 403)
|
|
941
|
+
return true;
|
|
942
|
+
return /Claude quota check failed: 403\b/.test(formatErrorMessage(error));
|
|
943
|
+
}
|
|
750
944
|
export function buildQuotaOperationError(input) {
|
|
751
945
|
const previousRetryCount = input.previous?.retryCount ?? 0;
|
|
752
946
|
const retryCount = previousRetryCount + 1;
|
|
@@ -773,6 +967,19 @@ export function getQuotaCheckIntervalMs(storage) {
|
|
|
773
967
|
const minutes = storage?.quota?.checkIntervalMinutes ?? DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES;
|
|
774
968
|
return Math.max(1, minutes) * 60_000;
|
|
775
969
|
}
|
|
970
|
+
export function getPersistedLogLevel(storage) {
|
|
971
|
+
return storage?.logging?.level;
|
|
972
|
+
}
|
|
973
|
+
export async function setLogLevelPersistent(level, path = getAccountStoragePath()) {
|
|
974
|
+
const { setLogLevel } = await import("./logger.js");
|
|
975
|
+
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
976
|
+
storage.logging = {
|
|
977
|
+
...(storage.logging ?? {}),
|
|
978
|
+
level,
|
|
979
|
+
};
|
|
980
|
+
await saveAccounts(storage, path);
|
|
981
|
+
setLogLevel(level);
|
|
982
|
+
}
|
|
776
983
|
export function getPersistedMainQuota(storage) {
|
|
777
984
|
if (!storage?.quota?.mainQuota || !storage.quota.mainQuotaCheckedAt)
|
|
778
985
|
return null;
|
|
@@ -804,6 +1011,9 @@ export function quotaSnapshotPassesPolicy(quota, storage) {
|
|
|
804
1011
|
const window = quota?.[key];
|
|
805
1012
|
if (!window)
|
|
806
1013
|
return !failClosedOnUnknownQuota(storage);
|
|
1014
|
+
if (!Number.isFinite(window.remainingPercent)) {
|
|
1015
|
+
return !failClosedOnUnknownQuota(storage);
|
|
1016
|
+
}
|
|
807
1017
|
if (window.remainingPercent < thresholds[key])
|
|
808
1018
|
return false;
|
|
809
1019
|
}
|
|
@@ -818,13 +1028,15 @@ export const DEFAULT_KILLSWITCH_THRESHOLDS = {
|
|
|
818
1028
|
seven_day: 10,
|
|
819
1029
|
};
|
|
820
1030
|
function normalizeKillswitchThresholds(thresholds) {
|
|
1031
|
+
const fiveHour = thresholds?.five_hour ?? thresholds?.['5h'];
|
|
1032
|
+
const sevenDay = thresholds?.seven_day ?? thresholds?.['1w'];
|
|
821
1033
|
return {
|
|
822
|
-
five_hour:
|
|
823
|
-
|
|
824
|
-
DEFAULT_KILLSWITCH_THRESHOLDS.five_hour,
|
|
825
|
-
seven_day:
|
|
826
|
-
|
|
827
|
-
DEFAULT_KILLSWITCH_THRESHOLDS.seven_day,
|
|
1034
|
+
five_hour: typeof fiveHour === 'number' && Number.isFinite(fiveHour)
|
|
1035
|
+
? fiveHour
|
|
1036
|
+
: DEFAULT_KILLSWITCH_THRESHOLDS.five_hour,
|
|
1037
|
+
seven_day: typeof sevenDay === 'number' && Number.isFinite(sevenDay)
|
|
1038
|
+
? sevenDay
|
|
1039
|
+
: DEFAULT_KILLSWITCH_THRESHOLDS.seven_day,
|
|
828
1040
|
};
|
|
829
1041
|
}
|
|
830
1042
|
export function isKillswitchEnabled(storage) {
|
|
@@ -856,6 +1068,10 @@ export function killswitchPassesPolicy(quota, storage, accountId) {
|
|
|
856
1068
|
sawUnknownWindow = true;
|
|
857
1069
|
continue;
|
|
858
1070
|
}
|
|
1071
|
+
if (!Number.isFinite(window.remainingPercent)) {
|
|
1072
|
+
sawUnknownWindow = true;
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
859
1075
|
if (window.remainingPercent < thresholds[key])
|
|
860
1076
|
return false;
|
|
861
1077
|
}
|
|
@@ -889,15 +1105,41 @@ export function getKillswitchConfig(storage) {
|
|
|
889
1105
|
return storage?.killswitch ?? { enabled: false };
|
|
890
1106
|
}
|
|
891
1107
|
export async function setKillswitchPersistent(config, path = getAccountStoragePath()) {
|
|
892
|
-
const storage = (await loadAccounts(path)) ??
|
|
893
|
-
version: 1,
|
|
894
|
-
main: { type: 'opencode', provider: 'anthropic' },
|
|
895
|
-
accounts: [],
|
|
896
|
-
};
|
|
1108
|
+
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
897
1109
|
storage.killswitch = config;
|
|
898
1110
|
await saveAccounts(storage, path);
|
|
899
1111
|
return storage;
|
|
900
1112
|
}
|
|
1113
|
+
export async function removeAccountPersistent(id, path = getAccountStoragePath()) {
|
|
1114
|
+
const storage = await loadAccounts(path);
|
|
1115
|
+
if (!storage)
|
|
1116
|
+
return false;
|
|
1117
|
+
const existed = removeAccount(storage, id);
|
|
1118
|
+
if (existed)
|
|
1119
|
+
await saveAccounts(storage, path);
|
|
1120
|
+
return existed;
|
|
1121
|
+
}
|
|
1122
|
+
export async function reorderAccountsPersistent(orderedIds, path = getAccountStoragePath()) {
|
|
1123
|
+
const storage = await loadAccounts(path);
|
|
1124
|
+
if (!storage)
|
|
1125
|
+
return;
|
|
1126
|
+
reorderAccounts(storage, orderedIds);
|
|
1127
|
+
await saveAccounts(storage, path);
|
|
1128
|
+
}
|
|
1129
|
+
export async function setAccountEnabledPersistent(id, enabled, path = getAccountStoragePath()) {
|
|
1130
|
+
const storage = await loadAccounts(path);
|
|
1131
|
+
if (!storage)
|
|
1132
|
+
return false;
|
|
1133
|
+
const found = setAccountEnabled(storage, id, enabled);
|
|
1134
|
+
if (found)
|
|
1135
|
+
await saveAccounts(storage, path);
|
|
1136
|
+
return found;
|
|
1137
|
+
}
|
|
1138
|
+
export async function addAccountPersistent(account, path = getAccountStoragePath()) {
|
|
1139
|
+
const storage = (await loadAccounts(path)) ?? createEmptyStorage();
|
|
1140
|
+
upsertAccount(storage, account);
|
|
1141
|
+
await saveAccounts(storage, path);
|
|
1142
|
+
}
|
|
901
1143
|
export function getQuotaNextRefreshAt(quota, storage, now) {
|
|
902
1144
|
if (!quotaEnabled(storage))
|
|
903
1145
|
return now + getQuotaCheckIntervalMs(storage);
|
|
@@ -948,13 +1190,21 @@ function cachedQuotaSnapshotStillRelevant(quota, now) {
|
|
|
948
1190
|
return ['five_hour', 'seven_day'].every((key) => cachedQuotaWindowStillRelevant(quota?.[key], now));
|
|
949
1191
|
}
|
|
950
1192
|
function isTransientQuotaError(error) {
|
|
951
|
-
const
|
|
952
|
-
if (
|
|
1193
|
+
const status = error.status;
|
|
1194
|
+
if (typeof status === 'number' && Number.isFinite(status)) {
|
|
1195
|
+
if (status === 429 || status >= 500)
|
|
1196
|
+
return true;
|
|
1197
|
+
}
|
|
1198
|
+
const formattedMessage = formatErrorMessage(error);
|
|
1199
|
+
if (/Claude quota check failed: (429|5\d\d)\b/.test(formattedMessage)) {
|
|
953
1200
|
return true;
|
|
954
|
-
|
|
1201
|
+
}
|
|
1202
|
+
if (formattedMessage.includes('Quota refresh is already in progress')) {
|
|
955
1203
|
return true;
|
|
1204
|
+
}
|
|
956
1205
|
if (!(error instanceof Error))
|
|
957
1206
|
return false;
|
|
1207
|
+
const message = error.message;
|
|
958
1208
|
const code = error.code;
|
|
959
1209
|
return (message.includes('fetch failed') ||
|
|
960
1210
|
code === 'ECONNRESET' ||
|
|
@@ -979,6 +1229,8 @@ function clampPercent(value) {
|
|
|
979
1229
|
function mapUsageWindow(window, checkedAt) {
|
|
980
1230
|
if (typeof window?.utilization !== 'number')
|
|
981
1231
|
return undefined;
|
|
1232
|
+
if (!Number.isFinite(window.utilization))
|
|
1233
|
+
return undefined;
|
|
982
1234
|
const usedPercent = clampPercent(window.utilization);
|
|
983
1235
|
return {
|
|
984
1236
|
usedPercent,
|
|
@@ -1001,7 +1253,11 @@ export async function fetchOAuthQuotaSnapshot(input) {
|
|
|
1001
1253
|
});
|
|
1002
1254
|
if (!response.ok) {
|
|
1003
1255
|
const body = await response.text().catch(() => '');
|
|
1004
|
-
|
|
1256
|
+
const error = Object.assign(new Error(`Claude quota check failed: ${response.status} — ${body}`), {
|
|
1257
|
+
status: response.status,
|
|
1258
|
+
retryAfter: parseRetryAfterHeader(response.headers.get('Retry-After')),
|
|
1259
|
+
});
|
|
1260
|
+
throw error;
|
|
1005
1261
|
}
|
|
1006
1262
|
const checkedAt = input.now?.() ?? Date.now();
|
|
1007
1263
|
const usage = (await response.json());
|
|
@@ -1015,6 +1271,46 @@ function updateStoredAccount(storage, account) {
|
|
|
1015
1271
|
if (index >= 0)
|
|
1016
1272
|
storage.accounts[index] = account;
|
|
1017
1273
|
}
|
|
1274
|
+
export function upsertAccount(storage, account) {
|
|
1275
|
+
const index = storage.accounts.findIndex((candidate) => candidate.id === account.id ||
|
|
1276
|
+
(account.label && candidate.label === account.label));
|
|
1277
|
+
if (index >= 0) {
|
|
1278
|
+
storage.accounts[index] = {
|
|
1279
|
+
...storage.accounts[index],
|
|
1280
|
+
...account,
|
|
1281
|
+
addedAt: storage.accounts[index]?.addedAt ?? account.addedAt,
|
|
1282
|
+
...(account.type === 'oauth' && {
|
|
1283
|
+
quota: account.quota,
|
|
1284
|
+
lastRefreshedAt: account.lastRefreshedAt,
|
|
1285
|
+
lastRefreshError: account.lastRefreshError,
|
|
1286
|
+
lastQuotaRefreshError: account.lastQuotaRefreshError,
|
|
1287
|
+
}),
|
|
1288
|
+
};
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
storage.accounts.push(account);
|
|
1292
|
+
}
|
|
1293
|
+
export function removeAccount(storage, id) {
|
|
1294
|
+
const index = storage.accounts.findIndex((c) => c.id === id);
|
|
1295
|
+
if (index < 0)
|
|
1296
|
+
return false;
|
|
1297
|
+
storage.accounts.splice(index, 1);
|
|
1298
|
+
return true;
|
|
1299
|
+
}
|
|
1300
|
+
export function reorderAccounts(storage, orderedIds) {
|
|
1301
|
+
const orderMap = new Map(orderedIds.map((id, i) => [id, i]));
|
|
1302
|
+
const known = storage.accounts.filter((a) => orderMap.has(a.id));
|
|
1303
|
+
const unknown = storage.accounts.filter((a) => !orderMap.has(a.id));
|
|
1304
|
+
known.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
|
1305
|
+
storage.accounts = [...known, ...unknown];
|
|
1306
|
+
}
|
|
1307
|
+
export function setAccountEnabled(storage, id, enabled) {
|
|
1308
|
+
const account = storage.accounts.find((c) => c.id === id);
|
|
1309
|
+
if (!account)
|
|
1310
|
+
return false;
|
|
1311
|
+
account.enabled = enabled;
|
|
1312
|
+
return true;
|
|
1313
|
+
}
|
|
1018
1314
|
function formatErrorMessage(error) {
|
|
1019
1315
|
return error instanceof Error ? error.message : String(error);
|
|
1020
1316
|
}
|
|
@@ -1027,12 +1323,14 @@ function recordRefreshError(account, error, now) {
|
|
|
1027
1323
|
});
|
|
1028
1324
|
}
|
|
1029
1325
|
function recordQuotaRefreshError(account, error, now) {
|
|
1326
|
+
if (isQuotaPolicyAuthError(error))
|
|
1327
|
+
return;
|
|
1030
1328
|
account.lastQuotaRefreshError = buildQuotaOperationError({
|
|
1031
1329
|
error,
|
|
1032
1330
|
now,
|
|
1033
1331
|
previous: account.lastQuotaRefreshError,
|
|
1034
1332
|
});
|
|
1035
|
-
if (error
|
|
1333
|
+
if (error.isRefreshError) {
|
|
1036
1334
|
recordRefreshError(account, error, now);
|
|
1037
1335
|
}
|
|
1038
1336
|
}
|
|
@@ -1220,7 +1518,7 @@ export class FallbackAccountManager {
|
|
|
1220
1518
|
changed = true;
|
|
1221
1519
|
}
|
|
1222
1520
|
catch (error) {
|
|
1223
|
-
|
|
1521
|
+
logger.warn('refresh', 'fallback oauth background failed', {
|
|
1224
1522
|
accountId: account.id,
|
|
1225
1523
|
error: error instanceof Error ? error.message : String(error),
|
|
1226
1524
|
});
|