opencode-anthropic-fix 0.0.26 → 0.0.28
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 +4126 -3550
- package/lib/accounts.mjs +51 -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.
|
|
@@ -207,6 +222,18 @@ export class AccountManager {
|
|
|
207
222
|
return true;
|
|
208
223
|
}
|
|
209
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Peek at the next account that would be selected without actually switching.
|
|
227
|
+
* Used for predictive rate limit avoidance messaging.
|
|
228
|
+
* @returns {ManagedAccount | null}
|
|
229
|
+
*/
|
|
230
|
+
peekNextAccount() {
|
|
231
|
+
const enabled = this.#accounts.filter((acc) => acc.enabled && acc.index !== this.#currentIndex);
|
|
232
|
+
if (enabled.length === 0) return null;
|
|
233
|
+
// Return the first enabled account that isn't the current one
|
|
234
|
+
return enabled[0] ?? null;
|
|
235
|
+
}
|
|
236
|
+
|
|
210
237
|
/**
|
|
211
238
|
* Get enabled account references for internal plugin operations.
|
|
212
239
|
* Returned objects are mutable managed accounts.
|
|
@@ -389,7 +416,7 @@ export class AccountManager {
|
|
|
389
416
|
const now = Date.now();
|
|
390
417
|
/** @type {ManagedAccount} */
|
|
391
418
|
const account = {
|
|
392
|
-
id: `${now}:${refreshToken
|
|
419
|
+
id: `${now}:${hashTokenFragment(refreshToken)}`,
|
|
393
420
|
index: this.#accounts.length,
|
|
394
421
|
email,
|
|
395
422
|
refreshToken,
|
|
@@ -445,11 +472,10 @@ export class AccountManager {
|
|
|
445
472
|
}
|
|
446
473
|
}
|
|
447
474
|
|
|
448
|
-
// Reset health
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
475
|
+
// Reset both health and token trackers since indices shifted after splice (QA fix H10).
|
|
476
|
+
// Rebuilding is simpler and safer than trying to remap individual entries.
|
|
477
|
+
this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
|
|
478
|
+
this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
|
|
453
479
|
this.requestSaveToDisk();
|
|
454
480
|
return true;
|
|
455
481
|
}
|
|
@@ -475,6 +501,10 @@ export class AccountManager {
|
|
|
475
501
|
this.#accounts = [];
|
|
476
502
|
this.#currentIndex = -1;
|
|
477
503
|
this.#cursor = 0;
|
|
504
|
+
// QA fix: reset trackers to avoid stale state leaking to new accounts
|
|
505
|
+
this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
|
|
506
|
+
this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
|
|
507
|
+
this.#statsDeltas.clear();
|
|
478
508
|
}
|
|
479
509
|
|
|
480
510
|
/**
|
|
@@ -617,10 +647,20 @@ export class AccountManager {
|
|
|
617
647
|
activeIndex: Math.max(0, this.#currentIndex),
|
|
618
648
|
};
|
|
619
649
|
|
|
620
|
-
|
|
650
|
+
// QA fix H8: snapshot delta keys before the async write to avoid losing
|
|
651
|
+
// deltas added during the await. Only delete keys we captured, not new ones.
|
|
652
|
+
const capturedDeltaKeys = new Set(this.#statsDeltas.keys());
|
|
621
653
|
|
|
622
|
-
//
|
|
623
|
-
|
|
654
|
+
// QA fix M15: pass pre-loaded disk data to saveAccounts to avoid TOCTOU double-read
|
|
655
|
+
const preloadedDiskData = diskAccountsById
|
|
656
|
+
? { version: 1, accounts: [...diskAccountsById.values()], activeIndex: Math.max(0, this.#currentIndex) }
|
|
657
|
+
: null;
|
|
658
|
+
await saveAccounts(storage, preloadedDiskData);
|
|
659
|
+
|
|
660
|
+
// Clear only the captured deltas; new ones added during await are preserved
|
|
661
|
+
for (const key of capturedDeltaKeys) {
|
|
662
|
+
this.#statsDeltas.delete(key);
|
|
663
|
+
}
|
|
624
664
|
|
|
625
665
|
// Update in-memory stats to match what we wrote (so subsequent reads
|
|
626
666
|
// reflect the merged values, not stale pre-merge values)
|
|
@@ -660,7 +700,7 @@ export class AccountManager {
|
|
|
660
700
|
const existing =
|
|
661
701
|
(acc.id && existingById.get(acc.id)) || (!acc.id ? existingByToken.get(acc.refreshToken) : null);
|
|
662
702
|
return {
|
|
663
|
-
id: acc.id || existing?.id || `${acc.addedAt}:${acc.refreshToken
|
|
703
|
+
id: acc.id || existing?.id || `${acc.addedAt}:${hashTokenFragment(acc.refreshToken)}`,
|
|
664
704
|
index,
|
|
665
705
|
email: acc.email ?? existing?.email,
|
|
666
706
|
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
|
/**
|