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/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.slice(0, 12)}`,
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.slice(0, 12)}`,
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 scores for all accounts since indices shifted after splice.
449
- // This is simpler and safer than trying to remap individual scores.
450
- for (let i = 0; i < this.#accounts.length; i++) {
451
- this.#healthTracker.reset(i);
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
- await saveAccounts(storage);
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
- // Clear deltas after successful write
623
- this.#statsDeltas.clear();
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.slice(0, 12)}`,
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,
@@ -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
- expect(saveAccounts).toHaveBeenCalledWith(
360
- expect.objectContaining({
361
- version: 1,
362
- accounts: expect.arrayContaining([
363
- expect.objectContaining({
364
- refreshToken: "token1",
365
- enabled: true,
366
- }),
367
- ]),
368
- activeIndex: expect.any(Number),
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
- expect(saveAccounts).toHaveBeenCalledWith(
610
- expect.objectContaining({
611
- accounts: expect.arrayContaining([
612
- expect.objectContaining({
613
- stats: expect.objectContaining({
614
- requests: 1,
615
- inputTokens: 100,
616
- outputTokens: 50,
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
- const tmpPath = configPath + `.tmp.${process.pid}`;
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
- const OAUTH_CLI_VERSION_PROFILE = "2.1.80";
14
- const OAUTH_USER_AGENT = `claude-code/${OAUTH_CLI_VERSION_PROFILE}`;
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
- * | { type: "failed", status?: number, code?: string, reason?: string, details?: string }
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
- let errorCode;
73
- let reason;
74
-
75
- if (rawText) {
76
- try {
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
- result = await fetch(OAUTH_TOKEN_URL, {
109
- method: "POST",
110
- headers: {
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
- if (!result.ok) {
128
- const raw =
129
- typeof result.text === "function"
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
- const resp = await fetch(OAUTH_TOKEN_URL, {
188
- method: "POST",
189
- headers: {
190
- "Content-Type": "application/json",
191
- "User-Agent": OAUTH_USER_AGENT,
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
- const parsed = JSON.parse(text);
208
- if (parsed.error) error.code = parsed.error;
209
- } catch {
210
- // Body may not be valid JSON
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
- throw error;
393
+
394
+ return resp.json();
213
395
  }
214
396
 
215
- return resp.json();
397
+ throw lastError instanceof Error ? lastError : new Error("Token refresh failed after retries");
216
398
  }
217
399
 
218
400
  /**