opencode-anthropic-fix 0.0.26 → 0.0.27
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 +30 -2
- package/index.mjs +3951 -3549
- package/lib/accounts.mjs +39 -11
- package/lib/accounts.test.mjs +22 -22
- package/lib/config.mjs +15 -1
- package/lib/oauth.mjs +256 -74
- package/lib/oauth.test.mjs +120 -3
- package/lib/rotation.mjs +2 -1
- package/lib/rotation.test.mjs +3 -3
- package/lib/storage.mjs +34 -6
- package/lib/storage.test.mjs +14 -7
- package/package.json +2 -2
package/lib/accounts.mjs
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { loadAccounts, saveAccounts, createDefaultStats } from "./storage.mjs";
|
|
2
3
|
import { HealthScoreTracker, TokenBucketTracker, selectAccount } from "./rotation.mjs";
|
|
3
4
|
import { calculateBackoffMs } from "./backoff.mjs";
|
|
4
5
|
|
|
6
|
+
/** QA fix M12: hash token fragment instead of leaking raw prefix */
|
|
7
|
+
function hashTokenFragment(token) {
|
|
8
|
+
return createHash("sha256").update(token).digest("hex").slice(0, 12);
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
/**
|
|
6
12
|
* @typedef {import('./config.mjs').AnthropicAuthConfig} AnthropicAuthConfig
|
|
7
13
|
* @typedef {import('./config.mjs').AccountSelectionStrategy} AccountSelectionStrategy
|
|
@@ -92,7 +98,7 @@ export class AccountManager {
|
|
|
92
98
|
// re-bootstrapping from OpenCode auth fallback credentials.
|
|
93
99
|
if (stored) {
|
|
94
100
|
manager.#accounts = stored.accounts.map((acc, index) => ({
|
|
95
|
-
id: acc.id || `${acc.addedAt}:${acc.refreshToken
|
|
101
|
+
id: acc.id || `${acc.addedAt}:${hashTokenFragment(acc.refreshToken)}`,
|
|
96
102
|
index,
|
|
97
103
|
email: acc.email,
|
|
98
104
|
refreshToken: acc.refreshToken,
|
|
@@ -193,6 +199,15 @@ export class AccountManager {
|
|
|
193
199
|
return this.#currentIndex;
|
|
194
200
|
}
|
|
195
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Get the health score for a specific account.
|
|
204
|
+
* @param {number} accountIndex
|
|
205
|
+
* @returns {number}
|
|
206
|
+
*/
|
|
207
|
+
getHealthScore(accountIndex) {
|
|
208
|
+
return this.#healthTracker.getScore(accountIndex);
|
|
209
|
+
}
|
|
210
|
+
|
|
196
211
|
/**
|
|
197
212
|
* Force the active account to a specific index.
|
|
198
213
|
* Used by OPENCODE_ANTHROPIC_INITIAL_ACCOUNT to pin a session to one account.
|
|
@@ -389,7 +404,7 @@ export class AccountManager {
|
|
|
389
404
|
const now = Date.now();
|
|
390
405
|
/** @type {ManagedAccount} */
|
|
391
406
|
const account = {
|
|
392
|
-
id: `${now}:${refreshToken
|
|
407
|
+
id: `${now}:${hashTokenFragment(refreshToken)}`,
|
|
393
408
|
index: this.#accounts.length,
|
|
394
409
|
email,
|
|
395
410
|
refreshToken,
|
|
@@ -445,11 +460,10 @@ export class AccountManager {
|
|
|
445
460
|
}
|
|
446
461
|
}
|
|
447
462
|
|
|
448
|
-
// Reset health
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
463
|
+
// Reset both health and token trackers since indices shifted after splice (QA fix H10).
|
|
464
|
+
// Rebuilding is simpler and safer than trying to remap individual entries.
|
|
465
|
+
this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
|
|
466
|
+
this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
|
|
453
467
|
this.requestSaveToDisk();
|
|
454
468
|
return true;
|
|
455
469
|
}
|
|
@@ -475,6 +489,10 @@ export class AccountManager {
|
|
|
475
489
|
this.#accounts = [];
|
|
476
490
|
this.#currentIndex = -1;
|
|
477
491
|
this.#cursor = 0;
|
|
492
|
+
// QA fix: reset trackers to avoid stale state leaking to new accounts
|
|
493
|
+
this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
|
|
494
|
+
this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
|
|
495
|
+
this.#statsDeltas.clear();
|
|
478
496
|
}
|
|
479
497
|
|
|
480
498
|
/**
|
|
@@ -617,10 +635,20 @@ export class AccountManager {
|
|
|
617
635
|
activeIndex: Math.max(0, this.#currentIndex),
|
|
618
636
|
};
|
|
619
637
|
|
|
620
|
-
|
|
638
|
+
// QA fix H8: snapshot delta keys before the async write to avoid losing
|
|
639
|
+
// deltas added during the await. Only delete keys we captured, not new ones.
|
|
640
|
+
const capturedDeltaKeys = new Set(this.#statsDeltas.keys());
|
|
621
641
|
|
|
622
|
-
//
|
|
623
|
-
|
|
642
|
+
// QA fix M15: pass pre-loaded disk data to saveAccounts to avoid TOCTOU double-read
|
|
643
|
+
const preloadedDiskData = diskAccountsById
|
|
644
|
+
? { version: 1, accounts: [...diskAccountsById.values()], activeIndex: Math.max(0, this.#currentIndex) }
|
|
645
|
+
: null;
|
|
646
|
+
await saveAccounts(storage, preloadedDiskData);
|
|
647
|
+
|
|
648
|
+
// Clear only the captured deltas; new ones added during await are preserved
|
|
649
|
+
for (const key of capturedDeltaKeys) {
|
|
650
|
+
this.#statsDeltas.delete(key);
|
|
651
|
+
}
|
|
624
652
|
|
|
625
653
|
// Update in-memory stats to match what we wrote (so subsequent reads
|
|
626
654
|
// reflect the merged values, not stale pre-merge values)
|
|
@@ -660,7 +688,7 @@ export class AccountManager {
|
|
|
660
688
|
const existing =
|
|
661
689
|
(acc.id && existingById.get(acc.id)) || (!acc.id ? existingByToken.get(acc.refreshToken) : null);
|
|
662
690
|
return {
|
|
663
|
-
id: acc.id || existing?.id || `${acc.addedAt}:${acc.refreshToken
|
|
691
|
+
id: acc.id || existing?.id || `${acc.addedAt}:${hashTokenFragment(acc.refreshToken)}`,
|
|
664
692
|
index,
|
|
665
693
|
email: acc.email ?? existing?.email,
|
|
666
694
|
refreshToken: acc.refreshToken,
|
package/lib/accounts.test.mjs
CHANGED
|
@@ -356,18 +356,18 @@ describe("AccountManager persistence", () => {
|
|
|
356
356
|
|
|
357
357
|
it("saveToDisk calls saveAccounts with correct format", async () => {
|
|
358
358
|
await manager.saveToDisk();
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}),
|
|
359
|
+
// M15: saveToDisk now passes optional 2nd arg (preloaded disk data)
|
|
360
|
+
const savedArg = saveAccounts.mock.calls[0][0];
|
|
361
|
+
expect(savedArg.version).toBe(1);
|
|
362
|
+
expect(savedArg.accounts).toEqual(
|
|
363
|
+
expect.arrayContaining([
|
|
364
|
+
expect.objectContaining({
|
|
365
|
+
refreshToken: "token1",
|
|
366
|
+
enabled: true,
|
|
367
|
+
}),
|
|
368
|
+
]),
|
|
370
369
|
);
|
|
370
|
+
expect(typeof savedArg.activeIndex).toBe("number");
|
|
371
371
|
});
|
|
372
372
|
|
|
373
373
|
it("requestSaveToDisk debounces saves", async () => {
|
|
@@ -606,18 +606,18 @@ describe("AccountManager usage stats", () => {
|
|
|
606
606
|
|
|
607
607
|
await manager.saveToDisk();
|
|
608
608
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
609
|
+
// M15: saveToDisk now passes optional 2nd arg (preloaded disk data)
|
|
610
|
+
const savedArg = saveAccounts.mock.calls[0][0];
|
|
611
|
+
expect(savedArg.accounts).toEqual(
|
|
612
|
+
expect.arrayContaining([
|
|
613
|
+
expect.objectContaining({
|
|
614
|
+
stats: expect.objectContaining({
|
|
615
|
+
requests: 1,
|
|
616
|
+
inputTokens: 100,
|
|
617
|
+
outputTokens: 50,
|
|
618
618
|
}),
|
|
619
|
-
|
|
620
|
-
|
|
619
|
+
}),
|
|
620
|
+
]),
|
|
621
621
|
);
|
|
622
622
|
});
|
|
623
623
|
});
|
package/lib/config.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* @typedef {'sticky' | 'round-robin' | 'hybrid'} AccountSelectionStrategy
|
|
@@ -68,6 +69,7 @@ import { homedir } from "node:os";
|
|
|
68
69
|
* @property {CachePolicyConfig} cache_policy
|
|
69
70
|
* @property {boolean} fast_mode
|
|
70
71
|
* @property {{emulate_minimal: boolean}} telemetry
|
|
72
|
+
* @property {boolean} usage_toast
|
|
71
73
|
*/
|
|
72
74
|
|
|
73
75
|
/** @type {AnthropicAuthConfig} */
|
|
@@ -119,6 +121,13 @@ export const DEFAULT_CONFIG = {
|
|
|
119
121
|
telemetry: {
|
|
120
122
|
emulate_minimal: false,
|
|
121
123
|
},
|
|
124
|
+
usage_toast: false,
|
|
125
|
+
headers: {
|
|
126
|
+
emulation_profile: "",
|
|
127
|
+
overrides: {},
|
|
128
|
+
disable: [],
|
|
129
|
+
billing_header: true,
|
|
130
|
+
},
|
|
122
131
|
};
|
|
123
132
|
|
|
124
133
|
export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
|
|
@@ -143,6 +152,7 @@ function createDefaultConfig() {
|
|
|
143
152
|
cache_policy: { ...DEFAULT_CONFIG.cache_policy },
|
|
144
153
|
fast_mode: DEFAULT_CONFIG.fast_mode,
|
|
145
154
|
telemetry: { ...DEFAULT_CONFIG.telemetry },
|
|
155
|
+
headers: { ...DEFAULT_CONFIG.headers, overrides: {}, disable: [] },
|
|
146
156
|
};
|
|
147
157
|
}
|
|
148
158
|
|
|
@@ -344,6 +354,9 @@ function validateConfig(raw) {
|
|
|
344
354
|
? raw.telemetry.emulate_minimal
|
|
345
355
|
: DEFAULT_CONFIG.telemetry.emulate_minimal;
|
|
346
356
|
|
|
357
|
+
// Usage toast
|
|
358
|
+
config.usage_toast = typeof raw.usage_toast === "boolean" ? raw.usage_toast : DEFAULT_CONFIG.usage_toast;
|
|
359
|
+
|
|
347
360
|
return config;
|
|
348
361
|
}
|
|
349
362
|
|
|
@@ -507,7 +520,8 @@ export function saveConfig(updates) {
|
|
|
507
520
|
const merged = deepMergeConfig(existing, updates);
|
|
508
521
|
|
|
509
522
|
// Atomic write: temp file + rename
|
|
510
|
-
|
|
523
|
+
// QA fix M16: use random bytes to avoid collision with same-process concurrent calls
|
|
524
|
+
const tmpPath = configPath + `.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
|
|
511
525
|
writeFileSync(tmpPath, JSON.stringify(merged, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
512
526
|
renameSync(tmpPath, configPath);
|
|
513
527
|
}
|
package/lib/oauth.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { CLIENT_ID } from "./config.mjs";
|
|
3
|
+
import { parseRetryAfterHeader, parseRetryAfterMsHeader } from "./backoff.mjs";
|
|
3
4
|
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// OAuth helpers — shared between plugin (index.mjs) and CLI (cli.mjs)
|
|
@@ -10,8 +11,18 @@ const OAUTH_MAX_HOST = "claude.ai";
|
|
|
10
11
|
const OAUTH_REDIRECT_URI = `https://${OAUTH_CONSOLE_HOST}/oauth/code/callback`;
|
|
11
12
|
const OAUTH_TOKEN_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/token`;
|
|
12
13
|
const OAUTH_REVOKE_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/revoke`;
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
// Real Claude Code uses axios 1.13.6 for OAuth calls — axios sets User-Agent
|
|
15
|
+
// automatically and adds Accept header. We must match this fingerprint exactly.
|
|
16
|
+
// CLI version profile: 2.1.81 (for reference; not sent on OAuth calls)
|
|
17
|
+
const OAUTH_AXIOS_VERSION = "1.13.6";
|
|
18
|
+
const OAUTH_USER_AGENT = `axios/${OAUTH_AXIOS_VERSION}`;
|
|
19
|
+
const OAUTH_ACCEPT = "application/json, text/plain, */*";
|
|
20
|
+
const OAUTH_MAX_RETRIES = 2;
|
|
21
|
+
const OAUTH_MAX_RETRY_DELAY_MS = 30_000;
|
|
22
|
+
const OAUTH_RATE_LIMIT_COOLDOWN_MS = 30_000;
|
|
23
|
+
const OAUTH_RETRY_AFTER_SOURCE_HEADER_MS = "retry-after-ms";
|
|
24
|
+
const OAUTH_RETRY_AFTER_SOURCE_HEADER = "retry-after";
|
|
25
|
+
const OAUTH_RETRY_AFTER_SOURCE_FALLBACK_429 = "fallback-429";
|
|
15
26
|
|
|
16
27
|
export const CLAUDE_AI_SCOPES = [
|
|
17
28
|
"user:profile",
|
|
@@ -33,6 +44,116 @@ function generatePKCE() {
|
|
|
33
44
|
return { verifier, challenge, state };
|
|
34
45
|
}
|
|
35
46
|
|
|
47
|
+
/**
|
|
48
|
+
* @param {number} ms
|
|
49
|
+
* @returns {Promise<void>}
|
|
50
|
+
*/
|
|
51
|
+
function sleep(ms) {
|
|
52
|
+
if (ms <= 0) return Promise.resolve();
|
|
53
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {number} status
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
function isRetryableTokenStatus(status) {
|
|
61
|
+
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {Response} resp
|
|
66
|
+
* @param {number} attempt
|
|
67
|
+
* @returns {number}
|
|
68
|
+
*/
|
|
69
|
+
function getRetryDelayMs(resp, attempt) {
|
|
70
|
+
if (!resp || !resp.headers || typeof resp.headers.get !== "function") {
|
|
71
|
+
return Math.min(1000 * 2 ** attempt, 8000);
|
|
72
|
+
}
|
|
73
|
+
const headerMs = parseRetryAfterMsHeader(resp);
|
|
74
|
+
const retryAfterMs = parseRetryAfterHeader(resp);
|
|
75
|
+
const fallback = Math.min(1000 * 2 ** attempt, 8000);
|
|
76
|
+
const resolved = headerMs ?? retryAfterMs ?? fallback;
|
|
77
|
+
return Math.max(250, Math.min(resolved, OAUTH_MAX_RETRY_DELAY_MS));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build a caller-facing cooldown hint from Retry-After headers.
|
|
82
|
+
* Falls back to a conservative 30s cooldown for 429s.
|
|
83
|
+
* @param {Response | null | undefined} resp
|
|
84
|
+
* @param {number | undefined} status
|
|
85
|
+
* @returns {{ retryAfterMs: number | null, retryAfterSource?: string }}
|
|
86
|
+
*/
|
|
87
|
+
function getCooldownHint(resp, status) {
|
|
88
|
+
if (resp && resp.headers && typeof resp.headers.get === "function") {
|
|
89
|
+
const headerMs = parseRetryAfterMsHeader(resp);
|
|
90
|
+
if (Number.isFinite(headerMs) && headerMs > 0) {
|
|
91
|
+
return {
|
|
92
|
+
retryAfterMs: Math.max(250, Math.min(headerMs, OAUTH_MAX_RETRY_DELAY_MS)),
|
|
93
|
+
retryAfterSource: OAUTH_RETRY_AFTER_SOURCE_HEADER_MS,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const retryAfterMs = parseRetryAfterHeader(resp);
|
|
98
|
+
if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
|
|
99
|
+
return {
|
|
100
|
+
retryAfterMs: Math.max(250, Math.min(retryAfterMs, OAUTH_MAX_RETRY_DELAY_MS)),
|
|
101
|
+
retryAfterSource: OAUTH_RETRY_AFTER_SOURCE_HEADER,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (status === 429) {
|
|
107
|
+
return {
|
|
108
|
+
retryAfterMs: OAUTH_RATE_LIMIT_COOLDOWN_MS,
|
|
109
|
+
retryAfterSource: OAUTH_RETRY_AFTER_SOURCE_FALLBACK_429,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { retryAfterMs: null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} rawText
|
|
118
|
+
* @returns {{ errorCode?: string, reason?: string }}
|
|
119
|
+
*/
|
|
120
|
+
function parseOAuthErrorBody(rawText) {
|
|
121
|
+
if (!rawText) return {};
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(rawText);
|
|
125
|
+
let errorCode;
|
|
126
|
+
let reason;
|
|
127
|
+
|
|
128
|
+
if (typeof parsed.error === "string" && parsed.error) {
|
|
129
|
+
errorCode = parsed.error;
|
|
130
|
+
} else if (parsed.error && typeof parsed.error === "object") {
|
|
131
|
+
if (typeof parsed.error.type === "string" && parsed.error.type) {
|
|
132
|
+
errorCode = parsed.error.type;
|
|
133
|
+
}
|
|
134
|
+
if (typeof parsed.error.message === "string" && parsed.error.message) {
|
|
135
|
+
reason = parsed.error.message;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!reason) {
|
|
140
|
+
if (typeof parsed.error_description === "string" && parsed.error_description) {
|
|
141
|
+
reason = parsed.error_description;
|
|
142
|
+
} else if (typeof parsed.message === "string" && parsed.message) {
|
|
143
|
+
reason = parsed.message;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
...(errorCode ? { errorCode } : {}),
|
|
149
|
+
...(reason ? { reason } : {}),
|
|
150
|
+
};
|
|
151
|
+
} catch {
|
|
152
|
+
const trimmed = rawText.trim();
|
|
153
|
+
return trimmed ? { reason: trimmed } : {};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
36
157
|
/**
|
|
37
158
|
* Build an OAuth authorization URL with PKCE challenge.
|
|
38
159
|
* @param {"max" | "console"} mode
|
|
@@ -64,29 +185,24 @@ export async function authorize(mode) {
|
|
|
64
185
|
* @param {string} verifier
|
|
65
186
|
* @returns {Promise<
|
|
66
187
|
* | { type: "success", refresh: string, access: string, expires: number, email?: string }
|
|
67
|
-
* | {
|
|
188
|
+
* | {
|
|
189
|
+
* type: "failed",
|
|
190
|
+
* status?: number,
|
|
191
|
+
* code?: string,
|
|
192
|
+
* reason?: string,
|
|
193
|
+
* details?: string,
|
|
194
|
+
* retryAfterMs?: number,
|
|
195
|
+
* retryAfterSource?: "retry-after-ms" | "retry-after" | "fallback-429"
|
|
196
|
+
* }
|
|
68
197
|
* >}
|
|
69
198
|
*/
|
|
70
199
|
export async function exchange(code, verifier) {
|
|
71
|
-
const fail = (status, rawText = "") => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const parsed = JSON.parse(rawText);
|
|
78
|
-
if (typeof parsed.error === "string" && parsed.error) {
|
|
79
|
-
errorCode = parsed.error;
|
|
80
|
-
}
|
|
81
|
-
if (typeof parsed.error_description === "string" && parsed.error_description) {
|
|
82
|
-
reason = parsed.error_description;
|
|
83
|
-
} else if (typeof parsed.message === "string" && parsed.message) {
|
|
84
|
-
reason = parsed.message;
|
|
85
|
-
}
|
|
86
|
-
} catch {
|
|
87
|
-
reason = rawText.trim() || undefined;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
200
|
+
const fail = (status, rawText = "", cooldownHint = { retryAfterMs: null }) => {
|
|
201
|
+
const { errorCode, reason } = parseOAuthErrorBody(rawText);
|
|
202
|
+
const retryAfterMs =
|
|
203
|
+
Number.isFinite(cooldownHint?.retryAfterMs) && cooldownHint.retryAfterMs > 0
|
|
204
|
+
? Number(cooldownHint.retryAfterMs)
|
|
205
|
+
: null;
|
|
90
206
|
|
|
91
207
|
const detailsParts = [];
|
|
92
208
|
if (typeof status === "number") detailsParts.push(`HTTP ${status}`);
|
|
@@ -99,43 +215,76 @@ export async function exchange(code, verifier) {
|
|
|
99
215
|
...(errorCode ? { code: errorCode } : {}),
|
|
100
216
|
...(reason ? { reason } : {}),
|
|
101
217
|
...(detailsParts.length ? { details: detailsParts.join(" · ") } : {}),
|
|
218
|
+
...(retryAfterMs ? { retryAfterMs } : {}),
|
|
219
|
+
...(cooldownHint?.retryAfterSource ? { retryAfterSource: cooldownHint.retryAfterSource } : {}),
|
|
102
220
|
};
|
|
103
221
|
};
|
|
104
222
|
|
|
105
223
|
const splits = code.split("#");
|
|
106
224
|
let result;
|
|
225
|
+
for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
|
|
226
|
+
try {
|
|
227
|
+
result = await fetch(OAUTH_TOKEN_URL, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: {
|
|
230
|
+
Accept: OAUTH_ACCEPT,
|
|
231
|
+
"Content-Type": "application/json",
|
|
232
|
+
"User-Agent": OAUTH_USER_AGENT,
|
|
233
|
+
},
|
|
234
|
+
body: JSON.stringify({
|
|
235
|
+
grant_type: "authorization_code",
|
|
236
|
+
code: splits[0],
|
|
237
|
+
redirect_uri: OAUTH_REDIRECT_URI,
|
|
238
|
+
client_id: CLIENT_ID,
|
|
239
|
+
code_verifier: verifier,
|
|
240
|
+
state: splits[1],
|
|
241
|
+
}),
|
|
242
|
+
signal: AbortSignal.timeout(15_000),
|
|
243
|
+
});
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (attempt < OAUTH_MAX_RETRIES) {
|
|
246
|
+
await sleep(Math.min(500 * 2 ** attempt, 2000));
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
return fail(undefined, err instanceof Error ? err.message : String(err));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!result.ok) {
|
|
253
|
+
const raw =
|
|
254
|
+
typeof result.text === "function"
|
|
255
|
+
? await result
|
|
256
|
+
.text()
|
|
257
|
+
.then((value) => (typeof value === "string" ? value : ""))
|
|
258
|
+
.catch(() => "")
|
|
259
|
+
: "";
|
|
260
|
+
|
|
261
|
+
if (attempt < OAUTH_MAX_RETRIES && isRetryableTokenStatus(result.status)) {
|
|
262
|
+
await sleep(getRetryDelayMs(result, attempt));
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return fail(result.status, raw, getCooldownHint(result, result.status));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!result) {
|
|
273
|
+
return fail(undefined, "Token exchange request did not complete");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let json;
|
|
107
277
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
"Content-Type": "application/json",
|
|
112
|
-
"User-Agent": OAUTH_USER_AGENT,
|
|
113
|
-
},
|
|
114
|
-
body: JSON.stringify({
|
|
115
|
-
code: splits[0],
|
|
116
|
-
state: splits[1],
|
|
117
|
-
grant_type: "authorization_code",
|
|
118
|
-
client_id: CLIENT_ID,
|
|
119
|
-
redirect_uri: OAUTH_REDIRECT_URI,
|
|
120
|
-
code_verifier: verifier,
|
|
121
|
-
}),
|
|
122
|
-
});
|
|
123
|
-
} catch (err) {
|
|
124
|
-
return fail(undefined, err instanceof Error ? err.message : String(err));
|
|
278
|
+
json = await result.json();
|
|
279
|
+
} catch {
|
|
280
|
+
return fail(result.status, "Invalid JSON in token response");
|
|
125
281
|
}
|
|
126
282
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
? await result
|
|
131
|
-
.text()
|
|
132
|
-
.then((value) => (typeof value === "string" ? value : ""))
|
|
133
|
-
.catch(() => "")
|
|
134
|
-
: "";
|
|
135
|
-
return fail(result.status, raw);
|
|
283
|
+
// Validate required fields before returning success (QA fix C4)
|
|
284
|
+
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
|
|
285
|
+
return fail(result.status, "Missing required fields in token response");
|
|
136
286
|
}
|
|
137
287
|
|
|
138
|
-
const json = await result.json();
|
|
139
288
|
return {
|
|
140
289
|
type: "success",
|
|
141
290
|
refresh: json.refresh_token,
|
|
@@ -160,6 +309,7 @@ export async function revoke(refreshToken) {
|
|
|
160
309
|
const resp = await fetch(OAUTH_REVOKE_URL, {
|
|
161
310
|
method: "POST",
|
|
162
311
|
headers: {
|
|
312
|
+
Accept: OAUTH_ACCEPT,
|
|
163
313
|
"Content-Type": "application/json",
|
|
164
314
|
"User-Agent": OAUTH_USER_AGENT,
|
|
165
315
|
},
|
|
@@ -184,35 +334,67 @@ export async function revoke(refreshToken) {
|
|
|
184
334
|
* @throws {Error} On HTTP errors or network failures
|
|
185
335
|
*/
|
|
186
336
|
export async function refreshToken(refreshTokenValue, options = {}) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
body: JSON.stringify({
|
|
194
|
-
grant_type: "refresh_token",
|
|
195
|
-
client_id: CLIENT_ID,
|
|
196
|
-
refresh_token: refreshTokenValue,
|
|
197
|
-
scope: CLAUDE_AI_SCOPES.join(" "),
|
|
198
|
-
}),
|
|
199
|
-
...(options.signal ? { signal: options.signal } : {}),
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
if (!resp.ok) {
|
|
203
|
-
const text = await resp.text().catch(() => "");
|
|
204
|
-
const error = new Error(`Token refresh failed (HTTP ${resp.status}): ${text}`);
|
|
205
|
-
error.status = resp.status;
|
|
337
|
+
// QA fix M13: default 30s timeout to prevent hanging on unresponsive server
|
|
338
|
+
const signal = options.signal ?? AbortSignal.timeout(30_000);
|
|
339
|
+
let lastError = null;
|
|
340
|
+
|
|
341
|
+
for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
|
|
342
|
+
let resp;
|
|
206
343
|
try {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
344
|
+
resp = await fetch(OAUTH_TOKEN_URL, {
|
|
345
|
+
method: "POST",
|
|
346
|
+
headers: {
|
|
347
|
+
Accept: OAUTH_ACCEPT,
|
|
348
|
+
"Content-Type": "application/json",
|
|
349
|
+
"User-Agent": OAUTH_USER_AGENT,
|
|
350
|
+
},
|
|
351
|
+
body: JSON.stringify({
|
|
352
|
+
grant_type: "refresh_token",
|
|
353
|
+
refresh_token: refreshTokenValue,
|
|
354
|
+
client_id: CLIENT_ID,
|
|
355
|
+
scope: options.scopes ? options.scopes.join(" ") : CLAUDE_AI_SCOPES.join(" "),
|
|
356
|
+
}),
|
|
357
|
+
signal,
|
|
358
|
+
});
|
|
359
|
+
} catch (err) {
|
|
360
|
+
lastError = err;
|
|
361
|
+
if (attempt < OAUTH_MAX_RETRIES) {
|
|
362
|
+
await sleep(Math.min(500 * 2 ** attempt, 2000));
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
throw err;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!resp.ok) {
|
|
369
|
+
const text = await resp.text().catch(() => "");
|
|
370
|
+
const retryAfterMs = getRetryDelayMs(resp, attempt);
|
|
371
|
+
|
|
372
|
+
if (attempt < OAUTH_MAX_RETRIES && isRetryableTokenStatus(resp.status)) {
|
|
373
|
+
await sleep(retryAfterMs);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const error = new Error(`Token refresh failed (HTTP ${resp.status}): ${text}`);
|
|
378
|
+
error.status = resp.status;
|
|
379
|
+
const cooldownHint = getCooldownHint(resp, resp.status);
|
|
380
|
+
error.retryAfterMs =
|
|
381
|
+
Number.isFinite(cooldownHint.retryAfterMs) && cooldownHint.retryAfterMs > 0
|
|
382
|
+
? cooldownHint.retryAfterMs
|
|
383
|
+
: retryAfterMs;
|
|
384
|
+
if (cooldownHint.retryAfterSource) {
|
|
385
|
+
error.retryAfterSource = cooldownHint.retryAfterSource;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const parsed = parseOAuthErrorBody(text);
|
|
389
|
+
if (parsed.errorCode) error.code = parsed.errorCode;
|
|
390
|
+
|
|
391
|
+
throw error;
|
|
211
392
|
}
|
|
212
|
-
|
|
393
|
+
|
|
394
|
+
return resp.json();
|
|
213
395
|
}
|
|
214
396
|
|
|
215
|
-
|
|
397
|
+
throw lastError instanceof Error ? lastError : new Error("Token refresh failed after retries");
|
|
216
398
|
}
|
|
217
399
|
|
|
218
400
|
/**
|