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/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.
@@ -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.slice(0, 12)}`,
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 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
- }
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
- await saveAccounts(storage);
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
- // Clear deltas after successful write
623
- this.#statsDeltas.clear();
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.slice(0, 12)}`,
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,
@@ -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
  /**