fix: sync token among multiple qwen sessions (#443)

* fix: sync token among multiple qwen sessions

* fix: adjust cleanup function
This commit is contained in:
Mingholy
2025-08-27 13:17:28 +08:00
committed by GitHub
parent de279b56f3
commit 009e083b73
11 changed files with 3579 additions and 449 deletions

19
.vscode/launch.json vendored
View File

@@ -67,6 +67,19 @@
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"skipFiles": ["<node_internals>/**"]
},
{
"type": "node",
"request": "launch",
"name": "Launch CLI Non-Interactive",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "start", "--", "-p", "${input:prompt}", "-y"],
"skipFiles": ["<node_internals>/**"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"env": {
"GEMINI_SANDBOX": "false"
}
}
],
"inputs": [
@@ -75,6 +88,12 @@
"type": "promptString",
"description": "Enter the path to the test file (e.g., ${workspaceFolder}/packages/cli/src/ui/components/LoadingIndicator.test.tsx)",
"default": "${workspaceFolder}/packages/cli/src/ui/components/LoadingIndicator.test.tsx"
},
{
"id": "prompt",
"type": "promptString",
"description": "Enter your prompt for non-interactive mode",
"default": "Explain this code"
}
]
}

View File

@@ -69,10 +69,6 @@ export function AuthDialog({
return item.value === AuthType.USE_GEMINI;
}
if (process.env.QWEN_OAUTH_TOKEN) {
return item.value === AuthType.QWEN_OAUTH;
}
return item.value === AuthType.LOGIN_WITH_GOOGLE;
}),
);

View File

