diff --git a/docs/features/subagents.md b/docs/features/subagents.md index 225384ca2..506d856f6 100644 --- a/docs/features/subagents.md +++ b/docs/features/subagents.md @@ -106,7 +106,7 @@ Subagents are configured using Markdown files with YAML frontmatter. This format --- name: agent-name description: Brief description of when and how to use this agent -tools: +tools: - tool1 - tool2 - tool3 # Optional @@ -170,7 +170,7 @@ Perfect for comprehensive test creation and test-driven development. --- name: testing-expert description: Writes comprehensive unit tests, integration tests, and handles test automation with best practices -tools: +tools: - read_file - write_file - read_many_files @@ -214,7 +214,7 @@ Specialized in creating clear, comprehensive documentation. --- name: documentation-writer description: Creates comprehensive documentation, README files, API docs, and user guides -tools: +tools: - read_file - write_file - read_many_files @@ -267,7 +267,7 @@ Focused on code quality, security, and best practices. --- name: code-reviewer description: Reviews code for best practices, security issues, performance, and maintainability -tools: +tools: - read_file - read_many_files --- @@ -311,7 +311,7 @@ Optimized for React development, hooks, and component patterns. --- name: react-specialist description: Expert in React development, hooks, component patterns, and modern React best practices -tools: +tools: - read_file - write_file - read_many_files diff --git a/docs/support/troubleshooting.md b/docs/support/troubleshooting.md index f53c25ec5..f654c1f64 100644 --- a/docs/support/troubleshooting.md +++ b/docs/support/troubleshooting.md @@ -14,6 +14,13 @@ This guide provides solutions to common issues and debugging tips, including top - **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file. - Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt` +- **Issue: Unable to display UI after authentication failure** + - **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI. + - **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file: + - Open `~/.qwen/settings.json` (or `./.qwen/settings.json` for project-specific settings) + - Remove the `security.auth.selectedType` field + - Restart the CLI to allow it to prompt for authentication again + ## Frequently asked questions (FAQs) - **Q: How do I update Qwen Code to the latest version?** diff --git a/integration-tests/context-compress-interactive.test.ts b/integration-tests/context-compress-interactive.test.ts index ddfa6839a..f63668a4c 100644 --- a/integration-tests/context-compress-interactive.test.ts +++ b/integration-tests/context-compress-interactive.test.ts @@ -96,7 +96,7 @@ describe('Interactive Mode', () => { ).toBe(true); await type(ptyProcess, '/compress'); - await new Promise((resolve) => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 1000)); await type(ptyProcess, '\r'); const foundEvent = await rig.waitForTelemetryEvent( diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index edc7709f2..aefcb103d 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -839,5 +839,6 @@ export function saveSettings(settingsFile: SettingsFile): void { ); } catch (error) { console.error('Error saving user settings file:', error); + throw error; } } diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts index 2284a1124..15bb5fd30 100644 --- a/packages/cli/src/core/auth.ts +++ b/packages/cli/src/core/auth.ts @@ -8,6 +8,8 @@ import { type AuthType, type Config, getErrorMessage, + logAuth, + AuthEvent, } from '@qwen-code/qwen-code-core'; /** @@ -25,11 +27,21 @@ export async function performInitialAuth( } try { - await config.refreshAuth(authType); + await config.refreshAuth(authType, true); // The console.log is intentionally left out here. // We can add a dedicated startup message later if needed. + + // Log authentication success + const authEvent = new AuthEvent(authType, 'auto', 'success'); + logAuth(config, authEvent); } catch (e) { - return `Failed to login. Message: ${getErrorMessage(e)}`; + const errorMessage = `Failed to login. Message: ${getErrorMessage(e)}`; + + // Log authentication failure + const authEvent = new AuthEvent(authType, 'auto', 'error', errorMessage); + logAuth(config, authEvent); + + return errorMessage; } return null; diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index 039b92777..870632d79 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -11,7 +11,7 @@ import { logIdeConnection, type Config, } from '@qwen-code/qwen-code-core'; -import { type LoadedSettings } from '../config/settings.js'; +import { type LoadedSettings, SettingScope } from '../config/settings.js'; import { performInitialAuth } from './auth.js'; import { validateTheme } from './theme.js'; @@ -33,10 +33,18 @@ export async function initializeApp( config: Config, settings: LoadedSettings, ): Promise { - const authError = await performInitialAuth( - config, - settings.merged.security?.auth?.selectedType, - ); + const authType = settings.merged.security?.auth?.selectedType; + const authError = await performInitialAuth(config, authType); + + // Fallback to user select when initial authentication fails + if (authError) { + settings.setValue( + SettingScope.User, + 'security.auth.selectedType', + undefined, + ); + } + const themeError = validateTheme(settings); const shouldOpenAuthDialog = diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index fbfc732bc..06cfe598d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -25,7 +25,6 @@ import { type HistoryItem, ToolCallStatus, type HistoryItemWithoutId, - AuthState, } from './types.js'; import { MessageType, StreamingState } from './types.js'; import { @@ -48,7 +47,6 @@ import { useHistory } from './hooks/useHistoryManager.js'; import { useMemoryMonitor } from './hooks/useMemoryMonitor.js'; import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useAuthCommand } from './auth/useAuth.js'; -import { useQwenAuth } from './hooks/useQwenAuth.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; @@ -93,6 +91,7 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { useQuitConfirmation } from './hooks/useQuitConfirmation.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; +import { useInitializationAuthError } from './hooks/useInitializationAuthError.js'; import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js'; import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; @@ -348,20 +347,13 @@ export const AppContainer = (props: AppContainerProps) => { onAuthError, isAuthDialogOpen, isAuthenticating, + pendingAuthType, + qwenAuthState, handleAuthSelect, openAuthDialog, + cancelAuthentication, } = useAuthCommand(settings, config); - // Qwen OAuth authentication state - const { - isQwenAuth, - isQwenAuthenticating, - deviceAuth, - authStatus, - authMessage, - cancelQwenAuth, - } = useQwenAuth(settings, isAuthenticating); - const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({ config, historyManager, @@ -370,19 +362,7 @@ export const AppContainer = (props: AppContainerProps) => { setModelSwitchedFromQuotaError, }); - // Handle Qwen OAuth timeout - const handleQwenAuthTimeout = useCallback(() => { - onAuthError('Qwen OAuth authentication timed out. Please try again.'); - cancelQwenAuth(); - setAuthState(AuthState.Updating); - }, [onAuthError, cancelQwenAuth, setAuthState]); - - // Handle Qwen OAuth cancel - const handleQwenAuthCancel = useCallback(() => { - onAuthError('Qwen OAuth authentication cancelled.'); - cancelQwenAuth(); - setAuthState(AuthState.Updating); - }, [onAuthError, cancelQwenAuth, setAuthState]); + useInitializationAuthError(initializationResult.authError, onAuthError); // Sync user tier from config when authentication changes // TODO: Implement getUserTier() method on Config if needed @@ -394,6 +374,8 @@ export const AppContainer = (props: AppContainerProps) => { // Check for enforced auth type mismatch useEffect(() => { + // Check for initialization error first + if ( settings.merged.security?.auth?.enforcedType && settings.merged.security?.auth.selectedType && @@ -952,7 +934,7 @@ export const AppContainer = (props: AppContainerProps) => { handleApprovalModeSelect, isAuthDialogOpen, handleAuthSelect, - selectedAuthType: settings.merged.security?.auth?.selectedType, + pendingAuthType, isEditorDialogOpen, exitEditorDialog, isSettingsDialogOpen, @@ -1194,7 +1176,7 @@ export const AppContainer = (props: AppContainerProps) => { isVisionSwitchDialogOpen || isPermissionsDialogOpen || isAuthDialogOpen || - (isAuthenticating && isQwenAuthenticating) || + isAuthenticating || isEditorDialogOpen || showIdeRestartPrompt || !!proQuotaRequest || @@ -1217,12 +1199,9 @@ export const AppContainer = (props: AppContainerProps) => { isConfigInitialized, authError, isAuthDialogOpen, + pendingAuthType, // Qwen OAuth state - isQwenAuth, - isQwenAuthenticating, - deviceAuth, - authStatus, - authMessage, + qwenAuthState, editorError, isEditorDialogOpen, corgiMode, @@ -1312,12 +1291,9 @@ export const AppContainer = (props: AppContainerProps) => { isConfigInitialized, authError, isAuthDialogOpen, + pendingAuthType, // Qwen OAuth state - isQwenAuth, - isQwenAuthenticating, - deviceAuth, - authStatus, - authMessage, + qwenAuthState, editorError, isEditorDialogOpen, corgiMode, @@ -1411,9 +1387,7 @@ export const AppContainer = (props: AppContainerProps) => { handleAuthSelect, setAuthState, onAuthError, - // Qwen OAuth handlers - handleQwenAuthTimeout, - handleQwenAuthCancel, + cancelAuthentication, handleEditorSelect, exitEditorDialog, closeSettingsDialog, @@ -1447,9 +1421,7 @@ export const AppContainer = (props: AppContainerProps) => { handleAuthSelect, setAuthState, onAuthError, - // Qwen OAuth handlers - handleQwenAuthTimeout, - handleQwenAuthCancel, + cancelAuthentication, handleEditorSelect, exitEditorDialog, closeSettingsDialog, diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 2011eac66..343989417 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -9,6 +9,53 @@ import { AuthDialog } from './AuthDialog.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; import { AuthType } from '@qwen-code/qwen-code-core'; import { renderWithProviders } from '../../test-utils/render.js'; +import { UIStateContext } from '../contexts/UIStateContext.js'; +import { UIActionsContext } from '../contexts/UIActionsContext.js'; +import type { UIState } from '../contexts/UIStateContext.js'; +import type { UIActions } from '../contexts/UIActionsContext.js'; + +const createMockUIState = (overrides: Partial = {}): UIState => { + // AuthDialog only uses authError and pendingAuthType + const baseState = { + authError: null, + pendingAuthType: undefined, + } as Partial; + + return { + ...baseState, + ...overrides, + } as UIState; +}; + +const createMockUIActions = (overrides: Partial = {}): UIActions => { + // AuthDialog only uses handleAuthSelect + const baseActions = { + handleAuthSelect: vi.fn(), + } as Partial; + + return { + ...baseActions, + ...overrides, + } as UIActions; +}; + +const renderAuthDialog = ( + settings: LoadedSettings, + uiStateOverrides: Partial = {}, + uiActionsOverrides: Partial = {}, +) => { + const uiState = createMockUIState(uiStateOverrides); + const uiActions = createMockUIActions(uiActionsOverrides); + + return renderWithProviders( + + + + + , + { settings }, + ); +}; describe('AuthDialog', () => { const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -66,13 +113,9 @@ describe('AuthDialog', () => { new Set(), ); - const { lastFrame } = renderWithProviders( - {}} - settings={settings} - initialErrorMessage="GEMINI_API_KEY environment variable not found" - />, - ); + const { lastFrame } = renderAuthDialog(settings, { + authError: 'GEMINI_API_KEY environment variable not found', + }); expect(lastFrame()).toContain( 'GEMINI_API_KEY environment variable not found', @@ -116,9 +159,7 @@ describe('AuthDialog', () => { new Set(), ); - const { lastFrame } = renderWithProviders( - {}} settings={settings} />, - ); + const { lastFrame } = renderAuthDialog(settings); // Since the auth dialog only shows OpenAI option now, // it won't show GEMINI_API_KEY messages @@ -162,9 +203,7 @@ describe('AuthDialog', () => { new Set(), ); - const { lastFrame } = renderWithProviders( - {}} settings={settings} />, - ); + const { lastFrame } = renderAuthDialog(settings); expect(lastFrame()).not.toContain( 'Existing API key detected (GEMINI_API_KEY)', @@ -208,9 +247,7 @@ describe('AuthDialog', () => { new Set(), ); - const { lastFrame } = renderWithProviders( - {}} settings={settings} />, - ); + const { lastFrame } = renderAuthDialog(settings); // Since the auth dialog only shows OpenAI option now, // it won't show GEMINI_API_KEY messages @@ -255,9 +292,7 @@ describe('AuthDialog', () => { new Set(), ); - const { lastFrame } = renderWithProviders( - {}} settings={settings} />, - ); + const { lastFrame } = renderAuthDialog(settings); // This is a bit brittle, but it's the best way to check which item is selected. expect(lastFrame()).toContain('● 2. OpenAI'); @@ -297,9 +332,7 @@ describe('AuthDialog', () => { new Set(), ); - const { lastFrame } = renderWithProviders( - {}} settings={settings} />, - ); + const { lastFrame } = renderAuthDialog(settings); // Default is Qwen OAuth (first option) expect(lastFrame()).toContain('● 1. Qwen OAuth'); @@ -341,9 +374,7 @@ describe('AuthDialog', () => { new Set(), ); - const { lastFrame } = renderWithProviders( - {}} settings={settings} />, - ); + const { lastFrame } = renderAuthDialog(settings); // Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore, // it will just show the default Qwen OAuth option @@ -352,7 +383,7 @@ describe('AuthDialog', () => { }); it('should prevent exiting when no auth method is selected and show error message', async () => { - const onSelect = vi.fn(); + const handleAuthSelect = vi.fn(); const settings: LoadedSettings = new LoadedSettings( { settings: { ui: { customThemes: {} }, mcpServers: {} }, @@ -386,8 +417,10 @@ describe('AuthDialog', () => { new Set(), ); - const { lastFrame, stdin, unmount } = renderWithProviders( - , + const { lastFrame, stdin, unmount } = renderAuthDialog( + settings, + {}, + { handleAuthSelect }, ); await wait(); @@ -395,16 +428,16 @@ describe('AuthDialog', () => { stdin.write('\u001b'); // ESC key await wait(); - // Should show error message instead of calling onSelect + // Should show error message instead of calling handleAuthSelect expect(lastFrame()).toContain( 'You must select an auth method to proceed. Press Ctrl+C again to exit.', ); - expect(onSelect).not.toHaveBeenCalled(); + expect(handleAuthSelect).not.toHaveBeenCalled(); unmount(); }); it('should not exit if there is already an error message', async () => { - const onSelect = vi.fn(); + const handleAuthSelect = vi.fn(); const settings: LoadedSettings = new LoadedSettings( { settings: { ui: { customThemes: {} }, mcpServers: {} }, @@ -438,12 +471,10 @@ describe('AuthDialog', () => { new Set(), ); - const { lastFrame, stdin, unmount } = renderWithProviders( - , + const { lastFrame, stdin, unmount } = renderAuthDialog( + settings, + { authError: 'Initial error' }, + { handleAuthSelect }, ); await wait(); @@ -453,13 +484,13 @@ describe('AuthDialog', () => { stdin.write('\u001b'); // ESC key await wait(); - // Should not call onSelect - expect(onSelect).not.toHaveBeenCalled(); + // Should not call handleAuthSelect + expect(handleAuthSelect).not.toHaveBeenCalled(); unmount(); }); it('should allow exiting when auth method is already selected', async () => { - const onSelect = vi.fn(); + const handleAuthSelect = vi.fn(); const settings: LoadedSettings = new LoadedSettings( { settings: { ui: { customThemes: {} }, mcpServers: {} }, @@ -493,8 +524,10 @@ describe('AuthDialog', () => { new Set(), ); - const { stdin, unmount } = renderWithProviders( - , + const { stdin, unmount } = renderAuthDialog( + settings, + {}, + { handleAuthSelect }, ); await wait(); @@ -502,8 +535,8 @@ describe('AuthDialog', () => { stdin.write('\u001b'); // ESC key await wait(); - // Should call onSelect with undefined to exit - expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User); + // Should call handleAuthSelect with undefined to exit + expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User); unmount(); }); }); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 9d9baa891..ec0b25774 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -8,26 +8,13 @@ import type React from 'react'; import { useState } from 'react'; import { AuthType } from '@qwen-code/qwen-code-core'; import { Box, Text } from 'ink'; -import { validateAuthMethod } from '../../config/auth.js'; -import { type LoadedSettings, SettingScope } from '../../config/settings.js'; +import { SettingScope } from '../../config/settings.js'; import { Colors } from '../colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { OpenAIKeyPrompt } from '../components/OpenAIKeyPrompt.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; - -interface AuthDialogProps { - onSelect: ( - authMethod: AuthType | undefined, - scope: SettingScope, - credentials?: { - apiKey?: string; - baseUrl?: string; - model?: string; - }, - ) => void; - settings: LoadedSettings; - initialErrorMessage?: string | null; -} +import { useUIState } from '../contexts/UIStateContext.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; function parseDefaultAuthType( defaultAuthType: string | undefined, @@ -41,15 +28,14 @@ function parseDefaultAuthType( return null; } -export function AuthDialog({ - onSelect, - settings, - initialErrorMessage, -}: AuthDialogProps): React.JSX.Element { - const [errorMessage, setErrorMessage] = useState( - initialErrorMessage || null, - ); - const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false); +export function AuthDialog(): React.JSX.Element { + const { pendingAuthType, authError } = useUIState(); + const { handleAuthSelect: onAuthSelect } = useUIActions(); + const settings = useSettings(); + + const [errorMessage, setErrorMessage] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(null); + const items = [ { key: AuthType.QWEN_OAUTH, @@ -62,10 +48,17 @@ export function AuthDialog({ const initialAuthIndex = Math.max( 0, items.findIndex((item) => { + // Priority 1: pendingAuthType + if (pendingAuthType) { + return item.value === pendingAuthType; + } + + // Priority 2: settings.merged.security?.auth?.selectedType if (settings.merged.security?.auth?.selectedType) { return item.value === settings.merged.security?.auth?.selectedType; } + // Priority 3: QWEN_DEFAULT_AUTH_TYPE env var const defaultAuthType = parseDefaultAuthType( process.env['QWEN_DEFAULT_AUTH_TYPE'], ); @@ -73,49 +66,29 @@ export function AuthDialog({ return item.value === defaultAuthType; } + // Priority 4: default to QWEN_OAUTH return item.value === AuthType.QWEN_OAUTH; }), ); - const handleAuthSelect = (authMethod: AuthType) => { - if (authMethod === AuthType.USE_OPENAI) { - setShowOpenAIKeyPrompt(true); - setErrorMessage(null); - } else { - const error = validateAuthMethod(authMethod); - if (error) { - setErrorMessage(error); - } else { - setErrorMessage(null); - onSelect(authMethod, SettingScope.User); - } - } - }; + const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey); + const currentSelectedAuthType = + selectedIndex !== null + ? items[selectedIndex]?.value + : items[initialAuthIndex]?.value; - const handleOpenAIKeySubmit = ( - apiKey: string, - baseUrl: string, - model: string, - ) => { - setShowOpenAIKeyPrompt(false); - onSelect(AuthType.USE_OPENAI, SettingScope.User, { - apiKey, - baseUrl, - model, - }); + const handleAuthSelect = async (authMethod: AuthType) => { + setErrorMessage(null); + await onAuthSelect(authMethod, SettingScope.User); }; - const handleOpenAIKeyCancel = () => { - setShowOpenAIKeyPrompt(false); - setErrorMessage('OpenAI API key is required to use OpenAI authentication.'); + const handleHighlight = (authMethod: AuthType) => { + const index = items.findIndex((item) => item.value === authMethod); + setSelectedIndex(index); }; useKeypress( (key) => { - if (showOpenAIKeyPrompt) { - return; - } - if (key.name === 'escape') { // Prevent exit if there is an error message. // This means they user is not authenticated yet. @@ -129,33 +102,11 @@ export function AuthDialog({ ); return; } - onSelect(undefined, SettingScope.User); + onAuthSelect(undefined, SettingScope.User); } }, { isActive: true }, ); - const getDefaultOpenAIConfig = () => { - const fromSettings = settings.merged.security?.auth; - const modelSettings = settings.merged.model; - return { - apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '', - baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '', - model: modelSettings?.name || process.env['OPENAI_MODEL'] || '', - }; - }; - - if (showOpenAIKeyPrompt) { - const defaults = getDefaultOpenAIConfig(); - return ( - - ); - } return ( - {errorMessage && ( + {(authError || errorMessage) && ( - {errorMessage} + {authError || errorMessage} )} (Use Enter to Set Auth) + {hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && ( + + + Note: Your existing API key in settings.json will not be cleared + when using Qwen OAuth. You can switch back to OpenAI authentication + later if needed. + + + )} Terms of Services and Privacy Notice for Qwen Code diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index e761043db..9b1198bfa 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -6,27 +6,19 @@ import { useState, useCallback, useEffect } from 'react'; import type { LoadedSettings, SettingScope } from '../../config/settings.js'; -import type { AuthType, Config } from '@qwen-code/qwen-code-core'; +import type { Config } from '@qwen-code/qwen-code-core'; import { + AuthType, clearCachedCredentialFile, getErrorMessage, + logAuth, + AuthEvent, } from '@qwen-code/qwen-code-core'; import { AuthState } from '../types.js'; -import { validateAuthMethod } from '../../config/auth.js'; - -export function validateAuthMethodWithSettings( - authType: AuthType, - settings: LoadedSettings, -): string | null { - const enforcedType = settings.merged.security?.auth?.enforcedType; - if (enforcedType && enforcedType !== authType) { - return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`; - } - if (settings.merged.security?.auth?.useExternal) { - return null; - } - return validateAuthMethod(authType); -} +import { useQwenAuth } from '../hooks/useQwenAuth.js'; +import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js'; + +export type { QwenAuthState } from '../hooks/useQwenAuth.js'; export const useAuthCommand = (settings: LoadedSettings, config: Config) => { const unAuthenticated = @@ -40,6 +32,14 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { const [isAuthenticating, setIsAuthenticating] = useState(false); const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated); + const [pendingAuthType, setPendingAuthType] = useState( + undefined, + ); + + const { qwenAuthState, cancelQwenAuth } = useQwenAuth( + pendingAuthType, + isAuthenticating, + ); const onAuthError = useCallback( (error: string | null) => { @@ -52,90 +52,123 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { [setAuthError, setAuthState], ); - // Authentication flow - useEffect(() => { - const authFlow = async () => { - const authType = settings.merged.security?.auth?.selectedType; - if (isAuthDialogOpen || !authType) { - return; + const handleAuthFailure = useCallback( + (error: unknown) => { + setIsAuthenticating(false); + const errorMessage = `Failed to authenticate. Message: ${getErrorMessage(error)}`; + onAuthError(errorMessage); + + // Log authentication failure + if (pendingAuthType) { + const authEvent = new AuthEvent( + pendingAuthType, + 'manual', + 'error', + errorMessage, + ); + logAuth(config, authEvent); } + }, + [onAuthError, pendingAuthType, config], + ); - const validationError = validateAuthMethodWithSettings( - authType, - settings, - ); - if (validationError) { - onAuthError(validationError); - return; - } - - try { - setIsAuthenticating(true); - await config.refreshAuth(authType); - console.log(`Authenticated via "${authType}".`); - setAuthError(null); - setAuthState(AuthState.Authenticated); - } catch (e) { - onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`); - } finally { - setIsAuthenticating(false); - } - }; - - void authFlow(); - }, [isAuthDialogOpen, settings, config, onAuthError]); - - // Handle auth selection from dialog - const handleAuthSelect = useCallback( + const handleAuthSuccess = useCallback( async ( - authType: AuthType | undefined, + authType: AuthType, scope: SettingScope, - credentials?: { - apiKey?: string; - baseUrl?: string; - model?: string; - }, + credentials?: OpenAICredentials, ) => { - if (authType) { - await clearCachedCredentialFile(); - - // Save OpenAI credentials if provided - if (credentials) { - // Update Config's internal generationConfig before calling refreshAuth - // This ensures refreshAuth has access to the new credentials - config.updateCredentials({ - apiKey: credentials.apiKey, - baseUrl: credentials.baseUrl, - model: credentials.model, - }); + try { + settings.setValue(scope, 'security.auth.selectedType', authType); - // Also set environment variables for compatibility with other parts of the code - if (credentials.apiKey) { + // Only update credentials if not switching to QWEN_OAUTH, + // so that OpenAI credentials are preserved when switching to QWEN_OAUTH. + if (authType !== AuthType.QWEN_OAUTH && credentials) { + if (credentials?.apiKey != null) { settings.setValue( scope, 'security.auth.apiKey', credentials.apiKey, ); } - if (credentials.baseUrl) { + if (credentials?.baseUrl != null) { settings.setValue( scope, 'security.auth.baseUrl', credentials.baseUrl, ); } - if (credentials.model) { + if (credentials?.model != null) { settings.setValue(scope, 'model.name', credentials.model); } + await clearCachedCredentialFile(); } - - settings.setValue(scope, 'security.auth.selectedType', authType); + } catch (error) { + handleAuthFailure(error); + return; } + setAuthError(null); + setAuthState(AuthState.Authenticated); + setPendingAuthType(undefined); setIsAuthDialogOpen(false); + setIsAuthenticating(false); + + // Log authentication success + const authEvent = new AuthEvent(authType, 'manual', 'success'); + logAuth(config, authEvent); + }, + [settings, handleAuthFailure, config], + ); + + const performAuth = useCallback( + async ( + authType: AuthType, + scope: SettingScope, + credentials?: OpenAICredentials, + ) => { + try { + await config.refreshAuth(authType); + handleAuthSuccess(authType, scope, credentials); + } catch (e) { + handleAuthFailure(e); + } + }, + [config, handleAuthSuccess, handleAuthFailure], + ); + + const handleAuthSelect = useCallback( + async ( + authType: AuthType | undefined, + scope: SettingScope, + credentials?: OpenAICredentials, + ) => { + if (!authType) { + setIsAuthDialogOpen(false); + setAuthError(null); + return; + } + + setPendingAuthType(authType); setAuthError(null); + setIsAuthDialogOpen(false); + setIsAuthenticating(true); + + if (authType === AuthType.USE_OPENAI) { + if (credentials) { + config.updateCredentials({ + apiKey: credentials.apiKey, + baseUrl: credentials.baseUrl, + model: credentials.model, + }); + await performAuth(authType, scope, credentials); + } + return; + } + + await performAuth(authType, scope); }, - [settings, config], + [config, performAuth], ); const openAuthDialog = useCallback(() => { @@ -143,8 +176,45 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { }, []); const cancelAuthentication = useCallback(() => { + if (isAuthenticating && pendingAuthType === AuthType.QWEN_OAUTH) { + cancelQwenAuth(); + } + + // Log authentication cancellation + if (isAuthenticating && pendingAuthType) { + const authEvent = new AuthEvent(pendingAuthType, 'manual', 'cancelled'); + logAuth(config, authEvent); + } + + // Do not reset pendingAuthType here, persist the previously selected type. setIsAuthenticating(false); - }, []); + setIsAuthDialogOpen(true); + setAuthError(null); + }, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]); + + /** + /** + * We previously used a useEffect to trigger authentication automatically when + * settings.security.auth.selectedType changed. This caused problems: if authentication failed, + * the UI could get stuck, since settings.json would update before success. Now, we + * update selectedType in settings only when authentication fully succeeds. + * Authentication is triggered explicitly—either during initial app startup or when the + * user switches methods—not reactively through settings changes. This avoids repeated + * or broken authentication cycles. + */ + useEffect(() => { + const defaultAuthType = process.env['QWEN_DEFAULT_AUTH_TYPE']; + if ( + defaultAuthType && + ![AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].includes( + defaultAuthType as AuthType, + ) + ) { + onAuthError( + `Invalid QWEN_DEFAULT_AUTH_TYPE value: "${defaultAuthType}". Valid values are: ${[AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', ')}`, + ); + } + }, [onAuthError]); return { authState, @@ -153,6 +223,8 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { onAuthError, isAuthDialogOpen, isAuthenticating, + pendingAuthType, + qwenAuthState, handleAuthSelect, openAuthDialog, cancelAuthentication, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 01d95392f..757957976 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -12,9 +12,9 @@ import { ShellConfirmationDialog } from './ShellConfirmationDialog.js'; import { ConsentPrompt } from './ConsentPrompt.js'; import { ThemeDialog } from './ThemeDialog.js'; import { SettingsDialog } from './SettingsDialog.js'; -import { AuthInProgress } from '../auth/AuthInProgress.js'; import { QwenOAuthProgress } from './QwenOAuthProgress.js'; import { AuthDialog } from '../auth/AuthDialog.js'; +import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js'; import { ProQuotaDialog } from './ProQuotaDialog.js'; @@ -26,6 +26,9 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; +import { SettingScope } from '../../config/settings.js'; +import { AuthState } from '../types.js'; +import { AuthType } from '@qwen-code/qwen-code-core'; import process from 'node:process'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; @@ -56,6 +59,16 @@ export const DialogManager = ({ const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } = uiState; + const getDefaultOpenAIConfig = () => { + const fromSettings = settings.merged.security?.auth; + const modelSettings = settings.merged.model; + return { + apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '', + baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '', + model: modelSettings?.name || process.env['OPENAI_MODEL'] || '', + }; + }; + if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) { return ( ; } + + if (uiState.isAuthDialogOpen || uiState.authError) { + return ( + + + + ); + } + if (uiState.isAuthenticating) { - // Show Qwen OAuth progress if it's Qwen auth and OAuth is active - if (uiState.isQwenAuth && uiState.isQwenAuthenticating) { + if (uiState.pendingAuthType === AuthType.USE_OPENAI) { + const defaults = getDefaultOpenAIConfig(); return ( - { + uiActions.handleAuthSelect(AuthType.USE_OPENAI, SettingScope.User, { + apiKey, + baseUrl, + model, + }); + }} + onCancel={() => { + uiActions.cancelAuthentication(); + uiActions.setAuthState(AuthState.Updating); + }} + defaultApiKey={defaults.apiKey} + defaultBaseUrl={defaults.baseUrl} + defaultModel={defaults.model} /> ); } - // Default auth progress for other auth types - return ( - { - uiActions.onAuthError('Authentication cancelled.'); - }} - /> - ); - } - if (uiState.isAuthDialogOpen) { - return ( - - { + uiActions.onAuthError('Qwen OAuth authentication timed out.'); + uiActions.cancelAuthentication(); + uiActions.setAuthState(AuthState.Updating); + }} + onCancel={() => { + uiActions.cancelAuthentication(); + uiActions.setAuthState(AuthState.Updating); + }} /> - - ); + ); + } } if (uiState.isEditorDialogOpen) { return ( diff --git a/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx b/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx index bc78b8c58..0dc89bc70 100644 --- a/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx +++ b/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx @@ -6,6 +6,7 @@ import type React from 'react'; import { useState } from 'react'; +import { z } from 'zod'; import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; @@ -18,6 +19,16 @@ interface OpenAIKeyPromptProps { defaultModel?: string; } +export const credentialSchema = z.object({ + apiKey: z.string().min(1, 'API key is required'), + baseUrl: z + .union([z.string().url('Base URL must be a valid URL'), z.literal('')]) + .optional(), + model: z.string().min(1, 'Model must be a non-empty string').optional(), +}); + +export type OpenAICredentials = z.infer; + export function OpenAIKeyPrompt({ onSubmit, onCancel, @@ -31,6 +42,34 @@ export function OpenAIKeyPrompt({ const [currentField, setCurrentField] = useState< 'apiKey' | 'baseUrl' | 'model' >('apiKey'); + const [validationError, setValidationError] = useState(null); + + const validateAndSubmit = () => { + setValidationError(null); + + try { + const validated = credentialSchema.parse({ + apiKey: apiKey.trim(), + baseUrl: baseUrl.trim() || undefined, + model: model.trim() || undefined, + }); + + onSubmit( + validated.apiKey, + validated.baseUrl === '' ? '' : validated.baseUrl || '', + validated.model || '', + ); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessage = error.errors + .map((e) => `${e.path.join('.')}: ${e.message}`) + .join(', '); + setValidationError(`Invalid credentials: ${errorMessage}`); + } else { + setValidationError('Failed to validate credentials'); + } + } + }; useKeypress( (key) => { @@ -52,7 +91,7 @@ export function OpenAIKeyPrompt({ } else if (currentField === 'model') { // 只有在提交时才检查 API key 是否为空 if (apiKey.trim()) { - onSubmit(apiKey.trim(), baseUrl.trim(), model.trim()); + validateAndSubmit(); } else { // 如果 API key 为空,回到 API key 字段 setCurrentField('apiKey'); @@ -168,6 +207,11 @@ export function OpenAIKeyPrompt({ OpenAI Configuration Required + {validationError && ( + + {validationError} + + )} Please enter your OpenAI configuration. You can get an API key from{' '} diff --git a/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx b/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx index 3cd31375e..a93db2b7a 100644 --- a/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx +++ b/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx @@ -8,7 +8,7 @@ import { render } from 'ink-testing-library'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { QwenOAuthProgress } from './QwenOAuthProgress.js'; -import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js'; +import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; import type { Key } from '../contexts/KeypressContext.js'; @@ -42,12 +42,13 @@ describe('QwenOAuthProgress', () => { let keypressHandler: ((key: Key) => void) | null = null; const createMockDeviceAuth = ( - overrides: Partial = {}, - ): DeviceAuthorizationInfo => ({ + overrides: Partial = {}, + ): DeviceAuthorizationData => ({ verification_uri: 'https://example.com/device', verification_uri_complete: 'https://example.com/device?user_code=ABC123', user_code: 'ABC123', expires_in: 300, + device_code: 'test-device-code', ...overrides, }); @@ -55,7 +56,7 @@ describe('QwenOAuthProgress', () => { const renderComponent = ( props: Partial<{ - deviceAuth: DeviceAuthorizationInfo; + deviceAuth: DeviceAuthorizationData; authStatus: | 'idle' | 'polling' @@ -158,7 +159,7 @@ describe('QwenOAuthProgress', () => { }); it('should format time correctly', () => { - const deviceAuthWithCustomTime: DeviceAuthorizationInfo = { + const deviceAuthWithCustomTime: DeviceAuthorizationData = { ...mockDeviceAuth, expires_in: 125, // 2 minutes and 5 seconds }; @@ -176,7 +177,7 @@ describe('QwenOAuthProgress', () => { }); it('should format single digit seconds with leading zero', () => { - const deviceAuthWithCustomTime: DeviceAuthorizationInfo = { + const deviceAuthWithCustomTime: DeviceAuthorizationData = { ...mockDeviceAuth, expires_in: 67, // 1 minute and 7 seconds }; @@ -196,7 +197,7 @@ describe('QwenOAuthProgress', () => { describe('Timer functionality', () => { it('should countdown and call onTimeout when timer expires', async () => { - const deviceAuthWithShortTime: DeviceAuthorizationInfo = { + const deviceAuthWithShortTime: DeviceAuthorizationData = { ...mockDeviceAuth, expires_in: 2, // 2 seconds }; @@ -520,7 +521,7 @@ describe('QwenOAuthProgress', () => { describe('Props changes', () => { it('should display initial timer value from deviceAuth', () => { - const deviceAuthWith10Min: DeviceAuthorizationInfo = { + const deviceAuthWith10Min: DeviceAuthorizationData = { ...mockDeviceAuth, expires_in: 600, // 10 minutes }; diff --git a/packages/cli/src/ui/components/QwenOAuthProgress.tsx b/packages/cli/src/ui/components/QwenOAuthProgress.tsx index 685cb1072..3e630fb39 100644 --- a/packages/cli/src/ui/components/QwenOAuthProgress.tsx +++ b/packages/cli/src/ui/components/QwenOAuthProgress.tsx @@ -11,13 +11,13 @@ import Spinner from 'ink-spinner'; import Link from 'ink-link'; import qrcode from 'qrcode-terminal'; import { Colors } from '../colors.js'; -import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js'; +import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; interface QwenOAuthProgressProps { onTimeout: () => void; onCancel: () => void; - deviceAuth?: DeviceAuthorizationInfo; + deviceAuth?: DeviceAuthorizationData; authStatus?: | 'idle' | 'polling' @@ -131,8 +131,8 @@ export function QwenOAuthProgress({ useKeypress( (key) => { - if (authStatus === 'timeout') { - // Any key press in timeout state should trigger cancel to return to auth dialog + if (authStatus === 'timeout' || authStatus === 'error') { + // Any key press in timeout or error state should trigger cancel to return to auth dialog onCancel(); } else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { onCancel(); @@ -234,6 +234,35 @@ export function QwenOAuthProgress({ ); } + if (authStatus === 'error') { + return ( + + + Qwen OAuth Authentication Error + + + + + {authMessage || + 'An error occurred during authentication. Please try again.'} + + + + + + Press any key to return to authentication type selection. + + + + ); + } + // Show loading state when no device auth is available yet if (!deviceAuth) { return ( diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 25ba9ec00..bbd18ecfa 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -487,8 +487,11 @@ describe('SettingsDialog', () => { it('loops back when reaching the end of an enum', async () => { vi.mocked(saveModifiedSettings).mockClear(); vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA); - const settings = createMockSettings(); - settings.setValue(SettingScope.User, 'ui.theme', StringEnum.BAZ); + const settings = createMockSettings({ + ui: { + theme: StringEnum.BAZ, + }, + }); const onSelect = vi.fn(); const component = ( diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 409b4c4c7..4788f7fac 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -16,6 +16,7 @@ import { import { type SettingScope } from '../../config/settings.js'; import type { AuthState } from '../types.js'; import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; +import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js'; export interface UIActions { handleThemeSelect: ( @@ -30,12 +31,11 @@ export interface UIActions { handleAuthSelect: ( authType: AuthType | undefined, scope: SettingScope, - ) => void; + credentials?: OpenAICredentials, + ) => Promise; setAuthState: (state: AuthState) => void; onAuthError: (error: string) => void; - // Qwen OAuth handlers - handleQwenAuthTimeout: () => void; - handleQwenAuthCancel: () => void; + cancelAuthentication: () => void; handleEditorSelect: ( editorType: EditorType | undefined, scope: SettingScope, diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index fae2db663..21ff5389f 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -16,10 +16,11 @@ import type { HistoryItemWithoutId, StreamingState, } from '../types.js'; -import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js'; +import type { QwenAuthState } from '../hooks/useQwenAuth.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; import type { + AuthType, IdeContext, ApprovalMode, UserTierId, @@ -49,18 +50,9 @@ export interface UIState { isConfigInitialized: boolean; authError: string | null; isAuthDialogOpen: boolean; + pendingAuthType: AuthType | undefined; // Qwen OAuth state - isQwenAuth: boolean; - isQwenAuthenticating: boolean; - deviceAuth: DeviceAuthorizationInfo | null; - authStatus: - | 'idle' - | 'polling' - | 'success' - | 'error' - | 'timeout' - | 'rate_limit'; - authMessage: string | null; + qwenAuthState: QwenAuthState; editorError: string | null; isEditorDialogOpen: boolean; corgiMode: boolean; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 06e221ac7..70a06abc9 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -7,6 +7,7 @@ import { useCallback } from 'react'; import { SettingScope } from '../../config/settings.js'; import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core'; +import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js'; export interface DialogCloseOptions { // Theme dialog @@ -25,8 +26,9 @@ export interface DialogCloseOptions { handleAuthSelect: ( authType: AuthType | undefined, scope: SettingScope, + credentials?: OpenAICredentials, ) => Promise; - selectedAuthType: AuthType | undefined; + pendingAuthType: AuthType | undefined; // Editor dialog isEditorDialogOpen: boolean; diff --git a/packages/cli/src/ui/hooks/useInitializationAuthError.ts b/packages/cli/src/ui/hooks/useInitializationAuthError.ts new file mode 100644 index 000000000..bb25d323d --- /dev/null +++ b/packages/cli/src/ui/hooks/useInitializationAuthError.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef } from 'react'; + +/** + * Hook that handles initialization authentication error only once. + * This ensures that if an auth error occurred during app initialization, + * it is reported to the user exactly once, even if the component re-renders. + * + * @param authError - The authentication error from initialization, or null if no error. + * @param onAuthError - Callback function to handle the authentication error. + * + * @example + * ```tsx + * useInitializationAuthError( + * initializationResult.authError, + * onAuthError + * ); + * ``` + */ +export const useInitializationAuthError = ( + authError: string | null, + onAuthError: (error: string) => void, +): void => { + const hasHandled = useRef(false); + const authErrorRef = useRef(authError); + const onAuthErrorRef = useRef(onAuthError); + + // Update refs to always use latest values + authErrorRef.current = authError; + onAuthErrorRef.current = onAuthError; + + useEffect(() => { + if (hasHandled.current) { + return; + } + + if (authErrorRef.current) { + hasHandled.current = true; + onAuthErrorRef.current(authErrorRef.current); + } + }, [authError, onAuthError]); +}; diff --git a/packages/cli/src/ui/hooks/useQwenAuth.test.ts b/packages/cli/src/ui/hooks/useQwenAuth.test.ts index 1e104136c..06644a00e 100644 --- a/packages/cli/src/ui/hooks/useQwenAuth.test.ts +++ b/packages/cli/src/ui/hooks/useQwenAuth.test.ts @@ -6,14 +6,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; -import type { DeviceAuthorizationInfo } from './useQwenAuth.js'; +import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core'; import { useQwenAuth } from './useQwenAuth.js'; import { AuthType, qwenOAuth2Events, QwenOAuth2Event, } from '@qwen-code/qwen-code-core'; -import type { LoadedSettings } from '../../config/settings.js'; // Mock the qwenOAuth2Events vi.mock('@qwen-code/qwen-code-core', async () => { @@ -36,24 +35,14 @@ vi.mock('@qwen-code/qwen-code-core', async () => { const mockQwenOAuth2Events = vi.mocked(qwenOAuth2Events); describe('useQwenAuth', () => { - const mockDeviceAuth: DeviceAuthorizationInfo = { + const mockDeviceAuth: DeviceAuthorizationData = { verification_uri: 'https://oauth.qwen.com/device', verification_uri_complete: 'https://oauth.qwen.com/device?user_code=ABC123', user_code: 'ABC123', expires_in: 1800, + device_code: 'device_code_123', }; - const createMockSettings = (authType: AuthType): LoadedSettings => - ({ - merged: { - security: { - auth: { - selectedType: authType, - }, - }, - }, - }) as LoadedSettings; - beforeEach(() => { vi.clearAllMocks(); }); @@ -63,36 +52,33 @@ describe('useQwenAuth', () => { }); it('should initialize with default state when not Qwen auth', () => { - const settings = createMockSettings(AuthType.USE_GEMINI); - const { result } = renderHook(() => useQwenAuth(settings, false)); + const { result } = renderHook(() => + useQwenAuth(AuthType.USE_GEMINI, false), + ); - expect(result.current).toEqual({ - isQwenAuthenticating: false, + expect(result.current.qwenAuthState).toEqual({ deviceAuth: null, authStatus: 'idle', authMessage: null, - isQwenAuth: false, - cancelQwenAuth: expect.any(Function), }); + expect(result.current.cancelQwenAuth).toBeInstanceOf(Function); }); it('should initialize with default state when Qwen auth but not authenticating', () => { - const settings = createMockSettings(AuthType.QWEN_OAUTH); - const { result } = renderHook(() => useQwenAuth(settings, false)); + const { result } = renderHook(() => + useQwenAuth(AuthType.QWEN_OAUTH, false), + ); - expect(result.current).toEqual({ - isQwenAuthenticating: false, + expect(result.current.qwenAuthState).toEqual({ deviceAuth: null, authStatus: 'idle', authMessage: null, - isQwenAuth: true, - cancelQwenAuth: expect.any(Function), }); + expect(result.current.cancelQwenAuth).toBeInstanceOf(Function); }); it('should set up event listeners when Qwen auth and authenticating', () => { - const settings = createMockSettings(AuthType.QWEN_OAUTH); - renderHook(() => useQwenAuth(settings, true)); + renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true)); expect(mockQwenOAuth2Events.on).toHaveBeenCalledWith( QwenOAuth2Event.AuthUri, @@ -105,8 +91,7 @@ describe('useQwenAuth', () => { }); it('should handle device auth event', () => { - const settings = createMockSettings(AuthType.QWEN_OAUTH); - let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void; + let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void; mockQwenOAuth2Events.on.mockImplementation((event, handler) => { if (event === QwenOAuth2Event.AuthUri) { @@ -115,19 +100,17 @@ describe('useQwenAuth', () => { return mockQwenOAuth2Events; }); - const { result } = renderHook(() => useQwenAuth(settings, true)); + const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true)); act(() => { handleDeviceAuth!(mockDeviceAuth); }); - expect(result.current.deviceAuth).toEqual(mockDeviceAuth); - expect(result.current.authStatus).toBe('polling'); - expect(result.current.isQwenAuthenticating).toBe(true); + expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth); + expect(result.current.qwenAuthState.authStatus).toBe('polling'); }); it('should handle auth progress event - success', () => { - const settings = createMockSettings(AuthType.QWEN_OAUTH); let handleAuthProgress: ( status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', message?: string, @@ -140,18 +123,19 @@ describe('useQwenAuth', () => { return mockQwenOAuth2Events; }); - const { result } = renderHook(() => useQwenAuth(settings, true)); + const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true)); act(() => { handleAuthProgress!('success', 'Authentication successful!'); }); - expect(result.current.authStatus).toBe('success'); - expect(result.current.authMessage).toBe('Authentication successful!'); + expect(result.current.qwenAuthState.authStatus).toBe('success'); + expect(result.current.qwenAuthState.authMessage).toBe( + 'Authentication successful!', + ); }); it('should handle auth progress event - error', () => { - const settings = createMockSettings(AuthType.QWEN_OAUTH); let handleAuthProgress: ( status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', message?: string, @@ -164,18 +148,19 @@ describe('useQwenAuth', () => { return mockQwenOAuth2Events; }); - const { result } = renderHook(() => useQwenAuth(settings, true)); + const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true)); act(() => { handleAuthProgress!('error', 'Authentication failed'); }); - expect(result.current.authStatus).toBe('error'); - expect(result.current.authMessage).toBe('Authentication failed'); + expect(result.current.qwenAuthState.authStatus).toBe('error'); + expect(result.current.qwenAuthState.authMessage).toBe( + 'Authentication failed', + ); }); it('should handle auth progress event - polling', () => { - const settings = createMockSettings(AuthType.QWEN_OAUTH); let handleAuthProgress: ( status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', message?: string, @@ -188,20 +173,19 @@ describe('useQwenAuth', () => { return mockQwenOAuth2Events; }); - const { result } = renderHook(() => useQwenAuth(settings, true)); + const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true)); act(() => { handleAuthProgress!('polling', 'Waiting for user authorization...'); }); - expect(result.current.authStatus).toBe('polling'); - expect(result.current.authMessage).toBe( + expect(result.current.qwenAuthState.authStatus).toBe('polling'); + expect(result.current.qwenAuthState.authMessage).toBe( 'Waiting for user authorization...', ); }); it('should handle auth progress event - rate_limit', () => { - const settings = createMockSettings(AuthType.QWEN_OAUTH); let handleAuthProgress: ( status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', message?: string, @@ -214,7 +198,7 @@ describe('useQwenAuth', () => { return mockQwenOAuth2Events; }); - const { result } = renderHook(() => useQwenAuth(settings, true)); + const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true)); act(() => { handleAuthProgress!( @@ -223,14 +207,13 @@ describe('useQwenAuth', () => { ); }); - expect(result.current.authStatus).toBe('rate_limit'); - expect(result.current.authMessage).toBe( + expect(result.current.qwenAuthState.authStatus).toBe('rate_limit'); + expect(result.current.qwenAuthState.authMessage).toBe( 'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.', ); }); it('should handle auth progress event without message', () => { - const settings = createMockSettings(AuthType.QWEN_OAUTH); let handleAuthProgress: ( status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', message?: string, @@ -243,27 +226,30 @@ describe('useQwenAuth', () => { return mockQwenOAuth2Events; }); - const { result } = renderHook(() => useQwenAuth(settings, true)); + const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true)); act(() => { handleAuthProgress!('success'); }); - expect(result.current.authStatus).toBe('success'); - expect(result.current.authMessage).toBe(null); + expect(result.current.qwenAuthState.authStatus).toBe('success'); + expect(result.current.qwenAuthState.authMessage).toBe(null); }); it('should clean up event listeners when auth type changes', () => { - const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH); const { rerender } = renderHook( - ({ settings, isAuthenticating }) => - useQwenAuth(settings, isAuthenticating), - { initialProps: { settings: qwenSettings, isAuthenticating: true } }, + ({ pendingAuthType, isAuthenticating }) => + useQwenAuth(pendingAuthType, isAuthenticating), + { + initialProps: { + pendingAuthType: AuthType.QWEN_OAUTH, + isAuthenticating: true, + }, + }, ); // Change to non-Qwen auth - const geminiSettings = createMockSettings(AuthType.USE_GEMINI); - rerender({ settings: geminiSettings, isAuthenticating: true }); + rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true }); expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith( QwenOAuth2Event.AuthUri, @@ -276,9 +262,9 @@ describe('useQwenAuth', () => { }); it('should clean up event listeners when authentication stops', () => { - const settings = createMockSettings(AuthType.QWEN_OAUTH); const { rerender } = renderHook( - ({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating), + ({ isAuthenticating }) => + useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating), { initialProps: { isAuthenticating: true } }, ); @@ -296,8 +282,9 @@ describe('useQwenAuth', () => { }); it('should clean up event listeners on unmount', () => { - const settings = createMockSettings(AuthType.QWEN_OAUTH); - const { unmount } = renderHook(() => useQwenAuth(settings, true)); + const { unmount } = renderHook(() => + useQwenAuth(AuthType.QWEN_OAUTH, true), + ); unmount(); @@ -312,8 +299,7 @@ describe('useQwenAuth', () => { }); it('should reset state when switching from Qwen auth to another auth type', () => { - const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH); - let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void; + let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void; mockQwenOAuth2Events.on.mockImplementation((event, handler) => { if (event === QwenOAuth2Event.AuthUri) { @@ -323,9 +309,14 @@ describe('useQwenAuth', () => { }); const { result, rerender } = renderHook( - ({ settings, isAuthenticating }) => - useQwenAuth(settings, isAuthenticating), - { initialProps: { settings: qwenSettings, isAuthenticating: true } }, + ({ pendingAuthType, isAuthenticating }) => + useQwenAuth(pendingAuthType, isAuthenticating), + { + initialProps: { + pendingAuthType: AuthType.QWEN_OAUTH, + isAuthenticating: true, + }, + }, ); // Simulate device auth @@ -333,22 +324,19 @@ describe('useQwenAuth', () => { handleDeviceAuth!(mockDeviceAuth); }); - expect(result.current.deviceAuth).toEqual(mockDeviceAuth); - expect(result.current.authStatus).toBe('polling'); + expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth); + expect(result.current.qwenAuthState.authStatus).toBe('polling'); // Switch to different auth type - const geminiSettings = createMockSettings(AuthType.USE_GEMINI); - rerender({ settings: geminiSettings, isAuthenticating: true }); + rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true }); - expect(result.current.isQwenAuthenticating).toBe(false); - expect(result.current.deviceAuth).toBe(null); - expect(result.current.authStatus).toBe('idle'); - expect(result.current.authMessage).toBe(null); + expect(result.current.qwenAuthState.deviceAuth).toBe(null); + expect(result.current.qwenAuthState.authStatus).toBe('idle'); + expect(result.current.qwenAuthState.authMessage).toBe(null); }); it('should reset state when authentication stops', () => { - const settings = createMockSettings(AuthType.QWEN_OAUTH); - let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void; + let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void; mockQwenOAuth2Events.on.mockImplementation((event, handler) => { if (event === QwenOAuth2Event.AuthUri) { @@ -358,7 +346,8 @@ describe('useQwenAuth', () => { }); const { result, rerender } = renderHook( - ({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating), + ({ isAuthenticating }) => + useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating), { initialProps: { isAuthenticating: true } }, ); @@ -367,21 +356,19 @@ describe('useQwenAuth', () => { handleDeviceAuth!(mockDeviceAuth); }); - expect(result.current.deviceAuth).toEqual(mockDeviceAuth); - expect(result.current.authStatus).toBe('polling'); + expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth); + expect(result.current.qwenAuthState.authStatus).toBe('polling'); // Stop authentication rerender({ isAuthenticating: false }); - expect(result.current.isQwenAuthenticating).toBe(false); - expect(result.current.deviceAuth).toBe(null); - expect(result.current.authStatus).toBe('idle'); - expect(result.current.authMessage).toBe(null); + expect(result.current.qwenAuthState.deviceAuth).toBe(null); + expect(result.current.qwenAuthState.authStatus).toBe('idle'); + expect(result.current.qwenAuthState.authMessage).toBe(null); }); it('should handle cancelQwenAuth function', () => { - const settings = createMockSettings(AuthType.QWEN_OAUTH); - let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void; + let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void; mockQwenOAuth2Events.on.mockImplementation((event, handler) => { if (event === QwenOAuth2Event.AuthUri) { @@ -390,53 +377,49 @@ describe('useQwenAuth', () => { return mockQwenOAuth2Events; }); - const { result } = renderHook(() => useQwenAuth(settings, true)); + const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true)); // Set up some state act(() => { handleDeviceAuth!(mockDeviceAuth); }); - expect(result.current.deviceAuth).toEqual(mockDeviceAuth); + expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth); // Cancel auth act(() => { result.current.cancelQwenAuth(); }); - expect(result.current.isQwenAuthenticating).toBe(false); - expect(result.current.deviceAuth).toBe(null); - expect(result.current.authStatus).toBe('idle'); - expect(result.current.authMessage).toBe(null); + expect(result.current.qwenAuthState.deviceAuth).toBe(null); + expect(result.current.qwenAuthState.authStatus).toBe('idle'); + expect(result.current.qwenAuthState.authMessage).toBe(null); }); - it('should maintain isQwenAuth flag correctly', () => { - // Test with Qwen OAuth - const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH); + it('should handle different auth types correctly', () => { + // Test with Qwen OAuth - should set up event listeners when authenticating const { result: qwenResult } = renderHook(() => - useQwenAuth(qwenSettings, false), + useQwenAuth(AuthType.QWEN_OAUTH, true), ); - expect(qwenResult.current.isQwenAuth).toBe(true); + expect(qwenResult.current.qwenAuthState.authStatus).toBe('idle'); + expect(mockQwenOAuth2Events.on).toHaveBeenCalled(); - // Test with other auth types - const geminiSettings = createMockSettings(AuthType.USE_GEMINI); + // Test with other auth types - should not set up event listeners const { result: geminiResult } = renderHook(() => - useQwenAuth(geminiSettings, false), + useQwenAuth(AuthType.USE_GEMINI, true), ); - expect(geminiResult.current.isQwenAuth).toBe(false); + expect(geminiResult.current.qwenAuthState.authStatus).toBe('idle'); - const oauthSettings = createMockSettings(AuthType.LOGIN_WITH_GOOGLE); const { result: oauthResult } = renderHook(() => - useQwenAuth(oauthSettings, false), + useQwenAuth(AuthType.LOGIN_WITH_GOOGLE, true), ); - expect(oauthResult.current.isQwenAuth).toBe(false); + expect(oauthResult.current.qwenAuthState.authStatus).toBe('idle'); }); - it('should set isQwenAuthenticating to true when starting authentication with Qwen auth', () => { - const settings = createMockSettings(AuthType.QWEN_OAUTH); - const { result } = renderHook(() => useQwenAuth(settings, true)); + it('should initialize with idle status when starting authentication with Qwen auth', () => { + const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true)); - expect(result.current.isQwenAuthenticating).toBe(true); - expect(result.current.authStatus).toBe('idle'); + expect(result.current.qwenAuthState.authStatus).toBe('idle'); + expect(mockQwenOAuth2Events.on).toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/ui/hooks/useQwenAuth.ts b/packages/cli/src/ui/hooks/useQwenAuth.ts index 44cd8fdf5..2b1819c1c 100644 --- a/packages/cli/src/ui/hooks/useQwenAuth.ts +++ b/packages/cli/src/ui/hooks/useQwenAuth.ts @@ -5,23 +5,15 @@ */ import { useState, useCallback, useEffect } from 'react'; -import type { LoadedSettings } from '../../config/settings.js'; import { AuthType, qwenOAuth2Events, QwenOAuth2Event, + type DeviceAuthorizationData, } from '@qwen-code/qwen-code-core'; -export interface DeviceAuthorizationInfo { - verification_uri: string; - verification_uri_complete: string; - user_code: string; - expires_in: number; -} - -interface QwenAuthState { - isQwenAuthenticating: boolean; - deviceAuth: DeviceAuthorizationInfo | null; +export interface QwenAuthState { + deviceAuth: DeviceAuthorizationData | null; authStatus: | 'idle' | 'polling' @@ -33,25 +25,22 @@ interface QwenAuthState { } export const useQwenAuth = ( - settings: LoadedSettings, + pendingAuthType: AuthType | undefined, isAuthenticating: boolean, ) => { const [qwenAuthState, setQwenAuthState] = useState({ - isQwenAuthenticating: false, deviceAuth: null, authStatus: 'idle', authMessage: null, }); - const isQwenAuth = - settings.merged.security?.auth?.selectedType === AuthType.QWEN_OAUTH; + const isQwenAuth = pendingAuthType === AuthType.QWEN_OAUTH; // Set up event listeners when authentication starts useEffect(() => { if (!isQwenAuth || !isAuthenticating) { // Reset state when not authenticating or not Qwen auth setQwenAuthState({ - isQwenAuthenticating: false, deviceAuth: null, authStatus: 'idle', authMessage: null, @@ -61,12 +50,11 @@ export const useQwenAuth = ( setQwenAuthState((prev) => ({ ...prev, - isQwenAuthenticating: true, authStatus: 'idle', })); // Set up event listeners - const handleDeviceAuth = (deviceAuth: DeviceAuthorizationInfo) => { + const handleDeviceAuth = (deviceAuth: DeviceAuthorizationData) => { setQwenAuthState((prev) => ({ ...prev, deviceAuth: { @@ -74,6 +62,7 @@ export const useQwenAuth = ( verification_uri_complete: deviceAuth.verification_uri_complete, user_code: deviceAuth.user_code, expires_in: deviceAuth.expires_in, + device_code: deviceAuth.device_code, }, authStatus: 'polling', })); @@ -106,7 +95,6 @@ export const useQwenAuth = ( qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel); setQwenAuthState({ - isQwenAuthenticating: false, deviceAuth: null, authStatus: 'idle', authMessage: null, @@ -114,8 +102,7 @@ export const useQwenAuth = ( }, []); return { - ...qwenAuthState, - isQwenAuth, + qwenAuthState, cancelQwenAuth, }; }; diff --git a/packages/core/index.ts b/packages/core/index.ts index c5f3ee41f..3227199e4 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -30,6 +30,7 @@ export { logExtensionEnable, logIdeConnection, logExtensionDisable, + logAuth, } from './src/telemetry/loggers.js'; export { @@ -40,6 +41,7 @@ export { ExtensionEnableEvent, ExtensionUninstallEvent, ModelSlashCommandEvent, + AuthEvent, } from './src/telemetry/types.js'; export { makeFakeConfig } from './src/test-utils/config.js'; export * from './src/utils/pathReader.js'; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 93a650353..c8fa74abe 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -562,7 +562,7 @@ export class Config { } } - async refreshAuth(authMethod: AuthType) { + async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) { // Vertex and Genai have incompatible encryption and sending history with // throughtSignature from Genai to Vertex will fail, we need to strip them if ( @@ -582,6 +582,7 @@ export class Config { newContentGeneratorConfig, this, this.getSessionId(), + isInitialAuth, ); // Only assign to instance properties after successful initialization this.contentGeneratorConfig = newContentGeneratorConfig; diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 4d0d33a91..2218832ed 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -120,6 +120,7 @@ export async function createContentGenerator( config: ContentGeneratorConfig, gcConfig: Config, sessionId?: string, + isInitialAuth?: boolean, ): Promise { const version = process.env['CLI_VERSION'] || process.version; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; @@ -191,13 +192,17 @@ export async function createContentGenerator( try { // Get the Qwen OAuth client (now includes integrated token management) - const qwenClient = await getQwenOauthClient(gcConfig); + // If this is initial auth, require cached credentials to detect missing credentials + const qwenClient = await getQwenOauthClient( + gcConfig, + isInitialAuth ? { requireCachedCredentials: true } : undefined, + ); // Create the content generator with dynamic token management return new QwenContentGenerator(qwenClient, config, gcConfig); } catch (error) { throw new Error( - `Failed to initialize Qwen: ${error instanceof Error ? error.message : String(error)}`, + `${error instanceof Error ? error.message : String(error)}`, ); } } diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 3369f22ca..2e8bf83e0 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -825,7 +825,7 @@ describe('getQwenOAuthClient', () => { import('./qwenOAuth2.js').then((module) => module.getQwenOAuthClient(mockConfig), ), - ).rejects.toThrow('Qwen OAuth authentication failed'); + ).rejects.toThrow('Device authorization flow failed'); SharedTokenManager.getInstance = originalGetInstance; }); @@ -983,7 +983,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { import('./qwenOAuth2.js').then((module) => module.getQwenOAuthClient(mockConfig), ), - ).rejects.toThrow('Qwen OAuth authentication failed'); + ).rejects.toThrow('Device authorization flow failed'); SharedTokenManager.getInstance = originalGetInstance; }); @@ -1032,7 +1032,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { import('./qwenOAuth2.js').then((module) => module.getQwenOAuthClient(mockConfig), ), - ).rejects.toThrow('Qwen OAuth authentication timed out'); + ).rejects.toThrow('Authorization timeout, please restart the process.'); SharedTokenManager.getInstance = originalGetInstance; }); @@ -1082,7 +1082,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { module.getQwenOAuthClient(mockConfig), ), ).rejects.toThrow( - 'Too many request for Qwen OAuth authentication, please try again later.', + 'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.', ); SharedTokenManager.getInstance = originalGetInstance; @@ -1119,7 +1119,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { import('./qwenOAuth2.js').then((module) => module.getQwenOAuthClient(mockConfig), ), - ).rejects.toThrow('Qwen OAuth authentication failed'); + ).rejects.toThrow('Device authorization flow failed'); SharedTokenManager.getInstance = originalGetInstance; }); @@ -1177,7 +1177,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => { import('./qwenOAuth2.js').then((module) => module.getQwenOAuthClient(mockConfig), ), - ).rejects.toThrow('Qwen OAuth authentication failed'); + ).rejects.toThrow('Device authorization flow failed'); SharedTokenManager.getInstance = originalGetInstance; }); @@ -1264,7 +1264,9 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => { import('./qwenOAuth2.js').then((module) => module.getQwenOAuthClient(mockConfig), ), - ).rejects.toThrow('Qwen OAuth authentication failed'); + ).rejects.toThrow( + 'Device code expired or invalid, please restart the authorization process.', + ); SharedTokenManager.getInstance = originalGetInstance; }); diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index 7a4c7beb8..b9a35bfff 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -467,6 +467,7 @@ export type AuthResult = | { success: false; reason: 'timeout' | 'cancelled' | 'error' | 'rate_limit'; + message?: string; // Detailed error message for better error reporting }; /** @@ -476,6 +477,7 @@ export const qwenOAuth2Events = new EventEmitter(); export async function getQwenOAuthClient( config: Config, + options?: { requireCachedCredentials?: boolean }, ): Promise { const client = new QwenOAuth2Client(); @@ -488,11 +490,6 @@ export async function getQwenOAuthClient( client.setCredentials(credentials); return client; } catch (error: unknown) { - console.debug( - 'Shared token manager failed, attempting device flow:', - error, - ); - // Handle specific token manager errors if (error instanceof TokenManagerError) { switch (error.type) { @@ -520,12 +517,20 @@ export async function getQwenOAuthClient( // Try device flow instead of forcing refresh const result = await authWithQwenDeviceFlow(client, config); if (!result.success) { - throw new Error('Qwen OAuth authentication failed'); + // Use detailed error message if available, otherwise use default + const errorMessage = + result.message || 'Qwen OAuth authentication failed'; + throw new Error(errorMessage); } return client; } - // No cached credentials, use device authorization flow for authentication + if (options?.requireCachedCredentials) { + throw new Error( + 'No cached Qwen-OAuth credentials found. Please re-authenticate.', + ); + } + const result = await authWithQwenDeviceFlow(client, config); if (!result.success) { // Only emit timeout event if the failure reason is actually timeout @@ -538,20 +543,24 @@ export async function getQwenOAuthClient( ); } - // Throw error with appropriate message based on failure reason - switch (result.reason) { - case 'timeout': - throw new Error('Qwen OAuth authentication timed out'); - case 'cancelled': - throw new Error('Qwen OAuth authentication was cancelled by user'); - case 'rate_limit': - throw new Error( - 'Too many request for Qwen OAuth authentication, please try again later.', - ); - case 'error': - default: - throw new Error('Qwen OAuth authentication failed'); - } + // Use detailed error message if available, otherwise use default based on reason + const errorMessage = + result.message || + (() => { + switch (result.reason) { + case 'timeout': + return 'Qwen OAuth authentication timed out'; + case 'cancelled': + return 'Qwen OAuth authentication was cancelled by user'; + case 'rate_limit': + return 'Too many request for Qwen OAuth authentication, please try again later.'; + case 'error': + default: + return 'Qwen OAuth authentication failed'; + } + })(); + + throw new Error(errorMessage); } return client; @@ -644,13 +653,10 @@ async function authWithQwenDeviceFlow( for (let attempt = 0; attempt < maxAttempts; attempt++) { // Check if authentication was cancelled if (isCancelled) { - console.debug('\nAuthentication cancelled by user.'); - qwenOAuth2Events.emit( - QwenOAuth2Event.AuthProgress, - 'error', - 'Authentication cancelled by user.', - ); - return { success: false, reason: 'cancelled' }; + const message = 'Authentication cancelled by user.'; + console.debug('\n' + message); + qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); + return { success: false, reason: 'cancelled', message }; } try { @@ -738,13 +744,14 @@ async function authWithQwenDeviceFlow( // Check for cancellation after waiting if (isCancelled) { - console.debug('\nAuthentication cancelled by user.'); + const message = 'Authentication cancelled by user.'; + console.debug('\n' + message); qwenOAuth2Events.emit( QwenOAuth2Event.AuthProgress, 'error', - 'Authentication cancelled by user.', + message, ); - return { success: false, reason: 'cancelled' }; + return { success: false, reason: 'cancelled', message }; } continue; @@ -758,7 +765,7 @@ async function authWithQwenDeviceFlow( ); } } catch (error: unknown) { - // Handle specific error cases + // Extract error information const errorMessage = error instanceof Error ? error.message : String(error); const statusCode = @@ -766,42 +773,49 @@ async function authWithQwenDeviceFlow( ? (error as Error & { status?: number }).status : null; - if (errorMessage.includes('401') || statusCode === 401) { - const message = - 'Device code expired or invalid, please restart the authorization process.'; + // Helper function to handle error and stop polling + const handleError = ( + reason: 'error' | 'rate_limit', + message: string, + eventType: 'error' | 'rate_limit' = 'error', + ): AuthResult => { + qwenOAuth2Events.emit( + QwenOAuth2Event.AuthProgress, + eventType, + message, + ); + console.error('\n' + message); + return { success: false, reason, message }; + }; - // Emit error event - qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); + // Handle credential caching failures - stop polling immediately + if (errorMessage.includes('Failed to cache credentials')) { + return handleError('error', errorMessage); + } - return { success: false, reason: 'error' }; + // Handle 401 Unauthorized - device code expired or invalid + if (errorMessage.includes('401') || statusCode === 401) { + return handleError( + 'error', + 'Device code expired or invalid, please restart the authorization process.', + ); } - // Handle 429 Too Many Requests error + // Handle 429 Too Many Requests - rate limiting if (errorMessage.includes('429') || statusCode === 429) { - const message = - 'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.'; - - // Emit rate limit event to notify user - qwenOAuth2Events.emit( - QwenOAuth2Event.AuthProgress, + return handleError( + 'rate_limit', + 'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.', 'rate_limit', - message, ); - - console.log('\n' + message); - - // Return false to stop polling and go back to auth selection - return { success: false, reason: 'rate_limit' }; } const message = `Error polling for token: ${errorMessage}`; - - // Emit error event qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); - // Check for cancellation before waiting if (isCancelled) { - return { success: false, reason: 'cancelled' }; + const message = 'Authentication cancelled by user.'; + return { success: false, reason: 'cancelled', message }; } await new Promise((resolve) => setTimeout(resolve, pollInterval)); @@ -818,11 +832,12 @@ async function authWithQwenDeviceFlow( ); console.error('\n' + timeoutMessage); - return { success: false, reason: 'timeout' }; + return { success: false, reason: 'timeout', message: timeoutMessage }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error('Device authorization flow failed:', errorMessage); - return { success: false, reason: 'error' }; + const message = `Device authorization flow failed: ${errorMessage}`; + console.error(message); + return { success: false, reason: 'error', message }; } finally { // Clean up event listener qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler); @@ -852,10 +867,30 @@ async function loadCachedQwenCredentials( async function cacheQwenCredentials(credentials: QwenCredentials) { const filePath = getQwenCachedCredentialPath(); - await fs.mkdir(path.dirname(filePath), { recursive: true }); + try { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + + const credString = JSON.stringify(credentials, null, 2); + await fs.writeFile(filePath, credString); + } catch (error: unknown) { + // Handle file system errors (e.g., EACCES permission denied) + const errorMessage = error instanceof Error ? error.message : String(error); + const errorCode = + error instanceof Error && 'code' in error + ? (error as Error & { code?: string }).code + : undefined; + + if (errorCode === 'EACCES') { + throw new Error( + `Failed to cache credentials: Permission denied (EACCES). Current user has no permission to access \`${filePath}\`. Please check permissions.`, + ); + } - const credString = JSON.stringify(credentials, null, 2); - await fs.writeFile(filePath, credString); + // Throw error for other file system failures + throw new Error( + `Failed to cache credentials: error when creating folder \`${path.dirname(filePath)}\` and writing to \`${filePath}\`. ${errorMessage}. Please check permissions.`, + ); + } } /** diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index fc8affedf..bc6546379 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -33,6 +33,7 @@ export const EVENT_MALFORMED_JSON_RESPONSE = export const EVENT_FILE_OPERATION = 'qwen-code.file_operation'; export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model'; export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution'; +export const EVENT_AUTH = 'qwen-code.auth'; // Performance Events export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance'; diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 6d2b6d9eb..16c230baf 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -43,6 +43,7 @@ export { logExtensionUninstall, logRipgrepFallback, logNextSpeakerCheck, + logAuth, } from './loggers.js'; export type { SlashCommandEvent, ChatCompressionEvent } from './types.js'; export { @@ -61,6 +62,7 @@ export { ToolOutputTruncatedEvent, RipgrepFallbackEvent, NextSpeakerCheckEvent, + AuthEvent, } from './types.js'; export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js'; export type { TelemetryEvent } from './types.js'; diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index fdb8810e7..5b56719ba 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -37,6 +37,7 @@ import { EVENT_SUBAGENT_EXECUTION, EVENT_MALFORMED_JSON_RESPONSE, EVENT_INVALID_CHUNK, + EVENT_AUTH, } from './constants.js'; import { recordApiErrorMetrics, @@ -83,6 +84,7 @@ import type { SubagentExecutionEvent, MalformedJsonResponseEvent, InvalidChunkEvent, + AuthEvent, } from './types.js'; import type { UiEvent } from './uiTelemetry.js'; import { uiTelemetryService } from './uiTelemetry.js'; @@ -838,3 +840,29 @@ export function logExtensionDisable( }; logger.emit(logRecord); } + +export function logAuth(config: Config, event: AuthEvent): void { + QwenLogger.getInstance(config)?.logAuthEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_AUTH, + 'event.timestamp': new Date().toISOString(), + auth_type: event.auth_type, + action_type: event.action_type, + status: event.status, + }; + + if (event.error_message) { + attributes['error.message'] = event.error_message; + } + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Auth event: ${event.action_type} ${event.status} for ${event.auth_type}`, + attributes, + }; + logger.emit(logRecord); +} diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index 96f796e4a..ef16562ba 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -36,6 +36,7 @@ import type { ExtensionEnableEvent, ModelSlashCommandEvent, ExtensionDisableEvent, + AuthEvent, } from '../types.js'; import { EndSessionEvent } from '../types.js'; import type { @@ -735,6 +736,25 @@ export class QwenLogger { this.flushIfNeeded(); } + logAuthEvent(event: AuthEvent): void { + const snapshots: Record = { + auth_type: event.auth_type, + action_type: event.action_type, + status: event.status, + }; + + if (event.error_message) { + snapshots['error_message'] = event.error_message; + } + + const rumEvent = this.createActionEvent('auth', 'auth', { + snapshots: JSON.stringify(snapshots), + }); + + this.enqueueLogEvent(rumEvent); + this.flushIfNeeded(); + } + // misc events logFlashFallbackEvent(event: FlashFallbackEvent): void { const rumEvent = this.createActionEvent('misc', 'flash_fallback', { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 1ba291160..cef833239 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -686,6 +686,29 @@ export class SubagentExecutionEvent implements BaseTelemetryEvent { } } +export class AuthEvent implements BaseTelemetryEvent { + 'event.name': 'auth'; + 'event.timestamp': string; + auth_type: AuthType; + action_type: 'auto' | 'manual'; + status: 'success' | 'error' | 'cancelled'; + error_message?: string; + + constructor( + auth_type: AuthType, + action_type: 'auto' | 'manual', + status: 'success' | 'error' | 'cancelled', + error_message?: string, + ) { + this['event.name'] = 'auth'; + this['event.timestamp'] = new Date().toISOString(); + this.auth_type = auth_type; + this.action_type = action_type; + this.status = status; + this.error_message = error_message; + } +} + export type TelemetryEvent = | StartSessionEvent | EndSessionEvent @@ -713,7 +736,8 @@ export type TelemetryEvent = | ExtensionInstallEvent | ExtensionUninstallEvent | ToolOutputTruncatedEvent - | ModelSlashCommandEvent; + | ModelSlashCommandEvent + | AuthEvent; export class ExtensionDisableEvent implements BaseTelemetryEvent { 'event.name': 'extension_disable';