opencode-anthropic-fix 0.0.16 → 0.0.17
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/cli.mjs +2 -0
- package/index.mjs +466 -90
- package/index.test.mjs +725 -56
- package/lib/account-state.mjs +50 -0
- package/lib/account-state.test.mjs +109 -0
- package/lib/accounts.mjs +100 -11
- package/lib/accounts.test.mjs +95 -0
- package/lib/config.mjs +60 -0
- package/lib/oauth.mjs +35 -0
- package/lib/refresh-lock.mjs +123 -0
- package/lib/refresh-lock.test.mjs +79 -0
- package/lib/storage.mjs +76 -1
- package/lib/storage.test.mjs +133 -0
- package/nul +1 -1
- package/package.json +1 -1
- package/test/helpers/accounts-fixtures.mjs +51 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('./storage.mjs').AccountMetadata} AccountMetadata
|
|
3
|
+
* @typedef {import('./storage.mjs').AccountStorage} AccountStorage
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reset transient account tracking fields.
|
|
8
|
+
* @param {AccountMetadata} account
|
|
9
|
+
*/
|
|
10
|
+
export function resetAccountTracking(account) {
|
|
11
|
+
account.rateLimitResetTimes = {};
|
|
12
|
+
account.consecutiveFailures = 0;
|
|
13
|
+
account.lastFailureTime = null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalize active index after removing one account.
|
|
18
|
+
* @param {AccountStorage} storage
|
|
19
|
+
* @param {number} removedIndex
|
|
20
|
+
*/
|
|
21
|
+
export function adjustActiveIndexAfterRemoval(storage, removedIndex) {
|
|
22
|
+
if (storage.accounts.length === 0) {
|
|
23
|
+
storage.activeIndex = 0;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (storage.activeIndex >= storage.accounts.length) {
|
|
28
|
+
storage.activeIndex = storage.accounts.length - 1;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (storage.activeIndex > removedIndex) {
|
|
33
|
+
storage.activeIndex -= 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Apply OAuth credentials to an existing account record.
|
|
39
|
+
* @param {AccountMetadata} account
|
|
40
|
+
* @param {{refresh: string, access: string, expires: number, email?: string}} credentials
|
|
41
|
+
*/
|
|
42
|
+
export function applyOAuthCredentials(account, credentials) {
|
|
43
|
+
account.refreshToken = credentials.refresh;
|
|
44
|
+
account.access = credentials.access;
|
|
45
|
+
account.expires = credentials.expires;
|
|
46
|
+
account.token_updated_at = Date.now();
|
|
47
|
+
if (credentials.email) {
|
|
48
|
+
account.email = credentials.email;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { applyOAuthCredentials, resetAccountTracking, adjustActiveIndexAfterRemoval } from "./account-state.mjs";
|
|
3
|
+
|
|
4
|
+
describe("resetAccountTracking", () => {
|
|
5
|
+
it("resets rate-limit and failure fields", () => {
|
|
6
|
+
const account = {
|
|
7
|
+
rateLimitResetTimes: { anthropic: Date.now() + 60_000 },
|
|
8
|
+
consecutiveFailures: 7,
|
|
9
|
+
lastFailureTime: Date.now(),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
resetAccountTracking(account);
|
|
13
|
+
|
|
14
|
+
expect(account.rateLimitResetTimes).toEqual({});
|
|
15
|
+
expect(account.consecutiveFailures).toBe(0);
|
|
16
|
+
expect(account.lastFailureTime).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("applyOAuthCredentials", () => {
|
|
21
|
+
it("applies refresh/access/expiry and optional email", () => {
|
|
22
|
+
const account = {
|
|
23
|
+
refreshToken: "old-refresh",
|
|
24
|
+
access: "old-access",
|
|
25
|
+
expires: 1,
|
|
26
|
+
email: "old@example.com",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
applyOAuthCredentials(account, {
|
|
30
|
+
refresh: "new-refresh",
|
|
31
|
+
access: "new-access",
|
|
32
|
+
expires: 123,
|
|
33
|
+
email: "new@example.com",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(account).toEqual({
|
|
37
|
+
refreshToken: "new-refresh",
|
|
38
|
+
access: "new-access",
|
|
39
|
+
expires: 123,
|
|
40
|
+
token_updated_at: expect.any(Number),
|
|
41
|
+
email: "new@example.com",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("preserves existing email when credentials omit email", () => {
|
|
46
|
+
const account = {
|
|
47
|
+
refreshToken: "old-refresh",
|
|
48
|
+
access: "old-access",
|
|
49
|
+
expires: 1,
|
|
50
|
+
email: "old@example.com",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
applyOAuthCredentials(account, {
|
|
54
|
+
refresh: "new-refresh",
|
|
55
|
+
access: "new-access",
|
|
56
|
+
expires: 456,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(account.email).toBe("old@example.com");
|
|
60
|
+
expect(account.refreshToken).toBe("new-refresh");
|
|
61
|
+
expect(account.access).toBe("new-access");
|
|
62
|
+
expect(account.expires).toBe(456);
|
|
63
|
+
expect(account.token_updated_at).toEqual(expect.any(Number));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("adjustActiveIndexAfterRemoval", () => {
|
|
68
|
+
it("resets activeIndex to 0 when no accounts remain", () => {
|
|
69
|
+
const storage = { accounts: [], activeIndex: 3 };
|
|
70
|
+
adjustActiveIndexAfterRemoval(storage, 0);
|
|
71
|
+
expect(storage.activeIndex).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("clamps activeIndex when it falls out of range", () => {
|
|
75
|
+
const storage = {
|
|
76
|
+
accounts: [{ id: "a" }, { id: "b" }],
|
|
77
|
+
activeIndex: 2,
|
|
78
|
+
};
|
|
79
|
+
adjustActiveIndexAfterRemoval(storage, 0);
|
|
80
|
+
expect(storage.activeIndex).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("decrements activeIndex when removed index is before active", () => {
|
|
84
|
+
const storage = {
|
|
85
|
+
accounts: [{ id: "a" }, { id: "b" }, { id: "c" }],
|
|
86
|
+
activeIndex: 2,
|
|
87
|
+
};
|
|
88
|
+
adjustActiveIndexAfterRemoval(storage, 0);
|
|
89
|
+
expect(storage.activeIndex).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("keeps activeIndex when removed index is after active", () => {
|
|
93
|
+
const storage = {
|
|
94
|
+
accounts: [{ id: "a" }, { id: "b" }, { id: "c" }],
|
|
95
|
+
activeIndex: 0,
|
|
96
|
+
};
|
|
97
|
+
adjustActiveIndexAfterRemoval(storage, 2);
|
|
98
|
+
expect(storage.activeIndex).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("keeps activeIndex when removed index was the active slot", () => {
|
|
102
|
+
const storage = {
|
|
103
|
+
accounts: [{ id: "a" }, { id: "b" }, { id: "c" }],
|
|
104
|
+
activeIndex: 1,
|
|
105
|
+
};
|
|
106
|
+
adjustActiveIndexAfterRemoval(storage, 1);
|
|
107
|
+
expect(storage.activeIndex).toBe(1);
|
|
108
|
+
});
|
|
109
|
+
});
|
package/lib/accounts.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { calculateBackoffMs } from "./backoff.mjs";
|
|
|
22
22
|
* @property {string} refreshToken
|
|
23
23
|
* @property {string} [access]
|
|
24
24
|
* @property {number} [expires]
|
|
25
|
+
* @property {number} tokenUpdatedAt
|
|
25
26
|
* @property {number} addedAt
|
|
26
27
|
* @property {number} lastUsed
|
|
27
28
|
* @property {boolean} enabled
|
|
@@ -97,6 +98,7 @@ export class AccountManager {
|
|
|
97
98
|
refreshToken: acc.refreshToken,
|
|
98
99
|
access: acc.access,
|
|
99
100
|
expires: acc.expires,
|
|
101
|
+
tokenUpdatedAt: acc.token_updated_at,
|
|
100
102
|
addedAt: acc.addedAt,
|
|
101
103
|
lastUsed: acc.lastUsed,
|
|
102
104
|
enabled: acc.enabled,
|
|
@@ -114,8 +116,17 @@ export class AccountManager {
|
|
|
114
116
|
if (authFallback && manager.#accounts.length > 0) {
|
|
115
117
|
const match = manager.#accounts.find((acc) => acc.refreshToken === authFallback.refresh);
|
|
116
118
|
if (match) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
+
const fallbackHasAccess = typeof authFallback.access === "string" && authFallback.access.length > 0;
|
|
120
|
+
const fallbackExpires = typeof authFallback.expires === "number" ? authFallback.expires : 0;
|
|
121
|
+
const matchExpires = typeof match.expires === "number" ? match.expires : 0;
|
|
122
|
+
const fallbackLooksFresh = fallbackHasAccess && fallbackExpires > Date.now();
|
|
123
|
+
const shouldAdoptFallback =
|
|
124
|
+
fallbackLooksFresh && (!match.access || !match.expires || fallbackExpires > matchExpires);
|
|
125
|
+
if (shouldAdoptFallback) {
|
|
126
|
+
match.access = authFallback.access;
|
|
127
|
+
match.expires = authFallback.expires;
|
|
128
|
+
match.tokenUpdatedAt = Math.max(match.tokenUpdatedAt || 0, fallbackExpires);
|
|
129
|
+
}
|
|
119
130
|
}
|
|
120
131
|
}
|
|
121
132
|
|
|
@@ -133,6 +144,7 @@ export class AccountManager {
|
|
|
133
144
|
refreshToken: authFallback.refresh,
|
|
134
145
|
access: authFallback.access,
|
|
135
146
|
expires: authFallback.expires,
|
|
147
|
+
tokenUpdatedAt: now,
|
|
136
148
|
addedAt: now,
|
|
137
149
|
lastUsed: 0,
|
|
138
150
|
enabled: true,
|
|
@@ -181,6 +193,16 @@ export class AccountManager {
|
|
|
181
193
|
return this.#currentIndex;
|
|
182
194
|
}
|
|
183
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Get enabled account references for internal plugin operations.
|
|
198
|
+
* Returned objects are mutable managed accounts.
|
|
199
|
+
* @param {Set<number>} [excludedIndices]
|
|
200
|
+
* @returns {ManagedAccount[]}
|
|
201
|
+
*/
|
|
202
|
+
getEnabledAccounts(excludedIndices) {
|
|
203
|
+
return this.#accounts.filter((acc) => acc.enabled && !excludedIndices?.has(acc.index));
|
|
204
|
+
}
|
|
205
|
+
|
|
184
206
|
/**
|
|
185
207
|
* Clear expired rate limits for an account.
|
|
186
208
|
* @param {ManagedAccount} account
|
|
@@ -314,6 +336,7 @@ export class AccountManager {
|
|
|
314
336
|
if (existing) {
|
|
315
337
|
existing.access = accessToken;
|
|
316
338
|
existing.expires = expires;
|
|
339
|
+
existing.tokenUpdatedAt = Date.now();
|
|
317
340
|
if (email) existing.email = email;
|
|
318
341
|
existing.enabled = true;
|
|
319
342
|
return existing;
|
|
@@ -328,6 +351,7 @@ export class AccountManager {
|
|
|
328
351
|
refreshToken,
|
|
329
352
|
access: accessToken,
|
|
330
353
|
expires,
|
|
354
|
+
tokenUpdatedAt: now,
|
|
331
355
|
addedAt: now,
|
|
332
356
|
lastUsed: 0,
|
|
333
357
|
enabled: true,
|
|
@@ -430,25 +454,57 @@ export class AccountManager {
|
|
|
430
454
|
*/
|
|
431
455
|
async saveToDisk() {
|
|
432
456
|
// Read current disk state to merge stats
|
|
433
|
-
|
|
457
|
+
/** @type {Map<string, import('./storage.mjs').AccountMetadata> | null} */
|
|
458
|
+
let diskAccountsById = null;
|
|
459
|
+
/** @type {Map<number, import('./storage.mjs').AccountMetadata[]> | null} */
|
|
460
|
+
let diskAccountsByAddedAt = null;
|
|
461
|
+
/** @type {Map<string, import('./storage.mjs').AccountMetadata> | null} */
|
|
462
|
+
let diskAccountsByRefreshToken = null;
|
|
434
463
|
try {
|
|
435
464
|
const diskData = await loadAccounts();
|
|
436
465
|
if (diskData) {
|
|
437
|
-
|
|
466
|
+
diskAccountsById = new Map(diskData.accounts.map((a) => [a.id, a]));
|
|
467
|
+
diskAccountsByAddedAt = new Map();
|
|
468
|
+
diskAccountsByRefreshToken = new Map();
|
|
469
|
+
for (const diskAcc of diskData.accounts) {
|
|
470
|
+
const bucket = diskAccountsByAddedAt.get(diskAcc.addedAt) || [];
|
|
471
|
+
bucket.push(diskAcc);
|
|
472
|
+
diskAccountsByAddedAt.set(diskAcc.addedAt, bucket);
|
|
473
|
+
diskAccountsByRefreshToken.set(diskAcc.refreshToken, diskAcc);
|
|
474
|
+
}
|
|
438
475
|
}
|
|
439
476
|
} catch {
|
|
440
477
|
// If we can't read, fall through to writing absolute values
|
|
441
478
|
}
|
|
442
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Resolve a corresponding disk account for auth merge.
|
|
482
|
+
* Prefer stable id, then unique addedAt, then refresh token.
|
|
483
|
+
* @param {ManagedAccount} account
|
|
484
|
+
*/
|
|
485
|
+
const findDiskAccount = (account) => {
|
|
486
|
+
const byId = diskAccountsById?.get(account.id);
|
|
487
|
+
if (byId) return byId;
|
|
488
|
+
|
|
489
|
+
const byAddedAt = diskAccountsByAddedAt?.get(account.addedAt);
|
|
490
|
+
if (byAddedAt?.length === 1) return byAddedAt[0];
|
|
491
|
+
|
|
492
|
+
const byToken = diskAccountsByRefreshToken?.get(account.refreshToken);
|
|
493
|
+
if (byToken) return byToken;
|
|
494
|
+
|
|
495
|
+
if (byAddedAt && byAddedAt.length > 0) return byAddedAt[0];
|
|
496
|
+
return null;
|
|
497
|
+
};
|
|
498
|
+
|
|
443
499
|
/** @type {AccountStorage} */
|
|
444
500
|
const storage = {
|
|
445
501
|
version: 1,
|
|
446
502
|
accounts: this.#accounts.map((acc) => {
|
|
447
503
|
const delta = this.#statsDeltas.get(acc.id);
|
|
448
504
|
let mergedStats = acc.stats;
|
|
505
|
+
const diskAcc = findDiskAccount(acc);
|
|
449
506
|
|
|
450
507
|
if (delta) {
|
|
451
|
-
const diskAcc = diskAccounts?.get(acc.id);
|
|
452
508
|
const diskStats = diskAcc?.stats;
|
|
453
509
|
|
|
454
510
|
if (delta.isReset) {
|
|
@@ -475,12 +531,35 @@ export class AccountManager {
|
|
|
475
531
|
// If no diskStats and no reset, we write acc.stats as-is (first save)
|
|
476
532
|
}
|
|
477
533
|
|
|
534
|
+
const memTokenUpdatedAt = acc.tokenUpdatedAt || 0;
|
|
535
|
+
const diskTokenUpdatedAt = diskAcc?.token_updated_at || 0;
|
|
536
|
+
const freshestAuth =
|
|
537
|
+
diskAcc && diskTokenUpdatedAt > memTokenUpdatedAt
|
|
538
|
+
? {
|
|
539
|
+
refreshToken: diskAcc.refreshToken,
|
|
540
|
+
access: diskAcc.access,
|
|
541
|
+
expires: diskAcc.expires,
|
|
542
|
+
tokenUpdatedAt: diskTokenUpdatedAt,
|
|
543
|
+
}
|
|
544
|
+
: {
|
|
545
|
+
refreshToken: acc.refreshToken,
|
|
546
|
+
access: acc.access,
|
|
547
|
+
expires: acc.expires,
|
|
548
|
+
tokenUpdatedAt: memTokenUpdatedAt,
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
acc.refreshToken = freshestAuth.refreshToken;
|
|
552
|
+
acc.access = freshestAuth.access;
|
|
553
|
+
acc.expires = freshestAuth.expires;
|
|
554
|
+
acc.tokenUpdatedAt = freshestAuth.tokenUpdatedAt;
|
|
555
|
+
|
|
478
556
|
return {
|
|
479
557
|
id: acc.id,
|
|
480
558
|
email: acc.email,
|
|
481
|
-
refreshToken:
|
|
482
|
-
access:
|
|
483
|
-
expires:
|
|
559
|
+
refreshToken: freshestAuth.refreshToken,
|
|
560
|
+
access: freshestAuth.access,
|
|
561
|
+
expires: freshestAuth.expires,
|
|
562
|
+
token_updated_at: freshestAuth.tokenUpdatedAt,
|
|
484
563
|
addedAt: acc.addedAt,
|
|
485
564
|
lastUsed: acc.lastUsed,
|
|
486
565
|
enabled: acc.enabled,
|
|
@@ -519,14 +598,23 @@ export class AccountManager {
|
|
|
519
598
|
if (!stored) return;
|
|
520
599
|
|
|
521
600
|
// Reconcile account list/enabled states with disk (CLI may add/remove/enable/disable)
|
|
522
|
-
const
|
|
523
|
-
const memSnapshot = this.#accounts.map((acc) => `${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
|
|
601
|
+
const existingByTokenForSnapshot = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
|
|
602
|
+
const memSnapshot = this.#accounts.map((acc) => `${acc.id}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
|
|
603
|
+
|
|
604
|
+
const diskSnapshot = stored.accounts
|
|
605
|
+
.map((acc) => {
|
|
606
|
+
const resolvedId = acc.id || existingByTokenForSnapshot.get(acc.refreshToken)?.id || acc.refreshToken;
|
|
607
|
+
return `${resolvedId}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`;
|
|
608
|
+
})
|
|
609
|
+
.join("|");
|
|
524
610
|
|
|
525
611
|
if (diskSnapshot !== memSnapshot) {
|
|
612
|
+
const existingById = new Map(this.#accounts.map((acc) => [acc.id, acc]));
|
|
526
613
|
const existingByToken = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
|
|
527
614
|
|
|
528
615
|
this.#accounts = stored.accounts.map((acc, index) => {
|
|
529
|
-
const existing =
|
|
616
|
+
const existing =
|
|
617
|
+
(acc.id && existingById.get(acc.id)) || (!acc.id ? existingByToken.get(acc.refreshToken) : null);
|
|
530
618
|
return {
|
|
531
619
|
id: acc.id || existing?.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
|
|
532
620
|
index,
|
|
@@ -534,6 +622,7 @@ export class AccountManager {
|
|
|
534
622
|
refreshToken: acc.refreshToken,
|
|
535
623
|
access: acc.access ?? existing?.access,
|
|
536
624
|
expires: acc.expires ?? existing?.expires,
|
|
625
|
+
tokenUpdatedAt: acc.token_updated_at ?? existing?.tokenUpdatedAt ?? acc.addedAt,
|
|
537
626
|
addedAt: acc.addedAt,
|
|
538
627
|
lastUsed: acc.lastUsed,
|
|
539
628
|
enabled: acc.enabled,
|
package/lib/accounts.test.mjs
CHANGED
|
@@ -100,6 +100,29 @@ describe("AccountManager.load", () => {
|
|
|
100
100
|
expect(snapshot[0].access).toBe("fresh-access");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
it("does not let stale/partial fallback override fresher stored auth", async () => {
|
|
104
|
+
loadAccounts.mockResolvedValue(
|
|
105
|
+
makeAccountsData([
|
|
106
|
+
{
|
|
107
|
+
lastUsed: 2000,
|
|
108
|
+
access: "disk-access",
|
|
109
|
+
expires: Date.now() + 6 * 3600_000,
|
|
110
|
+
token_updated_at: Date.now() + 6 * 3600_000,
|
|
111
|
+
},
|
|
112
|
+
]),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
116
|
+
refresh: "token1",
|
|
117
|
+
access: "stale-fallback-access",
|
|
118
|
+
expires: Date.now() - 60_000,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const snapshot = manager.getAccountsSnapshot();
|
|
122
|
+
expect(snapshot[0].access).toBe("disk-access");
|
|
123
|
+
expect(snapshot[0].expires).toBeGreaterThan(Date.now());
|
|
124
|
+
});
|
|
125
|
+
|
|
103
126
|
it("clamps activeIndex to valid range", async () => {
|
|
104
127
|
loadAccounts.mockResolvedValue(makeAccountsData([{ lastUsed: 2000 }], { activeIndex: 99 }));
|
|
105
128
|
const manager = await AccountManager.load(DEFAULT_CONFIG, null);
|
|
@@ -449,6 +472,29 @@ describe("AccountManager persistence", () => {
|
|
|
449
472
|
await manager.syncActiveIndexFromDisk();
|
|
450
473
|
expect(manager.getCurrentIndex()).toBe(1);
|
|
451
474
|
});
|
|
475
|
+
|
|
476
|
+
it("syncActiveIndexFromDisk reconciles by stable id when refresh token rotates", async () => {
|
|
477
|
+
const current = manager.getAccountsSnapshot()[0];
|
|
478
|
+
|
|
479
|
+
loadAccounts.mockResolvedValue(
|
|
480
|
+
makeAccountsData([
|
|
481
|
+
{
|
|
482
|
+
id: current.id,
|
|
483
|
+
refreshToken: "token1-rotated",
|
|
484
|
+
access: "rotated-access",
|
|
485
|
+
expires: Date.now() + 7200_000,
|
|
486
|
+
token_updated_at: Date.now() + 5_000,
|
|
487
|
+
},
|
|
488
|
+
]),
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
await manager.syncActiveIndexFromDisk();
|
|
492
|
+
const snapshot = manager.getAccountsSnapshot();
|
|
493
|
+
|
|
494
|
+
expect(snapshot[0].id).toBe(current.id);
|
|
495
|
+
expect(snapshot[0].refreshToken).toBe("token1-rotated");
|
|
496
|
+
expect(snapshot[0].access).toBe("rotated-access");
|
|
497
|
+
});
|
|
452
498
|
});
|
|
453
499
|
|
|
454
500
|
// ---------------------------------------------------------------------------
|
|
@@ -779,4 +825,53 @@ describe("AccountManager merge-on-save", () => {
|
|
|
779
825
|
expect(stats.requests).toBe(1);
|
|
780
826
|
expect(stats.inputTokens).toBe(100);
|
|
781
827
|
});
|
|
828
|
+
|
|
829
|
+
it("does not let stale in-memory auth overwrite fresher disk auth", async () => {
|
|
830
|
+
const manager = await createManagerWithAccounts(1);
|
|
831
|
+
const account = manager.getAccountsSnapshot()[0];
|
|
832
|
+
|
|
833
|
+
loadAccounts.mockResolvedValue(
|
|
834
|
+
makeAccountsData([
|
|
835
|
+
{
|
|
836
|
+
id: account.id,
|
|
837
|
+
refreshToken: "disk-rotated-refresh",
|
|
838
|
+
access: "disk-fresh-access",
|
|
839
|
+
expires: Date.now() + 9_000_000,
|
|
840
|
+
token_updated_at: Date.now() + 10_000,
|
|
841
|
+
},
|
|
842
|
+
]),
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
await manager.saveToDisk();
|
|
846
|
+
|
|
847
|
+
const saved = saveAccounts.mock.calls[0][0];
|
|
848
|
+
expect(saved.accounts[0].refreshToken).toBe("disk-rotated-refresh");
|
|
849
|
+
expect(saved.accounts[0].access).toBe("disk-fresh-access");
|
|
850
|
+
expect(saved.accounts[0].token_updated_at).toBeGreaterThan(account.tokenUpdatedAt);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it("matches id-less disk records by addedAt when merging auth freshness", async () => {
|
|
854
|
+
const manager = await createManagerWithAccounts(1);
|
|
855
|
+
const account = manager.getAccountsSnapshot()[0];
|
|
856
|
+
|
|
857
|
+
// Simulate legacy/id-less disk record after token rotation.
|
|
858
|
+
loadAccounts.mockResolvedValue(
|
|
859
|
+
makeAccountsData([
|
|
860
|
+
{
|
|
861
|
+
id: undefined,
|
|
862
|
+
addedAt: account.addedAt,
|
|
863
|
+
refreshToken: "disk-rotated-no-id",
|
|
864
|
+
access: "disk-fresh-no-id",
|
|
865
|
+
expires: Date.now() + 9_000_000,
|
|
866
|
+
token_updated_at: Date.now() + 10_000,
|
|
867
|
+
},
|
|
868
|
+
]),
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
await manager.saveToDisk();
|
|
872
|
+
|
|
873
|
+
const saved = saveAccounts.mock.calls[0][0];
|
|
874
|
+
expect(saved.accounts[0].refreshToken).toBe("disk-rotated-no-id");
|
|
875
|
+
expect(saved.accounts[0].access).toBe("disk-fresh-no-id");
|
|
876
|
+
});
|
|
782
877
|
});
|
package/lib/config.mjs
CHANGED
|
@@ -37,6 +37,13 @@ import { homedir } from "node:os";
|
|
|
37
37
|
* @property {number} output - Max output tokens to inject. 0 = leave model default unchanged.
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {object} IdleRefreshConfig
|
|
42
|
+
* @property {boolean} enabled - Opportunistically refresh near-expiry idle accounts
|
|
43
|
+
* @property {number} window_minutes - Refresh idle accounts within this many minutes of expiry
|
|
44
|
+
* @property {number} min_interval_minutes - Minimum minutes between idle refresh attempts per account
|
|
45
|
+
*/
|
|
46
|
+
|
|
40
47
|
/**
|
|
41
48
|
* @typedef {object} AnthropicAuthConfig
|
|
42
49
|
* @property {AccountSelectionStrategy} account_selection_strategy
|
|
@@ -47,6 +54,8 @@ import { homedir } from "node:os";
|
|
|
47
54
|
* @property {HealthScoreConfig} health_score
|
|
48
55
|
* @property {TokenBucketConfig} token_bucket
|
|
49
56
|
* @property {ToastConfig} toasts
|
|
57
|
+
* @property {HeaderConfig} headers
|
|
58
|
+
* @property {IdleRefreshConfig} idle_refresh
|
|
50
59
|
*/
|
|
51
60
|
|
|
52
61
|
/** @type {AnthropicAuthConfig} */
|
|
@@ -82,6 +91,11 @@ export const DEFAULT_CONFIG = {
|
|
|
82
91
|
quiet: false,
|
|
83
92
|
debounce_seconds: 30,
|
|
84
93
|
},
|
|
94
|
+
idle_refresh: {
|
|
95
|
+
enabled: true,
|
|
96
|
+
window_minutes: 60,
|
|
97
|
+
min_interval_minutes: 30,
|
|
98
|
+
},
|
|
85
99
|
};
|
|
86
100
|
|
|
87
101
|
export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
|
|
@@ -101,6 +115,7 @@ function createDefaultConfig() {
|
|
|
101
115
|
health_score: { ...DEFAULT_CONFIG.health_score },
|
|
102
116
|
token_bucket: { ...DEFAULT_CONFIG.token_bucket },
|
|
103
117
|
toasts: { ...DEFAULT_CONFIG.toasts },
|
|
118
|
+
idle_refresh: { ...DEFAULT_CONFIG.idle_refresh },
|
|
104
119
|
};
|
|
105
120
|
}
|
|
106
121
|
|
|
@@ -225,6 +240,51 @@ function validateConfig(raw) {
|
|
|
225
240
|
};
|
|
226
241
|
}
|
|
227
242
|
|
|
243
|
+
if (raw.headers && typeof raw.headers === "object") {
|
|
244
|
+
const h = /** @type {Record<string, unknown>} */ (raw.headers);
|
|
245
|
+
|
|
246
|
+
if (typeof h.emulation_profile === "string" && h.emulation_profile.trim()) {
|
|
247
|
+
config.headers.emulation_profile = h.emulation_profile.trim();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (h.overrides && typeof h.overrides === "object" && !Array.isArray(h.overrides)) {
|
|
251
|
+
/** @type {Record<string, string>} */
|
|
252
|
+
const overrides = {};
|
|
253
|
+
for (const [key, value] of Object.entries(/** @type {Record<string, unknown>} */ (h.overrides))) {
|
|
254
|
+
if (!key) continue;
|
|
255
|
+
if (typeof value === "string") {
|
|
256
|
+
overrides[key] = value;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
config.headers.overrides = overrides;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (Array.isArray(h.disable)) {
|
|
263
|
+
config.headers.disable = h.disable
|
|
264
|
+
.filter((v) => typeof v === "string")
|
|
265
|
+
.map((v) => v.trim().toLowerCase())
|
|
266
|
+
.filter(Boolean);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (typeof h.billing_header === "boolean") {
|
|
270
|
+
config.headers.billing_header = h.billing_header;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (raw.idle_refresh && typeof raw.idle_refresh === "object") {
|
|
275
|
+
const ir = /** @type {Record<string, unknown>} */ (raw.idle_refresh);
|
|
276
|
+
config.idle_refresh = {
|
|
277
|
+
enabled: typeof ir.enabled === "boolean" ? ir.enabled : DEFAULT_CONFIG.idle_refresh.enabled,
|
|
278
|
+
window_minutes: clampNumber(ir.window_minutes, 1, 24 * 60, DEFAULT_CONFIG.idle_refresh.window_minutes),
|
|
279
|
+
min_interval_minutes: clampNumber(
|
|
280
|
+
ir.min_interval_minutes,
|
|
281
|
+
1,
|
|
282
|
+
24 * 60,
|
|
283
|
+
DEFAULT_CONFIG.idle_refresh.min_interval_minutes,
|
|
284
|
+
),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
228
288
|
return config;
|
|
229
289
|
}
|
|
230
290
|
|
package/lib/oauth.mjs
CHANGED
|
@@ -101,3 +101,38 @@ export async function revoke(refreshToken) {
|
|
|
101
101
|
return false;
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Refresh an OAuth access token.
|
|
107
|
+
* @param {string} refreshTokenValue - The refresh token to use
|
|
108
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
109
|
+
* @returns {Promise<{access_token: string, refresh_token: string, expires_in: number}>}
|
|
110
|
+
* @throws {Error} On HTTP errors or network failures
|
|
111
|
+
*/
|
|
112
|
+
export async function refreshToken(refreshTokenValue, options = {}) {
|
|
113
|
+
const resp = await fetch("https://console.anthropic.com/v1/oauth/token", {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "Content-Type": "application/json" },
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
grant_type: "refresh_token",
|
|
118
|
+
client_id: CLIENT_ID,
|
|
119
|
+
refresh_token: refreshTokenValue,
|
|
120
|
+
}),
|
|
121
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!resp.ok) {
|
|
125
|
+
const text = await resp.text().catch(() => "");
|
|
126
|
+
const error = new Error(`Token refresh failed (HTTP ${resp.status}): ${text}`);
|
|
127
|
+
error.status = resp.status;
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(text);
|
|
130
|
+
if (parsed.error) error.code = parsed.error;
|
|
131
|
+
} catch {
|
|
132
|
+
// Body may not be valid JSON
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return resp.json();
|
|
138
|
+
}
|