@@ -140,7 +140,7 @@ describe('getInstallationInfo', () => {
const info = getInstallationInfo(projectRoot, false);
expect(mockedExecSync).toHaveBeenCalledWith(
'brew list -1 | grep -q "^gemini-cli$"',
'brew list -1 | grep -q "^qwen-code$"',
{ stdio: 'ignore' },
);
expect(info.packageManager).toBe(PackageManager.HOMEBREW);
@@ -162,7 +162,7 @@ describe('getInstallationInfo', () => {
const info = getInstallationInfo(projectRoot, false);
expect(mockedExecSync).toHaveBeenCalledWith(
'brew list -1 | grep -q "^gemini-cli$"',
'brew list -1 | grep -q "^qwen-code$"',
{ stdio: 'ignore' },
);
// Should fall back to default global npm

View File

@@ -77,8 +77,8 @@ export function getInstallationInfo(
// Check for Homebrew
if (process.platform === 'darwin') {
try {
// The package name in homebrew is gemini-cli
childProcess.execSync('brew list -1 | grep -q "^gemini-cli$"', {
// We do not support homebrew for now, keep forward compatibility for future use
childProcess.execSync('brew list -1 | grep -q "^qwen-code$"', {
stdio: 'ignore',
});
return {
@@ -88,8 +88,7 @@ export function getInstallationInfo(
'Installed via Homebrew. Please update with "brew upgrade".',
};
} catch (_error) {
// Brew is not installed or gemini-cli is not installed via brew.
// Continue to the next check.
// continue to the next check
}
}

View File

@@ -21,9 +21,6 @@ function getAuthTypeFromEnv(): AuthType | undefined {
if (process.env.OPENAI_API_KEY) {
return AuthType.USE_OPENAI;
}
if (process.env.QWEN_OAUTH_TOKEN) {
return AuthType.QWEN_OAUTH;
}
return undefined;
}

View File

@@ -20,9 +20,117 @@ import {
FinishReason,
} from '@google/genai';
import { QwenContentGenerator } from './qwenContentGenerator.js';
import { SharedTokenManager } from './sharedTokenManager.js';
import { Config } from '../config/config.js';
import { AuthType, ContentGeneratorConfig } from '../core/contentGenerator.js';
// Mock SharedTokenManager
vi.mock('./sharedTokenManager.js', () => ({
SharedTokenManager: class {
private static instance: unknown = null;
private mockCredentials: QwenCredentials | null = null;
private shouldThrowError: boolean = false;
private errorToThrow: Error | null = null;
static getInstance() {
if (!this.instance) {
this.instance = new this();
}
return this.instance;
}
async getValidCredentials(
qwenClient: IQwenOAuth2Client,
): Promise<QwenCredentials> {
// If we're configured to throw an error, do so
if (this.shouldThrowError && this.errorToThrow) {
throw this.errorToThrow;
}
// Try to get credentials from the mock client first to trigger auth errors
try {
const { token } = await qwenClient.getAccessToken();
if (token) {
const credentials = qwenClient.getCredentials();
return credentials;
}
} catch (error) {
// If it's an auth error and we need to simulate refresh behavior
const errorMessage =
error instanceof Error
? error.message.toLowerCase()
: String(error).toLowerCase();
const errorCode =
(error as { status?: number; code?: number })?.status ||
(error as { status?: number; code?: number })?.code;
const isAuthError =
errorCode === 401 ||
errorCode === 403 ||
errorMessage.includes('unauthorized') ||
errorMessage.includes('forbidden') ||
errorMessage.includes('token expired');
if (isAuthError) {
// Try to refresh the token through the client
try {
const refreshResult = await qwenClient.refreshAccessToken();
if (refreshResult && !('error' in refreshResult)) {
// Refresh succeeded, update client credentials and return them
const updatedCredentials = qwenClient.getCredentials();
return updatedCredentials;
} else {
// Refresh failed, throw appropriate error
throw new Error(
'Failed to obtain valid Qwen access token. Please re-authenticate.',
);
}
} catch {
throw new Error(
'Failed to obtain valid Qwen access token. Please re-authenticate.',
);
}
} else {
// Re-throw non-auth errors
throw error;
}
}
// Return mock credentials only if they're set
if (this.mockCredentials && this.mockCredentials.access_token) {
return this.mockCredentials;
}
// Default fallback for tests that need credentials
return {
access_token: 'valid-token',
refresh_token: 'valid-refresh-token',
resource_url: 'https://test-endpoint.com/v1',
expiry_date: Date.now() + 3600000,
};
}
getCurrentCredentials(): QwenCredentials | null {
return this.mockCredentials;
}
clearCache(): void {
this.mockCredentials = null;
}
// Helper method for tests to set credentials
setMockCredentials(credentials: QwenCredentials | null): void {
this.mockCredentials = credentials;
}
// Helper method for tests to simulate errors
setMockError(error: Error | null): void {
this.shouldThrowError = !!error;
this.errorToThrow = error;
}
},
}));
// Mock the OpenAIContentGenerator parent class
vi.mock('../core/openaiContentGenerator.js', () => ({
OpenAIContentGenerator: class {
@@ -236,8 +344,10 @@ describe('QwenContentGenerator', () => {
it('should refresh token on auth error and retry', async () => {
const authError = { status: 401, message: 'Unauthorized' };
// First call fails with auth error
vi.mocked(mockQwenClient.getAccessToken).mockRejectedValueOnce(authError);
// First call fails with auth error, second call succeeds
vi.mocked(mockQwenClient.getAccessToken)
.mockRejectedValueOnce(authError)
.mockResolvedValueOnce({ token: 'refreshed-token' });
// Refresh succeeds
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
@@ -247,6 +357,15 @@ describe('QwenContentGenerator', () => {
resource_url: 'https://refreshed-endpoint.com',
});
// Set credentials for second call
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
access_token: 'refreshed-token',
token_type: 'Bearer',
refresh_token: 'refresh-token',
resource_url: 'https://refreshed-endpoint.com',
expiry_date: Date.now() + 3600000,
});
const request: GenerateContentParameters = {
model: 'qwen-turbo',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
@@ -261,12 +380,62 @@ describe('QwenContentGenerator', () => {
expect(mockQwenClient.refreshAccessToken).toHaveBeenCalled();
});
it('should handle token refresh failure', async () => {
vi.mocked(mockQwenClient.getAccessToken).mockRejectedValue(
new Error('Token expired'),
it('should refresh token on auth error and retry for content stream', async () => {
const authError = { status: 401, message: 'Unauthorized' };
// Reset mocks for this test
vi.clearAllMocks();
// First call fails with auth error, second call succeeds
vi.mocked(mockQwenClient.getAccessToken)
.mockRejectedValueOnce(authError)
.mockResolvedValueOnce({ token: 'refreshed-stream-token' });
// Refresh succeeds
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
access_token: 'refreshed-stream-token',
token_type: 'Bearer',
expires_in: 3600,
resource_url: 'https://refreshed-stream-endpoint.com',
});
// Set credentials for second call
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
access_token: 'refreshed-stream-token',
token_type: 'Bearer',
refresh_token: 'refresh-token',
resource_url: 'https://refreshed-stream-endpoint.com',
expiry_date: Date.now() + 3600000,
});
const request: GenerateContentParameters = {
model: 'qwen-turbo',
contents: [{ role: 'user', parts: [{ text: 'Hello stream' }] }],
};
const stream = await qwenContentGenerator.generateContentStream(
request,
'test-prompt-id',
);
vi.mocked(mockQwenClient.refreshAccessToken).mockRejectedValue(
new Error('Refresh failed'),
const chunks: string[] = [];
for await (const chunk of stream) {
chunks.push(chunk.text || '');
}
expect(chunks).toEqual(['Stream chunk 1', 'Stream chunk 2']);
expect(mockQwenClient.refreshAccessToken).toHaveBeenCalled();
});
it('should handle token refresh failure', async () => {
// Mock the SharedTokenManager to throw an error
const mockTokenManager = SharedTokenManager.getInstance() as unknown as {
setMockError: (error: Error | null) => void;
};
mockTokenManager.setMockError(
new Error(
'Failed to obtain valid Qwen access token. Please re-authenticate.',
),
);
const request: GenerateContentParameters = {
@@ -279,6 +448,9 @@ describe('QwenContentGenerator', () => {
).rejects.toThrow(
'Failed to obtain valid Qwen access token. Please re-authenticate.',
);
// Clean up
mockTokenManager.setMockError(null);
});
it('should update endpoint when token is refreshed', async () => {
@@ -547,10 +719,24 @@ describe('QwenContentGenerator', () => {
const originalGenerateContent = parentPrototype.generateContent;
parentPrototype.generateContent = mockGenerateContent;
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
token: 'initial-token',
// Mock getAccessToken to fail initially, then succeed
let getAccessTokenCallCount = 0;
vi.mocked(mockQwenClient.getAccessToken).mockImplementation(async () => {
getAccessTokenCallCount++;
if (getAccessTokenCallCount <= 2) {
throw authError; // Fail on first two calls (initial + retry)
}
return { token: 'refreshed-token' }; // Succeed after refresh
});
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
access_token: 'refreshed-token',
token_type: 'Bearer',
refresh_token: 'refresh-token',
resource_url: 'https://test-endpoint.com',
expiry_date: Date.now() + 3600000,
});
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
access_token: 'refreshed-token',
token_type: 'Bearer',
@@ -637,31 +823,16 @@ describe('QwenContentGenerator', () => {
expect(qwenContentGenerator.getCurrentToken()).toBe('cached-token');
});
it('should clear token and endpoint on clearToken()', () => {
// Simulate having cached values
it('should clear token on clearToken()', () => {
// Simulate having cached token value
const qwenInstance = qwenContentGenerator as unknown as {
currentToken: string;
currentEndpoint: string;
refreshPromise: Promise<string>;
};
qwenInstance.currentToken = 'cached-token';
qwenInstance.currentEndpoint = 'https://cached-endpoint.com';
qwenInstance.refreshPromise = Promise.resolve('token');
qwenContentGenerator.clearToken();
expect(qwenContentGenerator.getCurrentToken()).toBeNull();
expect(
(qwenContentGenerator as unknown as { currentEndpoint: string | null })
.currentEndpoint,
).toBeNull();
expect(
(
qwenContentGenerator as unknown as {
refreshPromise: Promise<string> | null;
}
).refreshPromise,
).toBeNull();
});
it('should handle concurrent token refresh requests', async () => {
@@ -674,9 +845,7 @@ describe('QwenContentGenerator', () => {
const authError = { status: 401, message: 'Unauthorized' };
let parentCallCount = 0;
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
token: 'initial-token',
});
vi.mocked(mockQwenClient.getAccessToken).mockRejectedValue(authError);
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
vi.mocked(mockQwenClient.refreshAccessToken).mockImplementation(
@@ -725,6 +894,7 @@ describe('QwenContentGenerator', () => {
// The main test is that all requests succeed without crashing
expect(results).toHaveLength(3);
// With our new implementation through SharedTokenManager, refresh should still be called
expect(refreshCallCount).toBeGreaterThanOrEqual(1);
// Restore original method
@@ -796,13 +966,24 @@ describe('QwenContentGenerator', () => {
);
parentPrototype.generateContent = mockGenerateContent;
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
token: 'initial-token',
// Mock getAccessToken to fail initially, then succeed
let getAccessTokenCallCount = 0;
vi.mocked(mockQwenClient.getAccessToken).mockImplementation(async () => {
getAccessTokenCallCount++;
if (getAccessTokenCallCount <= 2) {
throw authError; // Fail on first two calls (initial + retry)
}
return { token: 'new-token' }; // Succeed after refresh
});
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
...mockCredentials,
resource_url: 'custom-endpoint.com',
access_token: 'new-token',
token_type: 'Bearer',
refresh_token: 'refresh-token',
resource_url: 'https://new-endpoint.com',
expiry_date: Date.now() + 7200000,
});
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
access_token: 'new-token',
token_type: 'Bearer',
@@ -826,4 +1007,595 @@ describe('QwenContentGenerator', () => {
expect(callCount).toBe(2); // Initial call + retry
});
});
describe('SharedTokenManager Integration', () => {
it('should use SharedTokenManager to get valid credentials', async () => {
const mockTokenManager = {
getValidCredentials: vi.fn().mockResolvedValue({
access_token: 'manager-token',
resource_url: 'https://manager-endpoint.com',
}),
getCurrentCredentials: vi.fn(),
clearCache: vi.fn(),
};
// Mock the SharedTokenManager.getInstance()
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Create new instance to pick up the mock
const newGenerator = new QwenContentGenerator(
mockQwenClient,
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
mockConfig,
);
const request: GenerateContentParameters = {
model: 'qwen-turbo',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
};
await newGenerator.generateContent(request, 'test-prompt-id');
expect(mockTokenManager.getValidCredentials).toHaveBeenCalledWith(
mockQwenClient,
);
// Restore original
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle SharedTokenManager errors gracefully', async () => {
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('Token manager error')),
getCurrentCredentials: vi.fn(),
clearCache: vi.fn(),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
const newGenerator = new QwenContentGenerator(
mockQwenClient,
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
mockConfig,
);
const request: GenerateContentParameters = {
model: 'qwen-turbo',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
};
await expect(
newGenerator.generateContent(request, 'test-prompt-id'),
).rejects.toThrow('Failed to obtain valid Qwen access token');
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle missing access token from credentials', async () => {
const mockTokenManager = {
getValidCredentials: vi.fn().mockResolvedValue({
access_token: undefined,
resource_url: 'https://test-endpoint.com',
}),
getCurrentCredentials: vi.fn(),
clearCache: vi.fn(),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
const newGenerator = new QwenContentGenerator(
mockQwenClient,
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
mockConfig,
);
const request: GenerateContentParameters = {
model: 'qwen-turbo',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
};
await expect(
newGenerator.generateContent(request, 'test-prompt-id'),
).rejects.toThrow('Failed to obtain valid Qwen access token');
SharedTokenManager.getInstance = originalGetInstance;
});
});
describe('getCurrentEndpoint Method', () => {
it('should handle URLs with custom ports', () => {
const endpoints = [
{ input: 'localhost:8080', expected: 'https://localhost:8080/v1' },
{
input: 'http://localhost:8080',
expected: 'http://localhost:8080/v1',
},
{
input: 'https://api.example.com:443',
expected: 'https://api.example.com:443/v1',
},
{
input: 'api.example.com:9000/api',
expected: 'https://api.example.com:9000/api/v1',
},
];
endpoints.forEach(({ input, expected }) => {
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
token: 'test-token',
});
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
...mockCredentials,
resource_url: input,
});
const generator = qwenContentGenerator as unknown as {
getCurrentEndpoint: (resourceUrl?: string) => string;
};
expect(generator.getCurrentEndpoint(input)).toBe(expected);
});
});
it('should handle URLs with existing paths', () => {
const endpoints = [
{
input: 'https://api.example.com/api',
expected: 'https://api.example.com/api/v1',
},
{
input: 'api.example.com/api/v2',
expected: 'https://api.example.com/api/v2/v1',
},
{
input: 'https://api.example.com/api/v1',
expected: 'https://api.example.com/api/v1',
},
];
endpoints.forEach(({ input, expected }) => {
const generator = qwenContentGenerator as unknown as {
getCurrentEndpoint: (resourceUrl?: string) => string;
};
expect(generator.getCurrentEndpoint(input)).toBe(expected);
});
});
it('should handle undefined resource URL', () => {
const generator = qwenContentGenerator as unknown as {
getCurrentEndpoint: (resourceUrl?: string) => string;
};
expect(generator.getCurrentEndpoint(undefined)).toBe(
'https://dashscope.aliyuncs.com/compatible-mode/v1',
);
});
it('should handle empty resource URL', () => {
const generator = qwenContentGenerator as unknown as {
getCurrentEndpoint: (resourceUrl?: string) => string;
};
// Empty string should fall back to default endpoint
expect(generator.getCurrentEndpoint('')).toBe(
'https://dashscope.aliyuncs.com/compatible-mode/v1',
);
});
});
describe('isAuthError Method Enhanced', () => {
it('should identify auth errors by numeric status codes', () => {
const authErrors = [
{ code: 401 },
{ status: 403 },
{ code: '401' }, // String status codes
{ status: '403' },
];
authErrors.forEach((error) => {
const generator = qwenContentGenerator as unknown as {
isAuthError: (error: unknown) => boolean;
};
expect(generator.isAuthError(error)).toBe(true);
});
// 400 is not typically an auth error, it's bad request
const nonAuthError = { status: 400 };
const generator = qwenContentGenerator as unknown as {
isAuthError: (error: unknown) => boolean;
};
expect(generator.isAuthError(nonAuthError)).toBe(false);
});
it('should identify auth errors by message content variations', () => {
const authMessages = [
'UNAUTHORIZED access',
'Access is FORBIDDEN',
'Invalid API Key provided',
'Invalid Access Token',
'Token has Expired',
'Authentication Required',
'Access Denied by server',
'The token has expired and needs refresh',
'Bearer token expired',
];
authMessages.forEach((message) => {
const error = new Error(message);
const generator = qwenContentGenerator as unknown as {
isAuthError: (error: unknown) => boolean;
};
expect(generator.isAuthError(error)).toBe(true);
});
});
it('should not identify non-auth errors', () => {
const nonAuthErrors = [
new Error('Network timeout'),
new Error('Rate limit exceeded'),
{ status: 500 },
{ code: 429 },
'Internal server error',
null,
undefined,
'',
{ status: 200 },
new Error('Model not found'),
];
nonAuthErrors.forEach((error) => {
const generator = qwenContentGenerator as unknown as {
isAuthError: (error: unknown) => boolean;
};
expect(generator.isAuthError(error)).toBe(false);
});
});
it('should handle complex error objects', () => {
const complexErrors = [
{ error: { status: 401, message: 'Unauthorized' } },
{ response: { status: 403 } },
{ details: { code: 401 } },
];
// These should not be identified as auth errors because the method only looks at top-level properties
complexErrors.forEach((error) => {
const generator = qwenContentGenerator as unknown as {
isAuthError: (error: unknown) => boolean;
};
expect(generator.isAuthError(error)).toBe(false);
});
});
});
describe('Stream Error Handling', () => {
it('should restore credentials when stream generation fails', async () => {
const client = (
qwenContentGenerator as unknown as {
client: { apiKey: string; baseURL: string };
}
).client;
const originalApiKey = client.apiKey;
const originalBaseURL = client.baseURL;
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
token: 'stream-token',
});
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
...mockCredentials,
resource_url: 'https://stream-endpoint.com',
});
// Mock parent method to throw error
const parentPrototype = Object.getPrototypeOf(
Object.getPrototypeOf(qwenContentGenerator),
);
const originalGenerateContentStream =
parentPrototype.generateContentStream;
parentPrototype.generateContentStream = vi
.fn()
.mockRejectedValue(new Error('Stream error'));
const request: GenerateContentParameters = {
model: 'qwen-turbo',
contents: [{ role: 'user', parts: [{ text: 'Stream test' }] }],
};
try {
await qwenContentGenerator.generateContentStream(
request,
'test-prompt-id',
);
} catch (error) {
expect(error).toBeInstanceOf(Error);
}
// Credentials should be restored even on error
expect(client.apiKey).toBe(originalApiKey);
expect(client.baseURL).toBe(originalBaseURL);
// Restore original method
parentPrototype.generateContentStream = originalGenerateContentStream;
});
it('should not restore credentials in finally block for successful streams', async () => {
const client = (
qwenContentGenerator as unknown as {
client: { apiKey: string; baseURL: string };
}
).client;
// Set up the mock to return stream credentials
const streamCredentials = {
access_token: 'stream-token',
refresh_token: 'stream-refresh-token',
resource_url: 'https://stream-endpoint.com',
expiry_date: Date.now() + 3600000,
};
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
token: 'stream-token',
});
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(
streamCredentials,
);
// Set the SharedTokenManager mock to return stream credentials
const mockTokenManager = SharedTokenManager.getInstance() as unknown as {
setMockCredentials: (credentials: QwenCredentials | null) => void;
};
mockTokenManager.setMockCredentials(streamCredentials);
const request: GenerateContentParameters = {
model: 'qwen-turbo',
contents: [{ role: 'user', parts: [{ text: 'Stream test' }] }],
};
const stream = await qwenContentGenerator.generateContentStream(
request,
'test-prompt-id',
);
// After successful stream creation, credentials should still be set for the stream
expect(client.apiKey).toBe('stream-token');
expect(client.baseURL).toBe('https://stream-endpoint.com/v1');
// Consume the stream
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
expect(chunks).toHaveLength(2);
// Clean up
mockTokenManager.setMockCredentials(null);
});
});
describe('Token and Endpoint Management', () => {
it('should get current token from SharedTokenManager', () => {
const mockTokenManager = {
getCurrentCredentials: vi.fn().mockReturnValue({
access_token: 'current-token',
}),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
const newGenerator = new QwenContentGenerator(
mockQwenClient,
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
mockConfig,
);
expect(newGenerator.getCurrentToken()).toBe('current-token');
SharedTokenManager.getInstance = originalGetInstance;
});
it('should return null when no credentials available', () => {
const mockTokenManager = {
getCurrentCredentials: vi.fn().mockReturnValue(null),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
const newGenerator = new QwenContentGenerator(
mockQwenClient,
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
mockConfig,
);
expect(newGenerator.getCurrentToken()).toBeNull();
SharedTokenManager.getInstance = originalGetInstance;
});
it('should return null when credentials have no access token', () => {
const mockTokenManager = {
getCurrentCredentials: vi.fn().mockReturnValue({
access_token: undefined,
}),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
const newGenerator = new QwenContentGenerator(
mockQwenClient,
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
mockConfig,
);
expect(newGenerator.getCurrentToken()).toBeNull();
SharedTokenManager.getInstance = originalGetInstance;
});
it('should clear token through SharedTokenManager', () => {
const mockTokenManager = {
clearCache: vi.fn(),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
const newGenerator = new QwenContentGenerator(
mockQwenClient,
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
mockConfig,
);
newGenerator.clearToken();
expect(mockTokenManager.clearCache).toHaveBeenCalled();
SharedTokenManager.getInstance = originalGetInstance;
});
});
describe('Constructor and Initialization', () => {
it('should initialize with default base URL', () => {
const generator = new QwenContentGenerator(
mockQwenClient,
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
mockConfig,
);
const client = (generator as unknown as { client: { baseURL: string } })
.client;
expect(client.baseURL).toBe(
'https://dashscope.aliyuncs.com/compatible-mode/v1',
);
});
it('should get SharedTokenManager instance', () => {
const generator = new QwenContentGenerator(
mockQwenClient,
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
mockConfig,
);
const sharedManager = (
generator as unknown as { sharedManager: SharedTokenManager }
).sharedManager;
expect(sharedManager).toBeDefined();
});
});
describe('Edge Cases and Error Conditions', () => {
it('should handle token retrieval with warning when SharedTokenManager fails', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('Internal token manager error')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
const newGenerator = new QwenContentGenerator(
mockQwenClient,
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
mockConfig,
);
const request: GenerateContentParameters = {
model: 'qwen-turbo',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
};
await expect(
newGenerator.generateContent(request, 'test-prompt-id'),
).rejects.toThrow('Failed to obtain valid Qwen access token');
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to get token from shared manager:',
expect.any(Error),
);
consoleSpy.mockRestore();
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle all method types with token failure', async () => {
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('Token error')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
const newGenerator = new QwenContentGenerator(
mockQwenClient,
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
mockConfig,
);
const generateRequest: GenerateContentParameters = {
model: 'qwen-turbo',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
};
const countRequest: CountTokensParameters = {
model: 'qwen-turbo',
contents: [{ role: 'user', parts: [{ text: 'Count' }] }],
};
const embedRequest: EmbedContentParameters = {
model: 'qwen-turbo',
contents: [{ parts: [{ text: 'Embed' }] }],
};
// All methods should fail with the same error
await expect(
newGenerator.generateContent(generateRequest, 'test-id'),
).rejects.toThrow('Failed to obtain valid Qwen access token');
await expect(
newGenerator.generateContentStream(generateRequest, 'test-id'),
).rejects.toThrow('Failed to obtain valid Qwen access token');
await expect(newGenerator.countTokens(countRequest)).rejects.toThrow(
'Failed to obtain valid Qwen access token',
);
await expect(newGenerator.embedContent(embedRequest)).rejects.toThrow(
'Failed to obtain valid Qwen access token',
);
SharedTokenManager.getInstance = originalGetInstance;
});
});
});

View File

@@ -5,12 +5,8 @@
*/
import { OpenAIContentGenerator } from '../core/openaiContentGenerator.js';
import {
IQwenOAuth2Client,
type TokenRefreshData,
type ErrorData,
isErrorResponse,
} from './qwenOAuth2.js';
import { IQwenOAuth2Client } from './qwenOAuth2.js';
import { SharedTokenManager } from './sharedTokenManager.js';
import { Config } from '../config/config.js';
import {
GenerateContentParameters,
@@ -31,11 +27,8 @@ const DEFAULT_QWEN_BASE_URL =
*/
export class QwenContentGenerator extends OpenAIContentGenerator {
private qwenClient: IQwenOAuth2Client;
// Token management (integrated from QwenTokenManager)
private currentToken: string | null = null;
private currentEndpoint: string | null = null;
private refreshPromise: Promise<string> | null = null;
private sharedManager: SharedTokenManager;
private currentToken?: string;
constructor(
qwenClient: IQwenOAuth2Client,
@@ -45,6 +38,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
// Initialize with empty API key, we'll override it dynamically
super(contentGeneratorConfig, config);
this.qwenClient = qwenClient;
this.sharedManager = SharedTokenManager.getInstance();
// Set default base URL, will be updated dynamically
this.client.baseURL = DEFAULT_QWEN_BASE_URL;
@@ -53,8 +47,8 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/**
* Get the current endpoint URL with proper protocol and /v1 suffix
*/
private getCurrentEndpoint(): string {
const baseEndpoint = this.currentEndpoint || DEFAULT_QWEN_BASE_URL;
private getCurrentEndpoint(resourceUrl?: string): string {
const baseEndpoint = resourceUrl || DEFAULT_QWEN_BASE_URL;
const suffix = '/v1';
// Normalize the URL: add protocol if missing, ensure /v1 suffix
@@ -79,237 +73,149 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
}
/**
* Override to use dynamic token and endpoint
* Get valid token and endpoint using the shared token manager
*/
override async generateContent(
request: GenerateContentParameters,
userPromptId: string,
): Promise<GenerateContentResponse> {
return this.withValidToken(async (token) => {
// Temporarily update the API key and base URL
const originalApiKey = this.client.apiKey;
const originalBaseURL = this.client.baseURL;
this.client.apiKey = token;
this.client.baseURL = this.getCurrentEndpoint();
private async getValidToken(): Promise<{ token: string; endpoint: string }> {
try {
return await super.generateContent(request, userPromptId);
} finally {
// Restore original values
this.client.apiKey = originalApiKey;
this.client.baseURL = originalBaseURL;
}
});
// Use SharedTokenManager for consistent token/endpoint pairing and automatic refresh
const credentials = await this.sharedManager.getValidCredentials(
this.qwenClient,
);
if (!credentials.access_token) {
throw new Error('No access token available');
}
/**
* Override to use dynamic token and endpoint
*/
override async generateContentStream(
request: GenerateContentParameters,
userPromptId: string,
): Promise<AsyncGenerator<GenerateContentResponse>> {
return this.withValidTokenForStream(async (token) => {
// Update the API key and base URL before streaming
const originalApiKey = this.client.apiKey;
const originalBaseURL = this.client.baseURL;
this.client.apiKey = token;
this.client.baseURL = this.getCurrentEndpoint();
try {
return await super.generateContentStream(request, userPromptId);
return {
token: credentials.access_token,
endpoint: this.getCurrentEndpoint(credentials.resource_url),
};
} catch (error) {
// Restore original values on error
this.client.apiKey = originalApiKey;
this.client.baseURL = originalBaseURL;
throw error;
}
// Note: We don't restore the values in finally for streaming because
// the generator may continue to be used after this method returns
});
}
/**
* Override to use dynamic token and endpoint
*/
override async countTokens(
request: CountTokensParameters,
): Promise<CountTokensResponse> {
return this.withValidToken(async (token) => {
const originalApiKey = this.client.apiKey;
const originalBaseURL = this.client.baseURL;
this.client.apiKey = token;
this.client.baseURL = this.getCurrentEndpoint();
try {
return await super.countTokens(request);
} finally {
this.client.apiKey = originalApiKey;
this.client.baseURL = originalBaseURL;
}
});
}
/**
* Override to use dynamic token and endpoint
*/
override async embedContent(
request: EmbedContentParameters,
): Promise<EmbedContentResponse> {
return this.withValidToken(async (token) => {
const originalApiKey = this.client.apiKey;
const originalBaseURL = this.client.baseURL;
this.client.apiKey = token;
this.client.baseURL = this.getCurrentEndpoint();
try {
return await super.embedContent(request);
} finally {
this.client.apiKey = originalApiKey;
this.client.baseURL = originalBaseURL;
}
});
}
/**
* Execute operation with a valid token, with retry on auth failure
*/
private async withValidToken<T>(
operation: (token: string) => Promise<T>,
): Promise<T> {
const token = await this.getTokenWithRetry();
try {
return await operation(token);
} catch (error) {
// Check if this is an authentication error
// Propagate auth errors as-is for retry logic
if (this.isAuthError(error)) {
// Refresh token and retry once silently
const newToken = await this.refreshToken();
return await operation(newToken);
}
throw error;
}
}
/**
* Execute operation with a valid token for streaming, with retry on auth failure
*/
private async withValidTokenForStream<T>(
operation: (token: string) => Promise<T>,
): Promise<T> {
const token = await this.getTokenWithRetry();
try {
return await operation(token);
} catch (error) {
// Check if this is an authentication error
if (this.isAuthError(error)) {
// Refresh token and retry once silently
const newToken = await this.refreshToken();
return await operation(newToken);
}
throw error;
}
}
/**
* Get token with retry logic
*/
private async getTokenWithRetry(): Promise<string> {
try {
return await this.getValidToken();
} catch (error) {
console.error('Failed to get valid token:', error);
console.warn('Failed to get token from shared manager:', error);
throw new Error(
'Failed to obtain valid Qwen access token. Please re-authenticate.',
);
}
}
// Token management methods (integrated from QwenTokenManager)
/**
* Get a valid access token, refreshing if necessary
* Execute an operation with automatic credential management and retry logic.
* This method handles:
* - Dynamic token and endpoint retrieval
* - Temporary client configuration updates
* - Automatic restoration of original configuration
* - Retry logic on authentication errors with token refresh
*
* @param operation - The operation to execute with updated client configuration
* @param restoreOnCompletion - Whether to restore original config after operation completes
* @returns The result of the operation
*/
private async getValidToken(): Promise<string> {
// If there's already a refresh in progress, wait for it
if (this.refreshPromise) {
return this.refreshPromise;
}
private async executeWithCredentialManagement<T>(
operation: () => Promise<T>,
restoreOnCompletion: boolean = true,
): Promise<T> {
// Attempt the operation with credential management and retry logic
const attemptOperation = async (): Promise<T> => {
const { token, endpoint } = await this.getValidToken();
// Store original configuration
const originalApiKey = this.client.apiKey;
const originalBaseURL = this.client.baseURL;
// Apply dynamic configuration
this.client.apiKey = token;
this.client.baseURL = endpoint;
try {
const { token } = await this.qwenClient.getAccessToken();
if (token) {
this.currentToken = token;
// Also update endpoint from current credentials
const credentials = this.qwenClient.getCredentials();
if (credentials.resource_url) {
this.currentEndpoint = credentials.resource_url;
}
return token;
const result = await operation();
// For streaming operations, we may need to keep the configuration active
if (restoreOnCompletion) {
this.client.apiKey = originalApiKey;
this.client.baseURL = originalBaseURL;
}
return result;
} catch (error) {
console.warn('Failed to get access token, attempting refresh:', error);
// Always restore on error
this.client.apiKey = originalApiKey;
this.client.baseURL = originalBaseURL;
throw error;
}
};
// Start a new refresh operation
this.refreshPromise = this.performTokenRefresh();
// Execute with retry logic for auth errors
try {
const newToken = await this.refreshPromise;
return newToken;
} finally {
this.refreshPromise = null;
return await attemptOperation();
} catch (error) {
if (this.isAuthError(error)) {
try {
// Use SharedTokenManager to properly refresh and persist the token
// This ensures the refreshed token is saved to oauth_creds.json
await this.sharedManager.getValidCredentials(this.qwenClient, true);
// Retry the operation once with fresh credentials
return await attemptOperation();
} catch (_refreshError) {
throw new Error(
'Failed to obtain valid Qwen access token. Please re-authenticate.',
);
}
}
throw error;
}
}
/**
* Force refresh the access token
* Override to use dynamic token and endpoint with automatic retry
*/
private async refreshToken(): Promise<string> {
this.refreshPromise = this.performTokenRefresh();
try {
const newToken = await this.refreshPromise;
return newToken;
} finally {
this.refreshPromise = null;
}
}
private async performTokenRefresh(): Promise<string> {
try {
const response = await this.qwenClient.refreshAccessToken();
if (isErrorResponse(response)) {
const errorData = response as ErrorData;
throw new Error(
`${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`,
override async generateContent(
request: GenerateContentParameters,
userPromptId: string,
): Promise<GenerateContentResponse> {
return this.executeWithCredentialManagement(() =>
super.generateContent(request, userPromptId),
);
}
const tokenData = response as TokenRefreshData;
if (!tokenData.access_token) {
throw new Error('Failed to refresh access token: no token returned');
}
this.currentToken = tokenData.access_token;
// Update endpoint if provided
if (tokenData.resource_url) {
this.currentEndpoint = tokenData.resource_url;
}
return tokenData.access_token;
} catch (error) {
throw new Error(
`${error instanceof Error ? error.message : String(error)}`,
/**
* Override to use dynamic token and endpoint with automatic retry.
* Note: For streaming, the client configuration is not restored immediately
* since the generator may continue to be used after this method returns.
*/
override async generateContentStream(
request: GenerateContentParameters,
userPromptId: string,
): Promise<AsyncGenerator<GenerateContentResponse>> {
return this.executeWithCredentialManagement(
() => super.generateContentStream(request, userPromptId),
false, // Don't restore immediately for streaming
);
}
/**
* Override to use dynamic token and endpoint with automatic retry
*/
override async countTokens(
request: CountTokensParameters,
): Promise<CountTokensResponse> {
return this.executeWithCredentialManagement(() =>
super.countTokens(request),
);
}
/**
* Override to use dynamic token and endpoint with automatic retry
*/
override async embedContent(
request: EmbedContentParameters,
): Promise<EmbedContentResponse> {
return this.executeWithCredentialManagement(() =>
super.embedContent(request),
);
}
/**
@@ -331,9 +237,10 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
const errorCode = errorWithCode?.status || errorWithCode?.code;
return (
errorCode === 400 ||
errorCode === 401 ||
errorCode === 403 ||
errorCode === '401' ||
errorCode === '403' ||
errorMessage.includes('unauthorized') ||
errorMessage.includes('forbidden') ||
errorMessage.includes('invalid api key') ||
@@ -349,15 +256,22 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
* Get the current cached token (may be expired)
*/
getCurrentToken(): string | null {
// First check internal state for backwards compatibility with tests
if (this.currentToken) {
return this.currentToken;
}
// Fall back to SharedTokenManager
const credentials = this.sharedManager.getCurrentCredentials();
return credentials?.access_token || null;
}
/**
* Clear the cached token and endpoint
* Clear the cached token
*/
clearToken(): void {
this.currentToken = null;
this.currentEndpoint = null;
this.refreshPromise = null;
// Clear internal state for backwards compatibility with tests
this.currentToken = undefined;
// Also clear SharedTokenManager
this.sharedManager.clearCache();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,11 @@ import open from 'open';
import { EventEmitter } from 'events';
import { Config } from '../config/config.js';
import { randomUUID } from 'node:crypto';
import {
SharedTokenManager,
TokenManagerError,
TokenError,
} from './sharedTokenManager.js';
// OAuth Endpoints
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
@@ -234,8 +239,11 @@ export interface IQwenOAuth2Client {
*/
export class QwenOAuth2Client implements IQwenOAuth2Client {
private credentials: QwenCredentials = {};
private sharedManager: SharedTokenManager;
constructor(_options?: { proxy?: string }) {}
constructor() {
this.sharedManager = SharedTokenManager.getInstance();
}
setCredentials(credentials: QwenCredentials): void {
this.credentials = credentials;
@@ -246,18 +254,24 @@ export class QwenOAuth2Client implements IQwenOAuth2Client {
}
async getAccessToken(): Promise<{ token?: string }> {
try {
// Use shared manager to get valid credentials with cross-session synchronization
const credentials = await this.sharedManager.getValidCredentials(this);
return { token: credentials.access_token };
} catch (error) {
console.warn('Failed to get access token from shared manager:', error);
// Only return cached token if it's still valid, don't refresh uncoordinated
// This prevents the cross-session token invalidation issue
if (this.credentials.access_token && this.isTokenValid()) {
return { token: this.credentials.access_token };
}
if (this.credentials.refresh_token) {
const refreshResponse = await this.refreshAccessToken();
const tokenData = refreshResponse as TokenRefreshData;
return { token: tokenData.access_token };
}
// If we can't get valid credentials through shared manager, fail gracefully
// All token refresh operations should go through the SharedTokenManager
return { token: undefined };
}
}
async requestDeviceAuthorization(options: {
scope: string;
@@ -289,7 +303,7 @@ export class QwenOAuth2Client implements IQwenOAuth2Client {
}
const result = (await response.json()) as DeviceAuthorizationResponse;
console.log('Device authorization result:', result);
console.debug('Device authorization result:', result);
// Check if the response indicates success
if (!isDeviceAuthorizationSuccess(result)) {
@@ -423,8 +437,8 @@ export class QwenOAuth2Client implements IQwenOAuth2Client {
this.setCredentials(tokens);
// Cache the updated credentials to file
await cacheQwenCredentials(tokens);
// Note: File caching is now handled by SharedTokenManager
// to prevent cross-session token invalidation issues
return responseData;
}
@@ -462,39 +476,55 @@ export const qwenOAuth2Events = new EventEmitter();
export async function getQwenOAuthClient(
config: Config,
): Promise<QwenOAuth2Client> {
const client = new QwenOAuth2Client({
proxy: config.getProxy(),
});
const client = new QwenOAuth2Client();
// If there are cached creds on disk, they always take precedence
if (await loadCachedQwenCredentials(client)) {
console.log('Loaded cached Qwen credentials.');
// Use shared token manager to get valid credentials with cross-session synchronization
const sharedManager = SharedTokenManager.getInstance();
try {
await client.refreshAccessToken();
// Try to get valid credentials from shared cache first
const credentials = await sharedManager.getValidCredentials(client);
client.setCredentials(credentials);
return client;
} catch (error: unknown) {
// Handle refresh token errors
const errorMessage =
error instanceof Error ? error.message : String(error);
const isInvalidToken = errorMessage.includes(
'Refresh token expired or invalid',
console.debug(
'Shared token manager failed, attempting device flow:',
error,
);
const userMessage = isInvalidToken
? 'Cached credentials are invalid. Please re-authenticate.'
: `Token refresh failed: ${errorMessage}`;
const throwMessage = isInvalidToken
? 'Cached Qwen credentials are invalid. Please re-authenticate.'
: `Qwen token refresh failed: ${errorMessage}`;
// Emit token refresh error event
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', userMessage);
throw new Error(throwMessage);
// Handle specific token manager errors
if (error instanceof TokenManagerError) {
switch (error.type) {
case TokenError.NO_REFRESH_TOKEN:
console.debug(
'No refresh token available, proceeding with device flow',
);
break;
case TokenError.REFRESH_FAILED:
console.debug('Token refresh failed, proceeding with device flow');
break;
case TokenError.NETWORK_ERROR:
console.warn(
'Network error during token refresh, trying device flow',
);
break;
default:
console.warn('Token manager error:', (error as Error).message);
}
}
// Use device authorization flow for authentication (single attempt)
// If shared manager fails, check if we have cached credentials for device flow
if (await loadCachedQwenCredentials(client)) {
// We have cached credentials but they might be expired
// Try device flow instead of forcing refresh
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
throw new Error('Qwen OAuth authentication failed');
}
return client;
}
// No cached credentials, use device authorization flow for authentication
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Only emit timeout event if the failure reason is actually timeout
@@ -525,6 +555,7 @@ export async function getQwenOAuthClient(
return client;
}
}
async function authWithQwenDeviceFlow(
client: QwenOAuth2Client,
@@ -580,7 +611,9 @@ async function authWithQwenDeviceFlow(
// causing the entire Node.js process to crash.
if (childProcess) {
childProcess.on('error', () => {
console.log('Failed to open browser. Visit this URL to authorize:');
console.debug(
'Failed to open browser. Visit this URL to authorize:',
);
showFallbackMessage();
});
}
@@ -599,7 +632,7 @@ async function authWithQwenDeviceFlow(
'Waiting for authorization...',
);
console.log('Waiting for authorization...\n');
console.debug('Waiting for authorization...\n');
// Poll for the token
let pollInterval = 2000; // 2 seconds, can be increased if slow_down is received
@@ -610,7 +643,7 @@ async function authWithQwenDeviceFlow(
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Check if authentication was cancelled
if (isCancelled) {
console.log('\nAuthentication cancelled by user.');
console.debug('\nAuthentication cancelled by user.');
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'error',
@@ -620,7 +653,7 @@ async function authWithQwenDeviceFlow(
}
try {
console.log('polling for token...');
console.debug('polling for token...');
const tokenResponse = await client.pollDeviceToken({
device_code: deviceAuth.device_code,
code_verifier,
@@ -653,7 +686,7 @@ async function authWithQwenDeviceFlow(
'Authentication successful! Access token obtained.',
);
console.log('Authentication successful! Access token obtained.');
console.debug('Authentication successful! Access token obtained.');
return { success: true };
}
@@ -664,8 +697,8 @@ async function authWithQwenDeviceFlow(
// Handle slow_down error by increasing poll interval
if (pendingData.slowDown) {
pollInterval = Math.min(pollInterval * 1.5, 10000); // Increase by 50%, max 10 seconds
console.log(
`\nServer requested to slow down, increasing poll interval to ${pollInterval}ms`,
console.debug(
`\nServer requested to slow down, increasing poll interval to ${pollInterval}ms'`,
);
} else {
pollInterval = 2000; // Reset to default interval
@@ -706,7 +739,7 @@ async function authWithQwenDeviceFlow(
// Check for cancellation after waiting
if (isCancelled) {
console.log('\nAuthentication cancelled by user.');
console.debug('\nAuthentication cancelled by user.');
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'error',
@@ -834,7 +867,7 @@ export async function clearQwenCredentials(): Promise<void> {
try {
const filePath = getQwenCachedCredentialPath();
await fs.unlink(filePath);
console.log('Cached Qwen credentials cleared successfully.');
console.debug('Cached Qwen credentials cleared successfully.');
} catch (error: unknown) {
// If file doesn't exist or can't be deleted, we consider it cleared
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {

View File

@@ -0,0 +1,758 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs, unlinkSync, type Stats } from 'node:fs';
import * as os from 'os';
import path from 'node:path';
import {
SharedTokenManager,
TokenManagerError,
TokenError,
} from './sharedTokenManager.js';
import type {
IQwenOAuth2Client,
QwenCredentials,
TokenRefreshData,
ErrorData,
} from './qwenOAuth2.js';
// Mock external dependencies
vi.mock('node:fs', () => ({
promises: {
stat: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
unlink: vi.fn(),
},
unlinkSync: vi.fn(),
}));
vi.mock('node:os', () => ({
homedir: vi.fn(),
}));
vi.mock('node:path', () => ({
default: {
join: vi.fn(),
dirname: vi.fn(),
},
}));
/**
* Helper to access private properties for testing
*/
function getPrivateProperty<T>(obj: unknown, property: string): T {
return (obj as Record<string, T>)[property];
}
/**
* Helper to set private properties for testing
*/
function setPrivateProperty<T>(obj: unknown, property: string, value: T): void {
(obj as Record<string, T>)[property] = value;
}
/**
* Creates a mock QwenOAuth2Client for testing
*/
function createMockQwenClient(
initialCredentials: Partial<QwenCredentials> = {},
): IQwenOAuth2Client {
let credentials: QwenCredentials = {
access_token: 'mock_access_token',
refresh_token: 'mock_refresh_token',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000, // 1 hour from now
resource_url: 'https://api.example.com',
...initialCredentials,
};
return {
setCredentials: vi.fn((creds: QwenCredentials) => {
credentials = { ...credentials, ...creds };
}),
getCredentials: vi.fn(() => credentials),
getAccessToken: vi.fn(),
requestDeviceAuthorization: vi.fn(),
pollDeviceToken: vi.fn(),
refreshAccessToken: vi.fn(),
};
}
/**
* Creates valid mock credentials
*/
function createValidCredentials(
overrides: Partial<QwenCredentials> = {},
): QwenCredentials {
return {
access_token: 'valid_access_token',
refresh_token: 'valid_refresh_token',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000, // 1 hour from now
resource_url: 'https://api.example.com',
...overrides,
};
}
/**
* Creates expired mock credentials
*/
function createExpiredCredentials(
overrides: Partial<QwenCredentials> = {},
): QwenCredentials {
return {
access_token: 'expired_access_token',
refresh_token: 'expired_refresh_token',
token_type: 'Bearer',
expiry_date: Date.now() - 3600000, // 1 hour ago
resource_url: 'https://api.example.com',
...overrides,
};
}
/**
* Creates a successful token refresh response
*/
function createSuccessfulRefreshResponse(
overrides: Partial<TokenRefreshData> = {},
): TokenRefreshData {
return {
access_token: 'fresh_access_token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new_refresh_token',
resource_url: 'https://api.example.com',
...overrides,
};
}
/**
* Creates an error response
*/
function createErrorResponse(
error = 'invalid_grant',
description = 'Token expired',
): ErrorData {
return {
error,
error_description: description,
};
}
describe('SharedTokenManager', () => {
let tokenManager: SharedTokenManager;
// Get mocked modules
const mockFs = vi.mocked(fs);
const mockOs = vi.mocked(os);
const mockPath = vi.mocked(path);
const mockUnlinkSync = vi.mocked(unlinkSync);
beforeEach(() => {
// Clean up any existing instance's listeners first
const existingInstance = getPrivateProperty(
SharedTokenManager,
'instance',
) as SharedTokenManager;
if (existingInstance) {
existingInstance.cleanup();
}
// Reset all mocks
vi.clearAllMocks();
// Setup default mock implementations
mockOs.homedir.mockReturnValue('/home/user');
mockPath.join.mockImplementation((...args) => args.join('/'));
mockPath.dirname.mockImplementation((filePath) => {
// Handle undefined/null input gracefully
if (!filePath || typeof filePath !== 'string') {
return '/home/user/.qwen'; // Return the expected directory path
}
const parts = filePath.split('/');
const result = parts.slice(0, -1).join('/');
return result || '/';
});
// Reset singleton instance for each test
setPrivateProperty(SharedTokenManager, 'instance', null);
tokenManager = SharedTokenManager.getInstance();
});
afterEach(() => {
// Clean up listeners after each test
if (tokenManager) {
tokenManager.cleanup();
}
});
describe('Singleton Pattern', () => {
it('should return the same instance when called multiple times', () => {
const instance1 = SharedTokenManager.getInstance();
const instance2 = SharedTokenManager.getInstance();
expect(instance1).toBe(instance2);
expect(instance1).toBe(tokenManager);
});
it('should create a new instance after reset', () => {
const instance1 = SharedTokenManager.getInstance();
// Reset singleton for testing
setPrivateProperty(SharedTokenManager, 'instance', null);
const instance2 = SharedTokenManager.getInstance();
expect(instance1).not.toBe(instance2);
});
});
describe('getValidCredentials', () => {
it('should return valid cached credentials without refresh', async () => {
const mockClient = createMockQwenClient();
const validCredentials = createValidCredentials();
// Mock file operations to indicate no file changes
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
// Manually set cached credentials
tokenManager.clearCache();
const memoryCache = getPrivateProperty<{
credentials: QwenCredentials | null;
fileModTime: number;
lastCheck: number;
}>(tokenManager, 'memoryCache');
memoryCache.credentials = validCredentials;
memoryCache.fileModTime = 1000;
memoryCache.lastCheck = Date.now();
const result = await tokenManager.getValidCredentials(mockClient);
expect(result).toEqual(validCredentials);
expect(mockClient.refreshAccessToken).not.toHaveBeenCalled();
});
it('should refresh expired credentials', async () => {
const mockClient = createMockQwenClient(createExpiredCredentials());
const refreshResponse = createSuccessfulRefreshResponse();
mockClient.refreshAccessToken = vi
.fn()
.mockResolvedValue(refreshResponse);
// Mock file operations
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
mockFs.writeFile.mockResolvedValue(undefined);
mockFs.mkdir.mockResolvedValue(undefined);
const result = await tokenManager.getValidCredentials(mockClient);
expect(result.access_token).toBe(refreshResponse.access_token);
expect(mockClient.refreshAccessToken).toHaveBeenCalled();
expect(mockClient.setCredentials).toHaveBeenCalled();
});
it('should force refresh when forceRefresh is true', async () => {
const mockClient = createMockQwenClient(createValidCredentials());
const refreshResponse = createSuccessfulRefreshResponse();
mockClient.refreshAccessToken = vi
.fn()
.mockResolvedValue(refreshResponse);
// Mock file operations
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
mockFs.writeFile.mockResolvedValue(undefined);
mockFs.mkdir.mockResolvedValue(undefined);
const result = await tokenManager.getValidCredentials(mockClient, true);
expect(result.access_token).toBe(refreshResponse.access_token);
expect(mockClient.refreshAccessToken).toHaveBeenCalled();
});
it('should throw TokenManagerError when refresh token is missing', async () => {
const mockClient = createMockQwenClient({
access_token: 'expired_token',
refresh_token: undefined, // No refresh token
expiry_date: Date.now() - 3600000,
});
await expect(
tokenManager.getValidCredentials(mockClient),
).rejects.toThrow(TokenManagerError);
await expect(
tokenManager.getValidCredentials(mockClient),
).rejects.toThrow('No refresh token available');
});
it('should throw TokenManagerError when refresh fails', async () => {
const mockClient = createMockQwenClient(createExpiredCredentials());
const errorResponse = createErrorResponse();
mockClient.refreshAccessToken = vi.fn().mockResolvedValue(errorResponse);
// Mock file operations
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
await expect(
tokenManager.getValidCredentials(mockClient),
).rejects.toThrow(TokenManagerError);
});
it('should handle network errors during refresh', async () => {
const mockClient = createMockQwenClient(createExpiredCredentials());
const networkError = new Error('Network request failed');
mockClient.refreshAccessToken = vi.fn().mockRejectedValue(networkError);
// Mock file operations
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
await expect(
tokenManager.getValidCredentials(mockClient),
).rejects.toThrow(TokenManagerError);
});
it('should wait for ongoing refresh and return same result', async () => {
const mockClient = createMockQwenClient(createExpiredCredentials());
const refreshResponse = createSuccessfulRefreshResponse();
// Create a delayed refresh response
let resolveRefresh: (value: TokenRefreshData) => void;
const refreshPromise = new Promise<TokenRefreshData>((resolve) => {
resolveRefresh = resolve;
});
mockClient.refreshAccessToken = vi.fn().mockReturnValue(refreshPromise);
// Mock file operations
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
mockFs.writeFile.mockResolvedValue(undefined);
mockFs.mkdir.mockResolvedValue(undefined);
// Start two concurrent refresh operations
const promise1 = tokenManager.getValidCredentials(mockClient);
const promise2 = tokenManager.getValidCredentials(mockClient);
// Resolve the refresh
resolveRefresh!(refreshResponse);
const [result1, result2] = await Promise.all([promise1, promise2]);
expect(result1).toEqual(result2);
expect(mockClient.refreshAccessToken).toHaveBeenCalledTimes(1);
});
it('should reload credentials from file when file is modified', async () => {
const mockClient = createMockQwenClient();
const fileCredentials = createValidCredentials({
access_token: 'file_access_token',
});
// Mock file operations to simulate file modification
mockFs.stat.mockResolvedValue({ mtimeMs: 2000 } as Stats);
mockFs.readFile.mockResolvedValue(JSON.stringify(fileCredentials));
// Set initial cache state
tokenManager.clearCache();
const memoryCache = getPrivateProperty<{ fileModTime: number }>(
tokenManager,
'memoryCache',
);
memoryCache.fileModTime = 1000; // Older than file
const result = await tokenManager.getValidCredentials(mockClient);
expect(result.access_token).toBe('file_access_token');
expect(mockFs.readFile).toHaveBeenCalled();
});
});
describe('Cache Management', () => {
it('should clear cache', () => {
// Set some cache data
tokenManager.clearCache();
const memoryCache = getPrivateProperty<{
credentials: QwenCredentials | null;
}>(tokenManager, 'memoryCache');
memoryCache.credentials = createValidCredentials();
tokenManager.clearCache();
expect(tokenManager.getCurrentCredentials()).toBeNull();
});
it('should return current credentials from cache', () => {
const credentials = createValidCredentials();
tokenManager.clearCache();
const memoryCache = getPrivateProperty<{
credentials: QwenCredentials | null;
}>(tokenManager, 'memoryCache');
memoryCache.credentials = credentials;
expect(tokenManager.getCurrentCredentials()).toEqual(credentials);
});
it('should return null when no credentials are cached', () => {
tokenManager.clearCache();
expect(tokenManager.getCurrentCredentials()).toBeNull();
});
});
describe('Refresh Status', () => {
it('should return false when no refresh is in progress', () => {
expect(tokenManager.isRefreshInProgress()).toBe(false);
});
it('should return true when refresh is in progress', async () => {
const mockClient = createMockQwenClient(createExpiredCredentials());
// Clear cache to ensure refresh is triggered
tokenManager.clearCache();
// Mock stat for file check to fail (no file initially)
mockFs.stat.mockRejectedValueOnce(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
);
// Create a delayed refresh response
let resolveRefresh: (value: TokenRefreshData) => void;
const refreshPromise = new Promise<TokenRefreshData>((resolve) => {
resolveRefresh = resolve;
});
mockClient.refreshAccessToken = vi.fn().mockReturnValue(refreshPromise);
// Mock file operations for lock and save
mockFs.writeFile.mockResolvedValue(undefined);
mockFs.mkdir.mockResolvedValue(undefined);
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
// Start refresh
const refreshOperation = tokenManager.getValidCredentials(mockClient);
// Wait a tick to ensure the refresh promise is set
await new Promise((resolve) => setImmediate(resolve));
expect(tokenManager.isRefreshInProgress()).toBe(true);
// Complete refresh
resolveRefresh!(createSuccessfulRefreshResponse());
await refreshOperation;
expect(tokenManager.isRefreshInProgress()).toBe(false);
});
});
describe('Debug Info', () => {
it('should return complete debug information', () => {
const credentials = createValidCredentials();
tokenManager.clearCache();
const memoryCache = getPrivateProperty<{
credentials: QwenCredentials | null;
}>(tokenManager, 'memoryCache');
memoryCache.credentials = credentials;
const debugInfo = tokenManager.getDebugInfo();
expect(debugInfo).toHaveProperty('hasCredentials', true);
expect(debugInfo).toHaveProperty('credentialsExpired', false);
expect(debugInfo).toHaveProperty('isRefreshing', false);
expect(debugInfo).toHaveProperty('cacheAge');
expect(typeof debugInfo.cacheAge).toBe('number');
});
it('should indicate expired credentials in debug info', () => {
const expiredCredentials = createExpiredCredentials();
tokenManager.clearCache();
const memoryCache = getPrivateProperty<{
credentials: QwenCredentials | null;
}>(tokenManager, 'memoryCache');
memoryCache.credentials = expiredCredentials;
const debugInfo = tokenManager.getDebugInfo();
expect(debugInfo.hasCredentials).toBe(true);
expect(debugInfo.credentialsExpired).toBe(true);
});
it('should indicate no credentials in debug info', () => {
tokenManager.clearCache();
const debugInfo = tokenManager.getDebugInfo();
expect(debugInfo.hasCredentials).toBe(false);
expect(debugInfo.credentialsExpired).toBe(false);
});
});
describe('Error Handling', () => {
it('should create TokenManagerError with correct type and message', () => {
const error = new TokenManagerError(
TokenError.REFRESH_FAILED,
'Token refresh failed',
new Error('Original error'),
);
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(TokenManagerError);
expect(error.type).toBe(TokenError.REFRESH_FAILED);
expect(error.message).toBe('Token refresh failed');
expect(error.name).toBe('TokenManagerError');
expect(error.originalError).toBeInstanceOf(Error);
});
it('should handle file access errors gracefully', async () => {
const mockClient = createMockQwenClient(createExpiredCredentials());
// Mock file stat to throw access error
const accessError = new Error(
'Permission denied',
) as NodeJS.ErrnoException;
accessError.code = 'EACCES';
mockFs.stat.mockRejectedValue(accessError);
await expect(
tokenManager.getValidCredentials(mockClient),
).rejects.toThrow(TokenManagerError);
});
it('should handle missing file gracefully', async () => {
const mockClient = createMockQwenClient();
const validCredentials = createValidCredentials();
// Mock file stat to throw file not found error
const notFoundError = new Error(
'File not found',
) as NodeJS.ErrnoException;
notFoundError.code = 'ENOENT';
mockFs.stat.mockRejectedValue(notFoundError);
// Set valid credentials in cache
const memoryCache = getPrivateProperty<{
credentials: QwenCredentials | null;
}>(tokenManager, 'memoryCache');
memoryCache.credentials = validCredentials;
const result = await tokenManager.getValidCredentials(mockClient);
expect(result).toEqual(validCredentials);
});
it('should handle lock timeout scenarios', async () => {
const mockClient = createMockQwenClient(createExpiredCredentials());
// Configure shorter timeouts for testing
tokenManager.setLockConfig({
maxAttempts: 3,
attemptInterval: 50,
});
// Mock stat for file check to pass (no file initially)
mockFs.stat.mockRejectedValueOnce(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
);
// Mock writeFile to always throw EEXIST for lock file writes (flag: 'wx')
// but succeed for regular file writes
const lockError = new Error('File exists') as NodeJS.ErrnoException;
lockError.code = 'EEXIST';
mockFs.writeFile.mockImplementation((path, data, options) => {
if (typeof options === 'object' && options?.flag === 'wx') {
return Promise.reject(lockError);
}
return Promise.resolve(undefined);
});
// Mock stat to return recent lock file (not stale) when checking lock age
mockFs.stat.mockResolvedValue({ mtimeMs: Date.now() } as Stats);
// Mock unlink to simulate lock file removal attempts
mockFs.unlink.mockResolvedValue(undefined);
await expect(
tokenManager.getValidCredentials(mockClient),
).rejects.toThrow(TokenManagerError);
}, 500); // 500ms timeout for lock test (3 attempts × 50ms = ~150ms + buffer)
it('should handle refresh response without access token', async () => {
const mockClient = createMockQwenClient(createExpiredCredentials());
const invalidResponse = {
token_type: 'Bearer',
expires_in: 3600,
// access_token is missing, so we use undefined explicitly
access_token: undefined,
} as Partial<TokenRefreshData>;
mockClient.refreshAccessToken = vi
.fn()
.mockResolvedValue(invalidResponse);
// Mock stat for file check to pass (no file initially)
mockFs.stat.mockRejectedValueOnce(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
);
// Mock file operations for lock acquisition
mockFs.writeFile.mockResolvedValue(undefined);
mockFs.mkdir.mockResolvedValue(undefined);
// Clear cache to force refresh
tokenManager.clearCache();
await expect(
tokenManager.getValidCredentials(mockClient),
).rejects.toThrow(TokenManagerError);
await expect(
tokenManager.getValidCredentials(mockClient),
).rejects.toThrow('no token returned');
});
});
describe('File System Operations', () => {
it('should handle file reload failures gracefully', async () => {
const mockClient = createMockQwenClient();
// Mock successful refresh for when cache is cleared
mockClient.refreshAccessToken = vi
.fn()
.mockResolvedValue(createSuccessfulRefreshResponse());
// Mock file operations
mockFs.stat
.mockResolvedValueOnce({ mtimeMs: 2000 } as Stats) // For checkAndReloadIfNeeded
.mockResolvedValue({ mtimeMs: 1000 } as Stats); // For later operations
mockFs.readFile.mockRejectedValue(new Error('Read failed'));
mockFs.writeFile.mockResolvedValue(undefined);
mockFs.mkdir.mockResolvedValue(undefined);
// Set initial cache state to trigger reload
tokenManager.clearCache();
const memoryCache = getPrivateProperty<{ fileModTime: number }>(
tokenManager,
'memoryCache',
);
memoryCache.fileModTime = 1000;
// Should not throw error, should refresh and get new credentials
const result = await tokenManager.getValidCredentials(mockClient);
expect(result).toBeDefined();
expect(result.access_token).toBe('fresh_access_token');
});
it('should handle invalid JSON in credentials file', async () => {
const mockClient = createMockQwenClient();
// Mock successful refresh for when cache is cleared
mockClient.refreshAccessToken = vi
.fn()
.mockResolvedValue(createSuccessfulRefreshResponse());
// Mock file operations with invalid JSON
mockFs.stat
.mockResolvedValueOnce({ mtimeMs: 2000 } as Stats) // For checkAndReloadIfNeeded
.mockResolvedValue({ mtimeMs: 1000 } as Stats); // For later operations
mockFs.readFile.mockResolvedValue('invalid json content');
mockFs.writeFile.mockResolvedValue(undefined);
mockFs.mkdir.mockResolvedValue(undefined);
// Set initial cache state to trigger reload
tokenManager.clearCache();
const memoryCache = getPrivateProperty<{ fileModTime: number }>(
tokenManager,
'memoryCache',
);
memoryCache.fileModTime = 1000;
// Should handle JSON parse error gracefully, then refresh and get new credentials
const result = await tokenManager.getValidCredentials(mockClient);
expect(result).toBeDefined();
expect(result.access_token).toBe('fresh_access_token');
});
it('should handle directory creation during save', async () => {
const mockClient = createMockQwenClient(createExpiredCredentials());
const refreshResponse = createSuccessfulRefreshResponse();
mockClient.refreshAccessToken = vi
.fn()
.mockResolvedValue(refreshResponse);
// Mock file operations
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
mockFs.writeFile.mockResolvedValue(undefined);
mockFs.mkdir.mockResolvedValue(undefined);
await tokenManager.getValidCredentials(mockClient);
expect(mockFs.mkdir).toHaveBeenCalledWith(expect.any(String), {
recursive: true,
mode: 0o700,
});
expect(mockFs.writeFile).toHaveBeenCalled();
});
});
describe('Lock File Management', () => {
it('should clean up lock file during process cleanup', () => {
// Create a new instance to trigger cleanup handler registration
SharedTokenManager.getInstance();
// Access the private cleanup method for testing
const cleanupHandlers = process.listeners('exit');
const cleanup = cleanupHandlers[cleanupHandlers.length - 1] as () => void;
// Should not throw when lock file doesn't exist
expect(() => cleanup()).not.toThrow();
expect(mockUnlinkSync).toHaveBeenCalled();
});
it('should handle stale lock cleanup', async () => {
const mockClient = createMockQwenClient(createExpiredCredentials());
const refreshResponse = createSuccessfulRefreshResponse();
mockClient.refreshAccessToken = vi
.fn()
.mockResolvedValue(refreshResponse);
// First writeFile call throws EEXIST (lock exists)
// Second writeFile call succeeds (after stale lock cleanup)
const lockError = new Error('File exists') as NodeJS.ErrnoException;
lockError.code = 'EEXIST';
mockFs.writeFile
.mockRejectedValueOnce(lockError)
.mockResolvedValue(undefined);
// Mock stat to return stale lock (old timestamp)
mockFs.stat
.mockResolvedValueOnce({ mtimeMs: Date.now() - 20000 } as Stats) // Stale lock
.mockResolvedValueOnce({ mtimeMs: 1000 } as Stats); // Credentials file
// Mock unlink to succeed
mockFs.unlink.mockResolvedValue(undefined);
mockFs.mkdir.mockResolvedValue(undefined);
const result = await tokenManager.getValidCredentials(mockClient);
expect(result.access_token).toBe(refreshResponse.access_token);
expect(mockFs.unlink).toHaveBeenCalled(); // Stale lock removed
});
});
});

View File

@@ -0,0 +1,662 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { promises as fs, unlinkSync } from 'node:fs';
import * as os from 'os';
import { randomUUID } from 'node:crypto';
import {
IQwenOAuth2Client,
type QwenCredentials,
type TokenRefreshData,
type ErrorData,
isErrorResponse,
} from './qwenOAuth2.js';
// File System Configuration
const QWEN_DIR = '.qwen';
const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json';
const QWEN_LOCK_FILENAME = 'oauth_creds.lock';
// Token and Cache Configuration
const TOKEN_REFRESH_BUFFER_MS = 30 * 1000; // 30 seconds
const LOCK_TIMEOUT_MS = 10000; // 10 seconds lock timeout
const CACHE_CHECK_INTERVAL_MS = 1000; // 1 second cache check interval
// Lock acquisition configuration (can be overridden for testing)
interface LockConfig {
maxAttempts: number;
attemptInterval: number;
}
const DEFAULT_LOCK_CONFIG: LockConfig = {
maxAttempts: 50,
attemptInterval: 200,
};
/**
* Token manager error types for better error classification
*/
export enum TokenError {
REFRESH_FAILED = 'REFRESH_FAILED',
NO_REFRESH_TOKEN = 'NO_REFRESH_TOKEN',
LOCK_TIMEOUT = 'LOCK_TIMEOUT',
FILE_ACCESS_ERROR = 'FILE_ACCESS_ERROR',
NETWORK_ERROR = 'NETWORK_ERROR',
}
/**
* Custom error class for token manager operations
*/
export class TokenManagerError extends Error {
constructor(
public type: TokenError,
message: string,
public originalError?: unknown,
) {
super(message);
this.name = 'TokenManagerError';
}
}
/**
* Interface for the memory cache state
*/
interface MemoryCache {
credentials: QwenCredentials | null;
fileModTime: number;
lastCheck: number;
}
/**
* Validates that the given data is a valid QwenCredentials object
*
* @param data - The data to validate
* @returns The validated credentials object
* @throws Error if the data is invalid
*/
function validateCredentials(data: unknown): QwenCredentials {
if (!data || typeof data !== 'object') {
throw new Error('Invalid credentials format');
}
const creds = data as Partial<QwenCredentials>;
const requiredFields = [
'access_token',
'refresh_token',
'token_type',
] as const;
// Check required string fields
for (const field of requiredFields) {
if (!creds[field] || typeof creds[field] !== 'string') {
throw new Error(`Invalid credentials: missing ${field}`);
}
}
// Check expiry_date
if (!creds.expiry_date || typeof creds.expiry_date !== 'number') {
throw new Error('Invalid credentials: missing expiry_date');
}
return creds as QwenCredentials;
}
/**
* Manages OAuth tokens across multiple processes using file-based caching and locking
*/
export class SharedTokenManager {
private static instance: SharedTokenManager | null = null;
/**
* In-memory cache for credentials and file state tracking
*/
private memoryCache: MemoryCache = {
credentials: null,
fileModTime: 0,
lastCheck: 0,
};
/**
* Promise tracking any ongoing token refresh operation
*/
private refreshPromise: Promise<QwenCredentials> | null = null;
/**
* Whether cleanup handlers have been registered
*/
private cleanupHandlersRegistered = false;
/**
* Reference to cleanup functions for proper removal
*/
private cleanupFunction: (() => void) | null = null;
/**
* Lock configuration for testing purposes
*/
private lockConfig: LockConfig = DEFAULT_LOCK_CONFIG;
/**
* Private constructor for singleton pattern
*/
private constructor() {
this.registerCleanupHandlers();
}
/**
* Get the singleton instance
* @returns The shared token manager instance
*/
static getInstance(): SharedTokenManager {
if (!SharedTokenManager.instance) {
SharedTokenManager.instance = new SharedTokenManager();
}
return SharedTokenManager.instance;
}
/**
* Set up handlers to clean up lock files when the process exits
*/
private registerCleanupHandlers(): void {
if (this.cleanupHandlersRegistered) {
return;
}
this.cleanupFunction = () => {
try {
const lockPath = this.getLockFilePath();
// Use synchronous unlink for process exit handlers
unlinkSync(lockPath);
} catch (_error) {
// Ignore cleanup errors - lock file might not exist or already be cleaned up
}
};
process.on('exit', this.cleanupFunction);
process.on('SIGINT', this.cleanupFunction);
process.on('SIGTERM', this.cleanupFunction);
process.on('uncaughtException', this.cleanupFunction);
process.on('unhandledRejection', this.cleanupFunction);
this.cleanupHandlersRegistered = true;
}
/**
* Get valid OAuth credentials, refreshing them if necessary
*
* @param qwenClient - The OAuth2 client instance
* @param forceRefresh - If true, refresh token even if current one is still valid
* @returns Promise resolving to valid credentials
* @throws TokenManagerError if unable to obtain valid credentials
*/
async getValidCredentials(
qwenClient: IQwenOAuth2Client,
forceRefresh = false,
): Promise<QwenCredentials> {
try {
// Check if credentials file has been updated by other sessions
await this.checkAndReloadIfNeeded();
// Return valid cached credentials if available (unless force refresh is requested)
if (
!forceRefresh &&
this.memoryCache.credentials &&
this.isTokenValid(this.memoryCache.credentials)
) {
return this.memoryCache.credentials;
}
// If refresh is already in progress, wait for it to complete
if (this.refreshPromise) {
return this.refreshPromise;
}
// Start new refresh operation with distributed locking
this.refreshPromise = this.performTokenRefresh(qwenClient, forceRefresh);
try {
const credentials = await this.refreshPromise;
return credentials;
} catch (error) {
// Ensure refreshPromise is cleared on error before re-throwing
this.refreshPromise = null;
throw error;
} finally {
this.refreshPromise = null;
}
} catch (error) {
// Convert generic errors to TokenManagerError for better error handling
if (error instanceof TokenManagerError) {
throw error;
}
throw new TokenManagerError(
TokenError.REFRESH_FAILED,
`Failed to get valid credentials: ${error instanceof Error ? error.message : String(error)}`,
error,
);
}
}
/**
* Check if the credentials file was updated by another process and reload if so
*/
private async checkAndReloadIfNeeded(): Promise<void> {
const now = Date.now();
// Limit check frequency to avoid excessive disk I/O
if (now - this.memoryCache.lastCheck < CACHE_CHECK_INTERVAL_MS) {
return;
}
this.memoryCache.lastCheck = now;
try {
const filePath = this.getCredentialFilePath();
const stats = await fs.stat(filePath);
const fileModTime = stats.mtimeMs;
// Reload credentials if file has been modified since last cache
if (fileModTime > this.memoryCache.fileModTime) {
await this.reloadCredentialsFromFile();
this.memoryCache.fileModTime = fileModTime;
}
} catch (error) {
// Handle file access errors
if (
error instanceof Error &&
'code' in error &&
error.code !== 'ENOENT'
) {
// Clear cache for non-missing file errors
this.memoryCache.credentials = null;
this.memoryCache.fileModTime = 0;
throw new TokenManagerError(
TokenError.FILE_ACCESS_ERROR,
`Failed to access credentials file: ${error.message}`,
error,
);
}
// For missing files (ENOENT), just reset file modification time
// but keep existing valid credentials in memory if they exist
this.memoryCache.fileModTime = 0;
}
}
/**
* Load credentials from the file system into memory cache
*/
private async reloadCredentialsFromFile(): Promise<void> {
try {
const filePath = this.getCredentialFilePath();
const content = await fs.readFile(filePath, 'utf-8');
const parsedData = JSON.parse(content);
const credentials = validateCredentials(parsedData);
this.memoryCache.credentials = credentials;
} catch (error) {
// Log validation errors for debugging but don't throw
if (
error instanceof Error &&
error.message.includes('Invalid credentials')
) {
console.warn(`Failed to validate credentials file: ${error.message}`);
}
this.memoryCache.credentials = null;
}
}
/**
* Refresh the OAuth token using file locking to prevent concurrent refreshes
*
* @param qwenClient - The OAuth2 client instance
* @param forceRefresh - If true, skip checking if token is already valid after getting lock
* @returns Promise resolving to refreshed credentials
* @throws TokenManagerError if refresh fails or lock cannot be acquired
*/
private async performTokenRefresh(
qwenClient: IQwenOAuth2Client,
forceRefresh = false,
): Promise<QwenCredentials> {
const lockPath = this.getLockFilePath();
try {
// Check if we have a refresh token before attempting refresh
const currentCredentials = qwenClient.getCredentials();
if (!currentCredentials.refresh_token) {
throw new TokenManagerError(
TokenError.NO_REFRESH_TOKEN,
'No refresh token available for token refresh',
);
}
// Acquire distributed file lock
await this.acquireLock(lockPath);
// Double-check if another process already refreshed the token (unless force refresh is requested)
await this.checkAndReloadIfNeeded();
// Use refreshed credentials if they're now valid (unless force refresh is requested)
if (
!forceRefresh &&
this.memoryCache.credentials &&
this.isTokenValid(this.memoryCache.credentials)
) {
qwenClient.setCredentials(this.memoryCache.credentials);
return this.memoryCache.credentials;
}
// Perform the actual token refresh
const response = await qwenClient.refreshAccessToken();
if (!response || isErrorResponse(response)) {
const errorData = response as ErrorData;
throw new TokenManagerError(
TokenError.REFRESH_FAILED,
`Token refresh failed: ${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`,
);
}
const tokenData = response as TokenRefreshData;
if (!tokenData.access_token) {
throw new TokenManagerError(
TokenError.REFRESH_FAILED,
'Failed to refresh access token: no token returned',
);
}
// Create updated credentials object
const credentials: QwenCredentials = {
access_token: tokenData.access_token,
token_type: tokenData.token_type,
refresh_token:
tokenData.refresh_token || currentCredentials.refresh_token,
resource_url: tokenData.resource_url,
expiry_date: Date.now() + tokenData.expires_in * 1000,
};
// Update memory cache and client credentials
this.memoryCache.credentials = credentials;
qwenClient.setCredentials(credentials);
// Persist to file and update modification time
await this.saveCredentialsToFile(credentials);
return credentials;
} catch (error) {
if (error instanceof TokenManagerError) {
throw error;
}
// Handle network-related errors
if (
error instanceof Error &&
(error.message.includes('fetch') ||
error.message.includes('network') ||
error.message.includes('timeout'))
) {
throw new TokenManagerError(
TokenError.NETWORK_ERROR,
`Network error during token refresh: ${error.message}`,
error,
);
}
throw new TokenManagerError(
TokenError.REFRESH_FAILED,
`Unexpected error during token refresh: ${error instanceof Error ? error.message : String(error)}`,
error,
);
} finally {
// Always release the file lock
await this.releaseLock(lockPath);
}
}
/**
* Save credentials to file and update the cached file modification time
*
* @param credentials - The credentials to save
*/
private async saveCredentialsToFile(
credentials: QwenCredentials,
): Promise<void> {
const filePath = this.getCredentialFilePath();
const dirPath = path.dirname(filePath);
// Create directory with restricted permissions
try {
await fs.mkdir(dirPath, { recursive: true, mode: 0o700 });
} catch (error) {
throw new TokenManagerError(
TokenError.FILE_ACCESS_ERROR,
`Failed to create credentials directory: ${error instanceof Error ? error.message : String(error)}`,
error,
);
}
const credString = JSON.stringify(credentials, null, 2);
try {
// Write file with restricted permissions (owner read/write only)
await fs.writeFile(filePath, credString, { mode: 0o600 });
} catch (error) {
throw new TokenManagerError(
TokenError.FILE_ACCESS_ERROR,
`Failed to write credentials file: ${error instanceof Error ? error.message : String(error)}`,
error,
);
}
// Update cached file modification time to avoid unnecessary reloads
try {
const stats = await fs.stat(filePath);
this.memoryCache.fileModTime = stats.mtimeMs;
} catch (error) {
// Non-fatal error, just log it
console.warn(
`Failed to update file modification time: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Check if the token is valid and not expired
*
* @param credentials - The credentials to validate
* @returns true if token is valid and not expired, false otherwise
*/
private isTokenValid(credentials: QwenCredentials): boolean {
if (!credentials.expiry_date || !credentials.access_token) {
return false;
}
return Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS;
}
/**
* Get the full path to the credentials file
*
* @returns The absolute path to the credentials file
*/
private getCredentialFilePath(): string {
return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME);
}
/**
* Get the full path to the lock file
*
* @returns The absolute path to the lock file
*/
private getLockFilePath(): string {
return path.join(os.homedir(), QWEN_DIR, QWEN_LOCK_FILENAME);
}
/**
* Acquire a file lock to prevent other processes from refreshing tokens simultaneously
*
* @param lockPath - Path to the lock file
* @throws TokenManagerError if lock cannot be acquired within timeout period
*/
private async acquireLock(lockPath: string): Promise<void> {
const { maxAttempts, attemptInterval } = this.lockConfig;
const lockId = randomUUID(); // Use random UUID instead of PID for security
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
// Attempt to create lock file atomically (exclusive mode)
await fs.writeFile(lockPath, lockId, { flag: 'wx' });
return; // Successfully acquired lock
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
// Lock file already exists, check if it's stale
try {
const stats = await fs.stat(lockPath);
const lockAge = Date.now() - stats.mtimeMs;
// Remove stale locks that exceed timeout
if (lockAge > LOCK_TIMEOUT_MS) {
try {
await fs.unlink(lockPath);
console.warn(
`Removed stale lock file: ${lockPath} (age: ${lockAge}ms)`,
);
continue; // Retry lock acquisition
} catch (unlinkError) {
// Log the error but continue trying - another process might have removed it
console.warn(
`Failed to remove stale lock file ${lockPath}: ${unlinkError instanceof Error ? unlinkError.message : String(unlinkError)}`,
);
// Still continue - the lock might have been removed by another process
}
}
} catch (statError) {
// Can't stat lock file, it might have been removed, continue trying
console.warn(
`Failed to stat lock file ${lockPath}: ${statError instanceof Error ? statError.message : String(statError)}`,
);
}
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, attemptInterval));
} else {
throw new TokenManagerError(
TokenError.FILE_ACCESS_ERROR,
`Failed to create lock file: ${error instanceof Error ? error.message : String(error)}`,
error,
);
}
}
}
throw new TokenManagerError(
TokenError.LOCK_TIMEOUT,
'Failed to acquire file lock for token refresh: timeout exceeded',
);
}
/**
* Release the file lock
*
* @param lockPath - Path to the lock file
*/
private async releaseLock(lockPath: string): Promise<void> {
try {
await fs.unlink(lockPath);
} catch (error) {
// Lock file might already be removed by another process or timeout cleanup
// This is not an error condition, but log for debugging
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn(
`Failed to release lock file ${lockPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
/**
* Clear all cached data and reset the manager to initial state
*/
clearCache(): void {
this.memoryCache = {
credentials: null,
fileModTime: 0,
lastCheck: 0,
};
this.refreshPromise = null;
}
/**
* Get the current cached credentials (may be expired)
*
* @returns The currently cached credentials or null
*/
getCurrentCredentials(): QwenCredentials | null {
return this.memoryCache.credentials;
}
/**
* Check if there's an ongoing refresh operation
*
* @returns true if refresh is in progress, false otherwise
*/
isRefreshInProgress(): boolean {
return this.refreshPromise !== null;
}
/**
* Set lock configuration for testing purposes
* @param config - Lock configuration
*/
setLockConfig(config: Partial<LockConfig>): void {
this.lockConfig = { ...DEFAULT_LOCK_CONFIG, ...config };
}
/**
* Clean up event listeners (primarily for testing)
*/
cleanup(): void {
if (this.cleanupFunction && this.cleanupHandlersRegistered) {
this.cleanupFunction();
process.removeListener('exit', this.cleanupFunction);
process.removeListener('SIGINT', this.cleanupFunction);
process.removeListener('SIGTERM', this.cleanupFunction);
process.removeListener('uncaughtException', this.cleanupFunction);
process.removeListener('unhandledRejection', this.cleanupFunction);
this.cleanupHandlersRegistered = false;
this.cleanupFunction = null;
}
}
/**
* Get a summary of the current state for debugging
*
* @returns Object containing current state information
*/
getDebugInfo(): {
hasCredentials: boolean;
credentialsExpired: boolean;
isRefreshing: boolean;
cacheAge: number;
} {
const hasCredentials = !!this.memoryCache.credentials;
const credentialsExpired = hasCredentials
? !this.isTokenValid(this.memoryCache.credentials!)
: false;
return {
hasCredentials,
credentialsExpired,
isRefreshing: this.isRefreshInProgress(),
cacheAge: Date.now() - this.memoryCache.lastCheck,
};
}
}