Skip to content

Commit 8c4d37e

Browse files
committed
feat: implement TokenManager with in-memory cache, file watcher, 401 recovery
- TokenManager with in-memory caching and promise tracking - File check throttling (5s interval) to reduce I/O overhead - File watcher for real-time cache invalidation when credentials change externally - Atomic cache state updates to prevent inconsistent states - Reactive 401 recovery: automatically forces token refresh and retries request - Comprehensive credentials validation matching official client - Fix: attach HTTP status to poll errors and handle 401 in device flow - Fix: add file locking for multi-process safety with atomic operations - Stale lock detection (10s threshold) matching official client - 5 process exit handlers (exit, SIGINT, SIGTERM, uncaughtException, unhandledRejection) - Atomic file writes using temp file + rename pattern - Timeout wrappers (3s) for file operations to prevent indefinite hangs - Fix: correctly convert snake_case to camelCase when loading credentials
1 parent 5ac70f3 commit 8c4d37e

8 files changed

Lines changed: 1253 additions & 729 deletions

File tree

src/index.ts

Lines changed: 95 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,27 @@
99
*/
1010

1111
import { spawn } from 'node:child_process';
12+
import { randomUUID } from 'node:crypto';
1213

1314
import { QWEN_PROVIDER_ID, QWEN_API_CONFIG, QWEN_MODELS, QWEN_OFFICIAL_HEADERS } from './constants.js';
1415
import type { QwenCredentials } from './types.js';
15-
import type { HttpError } from './utils/retry.js';
16-
import { saveCredentials, loadCredentials, resolveBaseUrl } from './plugin/auth.js';
16+
import { resolveBaseUrl } from './plugin/auth.js';
1717
import {
1818
generatePKCE,
1919
requestDeviceAuthorization,
2020
pollDeviceToken,
2121
tokenResponseToCredentials,
22-
refreshAccessToken,
2322
SlowDownError,
2423
} from './qwen/oauth.js';
25-
import { logTechnicalDetail } from './errors.js';
26-
import { retryWithBackoff } from './utils/retry.js';
24+
import { retryWithBackoff, getErrorStatus } from './utils/retry.js';
2725
import { RequestQueue } from './plugin/request-queue.js';
26+
import { tokenManager } from './plugin/token-manager.js';
27+
import { createDebugLogger } from './utils/debug-logger.js';
28+
29+
const debugLogger = createDebugLogger('PLUGIN');
2830

2931
// Global session ID for the plugin lifetime
30-
const PLUGIN_SESSION_ID = crypto.randomUUID();
32+
const PLUGIN_SESSION_ID = randomUUID();
3133

