opencode-qwen-cli-auth 2.2.9 → 2.3.1

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.
@@ -1,25 +1,73 @@
1
+ /**
2
+ * @fileoverview OAuth authentication utilities for Qwen Plugin
3
+ * Implements OAuth 2.0 Device Authorization Grant flow (RFC 8628)
4
+ * Handles token storage, refresh, and validation
5
+ * @license MIT
6
+ */
7
+
1
8
  import { generatePKCE } from "@openauthjs/openauth/pkce";
2
9
  import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, statSync } from "fs";
3
10
  import { QWEN_OAUTH, DEFAULT_QWEN_BASE_URL, TOKEN_REFRESH_BUFFER_MS, VERIFICATION_URI } from "../constants.js";
4
- import { getTokenPath, getQwenDir, getTokenLockPath, getLegacyTokenPath } from "../config.js";
11
+ import { getTokenPath, getQwenDir, getTokenLockPath, getLegacyTokenPath, getAccountsPath, getAccountsLockPath } from "../config.js";
5
12
  import { logError, logWarn, logInfo, LOGGING_ENABLED } from "../logger.js";
6
- const MAX_REFRESH_RETRIES = 1;
7
- const REFRESH_RETRY_DELAY_MS = 1000;
13
+
14
+ /** Maximum number of retries for token refresh operations */
15
+ const MAX_REFRESH_RETRIES = 2;
16
+ /** Delay between retry attempts in milliseconds */
17
+ const REFRESH_RETRY_DELAY_MS = 2000;
18
+ /** Timeout for OAuth HTTP requests in milliseconds */
8
19
  const OAUTH_REQUEST_TIMEOUT_MS = 15000;
20
+ /** Lock timeout for multi-process token refresh coordination */
9
21
  const LOCK_TIMEOUT_MS = 10000;
22
+ /** Interval between lock acquisition attempts */
10
23
  const LOCK_ATTEMPT_INTERVAL_MS = 100;
24
+ /** Backoff multiplier for lock retry interval */
11
25
  const LOCK_BACKOFF_MULTIPLIER = 1.5;
26
+ /** Maximum interval between lock attempts */
12
27
  const LOCK_MAX_INTERVAL_MS = 2000;
28
+ /** Maximum number of lock acquisition attempts */
13
29
  const LOCK_MAX_ATTEMPTS = 20;
30
+ /** Account schema version for ~/.qwen/oauth_accounts.json */
31
+ const ACCOUNT_STORE_VERSION = 1;
32
+ /** Default cooldown when account hits insufficient_quota */
33
+ const DEFAULT_QUOTA_COOLDOWN_MS = 30 * 60 * 1000;
34
+
35
+ /**
36
+ * Checks if an error is an AbortError (from AbortController)
37
+ * @param {*} error - The error to check
38
+ * @returns {boolean} True if error is an AbortError
39
+ */
14
40
  function isAbortError(error) {
15
41
  return typeof error === "object" && error !== null && ("name" in error) && error.name === "AbortError";
16
42
  }
43
+
44
+ /**
45
+ * Checks if an error has a specific error code (for Node.js system errors)
46
+ * @param {*} error - The error to check
47
+ * @param {string} code - The error code to look for (e.g., "EEXIST", "ENOENT")
48
+ * @returns {boolean} True if error has the specified code
49
+ */
17
50
  function hasErrorCode(error, code) {
18
51
  return typeof error === "object" && error !== null && ("code" in error) && error.code === code;
19
52
  }
53
+
54
+ /**
55
+ * Creates a promise that resolves after specified milliseconds
56
+ * @param {number} ms - Milliseconds to sleep
57
+ * @returns {Promise<void>} Promise that resolves after delay
58
+ */
20
59
  function sleep(ms) {
21
60
  return new Promise(resolve => setTimeout(resolve, ms));
22
61
  }
62
+ /**
63
+ * Performs fetch with timeout using AbortController
64
+ * Automatically aborts request if it exceeds timeout
65
+ * @param {string} url - URL to fetch
66
+ * @param {RequestInit} [init] - Fetch options
67
+ * @param {number} [timeoutMs=OAUTH_REQUEST_TIMEOUT_MS] - Timeout in milliseconds
68
+ * @returns {Promise<Response>} Fetch response
69
+ * @throws {Error} If request times out
70
+ */
23
71
  async function fetchWithTimeout(url, init, timeoutMs = OAUTH_REQUEST_TIMEOUT_MS) {
24
72
  const controller = new AbortController();
25
73
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -39,14 +87,23 @@ async function fetchWithTimeout(url, init, timeoutMs = OAUTH_REQUEST_TIMEOUT_MS)
39
87
  clearTimeout(timeoutId);
40
88
  }
41
89
  }
