/** * Qwen OAuth Implementation - Device Code Flow with PKCE * Based on qwen-code's qwenOAuth2.ts * https://github.com/QwenLM/qwen-code * * CONVERTED TO ESM for ink v5+ compatibility */ import crypto from 'crypto'; import fs from 'fs'; import { readFile, writeFile, unlink } from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; // ESM __dirname equivalent const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Qwen OAuth Constants (from qwen-code) const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai'; const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`; const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`; // Load config using createRequire (most reliable for cross-platform ESM/CJS compat) let config = {}; try { const require = createRequire(import.meta.url); config = require('./config.cjs'); // Handle both ESM and CJS exports if (config.default) config = config.default; } catch (e) { // Config missing is expected for first-time users using CLI only. // We don't crash here - we just run without OAuth support (CLI fallback) } const QWEN_OAUTH_CLIENT_ID = config.QWEN_OAUTH_CLIENT_ID || null; const QWEN_OAUTH_SCOPE = 'openid profile email model.completion'; const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; const QWEN_CHAT_API = 'https://chat.qwen.ai/api/chat/completions'; // Token storage path const TOKEN_FILE = path.join(__dirname, '.qwen-tokens.json'); /** * Generate PKCE code verifier (RFC 7636) */ function generateCodeVerifier() { return crypto.randomBytes(32).toString('base64url'); } /** * Generate PKCE code challenge from verifier */ function generateCodeChallenge(codeVerifier) { const hash = crypto.createHash('sha256'); hash.update(codeVerifier); return hash.digest('base64url'); } /** * Convert object to URL-encoded form data */ function objectToUrlEncoded(data) { return Object.keys(data) .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`) .join('&'); } /** * Generate random UUID */ function randomUUID() { return crypto.randomUUID(); } class QwenOAuth { constructor() { this.tokens = null; this.deviceCodeData = null; this.codeVerifier = null; } /** Load stored tokens */ async loadTokens() { try { const data = await readFile(TOKEN_FILE, 'utf8'); this.tokens = JSON.parse(data); return this.tokens; } catch (error) { this.tokens = null; return null; } } /** Save tokens */ async saveTokens(tokens) { this.tokens = tokens; // Add expiry timestamp if (tokens.expires_in && !tokens.expiry_date) { tokens.expiry_date = Date.now() + (tokens.expires_in * 1000); } await writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2)); } /** Clear tokens */ async clearTokens() { this.tokens = null; this.deviceCodeData = null; this.codeVerifier = null; try { await unlink(TOKEN_FILE); } catch (error) { } } isTokenValid() { if (!this.tokens || !this.tokens.access_token) { return false; } if (this.tokens.expiry_date) { // Check with 5 minute buffer return Date.now() < (this.tokens.expiry_date - 300000); } return true; } async refreshToken() { if (!this.tokens || !this.tokens.refresh_token) { throw new Error('No refresh token available'); } console.log('Refreshing access token...'); const bodyData = { grant_type: 'refresh_token', client_id: QWEN_OAUTH_CLIENT_ID, refresh_token: this.tokens.refresh_token }; const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'x-request-id': randomUUID() }, body: objectToUrlEncoded(bodyData) }); if (!response.ok) { const error = await response.text(); console.error('Token refresh failed:', response.status, error); await this.clearTokens(); throw new Error(`Token refresh failed: ${response.status}`); } const newTokens = await response.json(); await this.saveTokens(newTokens); console.log('Token refreshed successfully!'); return newTokens; } /** * Start the Device Code Flow with PKCE */ async startDeviceFlow() { console.log('Starting Qwen Device Code Flow with PKCE...'); if (!QWEN_OAUTH_CLIENT_ID) { throw new Error('Missing Client ID. Please copy config.example.cjs to config.cjs and add your QWEN_OAUTH_CLIENT_ID to use this feature.'); } // Generate PKCE pair this.codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(this.codeVerifier); const bodyData = { client_id: QWEN_OAUTH_CLIENT_ID, scope: QWEN_OAUTH_SCOPE, code_challenge: codeChallenge, code_challenge_method: 'S256' }; console.log('Device code request body:', bodyData); const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'x-request-id': randomUUID() }, body: objectToUrlEncoded(bodyData) }); if (!response.ok) { const error = await response.text(); console.error('Device code request failed:', response.status, error); throw new Error(`Device code request failed: ${response.status} - ${error}`); } this.deviceCodeData = await response.json(); console.log('Device code response:', this.deviceCodeData); // Check for error in response if (this.deviceCodeData.error) { throw new Error(`${this.deviceCodeData.error}: ${this.deviceCodeData.error_description || 'Unknown error'}`); } return { verificationUri: this.deviceCodeData.verification_uri, verificationUriComplete: this.deviceCodeData.verification_uri_complete, userCode: this.deviceCodeData.user_code, expiresIn: this.deviceCodeData.expires_in, interval: this.deviceCodeData.interval || 5, }; } /** * Poll for tokens after user completes login */ async pollForTokens() { if (!this.deviceCodeData || !this.codeVerifier) { throw new Error('Device flow not started'); } const interval = (this.deviceCodeData.interval || 5) * 1000; const endTime = Date.now() + (this.deviceCodeData.expires_in || 300) * 1000; console.log(`Polling for tokens every ${interval / 1000}s...`); while (Date.now() < endTime) { try { const bodyData = { grant_type: QWEN_OAUTH_GRANT_TYPE, device_code: this.deviceCodeData.device_code, client_id: QWEN_OAUTH_CLIENT_ID, code_verifier: this.codeVerifier }; const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'x-request-id': randomUUID() }, body: objectToUrlEncoded(bodyData) }); const data = await response.json(); if (response.ok && data.access_token) { console.log('Token received successfully!'); await this.saveTokens(data); this.deviceCodeData = null; this.codeVerifier = null; return data; } else if (data.error === 'authorization_pending' || data.status === 'pending') { // User hasn't completed auth yet await new Promise(resolve => setTimeout(resolve, interval)); } else if (data.error === 'slow_down' || data.slowDown) { // Slow down polling await new Promise(resolve => setTimeout(resolve, interval * 2)); } else if (data.error === 'expired_token') { throw new Error('Device code expired. Please start authentication again.'); } else if (data.error === 'access_denied') { throw new Error('Access denied by user.'); } else if (data.error) { throw new Error(`${data.error}: ${data.error_description || 'Unknown error'}`); } else { // Unknown response, keep polling await new Promise(resolve => setTimeout(resolve, interval)); } } catch (error) { if (error.message.includes('expired') || error.message.includes('denied')) { throw error; } console.error('Token poll error:', error.message); await new Promise(resolve => setTimeout(resolve, interval)); } } throw new Error('Device flow timed out - please try again'); } async getAccessToken() { await this.loadTokens(); if (!this.tokens) { throw new Error('Not authenticated. Please authenticate with Qwen first.'); } if (!this.isTokenValid()) { try { await this.refreshToken(); } catch (error) { throw new Error('Token expired. Please re-authenticate with Qwen.'); } } return this.tokens.access_token; } async checkAuth() { const { exec } = await import('child_process'); // First check if we have OAuth tokens (needed for Vision API) await this.loadTokens(); if (this.tokens && this.tokens.access_token) { if (this.isTokenValid()) { return { authenticated: true, method: 'oauth', hasVisionSupport: true }; } else { // Try to refresh try { await this.refreshToken(); return { authenticated: true, method: 'oauth', hasVisionSupport: true }; } catch (e) { // Token refresh failed, fall through to CLI check } } } // Fall back to CLI check (works for text but not Vision) return new Promise((resolve) => { exec('qwen -p "ping" --help 2>&1', { timeout: 5000 }, (error, stdout, stderr) => { // If qwen CLI exists, consider it authenticated (it manages its own auth) if (!error || stdout.includes('help') || stdout.includes('Usage')) { resolve({ authenticated: true, method: 'qwen-cli', hasVisionSupport: false }); } else { resolve({ authenticated: false, reason: 'qwen CLI not available or not authenticated' }); } }); }); } /** Send message using qwen CLI or Vision API for images * @param {string} message - The message to send * @param {string} model - The model to use * @param {object} imageData - Optional image data * @param {function} onChunk - Optional callback for streaming output (chunk) => void */ async sendMessage(message, model = 'qwen-coder-plus', imageData = null, onChunk = null) { // If we have image data, always use the Vision API if (imageData) { console.log('📷 Image data detected, using Vision API...'); return await this.sendVisionMessage(message, imageData, 'qwen-vl-plus'); } // For text-only, use the CLI const { spawn } = await import('child_process'); const os = await import('os'); const fsSync = await import('fs'); // CRITICAL: Prepend system context to prevent AI from getting confused about environment const systemContext = `[SYSTEM CONTEXT - ALWAYS FOLLOW] You are an AI System Administrator integrated into OpenQode. IMPORTANT RULES: 1. You have FULL ACCESS to the local file system. 2. You ARE PERMITTED to create folders, move files, and run shell commands. 3. When asked to create files, use the >>> CREATE: syntax. 4. When asked to manage files (move/delete), suggest the shell commands to do so. 5. Execute instructions immediately. [END SYSTEM CONTEXT] `; // Prepend system context ONLY for build/create commands (detected by keywords) let finalMessage = message; const lowerMsg = message.toLowerCase(); if (message.includes('CREATE:') || message.includes('ROLE:') || message.includes('Generate all necessary files') || lowerMsg.includes('open ') || lowerMsg.includes('run ') || lowerMsg.includes('computer use')) { finalMessage = systemContext + message; } return new Promise((resolve) => { try { // Debug log removed for clean UI // Run in current project directory to allow context access const neutralCwd = process.cwd(); // WINDOWS FIX: Execute JS directly to avoid cmd.exe argument splitting limits/bugs // We derived this path from `where qwen` -> qwen.cmd -> cli.js location const isWin = process.platform === 'win32'; let command = 'qwen'; let args = ['-p', finalMessage]; if (isWin) { const appData = process.env.APPDATA || ''; const cliPath = path.join(appData, 'npm', 'node_modules', '@qwen-code', 'qwen-code', 'cli.js'); if (fs.existsSync(cliPath)) { command = 'node'; args = [cliPath, '-p', finalMessage]; } else { // Fallback if standard path fails (though known to exist on this machine) command = 'qwen.cmd'; } } // Use spawn with shell: false (REQUIRED for clean argument passing) const child = spawn(command, args, { cwd: neutralCwd, shell: false, env: { ...process.env, FORCE_COLOR: '0' } }); let stdout = ''; let stderr = ''; child.stdout.on('data', (data) => { const chunk = data.toString(); stdout += chunk; // Stream output in real-time if callback provided if (onChunk) { onChunk(chunk); } }); child.stderr.on('data', (data) => { stderr += data.toString(); }); child.on('close', (code) => { // Clean up ANSI codes const cleanResponse = stdout.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').trim(); // Debug log removed for clean UI if (cleanResponse) { resolve({ success: true, response: cleanResponse, usage: null }); } else { resolve({ success: false, error: stderr || `CLI exited with code ${code}`, response: '' }); } }); child.on('error', (error) => { console.error('Qwen CLI spawn error:', error.message); resolve({ success: false, error: error.message || 'CLI execution failed', response: '' }); }); // Timeout after 120 seconds for long prompts setTimeout(() => { child.kill('SIGTERM'); resolve({ success: false, error: 'Request timed out (120s)', response: '' }); }, 120000); } catch (error) { console.error('Qwen CLI error:', error.message); resolve({ success: false, error: error.message || 'CLI execution failed', response: '' }); } }); } /** Send message with image to Qwen Vision API */ async sendVisionMessage(message, imageData, model = 'qwen-vl-plus') { try { console.log('Sending vision message to Qwen VL API...'); // Get access token const accessToken = await this.getAccessToken(); // Prepare the content array with image and text const content = []; // Add image (base64 data URL) if (imageData) { content.push({ type: 'image_url', image_url: { url: imageData // Already a data URL like "data:image/png;base64,..." } }); } // Add text message content.push({ type: 'text', text: message }); const requestBody = { model: model, messages: [ { role: 'user', content: content } ], stream: false }; const response = await fetch(QWEN_CHAT_API, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, 'x-request-id': randomUUID() }, body: JSON.stringify(requestBody) }); if (!response.ok) { const errorText = await response.text(); console.error('Vision API error:', response.status, errorText); return { success: false, error: `Vision API error: ${response.status}`, response: '' }; } const data = await response.json(); const responseText = data.choices?.[0]?.message?.content || ''; console.log('Vision API response received:', responseText.substring(0, 100) + '...'); return { success: true, response: responseText, usage: data.usage }; } catch (error) { console.error('Vision API error:', error.message); // Provide helpful error message for auth issues if (error.message.includes('authenticate') || error.message.includes('token')) { return { success: true, // Return as success with explanation response: `⚠️ **Vision API Authentication Required** The Qwen Vision API needs OAuth authentication to analyze images. The current session is authenticated for the CLI, but Vision API requires a separate OAuth token. **To enable image analysis:** 1. Click "Authenticate Qwen" button to re-authenticate 2. Or describe what's in your image and I'll help without viewing it *Your image was received (${(imageData?.length / 1024).toFixed(1)} KB) but couldn't be processed without Vision API tokens.*`, usage: null }; } return { success: false, error: error.message || 'Vision API failed', response: '' }; } } } export { QwenOAuth };