Skip to content

Commit 7fb8836

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 b1784db commit 7fb8836

8 files changed

Lines changed: 1367 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: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,110 @@
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';
1314

1415
/**
1516
* Get the path to the credentials file
17+
* Supports test override via QWEN_TEST_CREDS_PATH environment variable
1618
*/
1719
export function getCredentialsPath(): string {
20+
// Check for test override (prevents tests from modifying user credentials)
21+
if (process.env.QWEN_TEST_CREDS_PATH) {
22+
return process.env.QWEN_TEST_CREDS_PATH;
23+
}
1824
const homeDir = homedir();
1925
return join(homeDir, '.qwen', 'oauth_creds.json');
2026
}
2127

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

3187
try {
3288
const content = readFileSync(credPath, 'utf8');
33-
return JSON.parse(content);
89+
const data = JSON.parse(content);
90+
91+
// Convert snake_case (file format) to camelCase (internal format)
92+
// This matches qwen-code format for compatibility
93+
const converted: QwenCredentials = {
94+
accessToken: data.access_token,
95+
tokenType: data.token_type || 'Bearer',
96+
refreshToken: data.refresh_token,
97+
resourceUrl: data.resource_url,
98+
expiryDate: data.expiry_date,
99+
scope: data.scope,
100+
};
101+
102+
// Validate converted credentials structure
103+
const validated = validateCredentials(converted);
104+
105+
return validated;
34106
} catch (error) {
35-
console.error('Failed to load Qwen credentials:', error);
107+
const message = error instanceof Error ? error.message : String(error);
108+
console.error('[QwenAuth] Failed to load credentials:', message);
109+
110+
// Corrupted file - suggest re-authentication
111+
console.error('[QwenAuth] Credentials file may be corrupted. Please re-authenticate.');
36112
return null;
37113
}
38114
}
@@ -60,10 +136,11 @@ export function resolveBaseUrl(resourceUrl?: string): string {
60136

61137
/**
62138
* Save credentials to file in qwen-code compatible format
139+
* Uses atomic write (temp file + rename) to prevent corruption
63140
*/
64141
export function saveCredentials(credentials: QwenCredentials): void {
65142
const credPath = getCredentialsPath();
66-
const dir = join(homedir(), '.qwen');
143+
const dir = dirname(credPath);
67144

68145
if (!existsSync(dir)) {
69146
mkdirSync(dir, { recursive: true });
@@ -79,5 +156,19 @@ export function saveCredentials(credentials: QwenCredentials): void {
79156
scope: credentials.scope,
80157
};
81158

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

0 commit comments

Comments
 (0)