90
+
91
+ /**
92
+ * Normalizes resource URL to valid HTTPS URL format
93
+ * Adds https:// prefix if missing and validates URL format
94
+ * @param {string|undefined} resourceUrl - URL to normalize
95
+ * @returns {string|undefined} Normalized URL or undefined if invalid
96
+ */
42
97
  function normalizeResourceUrl(resourceUrl) {
43
98
  if (!resourceUrl)
44
99
  return undefined;
45
100
  try {
46
101
  let normalizedUrl = resourceUrl;
102
+ // Add https:// prefix if protocol is missing
47
103
  if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
48
104
  normalizedUrl = `https://${normalizedUrl}`;
49
105
  }
106
+ // Validate URL format
50
107
  new URL(normalizedUrl);
51
108
  if (LOGGING_ENABLED) {
52
109
  logInfo("Valid resource_url found and normalized:", normalizedUrl);
@@ -58,21 +115,37 @@ function normalizeResourceUrl(resourceUrl) {
58
115
  return undefined;
59
116
  }
60
117
  }
118
+
119
+ /**
120
+ * Validates OAuth token response has required fields
121
+ * @param {Object} json - Token response JSON
122
+ * @param {string} context - Context for error messages (e.g., "token response", "refresh response")
123
+ * @returns {boolean} True if response is valid
124
+ */
61
125
  function validateTokenResponse(json, context) {
126
+ // Check access_token exists and is string
62
127
  if (!json.access_token || typeof json.access_token !== "string") {
63
128
  logError(`${context} missing access_token`);
64
129
  return false;
65
130
  }
131
+ // Check refresh_token exists and is string
66
132
  if (!json.refresh_token || typeof json.refresh_token !== "string") {
67
133
  logError(`${context} missing refresh_token`);
68
134
  return false;
69
135
  }
136
+ // Check expires_in is valid positive number
70
137
  if (typeof json.expires_in !== "number" || json.expires_in <= 0) {
71
138
  logError(`${context} invalid expires_in:`, json.expires_in);
72
139
  return false;
73
140
  }
74
141
  return true;
75
142
  }
143
+ /**
144
+ * Converts raw token data to standardized stored token format
145
+ * Handles different field name variations (expiry_date vs expires)
146
+ * @param {Object} data - Raw token data from OAuth response or file
147
+ * @returns {Object|null} Normalized token data or null if invalid
148
+ */
76
149
  function toStoredTokenData(data) {
77
150
  if (!data || typeof data !== "object") {
78
151
  return null;
@@ -81,6 +154,7 @@ function toStoredTokenData(data) {
81
154
  const accessToken = typeof raw.access_token === "string" ? raw.access_token : undefined;
82
155
  const refreshToken = typeof raw.refresh_token === "string" ? raw.refresh_token : undefined;
83
156
  const tokenType = typeof raw.token_type === "string" && raw.token_type.length > 0 ? raw.token_type : "Bearer";
157
+ // Handle both expiry_date and expires field names
84
158
  const expiryDate = typeof raw.expiry_date === "number"
85
159
  ? raw.expiry_date
86
160
  : typeof raw.expires === "number"
@@ -89,6 +163,7 @@ function toStoredTokenData(data) {
89
163
  ? Number(raw.expiry_date)
90
164
  : undefined;
91
165
  const resourceUrl = typeof raw.resource_url === "string" ? normalizeResourceUrl(raw.resource_url) : undefined;
166
+ // Validate all required fields are present and valid
92
167
  if (!accessToken || !refreshToken || typeof expiryDate !== "number" || !Number.isFinite(expiryDate) || expiryDate <= 0) {
93
168
  return null;
94
169
  }
@@ -100,6 +175,231 @@ function toStoredTokenData(data) {
100
175
  resource_url: resourceUrl,
101
176
  };
102
177
  }
178
+
179
+ function getQuotaCooldownMs() {
180
+ const raw = process.env.OPENCODE_QWEN_QUOTA_COOLDOWN_MS;
181
+ if (typeof raw !== "string" || raw.trim().length === 0) {
182
+ return DEFAULT_QUOTA_COOLDOWN_MS;
183
+ }
184
+ const parsed = Number(raw);
185
+ if (!Number.isFinite(parsed) || parsed < 1000) {
186
+ return DEFAULT_QUOTA_COOLDOWN_MS;
187
+ }
188
+ return Math.floor(parsed);
189
+ }
190
+
191
+ function normalizeAccountStore(raw) {
192
+ const fallback = {
193
+ version: ACCOUNT_STORE_VERSION,
194
+ activeAccountId: null,
195
+ accounts: [],
196
+ };
197
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
198
+ return fallback;
199
+ }
200
+ const input = raw;
201
+ const accounts = Array.isArray(input.accounts) ? input.accounts : [];
202
+ const normalizedAccounts = [];
203
+ for (const item of accounts) {
204
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
205
+ continue;
206
+ }
207
+ const token = toStoredTokenData(item.token);
208
+ if (!token) {
209
+ continue;
210
+ }
211
+ const id = typeof item.id === "string" && item.id.trim().length > 0
212
+ ? item.id.trim()
213
+ : `acct_${Math.random().toString(16).slice(2)}_${Date.now().toString(36)}`;
214
+ const createdAt = typeof item.createdAt === "number" && Number.isFinite(item.createdAt) ? item.createdAt : Date.now();
215
+ const updatedAt = typeof item.updatedAt === "number" && Number.isFinite(item.updatedAt) ? item.updatedAt : createdAt;
216
+ const exhaustedUntil = typeof item.exhaustedUntil === "number" && Number.isFinite(item.exhaustedUntil) ? item.exhaustedUntil : 0;
217
+ const lastErrorCode = typeof item.lastErrorCode === "string" ? item.lastErrorCode : undefined;
218
+ const accountKey = typeof item.accountKey === "string" && item.accountKey.trim().length > 0 ? item.accountKey.trim() : undefined;
219
+ normalizedAccounts.push({
220
+ id,
221
+ token,
222
+ resource_url: token.resource_url,
223
+ exhaustedUntil,
224
+ lastErrorCode,
225
+ accountKey,
226
+ createdAt,
227
+ updatedAt,
228
+ });
229
+ }
230
+ let activeAccountId = typeof input.activeAccountId === "string" && input.activeAccountId.length > 0 ? input.activeAccountId : null;
231
+ if (activeAccountId && !normalizedAccounts.some(account => account.id === activeAccountId)) {
232
+ activeAccountId = null;
233
+ }
234
+ if (!activeAccountId && normalizedAccounts.length > 0) {
235
+ activeAccountId = normalizedAccounts[0].id;
236
+ }
237
+ return {
238
+ version: ACCOUNT_STORE_VERSION,
239
+ activeAccountId,
240
+ accounts: normalizedAccounts,
241
+ };
242
+ }
243
+
244
+ function normalizeTokenResultToStored(tokenResult) {
245
+ if (!tokenResult || tokenResult.type !== "success") {
246
+ return null;
247
+ }
248
+ return toStoredTokenData({
249
+ access_token: tokenResult.access,
250
+ refresh_token: tokenResult.refresh,
251
+ token_type: "Bearer",
252
+ expiry_date: tokenResult.expires,
253
+ resource_url: tokenResult.resourceUrl,
254
+ });
255
+ }
256
+
257
+ function parseJwtPayloadSegment(token) {
258
+ if (typeof token !== "string") {
259
+ return null;
260
+ }
261
+ const parts = token.split(".");
262
+ if (parts.length < 2) {
263
+ return null;
264
+ }
265
+ const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
266
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
267
+ try {
268
+ return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
269
+ }
270
+ catch (_error) {
271
+ return null;
272
+ }
273
+ }
274
+
275
+ function deriveAccountKeyFromToken(tokenData) {
276
+ if (!tokenData || typeof tokenData !== "object") {
277
+ return null;
278
+ }
279
+ const payload = parseJwtPayloadSegment(tokenData.access_token);
280
+ if (payload && typeof payload === "object") {
281
+ const candidates = ["sub", "uid", "user_id", "email", "username"];
282
+ for (const key of candidates) {
283
+ const value = payload[key];
284
+ if (typeof value === "string" && value.trim().length > 0) {
285
+ return `${key}:${value.trim()}`;
286
+ }
287
+ }
288
+ }
289
+ if (typeof tokenData.refresh_token === "string" && tokenData.refresh_token.length > 12) {
290
+ return `refresh:${tokenData.refresh_token}`;
291
+ }
292
+ return null;
293
+ }
294
+
295
+ function buildAccountEntry(tokenData, accountId, accountKey) {
296
+ const now = Date.now();
297
+ return {
298
+ id: accountId,
299
+ token: tokenData,
300
+ resource_url: tokenData.resource_url,
301
+ exhaustedUntil: 0,
302
+ lastErrorCode: undefined,
303
+ accountKey: accountKey || undefined,
304
+ createdAt: now,
305
+ updatedAt: now,
306
+ };
307
+ }
308
+
309
+ function writeAccountsStoreData(store) {
310
+ const qwenDir = getQwenDir();
311
+ if (!existsSync(qwenDir)) {
312
+ mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
313
+ }
314
+ const accountsPath = getAccountsPath();
315
+ const tempPath = `${accountsPath}.tmp.${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
316
+ const payload = {
317
+ version: ACCOUNT_STORE_VERSION,
318
+ activeAccountId: store.activeAccountId || null,
319
+ accounts: store.accounts.map(account => ({
320
+ id: account.id,
321
+ token: account.token,
322
+ resource_url: account.resource_url,
323
+ exhaustedUntil: account.exhaustedUntil || 0,
324
+ lastErrorCode: account.lastErrorCode,
325
+ accountKey: account.accountKey,
326
+ createdAt: account.createdAt,
327
+ updatedAt: account.updatedAt,
328
+ })),
329
+ };
330
+ try {
331
+ writeFileSync(tempPath, JSON.stringify(payload, null, 2), {
332
+ encoding: "utf-8",
333
+ mode: 0o600,
334
+ });
335
+ renameSync(tempPath, accountsPath);
336
+ }
337
+ catch (error) {
338
+ try {
339
+ if (existsSync(tempPath)) {
340
+ unlinkSync(tempPath);
341
+ }
342
+ }
343
+ catch (_cleanupError) {
344
+ }
345
+ throw error;
346
+ }
347
+ }
348
+
349
+ function loadAccountsStoreData() {
350
+ const path = getAccountsPath();
351
+ if (!existsSync(path)) {
352
+ return normalizeAccountStore(null);
353
+ }
354
+ try {
355
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
356
+ return normalizeAccountStore(raw);
357
+ }
358
+ catch (error) {
359
+ logWarn("Failed to read oauth_accounts.json, using empty store", error);
360
+ return normalizeAccountStore(null);
361
+ }
362
+ }
363
+
364
+ function pickNextHealthyAccount(store, excludedIds = new Set(), now = Date.now()) {
365
+ const accounts = Array.isArray(store.accounts) ? store.accounts : [];
366
+ if (accounts.length === 0) {
367
+ return null;
368
+ }
369
+ const activeIndex = accounts.findIndex(account => account.id === store.activeAccountId);
370
+ for (let step = 1; step <= accounts.length; step += 1) {
371
+ const index = activeIndex >= 0 ? (activeIndex + step) % accounts.length : (step - 1);
372
+ const candidate = accounts[index];
373
+ if (!candidate || excludedIds.has(candidate.id)) {
374
+ continue;
375
+ }
376
+ if (typeof candidate.exhaustedUntil === "number" && candidate.exhaustedUntil > now) {
377
+ continue;
378
+ }
379
+ return candidate;
380
+ }
381
+ return null;
382
+ }
383
+
384
+ function countHealthyAccounts(store, now = Date.now()) {
385
+ return store.accounts.filter(account => !(typeof account.exhaustedUntil === "number" && account.exhaustedUntil > now)).length;
386
+ }
387
+
388
+ function syncAccountToLegacyTokenFile(account) {
389
+ writeStoredTokenData({
390
+ access_token: account.token.access_token,
391
+ refresh_token: account.token.refresh_token,
392
+ token_type: account.token.token_type || "Bearer",
393
+ expiry_date: account.token.expiry_date,
394
+ resource_url: account.resource_url,
395
+ });
396
+ }
397
+
398
+ /**
399
+ * Builds token success object from stored token data
400
+ * @param {Object} stored - Stored token data from file
401
+ * @returns {Object} Token success object for SDK
402
+ */
103
403
  function buildTokenSuccessFromStored(stored) {
104
404
  return {
105
405
  type: "success",
@@ -109,12 +409,20 @@ function buildTokenSuccessFromStored(stored) {
109
409
  resourceUrl: stored.resource_url,
110
410
  };
111
411
  }
412
+ /**
413
+ * Writes token data to disk atomically using temp file + rename
414
+ * Uses secure file permissions (0o600 - owner read/write only)
415
+ * @param {Object} tokenData - Token data to write
416
+ * @throws {Error} If write operation fails
417
+ */
112
418
  function writeStoredTokenData(tokenData) {
113
419
  const qwenDir = getQwenDir();
420
+ // Create directory if it doesn't exist with secure permissions
114
421
  if (!existsSync(qwenDir)) {
115
422
  mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
116
423
  }
117
424
  const tokenPath = getTokenPath();
425
+ // Use atomic write: write to temp file then rename
118
426
  const tempPath = `${tokenPath}.tmp.${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
119
427
  try {
120
428
  writeFileSync(tempPath, JSON.stringify(tokenData, null, 2), {
@@ -124,6 +432,7 @@ function writeStoredTokenData(tokenData) {
124
432
  renameSync(tempPath, tokenPath);
125
433
  }
126
434
  catch (error) {
435
+ // Clean up temp file on error
127
436
  try {
128
437
  if (existsSync(tempPath)) {
129
438
  unlinkSync(tempPath);
@@ -134,12 +443,19 @@ function writeStoredTokenData(tokenData) {
134
443
  throw error;
135
444
  }
136
445
  }
446
+
447
+ /**
448
+ * Migrates legacy token from old plugin location to new location
449
+ * Checks if new token file exists, if not tries to migrate from legacy path
450
+ */
137
451
  function migrateLegacyTokenIfNeeded() {
138
452
  const tokenPath = getTokenPath();
453
+ // Skip if new token file already exists
139
454
  if (existsSync(tokenPath)) {
140
455
  return;
141
456
  }
142
457
  const legacyPath = getLegacyTokenPath();
458
+ // Skip if legacy file doesn't exist
143
459
  if (!existsSync(legacyPath)) {
144
460
  return;
145
461
  }
@@ -158,12 +474,54 @@ function migrateLegacyTokenIfNeeded() {
158
474
  logWarn("Failed to migrate legacy token:", error);
159
475
  }
160
476
  }
477
+
478
+ function migrateLegacyTokenToAccountsIfNeeded() {
479
+ const accountsPath = getAccountsPath();
480
+ if (existsSync(accountsPath)) {
481
+ return;
482
+ }
483
+ const legacyToken = loadStoredToken();
484
+ if (!legacyToken) {
485
+ return;
486
+ }
487
+ const tokenData = toStoredTokenData(legacyToken);
488
+ if (!tokenData) {
489
+ return;
490
+ }
491
+ const accountKey = deriveAccountKeyFromToken(tokenData);
492
+ const accountId = `acct_${Date.now().toString(36)}_${Math.random().toString(16).slice(2, 10)}`;
493
+ const store = normalizeAccountStore({
494
+ version: ACCOUNT_STORE_VERSION,
495
+ activeAccountId: accountId,
496
+ accounts: [buildAccountEntry(tokenData, accountId, accountKey)],
497
+ });
498
+ try {
499
+ writeAccountsStoreData(store);
500
+ if (LOGGING_ENABLED) {
501
+ logInfo("Migrated legacy oauth_creds.json to oauth_accounts.json");
502
+ }
503
+ }
504
+ catch (error) {
505
+ logWarn("Failed to migrate legacy token to oauth_accounts.json", error);
506
+ }
507
+ }
508
+ /**
509
+ * Acquires exclusive lock for token refresh to prevent concurrent refreshes
510
+ * Uses file-based locking with exponential backoff retry strategy
511
+ * @returns {Promise<string>} Lock file path if acquired successfully
512
+ * @throws {Error} If lock cannot be acquired within timeout
513
+ */
161
514
  async function acquireTokenLock() {
162
515
  const lockPath = getTokenLockPath();
516
+ const qwenDir = getQwenDir();
517
+ if (!existsSync(qwenDir)) {
518
+ mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
519
+ }
163
520
  const lockValue = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
164
521
  let waitMs = LOCK_ATTEMPT_INTERVAL_MS;
165
522
  for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
166
523
  try {
524
+ // Try to create lock file with exclusive flag
167
525
  writeFileSync(lockPath, lockValue, {
168
526
  encoding: "utf-8",
169
527
  flag: "wx",
@@ -172,12 +530,14 @@ async function acquireTokenLock() {
172
530
  return lockPath;
173
531
  }
174
532
  catch (error) {
533
+ // EEXIST means lock file already exists
175
534
  if (!hasErrorCode(error, "EEXIST")) {
176
535
  throw error;
177
536
  }
178
537
  try {
179
538
  const stats = statSync(lockPath);
180
539
  const ageMs = Date.now() - stats.mtimeMs;
540
+ // Remove stale lock if it's older than timeout
181
541
  if (ageMs > LOCK_TIMEOUT_MS) {
182
542
  try {
183
543
  unlinkSync(lockPath);
@@ -196,22 +556,112 @@ async function acquireTokenLock() {
196
556
  logWarn("Failed to inspect token lock file", statError);
197
557
  }
198
558
  }
559
+ // Wait with exponential backoff before retry
199
560
  await sleep(waitMs);
200
561
  waitMs = Math.min(Math.floor(waitMs * LOCK_BACKOFF_MULTIPLIER), LOCK_MAX_INTERVAL_MS);
201
562
  }
202
563
  }
203
564
  throw new Error("Token refresh lock timeout");
204
565
  }
566
+
567
+ /**
568
+ * Releases token refresh lock
569
+ * Silently ignores errors if lock file doesn't exist
570
+ * @param {string} lockPath - Path to lock file to release
571
+ */
205
572
  function releaseTokenLock(lockPath) {
206
573
  try {
207
574
  unlinkSync(lockPath);
208
575
  }
209
576
  catch (error) {
577
+ // Ignore ENOENT (file not found) errors
210
578
  if (!hasErrorCode(error, "ENOENT")) {
211
579
  logWarn("Failed to release token lock file", error);
212
580
  }
213
581
  }
214
582
  }
583
+
584
+ async function acquireAccountsLock() {
585
+ const lockPath = getAccountsLockPath();
586
+ const qwenDir = getQwenDir();
587
+ if (!existsSync(qwenDir)) {
588
+ mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
589
+ }
590
+ const lockValue = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
591
+ let waitMs = LOCK_ATTEMPT_INTERVAL_MS;
592
+ for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
593
+ try {
594
+ writeFileSync(lockPath, lockValue, {
595
+ encoding: "utf-8",
596
+ flag: "wx",
597
+ mode: 0o600,
598
+ });
599
+ return lockPath;
600
+ }
601
+ catch (error) {
602
+ if (!hasErrorCode(error, "EEXIST")) {
603
+ throw error;
604
+ }
605
+ try {
606
+ const stats = statSync(lockPath);
607
+ const ageMs = Date.now() - stats.mtimeMs;
608
+ if (ageMs > LOCK_TIMEOUT_MS) {
609
+ try {
610
+ unlinkSync(lockPath);
611
+ }
612
+ catch (staleError) {
613
+ if (!hasErrorCode(staleError, "ENOENT")) {
614
+ logWarn("Failed to remove stale accounts lock", staleError);
615
+ }
616
+ }
617
+ continue;
618
+ }
619
+ }
620
+ catch (statError) {
621
+ if (!hasErrorCode(statError, "ENOENT")) {
622
+ logWarn("Failed to inspect accounts lock file", statError);
623
+ }
624
+ }
625
+ await sleep(waitMs);
626
+ waitMs = Math.min(Math.floor(waitMs * LOCK_BACKOFF_MULTIPLIER), LOCK_MAX_INTERVAL_MS);
627
+ }
628
+ }
629
+ throw new Error("Accounts lock timeout");
630
+ }
631
+
632
+ function releaseAccountsLock(lockPath) {
633
+ try {
634
+ unlinkSync(lockPath);
635
+ }
636
+ catch (error) {
637
+ if (!hasErrorCode(error, "ENOENT")) {
638
+ logWarn("Failed to release accounts lock file", error);
639
+ }
640
+ }
641
+ }
642
+
643
+ async function withAccountsStoreLock(mutator) {
644
+ const lockPath = await acquireAccountsLock();
645
+ try {
646
+ const store = loadAccountsStoreData();
647
+ const next = await mutator(store);
648
+ if (next && typeof next === "object") {
649
+ writeAccountsStoreData(next);
650
+ return next;
651
+ }
652
+ writeAccountsStoreData(store);
653
+ return store;
654
+ }
655
+ finally {
656
+ releaseAccountsLock(lockPath);
657
+ }
658
+ }
659
+ /**
660
+ * Requests device code from Qwen OAuth server
661
+ * Initiates OAuth 2.0 Device Authorization Grant flow
662
+ * @param {{ challenge: string, verifier: string }} pkce - PKCE challenge and verifier
663
+ * @returns {Promise<Object|null>} Device auth response or null on failure
664
+ */
215
665
  export async function requestDeviceCode(pkce) {
216
666
  try {
217
667
  const res = await fetchWithTimeout(QWEN_OAUTH.DEVICE_CODE_URL, {
@@ -236,10 +686,12 @@ export async function requestDeviceCode(pkce) {
236
686
  if (LOGGING_ENABLED) {
237
687
  logInfo("Device code response received:", json);
238
688
  }
689
+ // Validate required fields are present
239
690
  if (!json.device_code || !json.user_code || !json.verification_uri) {
240
691
  logError("device code response missing fields:", json);
241
692
  return null;
242
693
  }
694
+ // Fix verification_uri_complete if missing client parameter
243
695
  if (!json.verification_uri_complete || !json.verification_uri_complete.includes(VERIFICATION_URI.CLIENT_PARAM_KEY)) {
244
696
  const baseUrl = json.verification_uri_complete || json.verification_uri;
245
697
  const separator = baseUrl.includes("?") ? "&" : "?";
@@ -255,6 +707,14 @@ export async function requestDeviceCode(pkce) {
255
707
  return null;
256
708
  }
257
709
  }
710
+ /**
711
+ * Polls Qwen OAuth server for access token using device code
712
+ * Implements OAuth 2.0 Device Flow polling with proper error handling
713
+ * @param {string} deviceCode - Device code from requestDeviceCode
714
+ * @param {string} verifier - PKCE code verifier
715
+ * @param {number} [interval=2] - Polling interval in seconds
716
+ * @returns {Promise<Object>} Token result object with type: success|pending|slow_down|failed|denied|expired
717
+ */
258
718
  export async function pollForToken(deviceCode, verifier, interval = 2) {
259
719
  try {
260
720
  const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
@@ -274,6 +734,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
274
734
  const json = await res.json().catch(() => ({}));
275
735
  const errorCode = typeof json.error === "string" ? json.error : undefined;
276
736
  const errorDescription = typeof json.error_description === "string" ? json.error_description : "No details provided";
737
+ // Handle standard OAuth 2.0 Device Flow errors
277
738
  if (errorCode === "authorization_pending") {
278
739
  return { type: "pending" };
279
740
  }
@@ -286,6 +747,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
286
747
  if (errorCode === "access_denied") {
287
748
  return { type: "denied" };
288
749
  }
750
+ // Log and return fatal error for unknown errors
289
751
  logError("token poll failed:", {
290
752
  status: res.status,
291
753
  error: errorCode,
@@ -309,6 +771,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
309
771
  all_fields: Object.keys(json),
310
772
  });
311
773
  }
774
+ // Validate token response structure
312
775
  if (!validateTokenResponse(json, "token response")) {
313
776
  return {
314
777
  type: "failed",
@@ -332,6 +795,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
332
795
  catch (error) {
333
796
  const message = error instanceof Error ? error.message : String(error);
334
797
  const lowered = message.toLowerCase();
798
+ // Identify transient errors that may succeed on retry
335
799
  const transient = lowered.includes("timed out") || lowered.includes("network") || lowered.includes("fetch");
336
800
  logWarn("token poll failed:", { message, transient });
337
801
  return {
@@ -341,6 +805,11 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
341
805
  };
342
806
  }
343
807
  }
808
+ /**
809
+ * Performs single token refresh attempt
810
+ * @param {string} refreshToken - Refresh token to use
811
+ * @returns {Promise<Object>} Token result object with type: success|failed
812
+ */
344
813
  async function refreshAccessTokenOnce(refreshToken) {
345
814
  try {
346
815
  const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
@@ -360,6 +829,7 @@ async function refreshAccessTokenOnce(refreshToken) {
360
829
  const lowered = text.toLowerCase();
361
830
  const isUnauthorized = res.status === 401 || res.status === 403;
362
831
  const isRateLimited = res.status === 429;
832
+ // Identify transient errors (5xx, timeout, network)
363
833
  const transient = res.status >= 500 || lowered.includes("timed out") || lowered.includes("network");
364
834
  logError("token refresh failed:", { status: res.status, text });
365
835
  return {
@@ -379,6 +849,7 @@ async function refreshAccessTokenOnce(refreshToken) {
379
849
  all_fields: Object.keys(json),
380
850
  });
381
851
  }
852
+ // Validate refresh response structure
382
853
  if (!validateTokenResponse(json, "refresh response")) {
383
854
  return {
384
855
  type: "failed",
@@ -402,6 +873,7 @@ async function refreshAccessTokenOnce(refreshToken) {
402
873
  catch (error) {
403
874
  const message = error instanceof Error ? error.message : String(error);
404
875
  const lowered = message.toLowerCase();
876
+ // Identify transient errors that may succeed on retry
405
877
  const transient = lowered.includes("timed out") || lowered.includes("network") || lowered.includes("fetch");
406
878
  logError("token refresh error:", { message, transient });
407
879
  return {
@@ -411,33 +883,47 @@ async function refreshAccessTokenOnce(refreshToken) {
411
883
  };
412
884
  }
413
885
  }
886
+ /**
887
+ * Refreshes access token using refresh token with lock coordination
888
+ * Implements retry logic for transient failures
889
+ * @param {string} refreshToken - Refresh token to use
890
+ * @returns {Promise<Object>} Token result object with type: success|failed
891
+ */
414
892
  export async function refreshAccessToken(refreshToken) {
893
+ // Acquire lock to prevent concurrent refresh operations
415
894
  const lockPath = await acquireTokenLock();
416
895
  try {
896
+ // Check if another process already refreshed the token
417
897
  const latest = loadStoredToken();
418
898
  if (latest && !isTokenExpired(latest.expiry_date)) {
419
899
  return buildTokenSuccessFromStored(latest);
420
900
  }
901
+ // Use latest refresh token if available
421
902
  const effectiveRefreshToken = latest?.refresh_token || refreshToken;
903
+ // Retry loop for transient failures
422
904
  for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
423
905
  const result = await refreshAccessTokenOnce(effectiveRefreshToken);
424
906
  if (result.type === "success") {
425
907
  saveToken(result);
426
908
  return result;
427
909
  }
910
+ // Non-retryable errors: 401/403 (unauthorized)
428
911
  if (result.status === 401 || result.status === 403) {
429
912
  logError(`Refresh token rejected (${result.status}), re-authentication required`);
430
913
  clearStoredToken();
431
914
  return { type: "failed", status: result.status, error: "refresh_token_rejected", fatal: true };
432
915
  }
916
+ // Non-retryable errors: 429 (rate limited)
433
917
  if (result.status === 429) {
434
918
  logError("Token refresh rate-limited (429), aborting retries");
435
919
  return { type: "failed", status: 429, error: "rate_limited", fatal: true };
436
920
  }
921
+ // Non-retryable errors: fatal flag set
437
922
  if (result.fatal) {
438
923
  logError("Token refresh failed with fatal error", result);
439
924
  return result;
440
925
  }
926
+ // Retry transient failures
441
927
  if (attempt < MAX_REFRESH_RETRIES) {
442
928
  if (LOGGING_ENABLED) {
443
929
  logInfo(`Token refresh transient failure, retrying attempt ${attempt + 2}/${MAX_REFRESH_RETRIES + 1}...`);
@@ -449,14 +935,24 @@ export async function refreshAccessToken(refreshToken) {
449
935
  return { type: "failed", error: "refresh_failed" };
450
936
  }
451
937
  finally {
938
+ // Always release lock
452
939
  releaseTokenLock(lockPath);
453
940
  }
454
941
  }
942
+ /**
943
+ * Generates PKCE challenge and verifier for OAuth flow
944
+ * @returns {Promise<{challenge: string, verifier: string}>} PKCE challenge and verifier pair
945
+ */
455
946
  export async function createPKCE() {
456
947
  const { challenge, verifier } = await generatePKCE();
457
948
  return { challenge, verifier };
458
949
  }
950
+ /**
951
+ * Loads stored token from disk with legacy migration
952
+ * @returns {Object|null} Stored token data or null if not found/invalid
953
+ */
459
954
  export function loadStoredToken() {
955
+ // Migrate legacy token if needed
460
956
  migrateLegacyTokenIfNeeded();
461
957
  const tokenPath = getTokenPath();
462
958
  if (!existsSync(tokenPath)) {
@@ -470,6 +966,7 @@ export function loadStoredToken() {
470
966
  logWarn("Invalid token data, re-authentication required");
471
967
  return null;
472
968
  }
969
+ // Check if token file needs format update
473
970
  const needsRewrite = typeof parsed.expiry_date !== "number" ||
474
971
  typeof parsed.token_type !== "string" ||
475
972
  typeof parsed.expires === "number" ||
@@ -489,6 +986,9 @@ export function loadStoredToken() {
489
986
  return null;
490
987
  }
491
988
  }
989
+ /**
990
+ * Clears stored token from both current and legacy paths
991
+ */
492
992
  export function clearStoredToken() {
493
993
  const targets = [getTokenPath(), getLegacyTokenPath()];
494
994
  for (const tokenPath of targets) {
@@ -504,6 +1004,11 @@ export function clearStoredToken() {
504
1004
  }
505
1005
  }
506
1006
  }
1007
+ /**
1008
+ * Saves token result to disk
1009
+ * @param {{ type: string, access: string, refresh: string, expires: number, resourceUrl?: string }} tokenResult - Token result from OAuth flow
1010
+ * @throws {Error} If token result is invalid or write fails
1011
+ */
507
1012
  export function saveToken(tokenResult) {
508
1013
  if (tokenResult.type !== "success") {
509
1014
  throw new Error("Cannot save non-success token result");
@@ -523,14 +1028,236 @@ export function saveToken(tokenResult) {
523
1028
  throw error;
524
1029
  }
525
1030
  }
1031
+
1032
+ function buildRuntimeAccountResponse(account, healthyCount, totalCount, accessToken, resourceUrl) {
1033
+ return {
1034
+ accountId: account.id,
1035
+ accessToken,
1036
+ resourceUrl: resourceUrl || account.resource_url,
1037
+ exhaustedUntil: account.exhaustedUntil || 0,
1038
+ healthyAccountCount: healthyCount,
1039
+ totalAccountCount: totalCount,
1040
+ };
1041
+ }
1042
+
1043
+ export async function upsertOAuthAccount(tokenResult, options = {}) {
1044
+ const tokenData = normalizeTokenResultToStored(tokenResult);
1045
+ if (!tokenData) {
1046
+ return null;
1047
+ }
1048
+ migrateLegacyTokenToAccountsIfNeeded();
1049
+ const accountKey = options.accountKey || deriveAccountKeyFromToken(tokenData);
1050
+ let selectedId = null;
1051
+ await withAccountsStoreLock((store) => {
1052
+ const now = Date.now();
1053
+ let index = -1;
1054
+ if (typeof options.accountId === "string" && options.accountId.length > 0) {
1055
+ index = store.accounts.findIndex(account => account.id === options.accountId);
1056
+ }
1057
+ if (index < 0 && accountKey) {
1058
+ index = store.accounts.findIndex(account => account.accountKey === accountKey);
1059
+ }
1060
+ if (index < 0) {
1061
+ index = store.accounts.findIndex(account => account.token?.refresh_token === tokenData.refresh_token);
1062
+ }
1063
+ if (index < 0) {
1064
+ const newId = typeof options.accountId === "string" && options.accountId.length > 0
1065
+ ? options.accountId
1066
+ : `acct_${Date.now().toString(36)}_${Math.random().toString(16).slice(2, 10)}`;
1067
+ store.accounts.push(buildAccountEntry(tokenData, newId, accountKey));
1068
+ index = store.accounts.length - 1;
1069
+ }
1070
+ const target = store.accounts[index];
1071
+ target.token = tokenData;
1072
+ target.resource_url = tokenData.resource_url;
1073
+ target.exhaustedUntil = 0;
1074
+ target.lastErrorCode = undefined;
1075
+ target.updatedAt = now;
1076
+ if (!target.createdAt || !Number.isFinite(target.createdAt)) {
1077
+ target.createdAt = now;
1078
+ }
1079
+ if (accountKey) {
1080
+ target.accountKey = accountKey;
1081
+ }
1082
+ selectedId = target.id;
1083
+ if (options.setActive || !store.activeAccountId) {
1084
+ store.activeAccountId = target.id;
1085
+ }
1086
+ return store;
1087
+ });
1088
+ if (!selectedId) {
1089
+ return null;
1090
+ }
1091
+ if (options.setActive) {
1092
+ return getActiveOAuthAccount({ allowExhausted: true, preferredAccountId: selectedId });
1093
+ }
1094
+ return getActiveOAuthAccount({ allowExhausted: true });
1095
+ }
1096
+
1097
+ export async function getActiveOAuthAccount(options = {}) {
1098
+ migrateLegacyTokenToAccountsIfNeeded();
1099
+ const lockPath = await acquireAccountsLock();
1100
+ let selected = null;
1101
+ let dirty = false;
1102
+ try {
1103
+ const store = loadAccountsStoreData();
1104
+ const now = Date.now();
1105
+ if (store.accounts.length === 0) {
1106
+ return null;
1107
+ }
1108
+ if (typeof options.preferredAccountId === "string" && options.preferredAccountId.length > 0) {
1109
+ const exists = store.accounts.some(account => account.id === options.preferredAccountId);
1110
+ if (exists && store.activeAccountId !== options.preferredAccountId) {
1111
+ store.activeAccountId = options.preferredAccountId;
1112
+ dirty = true;
1113
+ }
1114
+ }
1115
+ let active = store.accounts.find(account => account.id === store.activeAccountId);
1116
+ if (!active) {
1117
+ active = store.accounts[0];
1118
+ store.activeAccountId = active.id;
1119
+ dirty = true;
1120
+ }
1121
+ const activeHealthy = !(typeof active.exhaustedUntil === "number" && active.exhaustedUntil > now);
1122
+ if (!activeHealthy && !options.allowExhausted) {
1123
+ const replacement = pickNextHealthyAccount(store, new Set(), now);
1124
+ if (!replacement) {
1125
+ return null;
1126
+ }
1127
+ if (store.activeAccountId !== replacement.id) {
1128
+ store.activeAccountId = replacement.id;
1129
+ dirty = true;
1130
+ }
1131
+ active = replacement;
1132
+ }
1133
+ const healthyCount = countHealthyAccounts(store, now);
1134
+ selected = {
1135
+ account: { ...active },
1136
+ healthyCount,
1137
+ totalCount: store.accounts.length,
1138
+ };
1139
+ if (dirty) {
1140
+ writeAccountsStoreData(store);
1141
+ }
1142
+ }
1143
+ finally {
1144
+ releaseAccountsLock(lockPath);
1145
+ }
1146
+ if (!selected) {
1147
+ return null;
1148
+ }
1149
+ if (options.requireHealthy && selected.account.exhaustedUntil > Date.now()) {
1150
+ return null;
1151
+ }
1152
+ try {
1153
+ syncAccountToLegacyTokenFile(selected.account);
1154
+ }
1155
+ catch (error) {
1156
+ logWarn("Failed to sync active account token to oauth_creds.json", error);
1157
+ return null;
1158
+ }
1159
+ const valid = await getValidToken();
1160
+ if (!valid) {
1161
+ return null;
1162
+ }
1163
+ const latest = loadStoredToken();
1164
+ if (latest) {
1165
+ try {
1166
+ await withAccountsStoreLock((store) => {
1167
+ const target = store.accounts.find(account => account.id === selected.account.id);
1168
+ if (!target) {
1169
+ return store;
1170
+ }
1171
+ target.token = latest;
1172
+ target.resource_url = latest.resource_url;
1173
+ target.updatedAt = Date.now();
1174
+ return store;
1175
+ });
1176
+ }
1177
+ catch (error) {
1178
+ logWarn("Failed to update account token from refreshed legacy token", error);
1179
+ }
1180
+ }
1181
+ return buildRuntimeAccountResponse(selected.account, selected.healthyCount, selected.totalCount, valid.accessToken, valid.resourceUrl);
1182
+ }
1183
+
1184
+ export async function markOAuthAccountQuotaExhausted(accountId, errorCode = "insufficient_quota") {
1185
+ if (typeof accountId !== "string" || accountId.length === 0) {
1186
+ return null;
1187
+ }
1188
+ migrateLegacyTokenToAccountsIfNeeded();
1189
+ const cooldownMs = getQuotaCooldownMs();
1190
+ let outcome = null;
1191
+ await withAccountsStoreLock((store) => {
1192
+ const now = Date.now();
1193
+ const target = store.accounts.find(account => account.id === accountId);
1194
+ if (!target) {
1195
+ return store;
1196
+ }
1197
+ target.exhaustedUntil = now + cooldownMs;
1198
+ target.lastErrorCode = errorCode;
1199
+ target.updatedAt = now;
1200
+ if (store.activeAccountId === target.id) {
1201
+ const next = pickNextHealthyAccount(store, new Set([target.id]), now);
1202
+ if (next) {
1203
+ store.activeAccountId = next.id;
1204
+ }
1205
+ }
1206
+ outcome = {
1207
+ accountId: target.id,
1208
+ exhaustedUntil: target.exhaustedUntil,
1209
+ healthyAccountCount: countHealthyAccounts(store, now),
1210
+ totalAccountCount: store.accounts.length,
1211
+ };
1212
+ return store;
1213
+ });
1214
+ return outcome;
1215
+ }
1216
+
1217
+ export async function switchToNextHealthyOAuthAccount(excludedAccountIds = []) {
1218
+ migrateLegacyTokenToAccountsIfNeeded();
1219
+ const excluded = new Set(Array.isArray(excludedAccountIds)
1220
+ ? excludedAccountIds.filter(id => typeof id === "string" && id.length > 0)
1221
+ : []);
1222
+ let switchedId = null;
1223
+ await withAccountsStoreLock((store) => {
1224
+ const next = pickNextHealthyAccount(store, excluded, Date.now());
1225
+ if (!next) {
1226
+ return store;
1227
+ }
1228
+ store.activeAccountId = next.id;
1229
+ switchedId = next.id;
1230
+ return store;
1231
+ });
1232
+ if (!switchedId) {
1233
+ return null;
1234
+ }
1235
+ return getActiveOAuthAccount({
1236
+ allowExhausted: false,
1237
+ requireHealthy: true,
1238
+ preferredAccountId: switchedId,
1239
+ });
1240
+ }
1241
+
1242
+ /**
1243
+ * Checks if token is expired (with buffer)
1244
+ * @param {number} expiresAt - Token expiry timestamp in milliseconds
1245
+ * @returns {boolean} True if token is expired or expiring soon
1246
+ */
526
1247
  export function isTokenExpired(expiresAt) {
527
1248
  return Date.now() >= expiresAt - TOKEN_REFRESH_BUFFER_MS;
528
1249
  }
1250
+
1251
+ /**
1252
+ * Gets valid access token, refreshing if expired
1253
+ * @returns {Promise<{ accessToken: string, resourceUrl?: string }|null>} Valid token or null if unavailable
1254
+ */
529
1255
  export async function getValidToken() {
530
1256
  const stored = loadStoredToken();
531
1257
  if (!stored) {
532
1258
  return null;
533
1259
  }
1260
+ // Return cached token if still valid
534
1261
  if (!isTokenExpired(stored.expiry_date)) {
535
1262
  return {
536
1263
  accessToken: stored.access_token,
@@ -540,6 +1267,7 @@ export async function getValidToken() {
540
1267
  if (LOGGING_ENABLED) {
541
1268
  logInfo("Token expired, refreshing...");
542
1269
  }
1270
+ // Token expired, try to refresh
543
1271
  const refreshResult = await refreshAccessToken(stored.refresh_token);
544
1272
  if (refreshResult.type !== "success") {
545
1273
  logError("Token refresh failed, re-authentication required");
@@ -551,6 +1279,12 @@ export async function getValidToken() {
551
1279
  resourceUrl: refreshResult.resourceUrl,
552
1280
  };
553
1281
  }
1282
+
1283
+ /**
1284
+ * Constructs DashScope API base URL from resource_url
1285
+ * @param {string} [resourceUrl] - Resource URL from token (optional)
1286
+ * @returns {string} DashScope API base URL
1287
+ */
554
1288
  export function getApiBaseUrl(resourceUrl) {
555
1289
  if (resourceUrl) {
556
1290
  try {
@@ -564,6 +1298,7 @@ export function getApiBaseUrl(resourceUrl) {
564
1298
  logWarn("Invalid resource_url protocol, using default DashScope endpoint");
565
1299
  return DEFAULT_QWEN_BASE_URL;
566
1300
  }
1301
+ // Ensure URL ends with /v1 suffix
567
1302
  let baseUrl = normalizedResourceUrl.replace(/\/$/, "");
568
1303
  const suffix = "/v1";
569
1304
  if (!baseUrl.endsWith(suffix)) {