3234
// Singleton request queue for throttling (shared across all requests)
3335
const requestQueue = new RequestQueue();
@@ -86,7 +88,7 @@ export const QwenAuthPlugin = async (_input: unknown) => {
8688
provider: QWEN_PROVIDER_ID,
8789

8890
loader: async (
89-
getAuth: () => Promise<{ type: string; access?: string; refresh?: string; expires?: number }>,
91+
getAuth: any,
9092
provider: { models?: Record<string, { cost?: { input: number; output: number } }> },
9193
) => {
9294
// Zerar custo dos modelos (gratuito via OAuth)
@@ -96,66 +98,103 @@ export const QwenAuthPlugin = async (_input: unknown) => {
9698
}
9799
}
98100

99-
const accessToken = await getValidAccessToken(getAuth);
100-
if (!accessToken) return null;
101+
// Get latest valid credentials
102+
const credentials = await tokenManager.getValidCredentials();
103+
if (!credentials?.accessToken) return null;
101104

102-
// Load credentials to resolve region-specific base URL
103-
const creds = loadCredentials();
104-
const baseURL = resolveBaseUrl(creds?.resource_url);
105+
const baseURL = resolveBaseUrl(credentials.resourceUrl);
105106

106107
return {
107-
apiKey: accessToken,
108+
apiKey: credentials.accessToken,
108109
baseURL: baseURL,
109110
headers: {
110111
...QWEN_OFFICIAL_HEADERS,
111-
// Custom metadata object required by official backend for free quota
112-
'X-Metadata': JSON.stringify({
113-
sessionId: PLUGIN_SESSION_ID,
114-
promptId: crypto.randomUUID(),
115-
source: 'opencode-qwencode-auth'
116-
})
117112
},
118-
// Custom fetch with throttling and retry
119-
fetch: async (url: string, options?: RequestInit) => {
113+
// Custom fetch with throttling, retry and 401 recovery
114+
fetch: async (url: string, options: any = {}) => {
120115
return requestQueue.enqueue(async () => {
121-
return retryWithBackoff(
122-
async () => {
123-
// Generate new promptId for each request
124-
const headers = new Headers(options?.headers);
125-
headers.set('Authorization', `Bearer ${accessToken}`);
126-
headers.set(
127-
'X-Metadata',
128-
JSON.stringify({
129-
sessionId: PLUGIN_SESSION_ID,
130-
promptId: crypto.randomUUID(),
131-
source: 'opencode-qwencode-auth',
132-
})
133-
);
134-
135-
const response = await fetch(url, {
136-
...options,
137-
headers,
138-
});
139-
140-
if (!response.ok) {
141-
const errorText = await response.text().catch(() => '');
142-
const error = new Error(`HTTP ${response.status}: ${errorText}`) as HttpError & { status?: number };
143-
error.status = response.status;
144-
(error as any).response = response;
145-
throw error;
116+
let authRetryCount = 0;
117+
118+
const executeRequest = async (): Promise<Response> => {
119+
// Get latest token (possibly refreshed by concurrent request)
120+
const currentCreds = await tokenManager.getValidCredentials();
121+
const token = currentCreds?.accessToken;
122+
123+
if (!token) throw new Error('No access token available');
124+
125+
// Prepare merged headers
126+
const mergedHeaders: Record<string, string> = {
127+
...QWEN_OFFICIAL_HEADERS,
128+
};
129+
130+
// Merge provided headers (handles both plain object and Headers instance)
131+
if (options.headers) {
132+
if (typeof (options.headers as any).entries === 'function') {
133+
for (const [k, v] of (options.headers as any).entries()) {
134+
const kl = k.toLowerCase();
135+
if (!kl.startsWith('x-dashscope') && kl !== 'user-agent' && kl !== 'authorization') {
136+
mergedHeaders[k] = v;
137+
}
138+
}
139+
} else {
140+
for (const [k, v] of Object.entries(options.headers)) {
141+
const kl = k.toLowerCase();
142+
if (!kl.startsWith('x-dashscope') && kl !== 'user-agent' && kl !== 'authorization') {
143+
mergedHeaders[k] = v as string;
144+
}
145+
}
146146
}
147+
}
147148

148-
return response;
149-
},
150-
{
151-
authType: 'qwen-oauth',
152-
maxAttempts: 7,
153-
initialDelayMs: 1500,
154-
maxDelayMs: 30000,
149+
// Force our Authorization token
150+
mergedHeaders['Authorization'] = `Bearer ${token}`;
151+
152+
// Optional: X-Metadata might be expected by some endpoints for free quota tracking
153+
// but let's try without it first to match official client closer
154+
// mergedHeaders['X-Metadata'] = JSON.stringify({ ... });
155+
156+
// Perform the request
157+
const response = await fetch(url, {
158+
...options,
159+
headers: mergedHeaders
160+
});
161+
162+
// Reactive recovery for 401 (token expired mid-session)
163+
if (response.status === 401 && authRetryCount < 1) {
164+
authRetryCount++;
165+
debugLogger.warn('401 Unauthorized detected. Forcing token refresh...');
166+
167+
// Force refresh from API
168+
const refreshed = await tokenManager.getValidCredentials(true);
169+
if (refreshed?.accessToken) {
170+
debugLogger.info('Token refreshed, retrying request...');
171+
return executeRequest(); // Recursive retry with new token
172+
}
155173
}
156-
);
174+
175+
// Error handling for retryWithBackoff
176+
if (!response.ok) {
177+
const errorText = await response.text().catch(() => '');
178+
const error: any = new Error(`HTTP ${response.status}: ${errorText}`);
179+
error.status = response.status;
180+
throw error;
181+
}
182+
183+
return response;
184+
};
185+
186+
// Use official retry logic for 429/5xx errors
187+
return retryWithBackoff(() => executeRequest(), {
188+
authType: 'qwen-oauth',
189+
maxAttempts: 7,
190+
shouldRetryOnError: (error: any) => {
191+
const status = error.status || getErrorStatus(error);
192+
// Retry on 401 (handled by executeRequest recursion too), 429, and 5xx
193+
return status === 401 || status === 429 || (status !== undefined && status >= 500 && status < 600);
194+
}
195+
});
157196
});
158-
},
197+
}
159198
};
160199
},
161200

@@ -189,7 +228,7 @@ export const QwenAuthPlugin = async (_input: unknown) => {
189228

190229
if (tokenResponse) {
191230
const credentials = tokenResponseToCredentials(tokenResponse);
192-
saveCredentials(credentials);
231+
tokenManager.setCredentials(credentials);
193232

194233
return {
195234
type: 'success' as const,

src/plugin/auth.ts

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
*/
66

77
import { homedir } from 'node:os';
8-
import { join } from 'node:path';
9-
import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
8+
import { join, dirname } from 'node:path';
9+
import { existsSync, writeFileSync, mkdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
10+
import { randomUUID } from 'node:crypto';
1011

1112
import type { QwenCredentials } from '../types.js';
1213
import { QWEN_API_CONFIG } from '../constants.js';
@@ -20,19 +21,89 @@ export function getCredentialsPath(): string {
2021
}
2122

2223
/**
23-
* Load credentials from file
24+
* Validate credentials structure
25+
* Matches official client's validateCredentials() function
2426
*/
25-
export function loadCredentials(): any {
27+
function validateCredentials(data: unknown): QwenCredentials {
28+
if (!data || typeof data !== 'object') {
29+
throw new Error('Invalid credentials format: expected object');
30+
}
31+
32+
const creds = data as Partial<QwenCredentials>;
33+
const requiredFields = ['accessToken', 'tokenType'] as const;
34+
35+
// Validate required string fields
36+
for (const field of requiredFields) {
37+
if (!creds[field] || typeof creds[field] !== 'string') {
38+
throw new Error(`Invalid credentials: missing or invalid ${field}`);
39+
}
40+
}
41+
42+
// Validate refreshToken (optional but should be string if present)
43+
if (creds.refreshToken !== undefined && typeof creds.refreshToken !== 'string') {
44+
throw new Error('Invalid credentials: refreshToken must be a string');
45+
}
46+
47+
// Validate expiryDate (required for token management)
48+
if (!creds.expiryDate || typeof creds.expiryDate !== 'number') {
49+
throw new Error('Invalid credentials: missing or invalid expiryDate');
50+
}
51+
52+
// Validate resourceUrl (optional but should be string if present)
53+
if (creds.resourceUrl !== undefined && typeof creds.resourceUrl !== 'string') {
54+
throw new Error('Invalid credentials: resourceUrl must be a string');
55+
}
56+
57+
// Validate scope (optional but should be string if present)
58+
if (creds.scope !== undefined && typeof creds.scope !== 'string') {
59+
throw new Error('Invalid credentials: scope must be a string');
60+
}
61+
62+
return {
63+
accessToken: creds.accessToken!,
64+
tokenType: creds.tokenType!,
65+
refreshToken: creds.refreshToken,
66+
resourceUrl: creds.resourceUrl,
67+
expiryDate: creds.expiryDate!,
68+
scope: creds.scope,
69+
};
70+
}
71+
72+
/**
73+
* Load credentials from file and map to camelCase QwenCredentials
74+
* Includes comprehensive validation matching official client
75+
*/
76+
export function loadCredentials(): QwenCredentials | null {
2677
const credPath = getCredentialsPath();
2778
if (!existsSync(credPath)) {
2879
return null;
2980
}
3081

3182
try {
3283
const content = readFileSync(credPath, 'utf8');
33-
return JSON.parse(content);
84+
const data = JSON.parse(content);
85+
86+
// Convert snake_case (file format) to camelCase (internal format)
87+
// This matches qwen-code format for compatibility
88+
const converted: QwenCredentials = {
89+
accessToken: data.access_token,
90+
tokenType: data.token_type || 'Bearer',
91+
refreshToken: data.refresh_token,
92+
resourceUrl: data.resource_url,
93+
expiryDate: data.expiry_date,
94+
scope: data.scope,
95+
};
96+
97+
// Validate converted credentials structure
98+
const validated = validateCredentials(converted);
99+
100+
return validated;
34101
} catch (error) {
35-
console.error('Failed to load Qwen credentials:', error);
102+
const message = error instanceof Error ? error.message : String(error);
103+
console.error('[QwenAuth] Failed to load credentials:', message);
104+
105+
// Corrupted file - suggest re-authentication
106+
console.error('[QwenAuth] Credentials file may be corrupted. Please re-authenticate.');
36107
return null;
37108
}
38109
}
@@ -60,10 +131,11 @@ export function resolveBaseUrl(resourceUrl?: string): string {
60131

61132
/**
62133
* Save credentials to file in qwen-code compatible format
134+
* Uses atomic write (temp file + rename) to prevent corruption
63135
*/
64136
export function saveCredentials(credentials: QwenCredentials): void {
65137
const credPath = getCredentialsPath();
66-
const dir = join(homedir(), '.qwen');
138+
const dir = dirname(credPath);
67139

68140
if (!existsSync(dir)) {
69141
mkdirSync(dir, { recursive: true });
@@ -79,5 +151,19 @@ export function saveCredentials(credentials: QwenCredentials): void {
79151
scope: credentials.scope,
80152
};
81153

82-
writeFileSync(credPath, JSON.stringify(data, null, 2));
154+
// ATOMIC WRITE: temp file + rename to prevent corruption
155+
const tempPath = `${credPath}.tmp.${randomUUID()}`;
156+
157+
try {
158+
writeFileSync(tempPath, JSON.stringify(data, null, 2));
159+
renameSync(tempPath, credPath); // Atomic on POSIX systems
160+
} catch (error) {
161+
// Cleanup temp file if rename fails
162+
try {
163+
if (existsSync(tempPath)) {
164+
unlinkSync(tempPath);
165+
}
166+
} catch {}
167+
throw error;
168+
}
83169
}

0 commit comments

Comments
 (0)