Merge branch 'main' of github.com:QwenLM/qwen-code into mingholy/feat/cli-sdk

This commit is contained in:
mingholy.lmh
2025-11-19 13:51:17 +08:00
37 changed files with 1075 additions and 782 deletions

View File

@@ -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. - **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` - 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) ## Frequently asked questions (FAQs)
- **Q: How do I update Qwen Code to the latest version?** - **Q: How do I update Qwen Code to the latest version?**

View File

@@ -21,23 +21,21 @@ describe('Interactive Mode', () => {
it.skipIf(process.platform === 'win32')( it.skipIf(process.platform === 'win32')(
'should trigger chat compression with /compress command', 'should trigger chat compression with /compress command',
async () => { async () => {
await rig.setup('interactive-compress-test'); await rig.setup('interactive-compress-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
const { ptyProcess } = rig.runInteractive(); const { ptyProcess } = rig.runInteractive();
let fullOutput = ''; let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data)); ptyProcess.onData((data) => (fullOutput += data));
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// Wait for the app to be ready // Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000); const isReady = await rig.waitForText('Type your message', 15000);
expect( expect(
@@ -68,49 +66,43 @@ describe('Interactive Mode', () => {
}, },
); );
it.skipIf(process.platform === 'win32')( it.skip('should handle compression failure on token inflation', async () => {
'should handle compression failure on token inflation', await rig.setup('interactive-compress-test', {
async () => { settings: {
await rig.setup('interactive-compress-test'); security: {
auth: {
selectedType: 'openai',
},
},
},
});
const { ptyProcess } = rig.runInteractive(); const { ptyProcess } = rig.runInteractive();
let fullOutput = ''; let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data)); ptyProcess.onData((data) => (fullOutput += data));
const authDialogAppeared = await rig.waitForText( // Wait for the app to be ready
'How would you like to authenticate', const isReady = await rig.waitForText('Type your message', 25000);
5000, expect(isReady, 'CLI did not start up in interactive mode correctly').toBe(
); true,
);
// select the second option if auth dialog come's up await type(ptyProcess, '/compress');
if (authDialogAppeared) { await new Promise((resolve) => setTimeout(resolve, 1000));
ptyProcess.write('2'); await type(ptyProcess, '\r');
}
// Wait for the app to be ready const foundEvent = await rig.waitForTelemetryEvent(
const isReady = await rig.waitForText('Type your message', 25000); 'chat_compression',
expect( 90000,
isReady, );
'CLI did not start up in interactive mode correctly', expect(foundEvent).toBe(true);
).toBe(true);
await type(ptyProcess, '/compress'); const compressionFailed = await rig.waitForText(
await new Promise((resolve) => setTimeout(resolve, 100)); 'Nothing to compress.',
await type(ptyProcess, '\r'); 25000,
);
const foundEvent = await rig.waitForTelemetryEvent( expect(compressionFailed).toBe(true);
'chat_compression', });
90000,
);
expect(foundEvent).toBe(true);
const compressionFailed = await rig.waitForText(
'compression was not beneficial',
25000,
);
expect(compressionFailed).toBe(true);
},
);
}); });

View File

@@ -22,21 +22,19 @@ describe('Interactive file system', () => {
'should perform a read-then-write sequence in interactive mode', 'should perform a read-then-write sequence in interactive mode',
async () => { async () => {
const fileName = 'version.txt'; const fileName = 'version.txt';
await rig.setup('interactive-read-then-write'); await rig.setup('interactive-read-then-write', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
rig.createFile(fileName, '1.0.0'); rig.createFile(fileName, '1.0.0');
const { ptyProcess } = rig.runInteractive(); const { ptyProcess } = rig.runInteractive();
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// Wait for the app to be ready // Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000); const isReady = await rig.waitForText('Type your message', 15000);
expect( expect(

View File

@@ -860,5 +860,6 @@ export function saveSettings(settingsFile: SettingsFile): void {
); );
} catch (error) { } catch (error) {
console.error('Error saving user settings file:', error); console.error('Error saving user settings file:', error);
throw error;
} }
} }

View File

@@ -8,6 +8,8 @@ import {
type AuthType, type AuthType,
type Config, type Config,
getErrorMessage, getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
/** /**
@@ -25,11 +27,21 @@ export async function performInitialAuth(
} }
try { try {
await config.refreshAuth(authType); await config.refreshAuth(authType, true);
// The console.log is intentionally left out here. // The console.log is intentionally left out here.
// We can add a dedicated startup message later if needed. // 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) { } 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; return null;

View File

@@ -11,7 +11,7 @@ import {
logIdeConnection, logIdeConnection,
type Config, type Config,
} from '@qwen-code/qwen-code-core'; } 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 { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js'; import { validateTheme } from './theme.js';
@@ -33,10 +33,18 @@ export async function initializeApp(
config: Config, config: Config,
settings: LoadedSettings, settings: LoadedSettings,
): Promise<InitializationResult> { ): Promise<InitializationResult> {
const authError = await performInitialAuth( const authType = settings.merged.security?.auth?.selectedType;
config, const authError = await performInitialAuth(config, authType);
settings.merged.security?.auth?.selectedType,
); // Fallback to user select when initial authentication fails
if (authError) {
settings.setValue(
SettingScope.User,
'security.auth.selectedType',
undefined,
);
}
const themeError = validateTheme(settings); const themeError = validateTheme(settings);
const shouldOpenAuthDialog = const shouldOpenAuthDialog =

View File

@@ -25,7 +25,6 @@ import {
type HistoryItem, type HistoryItem,
ToolCallStatus, ToolCallStatus,
type HistoryItemWithoutId, type HistoryItemWithoutId,
AuthState,
} from './types.js'; } from './types.js';
import { MessageType, StreamingState } from './types.js'; import { MessageType, StreamingState } from './types.js';
import { import {
@@ -48,7 +47,6 @@ import { useHistory } from './hooks/useHistoryManager.js';
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js'; import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './auth/useAuth.js'; import { useAuthCommand } from './auth/useAuth.js';
import { useQwenAuth } from './hooks/useQwenAuth.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js';
@@ -93,6 +91,7 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js'; import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js'; import { useDialogClose } from './hooks/useDialogClose.js';
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js'; import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js';
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
@@ -349,20 +348,13 @@ export const AppContainer = (props: AppContainerProps) => {
onAuthError, onAuthError,
isAuthDialogOpen, isAuthDialogOpen,
isAuthenticating, isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect, handleAuthSelect,
openAuthDialog, openAuthDialog,
cancelAuthentication,
} = useAuthCommand(settings, config); } = useAuthCommand(settings, config);
// Qwen OAuth authentication state
const {
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
cancelQwenAuth,
} = useQwenAuth(settings, isAuthenticating);
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({ const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
config, config,
historyManager, historyManager,
@@ -371,19 +363,7 @@ export const AppContainer = (props: AppContainerProps) => {
setModelSwitchedFromQuotaError, setModelSwitchedFromQuotaError,
}); });
// Handle Qwen OAuth timeout useInitializationAuthError(initializationResult.authError, onAuthError);
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]);
// Sync user tier from config when authentication changes // Sync user tier from config when authentication changes
// TODO: Implement getUserTier() method on Config if needed // TODO: Implement getUserTier() method on Config if needed
@@ -395,6 +375,8 @@ export const AppContainer = (props: AppContainerProps) => {
// Check for enforced auth type mismatch // Check for enforced auth type mismatch
useEffect(() => { useEffect(() => {
// Check for initialization error first
if ( if (
settings.merged.security?.auth?.enforcedType && settings.merged.security?.auth?.enforcedType &&
settings.merged.security?.auth.selectedType && settings.merged.security?.auth.selectedType &&
@@ -959,7 +941,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleApprovalModeSelect, handleApprovalModeSelect,
isAuthDialogOpen, isAuthDialogOpen,
handleAuthSelect, handleAuthSelect,
selectedAuthType: settings.merged.security?.auth?.selectedType, pendingAuthType,
isEditorDialogOpen, isEditorDialogOpen,
exitEditorDialog, exitEditorDialog,
isSettingsDialogOpen, isSettingsDialogOpen,
@@ -1201,7 +1183,7 @@ export const AppContainer = (props: AppContainerProps) => {
isVisionSwitchDialogOpen || isVisionSwitchDialogOpen ||
isPermissionsDialogOpen || isPermissionsDialogOpen ||
isAuthDialogOpen || isAuthDialogOpen ||
(isAuthenticating && isQwenAuthenticating) || isAuthenticating ||
isEditorDialogOpen || isEditorDialogOpen ||
showIdeRestartPrompt || showIdeRestartPrompt ||
!!proQuotaRequest || !!proQuotaRequest ||
@@ -1224,12 +1206,9 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized, isConfigInitialized,
authError, authError,
isAuthDialogOpen, isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state // Qwen OAuth state
isQwenAuth, qwenAuthState,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
editorError, editorError,
isEditorDialogOpen, isEditorDialogOpen,
corgiMode, corgiMode,
@@ -1319,12 +1298,9 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized, isConfigInitialized,
authError, authError,
isAuthDialogOpen, isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state // Qwen OAuth state
isQwenAuth, qwenAuthState,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
editorError, editorError,
isEditorDialogOpen, isEditorDialogOpen,
corgiMode, corgiMode,
@@ -1418,9 +1394,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleAuthSelect, handleAuthSelect,
setAuthState, setAuthState,
onAuthError, onAuthError,
// Qwen OAuth handlers cancelAuthentication,
handleQwenAuthTimeout,
handleQwenAuthCancel,
handleEditorSelect, handleEditorSelect,
exitEditorDialog, exitEditorDialog,
closeSettingsDialog, closeSettingsDialog,
@@ -1454,9 +1428,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleAuthSelect, handleAuthSelect,
setAuthState, setAuthState,
onAuthError, onAuthError,
// Qwen OAuth handlers cancelAuthentication,
handleQwenAuthTimeout,
handleQwenAuthCancel,
handleEditorSelect, handleEditorSelect,
exitEditorDialog, exitEditorDialog,
closeSettingsDialog, closeSettingsDialog,

View File

@@ -9,6 +9,53 @@ import { AuthDialog } from './AuthDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType } from '@qwen-code/qwen-code-core'; import { AuthType } from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../test-utils/render.js'; 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> = {}): UIState => {
// AuthDialog only uses authError and pendingAuthType
const baseState = {
authError: null,
pendingAuthType: undefined,
} as Partial<UIState>;
return {
...baseState,
...overrides,
} as UIState;
};
const createMockUIActions = (overrides: Partial<UIActions> = {}): UIActions => {
// AuthDialog only uses handleAuthSelect
const baseActions = {
handleAuthSelect: vi.fn(),
} as Partial<UIActions>;
return {
...baseActions,
...overrides,
} as UIActions;
};
const renderAuthDialog = (
settings: LoadedSettings,
uiStateOverrides: Partial<UIState> = {},
uiActionsOverrides: Partial<UIActions> = {},
) => {
const uiState = createMockUIState(uiStateOverrides);
const uiActions = createMockUIActions(uiActionsOverrides);
return renderWithProviders(
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<AuthDialog />
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings },
);
};
describe('AuthDialog', () => { describe('AuthDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -66,13 +113,9 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings, {
<AuthDialog authError: 'GEMINI_API_KEY environment variable not found',
onSelect={() => {}} });
settings={settings}
initialErrorMessage="GEMINI_API_KEY environment variable not found"
/>,
);
expect(lastFrame()).toContain( expect(lastFrame()).toContain(
'GEMINI_API_KEY environment variable not found', 'GEMINI_API_KEY environment variable not found',
@@ -116,9 +159,7 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings);
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog only shows OpenAI option now, // Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages // it won't show GEMINI_API_KEY messages
@@ -162,9 +203,7 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings);
<AuthDialog onSelect={() => {}} settings={settings} />,
);
expect(lastFrame()).not.toContain( expect(lastFrame()).not.toContain(
'Existing API key detected (GEMINI_API_KEY)', 'Existing API key detected (GEMINI_API_KEY)',
@@ -208,9 +247,7 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings);
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog only shows OpenAI option now, // Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages // it won't show GEMINI_API_KEY messages
@@ -255,9 +292,7 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings);
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// This is a bit brittle, but it's the best way to check which item is selected. // This is a bit brittle, but it's the best way to check which item is selected.
expect(lastFrame()).toContain('● 2. OpenAI'); expect(lastFrame()).toContain('● 2. OpenAI');
@@ -297,9 +332,7 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings);
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Default is Qwen OAuth (first option) // Default is Qwen OAuth (first option)
expect(lastFrame()).toContain('● 1. Qwen OAuth'); expect(lastFrame()).toContain('● 1. Qwen OAuth');
@@ -341,9 +374,7 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings);
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore, // Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default Qwen OAuth option // 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 () => { 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( const settings: LoadedSettings = new LoadedSettings(
{ {
settings: { ui: { customThemes: {} }, mcpServers: {} }, settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -386,8 +417,10 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame, stdin, unmount } = renderWithProviders( const { lastFrame, stdin, unmount } = renderAuthDialog(
<AuthDialog onSelect={onSelect} settings={settings} />, settings,
{},
{ handleAuthSelect },
); );
await wait(); await wait();
@@ -395,16 +428,16 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key stdin.write('\u001b'); // ESC key
await wait(); await wait();
// Should show error message instead of calling onSelect // Should show error message instead of calling handleAuthSelect
expect(lastFrame()).toContain( expect(lastFrame()).toContain(
'You must select an auth method to proceed. Press Ctrl+C again to exit.', 'You must select an auth method to proceed. Press Ctrl+C again to exit.',
); );
expect(onSelect).not.toHaveBeenCalled(); expect(handleAuthSelect).not.toHaveBeenCalled();
unmount(); unmount();
}); });
it('should not exit if there is already an error message', async () => { 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( const settings: LoadedSettings = new LoadedSettings(
{ {
settings: { ui: { customThemes: {} }, mcpServers: {} }, settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -438,12 +471,10 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame, stdin, unmount } = renderWithProviders( const { lastFrame, stdin, unmount } = renderAuthDialog(
<AuthDialog settings,
onSelect={onSelect} { authError: 'Initial error' },
settings={settings} { handleAuthSelect },
initialErrorMessage="Initial error"
/>,
); );
await wait(); await wait();
@@ -453,13 +484,13 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key stdin.write('\u001b'); // ESC key
await wait(); await wait();
// Should not call onSelect // Should not call handleAuthSelect
expect(onSelect).not.toHaveBeenCalled(); expect(handleAuthSelect).not.toHaveBeenCalled();
unmount(); unmount();
}); });
it('should allow exiting when auth method is already selected', async () => { it('should allow exiting when auth method is already selected', async () => {
const onSelect = vi.fn(); const handleAuthSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings( const settings: LoadedSettings = new LoadedSettings(
{ {
settings: { ui: { customThemes: {} }, mcpServers: {} }, settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -493,8 +524,10 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { stdin, unmount } = renderWithProviders( const { stdin, unmount } = renderAuthDialog(
<AuthDialog onSelect={onSelect} settings={settings} />, settings,
{},
{ handleAuthSelect },
); );
await wait(); await wait();
@@ -502,8 +535,8 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key stdin.write('\u001b'); // ESC key
await wait(); await wait();
// Should call onSelect with undefined to exit // Should call handleAuthSelect with undefined to exit
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User); expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
unmount(); unmount();
}); });
}); });

View File

@@ -8,26 +8,13 @@ import type React from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core'; import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { validateAuthMethod } from '../../config/auth.js'; import { SettingScope } from '../../config/settings.js';
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import { OpenAIKeyPrompt } from '../components/OpenAIKeyPrompt.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { useUIState } from '../contexts/UIStateContext.js';
interface AuthDialogProps { import { useUIActions } from '../contexts/UIActionsContext.js';
onSelect: ( import { useSettings } from '../contexts/SettingsContext.js';
authMethod: AuthType | undefined,
scope: SettingScope,
credentials?: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
) => void;
settings: LoadedSettings;
initialErrorMessage?: string | null;
}
function parseDefaultAuthType( function parseDefaultAuthType(
defaultAuthType: string | undefined, defaultAuthType: string | undefined,
@@ -41,15 +28,14 @@ function parseDefaultAuthType(
return null; return null;
} }
export function AuthDialog({ export function AuthDialog(): React.JSX.Element {
onSelect, const { pendingAuthType, authError } = useUIState();
settings, const { handleAuthSelect: onAuthSelect } = useUIActions();
initialErrorMessage, const settings = useSettings();
}: AuthDialogProps): React.JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>( const [errorMessage, setErrorMessage] = useState<string | null>(null);
initialErrorMessage || null, const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
);
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
const items = [ const items = [
{ {
key: AuthType.QWEN_OAUTH, key: AuthType.QWEN_OAUTH,
@@ -62,10 +48,17 @@ export function AuthDialog({
const initialAuthIndex = Math.max( const initialAuthIndex = Math.max(
0, 0,
items.findIndex((item) => { 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) { if (settings.merged.security?.auth?.selectedType) {
return item.value === settings.merged.security?.auth?.selectedType; return item.value === settings.merged.security?.auth?.selectedType;
} }
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
const defaultAuthType = parseDefaultAuthType( const defaultAuthType = parseDefaultAuthType(
process.env['QWEN_DEFAULT_AUTH_TYPE'], process.env['QWEN_DEFAULT_AUTH_TYPE'],
); );
@@ -73,49 +66,29 @@ export function AuthDialog({
return item.value === defaultAuthType; return item.value === defaultAuthType;
} }
// Priority 4: default to QWEN_OAUTH
return item.value === AuthType.QWEN_OAUTH; return item.value === AuthType.QWEN_OAUTH;
}), }),
); );
const handleAuthSelect = (authMethod: AuthType) => { const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey);
if (authMethod === AuthType.USE_OPENAI) { const currentSelectedAuthType =
setShowOpenAIKeyPrompt(true); selectedIndex !== null
setErrorMessage(null); ? items[selectedIndex]?.value
} else { : items[initialAuthIndex]?.value;
const error = validateAuthMethod(authMethod);
if (error) { const handleAuthSelect = async (authMethod: AuthType) => {
setErrorMessage(error); setErrorMessage(null);
} else { await onAuthSelect(authMethod, SettingScope.User);
setErrorMessage(null);
onSelect(authMethod, SettingScope.User);
}
}
}; };
const handleOpenAIKeySubmit = ( const handleHighlight = (authMethod: AuthType) => {
apiKey: string, const index = items.findIndex((item) => item.value === authMethod);
baseUrl: string, setSelectedIndex(index);
model: string,
) => {
setShowOpenAIKeyPrompt(false);
onSelect(AuthType.USE_OPENAI, SettingScope.User, {
apiKey,
baseUrl,
model,
});
};
const handleOpenAIKeyCancel = () => {
setShowOpenAIKeyPrompt(false);
setErrorMessage('OpenAI API key is required to use OpenAI authentication.');
}; };
useKeypress( useKeypress(
(key) => { (key) => {
if (showOpenAIKeyPrompt) {
return;
}
if (key.name === 'escape') { if (key.name === 'escape') {
// Prevent exit if there is an error message. // Prevent exit if there is an error message.
// This means they user is not authenticated yet. // This means they user is not authenticated yet.
@@ -129,33 +102,11 @@ export function AuthDialog({
); );
return; return;
} }
onSelect(undefined, SettingScope.User); onAuthSelect(undefined, SettingScope.User);
} }
}, },
{ isActive: true }, { 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 (
<OpenAIKeyPrompt
defaultApiKey={defaults.apiKey}
defaultBaseUrl={defaults.baseUrl}
defaultModel={defaults.model}
onSubmit={handleOpenAIKeySubmit}
onCancel={handleOpenAIKeyCancel}
/>
);
}
return ( return (
<Box <Box
@@ -174,16 +125,26 @@ export function AuthDialog({
items={items} items={items}
initialIndex={initialAuthIndex} initialIndex={initialAuthIndex}
onSelect={handleAuthSelect} onSelect={handleAuthSelect}
onHighlight={handleHighlight}
/> />
</Box> </Box>
{errorMessage && ( {(authError || errorMessage) && (
<Box marginTop={1}> <Box marginTop={1}>
<Text color={Colors.AccentRed}>{errorMessage}</Text> <Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
</Box> </Box>
)} )}
<Box marginTop={1}> <Box marginTop={1}>
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text> <Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
</Box> </Box>
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
<Box marginTop={1}>
<Text color={Colors.Gray}>
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.
</Text>
</Box>
)}
<Box marginTop={1}> <Box marginTop={1}>
<Text>Terms of Services and Privacy Notice for Qwen Code</Text> <Text>Terms of Services and Privacy Notice for Qwen Code</Text>
</Box> </Box>

View File

@@ -6,27 +6,19 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js'; 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 { import {
AuthType,
clearCachedCredentialFile, clearCachedCredentialFile,
getErrorMessage, getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { AuthState } from '../types.js'; import { AuthState } from '../types.js';
import { validateAuthMethod } from '../../config/auth.js'; import { useQwenAuth } from '../hooks/useQwenAuth.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
export function validateAuthMethodWithSettings( export type { QwenAuthState } from '../hooks/useQwenAuth.js';
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);
}
export const useAuthCommand = (settings: LoadedSettings, config: Config) => { export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
const unAuthenticated = const unAuthenticated =
@@ -40,6 +32,14 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
const [isAuthenticating, setIsAuthenticating] = useState(false); const [isAuthenticating, setIsAuthenticating] = useState(false);
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated); const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated);
const [pendingAuthType, setPendingAuthType] = useState<AuthType | undefined>(
undefined,
);
const { qwenAuthState, cancelQwenAuth } = useQwenAuth(
pendingAuthType,
isAuthenticating,
);
const onAuthError = useCallback( const onAuthError = useCallback(
(error: string | null) => { (error: string | null) => {
@@ -52,90 +52,123 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
[setAuthError, setAuthState], [setAuthError, setAuthState],
); );
// Authentication flow const handleAuthFailure = useCallback(
useEffect(() => { (error: unknown) => {
const authFlow = async () => { setIsAuthenticating(false);
const authType = settings.merged.security?.auth?.selectedType; const errorMessage = `Failed to authenticate. Message: ${getErrorMessage(error)}`;
if (isAuthDialogOpen || !authType) { onAuthError(errorMessage);
return;
// Log authentication failure
if (pendingAuthType) {
const authEvent = new AuthEvent(
pendingAuthType,
'manual',
'error',
errorMessage,
);
logAuth(config, authEvent);
} }
},
[onAuthError, pendingAuthType, config],
);
const validationError = validateAuthMethodWithSettings( const handleAuthSuccess = useCallback(
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(
async ( async (
authType: AuthType | undefined, authType: AuthType,
scope: SettingScope, scope: SettingScope,
credentials?: { credentials?: OpenAICredentials,
apiKey?: string;
baseUrl?: string;
model?: string;
},
) => { ) => {
if (authType) { try {
await clearCachedCredentialFile(); settings.setValue(scope, 'security.auth.selectedType', authType);
// Save OpenAI credentials if provided // Only update credentials if not switching to QWEN_OAUTH,
if (credentials) { // so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
// Update Config's internal generationConfig before calling refreshAuth if (authType !== AuthType.QWEN_OAUTH && credentials) {
// This ensures refreshAuth has access to the new credentials if (credentials?.apiKey != null) {
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
});
// Also set environment variables for compatibility with other parts of the code
if (credentials.apiKey) {
settings.setValue( settings.setValue(
scope, scope,
'security.auth.apiKey', 'security.auth.apiKey',
credentials.apiKey, credentials.apiKey,
); );
} }
if (credentials.baseUrl) { if (credentials?.baseUrl != null) {
settings.setValue( settings.setValue(
scope, scope,
'security.auth.baseUrl', 'security.auth.baseUrl',
credentials.baseUrl, credentials.baseUrl,
); );
} }
if (credentials.model) { if (credentials?.model != null) {
settings.setValue(scope, 'model.name', credentials.model); settings.setValue(scope, 'model.name', credentials.model);
} }
await clearCachedCredentialFile();
} }
} catch (error) {
settings.setValue(scope, 'security.auth.selectedType', authType); handleAuthFailure(error);
return;
} }
setIsAuthDialogOpen(false);
setAuthError(null); 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, config], [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);
},
[config, performAuth],
); );
const openAuthDialog = useCallback(() => { const openAuthDialog = useCallback(() => {
@@ -143,8 +176,45 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
}, []); }, []);
const cancelAuthentication = useCallback(() => { 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); 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 { return {
authState, authState,
@@ -153,6 +223,8 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
onAuthError, onAuthError,
isAuthDialogOpen, isAuthDialogOpen,
isAuthenticating, isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect, handleAuthSelect,
openAuthDialog, openAuthDialog,
cancelAuthentication, cancelAuthentication,

View File

@@ -12,9 +12,9 @@ import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js'; import { ConsentPrompt } from './ConsentPrompt.js';
import { ThemeDialog } from './ThemeDialog.js'; import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js'; import { SettingsDialog } from './SettingsDialog.js';
import { AuthInProgress } from '../auth/AuthInProgress.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js'; import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js'; import { AuthDialog } from '../auth/AuthDialog.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js'; import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
import { ProQuotaDialog } from './ProQuotaDialog.js'; import { ProQuotaDialog } from './ProQuotaDialog.js';
@@ -26,6 +26,9 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js'; import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.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 process from 'node:process';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
@@ -56,6 +59,16 @@ export const DialogManager = ({
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } = const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
uiState; 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) { if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
return ( return (
<WelcomeBackDialog <WelcomeBackDialog
@@ -207,39 +220,56 @@ export const DialogManager = ({
if (uiState.isVisionSwitchDialogOpen) { if (uiState.isVisionSwitchDialogOpen) {
return <ModelSwitchDialog onSelect={uiActions.handleVisionSwitchSelect} />; return <ModelSwitchDialog onSelect={uiActions.handleVisionSwitchSelect} />;
} }
if (uiState.isAuthDialogOpen || uiState.authError) {
return (
<Box flexDirection="column">
<AuthDialog />
</Box>
);
}
if (uiState.isAuthenticating) { if (uiState.isAuthenticating) {
// Show Qwen OAuth progress if it's Qwen auth and OAuth is active if (uiState.pendingAuthType === AuthType.USE_OPENAI) {
if (uiState.isQwenAuth && uiState.isQwenAuthenticating) { const defaults = getDefaultOpenAIConfig();
return ( return (
<QwenOAuthProgress <OpenAIKeyPrompt
deviceAuth={uiState.deviceAuth || undefined} onSubmit={(apiKey, baseUrl, model) => {
authStatus={uiState.authStatus} uiActions.handleAuthSelect(AuthType.USE_OPENAI, SettingScope.User, {
authMessage={uiState.authMessage} apiKey,
onTimeout={uiActions.handleQwenAuthTimeout} baseUrl,
onCancel={uiActions.handleQwenAuthCancel} model,
});
}}
onCancel={() => {
uiActions.cancelAuthentication();
uiActions.setAuthState(AuthState.Updating);
}}
defaultApiKey={defaults.apiKey}
defaultBaseUrl={defaults.baseUrl}
defaultModel={defaults.model}
/> />
); );
} }
// Default auth progress for other auth types if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) {
return ( return (
<AuthInProgress <QwenOAuthProgress
onTimeout={() => { deviceAuth={uiState.qwenAuthState.deviceAuth || undefined}
uiActions.onAuthError('Authentication cancelled.'); authStatus={uiState.qwenAuthState.authStatus}
}} authMessage={uiState.qwenAuthState.authMessage}
/> onTimeout={() => {
); uiActions.onAuthError('Qwen OAuth authentication timed out.');
} uiActions.cancelAuthentication();
if (uiState.isAuthDialogOpen) { uiActions.setAuthState(AuthState.Updating);
return ( }}
<Box flexDirection="column"> onCancel={() => {
<AuthDialog uiActions.cancelAuthentication();
onSelect={uiActions.handleAuthSelect} uiActions.setAuthState(AuthState.Updating);
settings={settings} }}
initialErrorMessage={uiState.authError}
/> />
</Box> );
); }
} }
if (uiState.isEditorDialogOpen) { if (uiState.isEditorDialogOpen) {
return ( return (

View File

@@ -6,6 +6,7 @@
import type React from 'react'; import type React from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { z } from 'zod';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
@@ -18,6 +19,16 @@ interface OpenAIKeyPromptProps {
defaultModel?: string; 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<typeof credentialSchema>;
export function OpenAIKeyPrompt({ export function OpenAIKeyPrompt({
onSubmit, onSubmit,
onCancel, onCancel,
@@ -31,6 +42,34 @@ export function OpenAIKeyPrompt({
const [currentField, setCurrentField] = useState< const [currentField, setCurrentField] = useState<
'apiKey' | 'baseUrl' | 'model' 'apiKey' | 'baseUrl' | 'model'
>('apiKey'); >('apiKey');
const [validationError, setValidationError] = useState<string | null>(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( useKeypress(
(key) => { (key) => {
@@ -52,7 +91,7 @@ export function OpenAIKeyPrompt({
} else if (currentField === 'model') { } else if (currentField === 'model') {
// 只有在提交时才检查 API key 是否为空 // 只有在提交时才检查 API key 是否为空
if (apiKey.trim()) { if (apiKey.trim()) {
onSubmit(apiKey.trim(), baseUrl.trim(), model.trim()); validateAndSubmit();
} else { } else {
// 如果 API key 为空,回到 API key 字段 // 如果 API key 为空,回到 API key 字段
setCurrentField('apiKey'); setCurrentField('apiKey');
@@ -168,6 +207,11 @@ export function OpenAIKeyPrompt({
<Text bold color={Colors.AccentBlue}> <Text bold color={Colors.AccentBlue}>
OpenAI Configuration Required OpenAI Configuration Required
</Text> </Text>
{validationError && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{validationError}</Text>
</Box>
)}
<Box marginTop={1}> <Box marginTop={1}>
<Text> <Text>
Please enter your OpenAI configuration. You can get an API key from{' '} Please enter your OpenAI configuration. You can get an API key from{' '}

View File

@@ -8,7 +8,7 @@
import { render } from 'ink-testing-library'; import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QwenOAuthProgress } from './QwenOAuthProgress.js'; 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 { useKeypress } from '../hooks/useKeypress.js';
import type { Key } from '../contexts/KeypressContext.js'; import type { Key } from '../contexts/KeypressContext.js';
@@ -42,12 +42,13 @@ describe('QwenOAuthProgress', () => {
let keypressHandler: ((key: Key) => void) | null = null; let keypressHandler: ((key: Key) => void) | null = null;
const createMockDeviceAuth = ( const createMockDeviceAuth = (
overrides: Partial<DeviceAuthorizationInfo> = {}, overrides: Partial<DeviceAuthorizationData> = {},
): DeviceAuthorizationInfo => ({ ): DeviceAuthorizationData => ({
verification_uri: 'https://example.com/device', verification_uri: 'https://example.com/device',
verification_uri_complete: 'https://example.com/device?user_code=ABC123', verification_uri_complete: 'https://example.com/device?user_code=ABC123',
user_code: 'ABC123', user_code: 'ABC123',
expires_in: 300, expires_in: 300,
device_code: 'test-device-code',
...overrides, ...overrides,
}); });
@@ -55,7 +56,7 @@ describe('QwenOAuthProgress', () => {
const renderComponent = ( const renderComponent = (
props: Partial<{ props: Partial<{
deviceAuth: DeviceAuthorizationInfo; deviceAuth: DeviceAuthorizationData;
authStatus: authStatus:
| 'idle' | 'idle'
| 'polling' | 'polling'
@@ -158,7 +159,7 @@ describe('QwenOAuthProgress', () => {
}); });
it('should format time correctly', () => { it('should format time correctly', () => {
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = { const deviceAuthWithCustomTime: DeviceAuthorizationData = {
...mockDeviceAuth, ...mockDeviceAuth,
expires_in: 125, // 2 minutes and 5 seconds expires_in: 125, // 2 minutes and 5 seconds
}; };
@@ -176,7 +177,7 @@ describe('QwenOAuthProgress', () => {
}); });
it('should format single digit seconds with leading zero', () => { it('should format single digit seconds with leading zero', () => {
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = { const deviceAuthWithCustomTime: DeviceAuthorizationData = {
...mockDeviceAuth, ...mockDeviceAuth,
expires_in: 67, // 1 minute and 7 seconds expires_in: 67, // 1 minute and 7 seconds
}; };
@@ -196,7 +197,7 @@ describe('QwenOAuthProgress', () => {
describe('Timer functionality', () => { describe('Timer functionality', () => {
it('should countdown and call onTimeout when timer expires', async () => { it('should countdown and call onTimeout when timer expires', async () => {
const deviceAuthWithShortTime: DeviceAuthorizationInfo = { const deviceAuthWithShortTime: DeviceAuthorizationData = {
...mockDeviceAuth, ...mockDeviceAuth,
expires_in: 2, // 2 seconds expires_in: 2, // 2 seconds
}; };
@@ -520,7 +521,7 @@ describe('QwenOAuthProgress', () => {
describe('Props changes', () => { describe('Props changes', () => {
it('should display initial timer value from deviceAuth', () => { it('should display initial timer value from deviceAuth', () => {
const deviceAuthWith10Min: DeviceAuthorizationInfo = { const deviceAuthWith10Min: DeviceAuthorizationData = {
...mockDeviceAuth, ...mockDeviceAuth,
expires_in: 600, // 10 minutes expires_in: 600, // 10 minutes
}; };

View File

@@ -11,13 +11,13 @@ import Spinner from 'ink-spinner';
import Link from 'ink-link'; import Link from 'ink-link';
import qrcode from 'qrcode-terminal'; import qrcode from 'qrcode-terminal';
import { Colors } from '../colors.js'; 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'; import { useKeypress } from '../hooks/useKeypress.js';
interface QwenOAuthProgressProps { interface QwenOAuthProgressProps {
onTimeout: () => void; onTimeout: () => void;
onCancel: () => void; onCancel: () => void;
deviceAuth?: DeviceAuthorizationInfo; deviceAuth?: DeviceAuthorizationData;
authStatus?: authStatus?:
| 'idle' | 'idle'
| 'polling' | 'polling'
@@ -131,8 +131,8 @@ export function QwenOAuthProgress({
useKeypress( useKeypress(
(key) => { (key) => {
if (authStatus === 'timeout') { if (authStatus === 'timeout' || authStatus === 'error') {
// Any key press in timeout state should trigger cancel to return to auth dialog // Any key press in timeout or error state should trigger cancel to return to auth dialog
onCancel(); onCancel();
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { } else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onCancel(); onCancel();
@@ -234,6 +234,35 @@ export function QwenOAuthProgress({
); );
} }
if (authStatus === 'error') {
return (
<Box
borderStyle="round"
borderColor={Colors.AccentRed}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentRed}>
Qwen OAuth Authentication Error
</Text>
<Box marginTop={1}>
<Text>
{authMessage ||
'An error occurred during authentication. Please try again.'}
</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
Press any key to return to authentication type selection.
</Text>
</Box>
</Box>
);
}
// Show loading state when no device auth is available yet // Show loading state when no device auth is available yet
if (!deviceAuth) { if (!deviceAuth) {
return ( return (

View File

@@ -487,8 +487,11 @@ describe('SettingsDialog', () => {
it('loops back when reaching the end of an enum', async () => { it('loops back when reaching the end of an enum', async () => {
vi.mocked(saveModifiedSettings).mockClear(); vi.mocked(saveModifiedSettings).mockClear();
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA); vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
const settings = createMockSettings(); const settings = createMockSettings({
settings.setValue(SettingScope.User, 'ui.theme', StringEnum.BAZ); ui: {
theme: StringEnum.BAZ,
},
});
const onSelect = vi.fn(); const onSelect = vi.fn();
const component = ( const component = (
<KeypressProvider kittyProtocolEnabled={false}> <KeypressProvider kittyProtocolEnabled={false}>

View File

@@ -47,7 +47,7 @@ export function CompressionMessage({
case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR: case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
return 'Could not compress chat history due to a token counting error.'; return 'Could not compress chat history due to a token counting error.';
case CompressionStatus.NOOP: case CompressionStatus.NOOP:
return 'Chat history is already compressed.'; return 'Nothing to compress.';
default: default:
return ''; return '';
} }

View File

@@ -16,6 +16,7 @@ import {
import { type SettingScope } from '../../config/settings.js'; import { type SettingScope } from '../../config/settings.js';
import type { AuthState } from '../types.js'; import type { AuthState } from '../types.js';
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
export interface UIActions { export interface UIActions {
handleThemeSelect: ( handleThemeSelect: (
@@ -30,12 +31,11 @@ export interface UIActions {
handleAuthSelect: ( handleAuthSelect: (
authType: AuthType | undefined, authType: AuthType | undefined,
scope: SettingScope, scope: SettingScope,
) => void; credentials?: OpenAICredentials,
) => Promise<void>;
setAuthState: (state: AuthState) => void; setAuthState: (state: AuthState) => void;
onAuthError: (error: string) => void; onAuthError: (error: string) => void;
// Qwen OAuth handlers cancelAuthentication: () => void;
handleQwenAuthTimeout: () => void;
handleQwenAuthCancel: () => void;
handleEditorSelect: ( handleEditorSelect: (
editorType: EditorType | undefined, editorType: EditorType | undefined,
scope: SettingScope, scope: SettingScope,

View File

@@ -16,10 +16,11 @@ import type {
HistoryItemWithoutId, HistoryItemWithoutId,
StreamingState, StreamingState,
} from '../types.js'; } 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 { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js'; import type { TextBuffer } from '../components/shared/text-buffer.js';
import type { import type {
AuthType,
IdeContext, IdeContext,
ApprovalMode, ApprovalMode,
UserTierId, UserTierId,
@@ -49,18 +50,9 @@ export interface UIState {
isConfigInitialized: boolean; isConfigInitialized: boolean;
authError: string | null; authError: string | null;
isAuthDialogOpen: boolean; isAuthDialogOpen: boolean;
pendingAuthType: AuthType | undefined;
// Qwen OAuth state // Qwen OAuth state
isQwenAuth: boolean; qwenAuthState: QwenAuthState;
isQwenAuthenticating: boolean;
deviceAuth: DeviceAuthorizationInfo | null;
authStatus:
| 'idle'
| 'polling'
| 'success'
| 'error'
| 'timeout'
| 'rate_limit';
authMessage: string | null;
editorError: string | null; editorError: string | null;
isEditorDialogOpen: boolean; isEditorDialogOpen: boolean;
corgiMode: boolean; corgiMode: boolean;

View File

@@ -7,6 +7,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js';
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core'; import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
export interface DialogCloseOptions { export interface DialogCloseOptions {
// Theme dialog // Theme dialog
@@ -25,8 +26,9 @@ export interface DialogCloseOptions {
handleAuthSelect: ( handleAuthSelect: (
authType: AuthType | undefined, authType: AuthType | undefined,
scope: SettingScope, scope: SettingScope,
credentials?: OpenAICredentials,
) => Promise<void>; ) => Promise<void>;
selectedAuthType: AuthType | undefined; pendingAuthType: AuthType | undefined;
// Editor dialog // Editor dialog
isEditorDialogOpen: boolean; isEditorDialogOpen: boolean;

View File

@@ -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]);
};

View File

@@ -6,14 +6,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react'; 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 { useQwenAuth } from './useQwenAuth.js';
import { import {
AuthType, AuthType,
qwenOAuth2Events, qwenOAuth2Events,
QwenOAuth2Event, QwenOAuth2Event,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
// Mock the qwenOAuth2Events // Mock the qwenOAuth2Events
vi.mock('@qwen-code/qwen-code-core', async () => { 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); const mockQwenOAuth2Events = vi.mocked(qwenOAuth2Events);
describe('useQwenAuth', () => { describe('useQwenAuth', () => {
const mockDeviceAuth: DeviceAuthorizationInfo = { const mockDeviceAuth: DeviceAuthorizationData = {
verification_uri: 'https://oauth.qwen.com/device', verification_uri: 'https://oauth.qwen.com/device',
verification_uri_complete: 'https://oauth.qwen.com/device?user_code=ABC123', verification_uri_complete: 'https://oauth.qwen.com/device?user_code=ABC123',
user_code: 'ABC123', user_code: 'ABC123',
expires_in: 1800, expires_in: 1800,
device_code: 'device_code_123',
}; };
const createMockSettings = (authType: AuthType): LoadedSettings =>
({
merged: {
security: {
auth: {
selectedType: authType,
},
},
},
}) as LoadedSettings;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -63,36 +52,33 @@ describe('useQwenAuth', () => {
}); });
it('should initialize with default state when not Qwen auth', () => { it('should initialize with default state when not Qwen auth', () => {
const settings = createMockSettings(AuthType.USE_GEMINI); const { result } = renderHook(() =>
const { result } = renderHook(() => useQwenAuth(settings, false)); useQwenAuth(AuthType.USE_GEMINI, false),
);
expect(result.current).toEqual({ expect(result.current.qwenAuthState).toEqual({
isQwenAuthenticating: false,
deviceAuth: null, deviceAuth: null,
authStatus: 'idle', authStatus: 'idle',
authMessage: null, 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', () => { it('should initialize with default state when Qwen auth but not authenticating', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); const { result } = renderHook(() =>
const { result } = renderHook(() => useQwenAuth(settings, false)); useQwenAuth(AuthType.QWEN_OAUTH, false),
);
expect(result.current).toEqual({ expect(result.current.qwenAuthState).toEqual({
isQwenAuthenticating: false,
deviceAuth: null, deviceAuth: null,
authStatus: 'idle', authStatus: 'idle',
authMessage: null, 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', () => { it('should set up event listeners when Qwen auth and authenticating', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
renderHook(() => useQwenAuth(settings, true));
expect(mockQwenOAuth2Events.on).toHaveBeenCalledWith( expect(mockQwenOAuth2Events.on).toHaveBeenCalledWith(
QwenOAuth2Event.AuthUri, QwenOAuth2Event.AuthUri,
@@ -105,8 +91,7 @@ describe('useQwenAuth', () => {
}); });
it('should handle device auth event', () => { it('should handle device auth event', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => { mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) { if (event === QwenOAuth2Event.AuthUri) {
@@ -115,19 +100,17 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
act(() => { act(() => {
handleDeviceAuth!(mockDeviceAuth); handleDeviceAuth!(mockDeviceAuth);
}); });
expect(result.current.deviceAuth).toEqual(mockDeviceAuth); expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.authStatus).toBe('polling'); expect(result.current.qwenAuthState.authStatus).toBe('polling');
expect(result.current.isQwenAuthenticating).toBe(true);
}); });
it('should handle auth progress event - success', () => { it('should handle auth progress event - success', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: ( let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string, message?: string,
@@ -140,18 +123,19 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
act(() => { act(() => {
handleAuthProgress!('success', 'Authentication successful!'); handleAuthProgress!('success', 'Authentication successful!');
}); });
expect(result.current.authStatus).toBe('success'); expect(result.current.qwenAuthState.authStatus).toBe('success');
expect(result.current.authMessage).toBe('Authentication successful!'); expect(result.current.qwenAuthState.authMessage).toBe(
'Authentication successful!',
);
}); });
it('should handle auth progress event - error', () => { it('should handle auth progress event - error', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: ( let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string, message?: string,
@@ -164,18 +148,19 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
act(() => { act(() => {
handleAuthProgress!('error', 'Authentication failed'); handleAuthProgress!('error', 'Authentication failed');
}); });
expect(result.current.authStatus).toBe('error'); expect(result.current.qwenAuthState.authStatus).toBe('error');
expect(result.current.authMessage).toBe('Authentication failed'); expect(result.current.qwenAuthState.authMessage).toBe(
'Authentication failed',
);
}); });
it('should handle auth progress event - polling', () => { it('should handle auth progress event - polling', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: ( let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string, message?: string,
@@ -188,20 +173,19 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
act(() => { act(() => {
handleAuthProgress!('polling', 'Waiting for user authorization...'); handleAuthProgress!('polling', 'Waiting for user authorization...');
}); });
expect(result.current.authStatus).toBe('polling'); expect(result.current.qwenAuthState.authStatus).toBe('polling');
expect(result.current.authMessage).toBe( expect(result.current.qwenAuthState.authMessage).toBe(
'Waiting for user authorization...', 'Waiting for user authorization...',
); );
}); });
it('should handle auth progress event - rate_limit', () => { it('should handle auth progress event - rate_limit', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: ( let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string, message?: string,
@@ -214,7 +198,7 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
act(() => { act(() => {
handleAuthProgress!( handleAuthProgress!(
@@ -223,14 +207,13 @@ describe('useQwenAuth', () => {
); );
}); });
expect(result.current.authStatus).toBe('rate_limit'); expect(result.current.qwenAuthState.authStatus).toBe('rate_limit');
expect(result.current.authMessage).toBe( 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.', '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', () => { it('should handle auth progress event without message', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: ( let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string, message?: string,
@@ -243,27 +226,30 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
act(() => { act(() => {
handleAuthProgress!('success'); handleAuthProgress!('success');
}); });
expect(result.current.authStatus).toBe('success'); expect(result.current.qwenAuthState.authStatus).toBe('success');
expect(result.current.authMessage).toBe(null); expect(result.current.qwenAuthState.authMessage).toBe(null);
}); });
it('should clean up event listeners when auth type changes', () => { it('should clean up event listeners when auth type changes', () => {
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
const { rerender } = renderHook( const { rerender } = renderHook(
({ settings, isAuthenticating }) => ({ pendingAuthType, isAuthenticating }) =>
useQwenAuth(settings, isAuthenticating), useQwenAuth(pendingAuthType, isAuthenticating),
{ initialProps: { settings: qwenSettings, isAuthenticating: true } }, {
initialProps: {
pendingAuthType: AuthType.QWEN_OAUTH,
isAuthenticating: true,
},
},
); );
// Change to non-Qwen auth // Change to non-Qwen auth
const geminiSettings = createMockSettings(AuthType.USE_GEMINI); rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
rerender({ settings: geminiSettings, isAuthenticating: true });
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith( expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
QwenOAuth2Event.AuthUri, QwenOAuth2Event.AuthUri,
@@ -276,9 +262,9 @@ describe('useQwenAuth', () => {
}); });
it('should clean up event listeners when authentication stops', () => { it('should clean up event listeners when authentication stops', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
const { rerender } = renderHook( const { rerender } = renderHook(
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating), ({ isAuthenticating }) =>
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
{ initialProps: { isAuthenticating: true } }, { initialProps: { isAuthenticating: true } },
); );
@@ -296,8 +282,9 @@ describe('useQwenAuth', () => {
}); });
it('should clean up event listeners on unmount', () => { it('should clean up event listeners on unmount', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); const { unmount } = renderHook(() =>
const { unmount } = renderHook(() => useQwenAuth(settings, true)); useQwenAuth(AuthType.QWEN_OAUTH, true),
);
unmount(); unmount();
@@ -312,8 +299,7 @@ describe('useQwenAuth', () => {
}); });
it('should reset state when switching from Qwen auth to another auth type', () => { it('should reset state when switching from Qwen auth to another auth type', () => {
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH); let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => { mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) { if (event === QwenOAuth2Event.AuthUri) {
@@ -323,9 +309,14 @@ describe('useQwenAuth', () => {
}); });
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ settings, isAuthenticating }) => ({ pendingAuthType, isAuthenticating }) =>
useQwenAuth(settings, isAuthenticating), useQwenAuth(pendingAuthType, isAuthenticating),
{ initialProps: { settings: qwenSettings, isAuthenticating: true } }, {
initialProps: {
pendingAuthType: AuthType.QWEN_OAUTH,
isAuthenticating: true,
},
},
); );
// Simulate device auth // Simulate device auth
@@ -333,22 +324,19 @@ describe('useQwenAuth', () => {
handleDeviceAuth!(mockDeviceAuth); handleDeviceAuth!(mockDeviceAuth);
}); });
expect(result.current.deviceAuth).toEqual(mockDeviceAuth); expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.authStatus).toBe('polling'); expect(result.current.qwenAuthState.authStatus).toBe('polling');
// Switch to different auth type // Switch to different auth type
const geminiSettings = createMockSettings(AuthType.USE_GEMINI); rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
rerender({ settings: geminiSettings, isAuthenticating: true });
expect(result.current.isQwenAuthenticating).toBe(false); expect(result.current.qwenAuthState.deviceAuth).toBe(null);
expect(result.current.deviceAuth).toBe(null); expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.authStatus).toBe('idle'); expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.authMessage).toBe(null);
}); });
it('should reset state when authentication stops', () => { it('should reset state when authentication stops', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => { mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) { if (event === QwenOAuth2Event.AuthUri) {
@@ -358,7 +346,8 @@ describe('useQwenAuth', () => {
}); });
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating), ({ isAuthenticating }) =>
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
{ initialProps: { isAuthenticating: true } }, { initialProps: { isAuthenticating: true } },
); );
@@ -367,21 +356,19 @@ describe('useQwenAuth', () => {
handleDeviceAuth!(mockDeviceAuth); handleDeviceAuth!(mockDeviceAuth);
}); });
expect(result.current.deviceAuth).toEqual(mockDeviceAuth); expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.authStatus).toBe('polling'); expect(result.current.qwenAuthState.authStatus).toBe('polling');
// Stop authentication // Stop authentication
rerender({ isAuthenticating: false }); rerender({ isAuthenticating: false });
expect(result.current.isQwenAuthenticating).toBe(false); expect(result.current.qwenAuthState.deviceAuth).toBe(null);
expect(result.current.deviceAuth).toBe(null); expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.authStatus).toBe('idle'); expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.authMessage).toBe(null);
}); });
it('should handle cancelQwenAuth function', () => { it('should handle cancelQwenAuth function', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => { mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) { if (event === QwenOAuth2Event.AuthUri) {
@@ -390,53 +377,49 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
// Set up some state // Set up some state
act(() => { act(() => {
handleDeviceAuth!(mockDeviceAuth); handleDeviceAuth!(mockDeviceAuth);
}); });
expect(result.current.deviceAuth).toEqual(mockDeviceAuth); expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
// Cancel auth // Cancel auth
act(() => { act(() => {
result.current.cancelQwenAuth(); result.current.cancelQwenAuth();
}); });
expect(result.current.isQwenAuthenticating).toBe(false); expect(result.current.qwenAuthState.deviceAuth).toBe(null);
expect(result.current.deviceAuth).toBe(null); expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.authStatus).toBe('idle'); expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.authMessage).toBe(null);
}); });
it('should maintain isQwenAuth flag correctly', () => { it('should handle different auth types correctly', () => {
// Test with Qwen OAuth // Test with Qwen OAuth - should set up event listeners when authenticating
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
const { result: qwenResult } = renderHook(() => 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 // Test with other auth types - should not set up event listeners
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
const { result: geminiResult } = renderHook(() => 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(() => 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', () => { it('should initialize with idle status when starting authentication with Qwen auth', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
expect(result.current.isQwenAuthenticating).toBe(true); expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.authStatus).toBe('idle'); expect(mockQwenOAuth2Events.on).toHaveBeenCalled();
}); });
}); });

View File

@@ -5,23 +5,15 @@
*/ */
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import type { LoadedSettings } from '../../config/settings.js';
import { import {
AuthType, AuthType,
qwenOAuth2Events, qwenOAuth2Events,
QwenOAuth2Event, QwenOAuth2Event,
type DeviceAuthorizationData,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
export interface DeviceAuthorizationInfo { export interface QwenAuthState {
verification_uri: string; deviceAuth: DeviceAuthorizationData | null;
verification_uri_complete: string;
user_code: string;
expires_in: number;
}
interface QwenAuthState {
isQwenAuthenticating: boolean;
deviceAuth: DeviceAuthorizationInfo | null;
authStatus: authStatus:
| 'idle' | 'idle'
| 'polling' | 'polling'
@@ -33,25 +25,22 @@ interface QwenAuthState {
} }
export const useQwenAuth = ( export const useQwenAuth = (
settings: LoadedSettings, pendingAuthType: AuthType | undefined,
isAuthenticating: boolean, isAuthenticating: boolean,
) => { ) => {
const [qwenAuthState, setQwenAuthState] = useState<QwenAuthState>({ const [qwenAuthState, setQwenAuthState] = useState<QwenAuthState>({
isQwenAuthenticating: false,
deviceAuth: null, deviceAuth: null,
authStatus: 'idle', authStatus: 'idle',
authMessage: null, authMessage: null,
}); });
const isQwenAuth = const isQwenAuth = pendingAuthType === AuthType.QWEN_OAUTH;
settings.merged.security?.auth?.selectedType === AuthType.QWEN_OAUTH;
// Set up event listeners when authentication starts // Set up event listeners when authentication starts
useEffect(() => { useEffect(() => {
if (!isQwenAuth || !isAuthenticating) { if (!isQwenAuth || !isAuthenticating) {
// Reset state when not authenticating or not Qwen auth // Reset state when not authenticating or not Qwen auth
setQwenAuthState({ setQwenAuthState({
isQwenAuthenticating: false,
deviceAuth: null, deviceAuth: null,
authStatus: 'idle', authStatus: 'idle',
authMessage: null, authMessage: null,
@@ -61,12 +50,11 @@ export const useQwenAuth = (
setQwenAuthState((prev) => ({ setQwenAuthState((prev) => ({
...prev, ...prev,
isQwenAuthenticating: true,
authStatus: 'idle', authStatus: 'idle',
})); }));
// Set up event listeners // Set up event listeners
const handleDeviceAuth = (deviceAuth: DeviceAuthorizationInfo) => { const handleDeviceAuth = (deviceAuth: DeviceAuthorizationData) => {
setQwenAuthState((prev) => ({ setQwenAuthState((prev) => ({
...prev, ...prev,
deviceAuth: { deviceAuth: {
@@ -74,6 +62,7 @@ export const useQwenAuth = (
verification_uri_complete: deviceAuth.verification_uri_complete, verification_uri_complete: deviceAuth.verification_uri_complete,
user_code: deviceAuth.user_code, user_code: deviceAuth.user_code,
expires_in: deviceAuth.expires_in, expires_in: deviceAuth.expires_in,
device_code: deviceAuth.device_code,
}, },
authStatus: 'polling', authStatus: 'polling',
})); }));
@@ -106,7 +95,6 @@ export const useQwenAuth = (
qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel); qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel);
setQwenAuthState({ setQwenAuthState({
isQwenAuthenticating: false,
deviceAuth: null, deviceAuth: null,
authStatus: 'idle', authStatus: 'idle',
authMessage: null, authMessage: null,
@@ -114,8 +102,7 @@ export const useQwenAuth = (
}, []); }, []);
return { return {
...qwenAuthState, qwenAuthState,
isQwenAuth,
cancelQwenAuth, cancelQwenAuth,
}; };
}; };

View File

@@ -30,6 +30,7 @@ export {
logExtensionEnable, logExtensionEnable,
logIdeConnection, logIdeConnection,
logExtensionDisable, logExtensionDisable,
logAuth,
} from './src/telemetry/loggers.js'; } from './src/telemetry/loggers.js';
export { export {
@@ -40,6 +41,7 @@ export {
ExtensionEnableEvent, ExtensionEnableEvent,
ExtensionUninstallEvent, ExtensionUninstallEvent,
ModelSlashCommandEvent, ModelSlashCommandEvent,
AuthEvent,
} from './src/telemetry/types.js'; } from './src/telemetry/types.js';
export { makeFakeConfig } from './src/test-utils/config.js'; export { makeFakeConfig } from './src/test-utils/config.js';
export * from './src/utils/pathReader.js'; export * from './src/utils/pathReader.js';

View File

@@ -20,66 +20,81 @@ const vendorDir = path.join(packageRoot, 'vendor', 'ripgrep');
/** /**
* Remove quarantine attribute and set executable permissions on macOS/Linux * Remove quarantine attribute and set executable permissions on macOS/Linux
* This script never throws errors to avoid blocking npm workflows.
*/ */
function setupRipgrepBinaries() { function setupRipgrepBinaries() {
if (!fs.existsSync(vendorDir)) {
console.log('Vendor directory not found, skipping ripgrep setup');
return;
}
const platform = process.platform;
const arch = process.arch;
// Determine the binary directory based on platform and architecture
let binaryDir;
if (platform === 'darwin' || platform === 'linux') {
const archStr = arch === 'x64' || arch === 'arm64' ? arch : null;
if (archStr) {
binaryDir = path.join(vendorDir, `${archStr}-${platform}`);
}
} else if (platform === 'win32') {
// Windows doesn't need these fixes
return;
}
if (!binaryDir || !fs.existsSync(binaryDir)) {
console.log(
`Binary directory not found for ${platform}-${arch}, skipping ripgrep setup`,
);
return;
}
const rgBinary = path.join(binaryDir, 'rg');
if (!fs.existsSync(rgBinary)) {
console.log(`Ripgrep binary not found at ${rgBinary}`);
return;
}
try { try {
// Set executable permissions if (!fs.existsSync(vendorDir)) {
fs.chmodSync(rgBinary, 0o755); console.log(' Vendor directory not found, skipping ripgrep setup');
console.log(`✓ Set executable permissions on ${rgBinary}`); return;
}
// On macOS, remove quarantine attribute const platform = process.platform;
if (platform === 'darwin') { const arch = process.arch;
try {
execSync(`xattr -d com.apple.quarantine "${rgBinary}"`, { // Determine the binary directory based on platform and architecture
stdio: 'pipe', let binaryDir;
}); if (platform === 'darwin' || platform === 'linux') {
console.log(`✓ Removed quarantine attribute from ${rgBinary}`); const archStr = arch === 'x64' || arch === 'arm64' ? arch : null;
} catch (error) { if (archStr) {
// Quarantine attribute might not exist, which is fine binaryDir = path.join(vendorDir, `${archStr}-${platform}`);
if (error.message && !error.message.includes('No such xattr')) { }
console.warn( } else if (platform === 'win32') {
`Warning: Could not remove quarantine attribute: ${error.message}`, // Windows doesn't need these fixes
); console.log(' Windows detected, skipping ripgrep setup');
return;
}
if (!binaryDir || !fs.existsSync(binaryDir)) {
console.log(
` Binary directory not found for ${platform}-${arch}, skipping ripgrep setup`,
);
return;
}
const rgBinary = path.join(binaryDir, 'rg');
if (!fs.existsSync(rgBinary)) {
console.log(` Ripgrep binary not found at ${rgBinary}, skipping setup`);
return;
}
try {
// Set executable permissions
fs.chmodSync(rgBinary, 0o755);
console.log(`✓ Set executable permissions on ${rgBinary}`);
// On macOS, remove quarantine attribute
if (platform === 'darwin') {
try {
execSync(`xattr -d com.apple.quarantine "${rgBinary}"`, {
stdio: 'pipe',
});
console.log(`✓ Removed quarantine attribute from ${rgBinary}`);
} catch {
// Quarantine attribute might not exist, which is fine
console.log(' Quarantine attribute not present or already removed');
} }
} }
} catch (error) {
console.log(
`⚠ Could not complete ripgrep setup: ${error.message || 'Unknown error'}`,
);
console.log(' This is not critical - ripgrep may still work correctly');
} }
} catch (error) { } catch (error) {
console.error(`Error setting up ripgrep binary: ${error.message}`); console.log(
`⚠ Ripgrep setup encountered an issue: ${error.message || 'Unknown error'}`,
);
console.log(' Continuing anyway - this should not affect functionality');
} }
} }
setupRipgrepBinaries(); // Wrap the entire execution to ensure no errors escape to npm
try {
setupRipgrepBinaries();
} catch {
// Last resort catch - never let errors block npm
console.log('⚠ Postinstall script encountered an unexpected error');
console.log(' This will not affect the installation');
}

View File

@@ -589,7 +589,7 @@ export class Config {
} }
} }
async refreshAuth(authMethod: AuthType) { async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) {
// Vertex and Genai have incompatible encryption and sending history with // Vertex and Genai have incompatible encryption and sending history with
// throughtSignature from Genai to Vertex will fail, we need to strip them // throughtSignature from Genai to Vertex will fail, we need to strip them
if ( if (
@@ -609,6 +609,7 @@ export class Config {
newContentGeneratorConfig, newContentGeneratorConfig,
this, this,
this.getSessionId(), this.getSessionId(),
isInitialAuth,
); );
// Only assign to instance properties after successful initialization // Only assign to instance properties after successful initialization
this.contentGeneratorConfig = newContentGeneratorConfig; this.contentGeneratorConfig = newContentGeneratorConfig;

View File

@@ -120,6 +120,7 @@ export async function createContentGenerator(
config: ContentGeneratorConfig, config: ContentGeneratorConfig,
gcConfig: Config, gcConfig: Config,
sessionId?: string, sessionId?: string,
isInitialAuth?: boolean,
): Promise<ContentGenerator> { ): Promise<ContentGenerator> {
const version = process.env['CLI_VERSION'] || process.version; const version = process.env['CLI_VERSION'] || process.version;
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
@@ -191,13 +192,17 @@ export async function createContentGenerator(
try { try {
// Get the Qwen OAuth client (now includes integrated token management) // 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 // Create the content generator with dynamic token management
return new QwenContentGenerator(qwenClient, config, gcConfig); return new QwenContentGenerator(qwenClient, config, gcConfig);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to initialize Qwen: ${error instanceof Error ? error.message : String(error)}`, `${error instanceof Error ? error.message : String(error)}`,
); );
} }
} }

View File

@@ -825,7 +825,7 @@ describe('getQwenOAuthClient', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -983,7 +983,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -1032,7 +1032,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication timed out'); ).rejects.toThrow('Authorization timeout, please restart the process.');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -1082,7 +1082,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow( ).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; SharedTokenManager.getInstance = originalGetInstance;
@@ -1119,7 +1119,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -1177,7 +1177,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -1264,7 +1264,9 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow(
'Device code expired or invalid, please restart the authorization process.',
);
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });

View File

@@ -467,6 +467,7 @@ export type AuthResult =
| { | {
success: false; success: false;
reason: 'timeout' | 'cancelled' | 'error' | 'rate_limit'; 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( export async function getQwenOAuthClient(
config: Config, config: Config,
options?: { requireCachedCredentials?: boolean },
): Promise<QwenOAuth2Client> { ): Promise<QwenOAuth2Client> {
const client = new QwenOAuth2Client(); const client = new QwenOAuth2Client();
@@ -488,11 +490,6 @@ export async function getQwenOAuthClient(
client.setCredentials(credentials); client.setCredentials(credentials);
return client; return client;
} catch (error: unknown) { } catch (error: unknown) {
console.debug(
'Shared token manager failed, attempting device flow:',
error,
);
// Handle specific token manager errors // Handle specific token manager errors
if (error instanceof TokenManagerError) { if (error instanceof TokenManagerError) {
switch (error.type) { switch (error.type) {
@@ -520,12 +517,20 @@ export async function getQwenOAuthClient(
// Try device flow instead of forcing refresh // Try device flow instead of forcing refresh
const result = await authWithQwenDeviceFlow(client, config); const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) { 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; 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); const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) { if (!result.success) {
// Only emit timeout event if the failure reason is actually timeout // 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 // Use detailed error message if available, otherwise use default based on reason
switch (result.reason) { const errorMessage =
case 'timeout': result.message ||
throw new Error('Qwen OAuth authentication timed out'); (() => {
case 'cancelled': switch (result.reason) {
throw new Error('Qwen OAuth authentication was cancelled by user'); case 'timeout':
case 'rate_limit': return 'Qwen OAuth authentication timed out';
throw new Error( case 'cancelled':
'Too many request for Qwen OAuth authentication, please try again later.', return 'Qwen OAuth authentication was cancelled by user';
); case 'rate_limit':
case 'error': return 'Too many request for Qwen OAuth authentication, please try again later.';
default: case 'error':
throw new Error('Qwen OAuth authentication failed'); default:
} return 'Qwen OAuth authentication failed';
}
})();
throw new Error(errorMessage);
} }
return client; return client;
@@ -644,13 +653,10 @@ async function authWithQwenDeviceFlow(
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Check if authentication was cancelled // Check if authentication was cancelled
if (isCancelled) { if (isCancelled) {
console.debug('\nAuthentication cancelled by user.'); const message = 'Authentication cancelled by user.';
qwenOAuth2Events.emit( console.debug('\n' + message);
QwenOAuth2Event.AuthProgress, qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
'error', return { success: false, reason: 'cancelled', message };
'Authentication cancelled by user.',
);
return { success: false, reason: 'cancelled' };
} }
try { try {
@@ -738,13 +744,14 @@ async function authWithQwenDeviceFlow(
// Check for cancellation after waiting // Check for cancellation after waiting
if (isCancelled) { if (isCancelled) {
console.debug('\nAuthentication cancelled by user.'); const message = 'Authentication cancelled by user.';
console.debug('\n' + message);
qwenOAuth2Events.emit( qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress, QwenOAuth2Event.AuthProgress,
'error', 'error',
'Authentication cancelled by user.', message,
); );
return { success: false, reason: 'cancelled' }; return { success: false, reason: 'cancelled', message };
} }
continue; continue;
@@ -758,7 +765,7 @@ async function authWithQwenDeviceFlow(
); );
} }
} catch (error: unknown) { } catch (error: unknown) {
// Handle specific error cases // Extract error information
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
const statusCode = const statusCode =
@@ -766,42 +773,49 @@ async function authWithQwenDeviceFlow(
? (error as Error & { status?: number }).status ? (error as Error & { status?: number }).status
: null; : null;
if (errorMessage.includes('401') || statusCode === 401) { // Helper function to handle error and stop polling
const message = const handleError = (
'Device code expired or invalid, please restart the authorization process.'; reason: 'error' | 'rate_limit',
message: string,
// Emit error event eventType: 'error' | 'rate_limit' = 'error',
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); ): AuthResult => {
return { success: false, reason: 'error' };
}
// Handle 429 Too Many Requests error
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( qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress, QwenOAuth2Event.AuthProgress,
'rate_limit', eventType,
message, message,
); );
console.error('\n' + message);
return { success: false, reason, message };
};
console.log('\n' + message); // Handle credential caching failures - stop polling immediately
if (errorMessage.includes('Failed to cache credentials')) {
return handleError('error', errorMessage);
}
// Return false to stop polling and go back to auth selection // Handle 401 Unauthorized - device code expired or invalid
return { success: false, reason: 'rate_limit' }; if (errorMessage.includes('401') || statusCode === 401) {
return handleError(
'error',
'Device code expired or invalid, please restart the authorization process.',
);
}
// Handle 429 Too Many Requests - rate limiting
if (errorMessage.includes('429') || statusCode === 429) {
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',
);
} }
const message = `Error polling for token: ${errorMessage}`; const message = `Error polling for token: ${errorMessage}`;
// Emit error event
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
// Check for cancellation before waiting
if (isCancelled) { 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)); await new Promise((resolve) => setTimeout(resolve, pollInterval));
@@ -818,11 +832,12 @@ async function authWithQwenDeviceFlow(
); );
console.error('\n' + timeoutMessage); console.error('\n' + timeoutMessage);
return { success: false, reason: 'timeout' }; return { success: false, reason: 'timeout', message: timeoutMessage };
} catch (error: unknown) { } catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Device authorization flow failed:', errorMessage); const message = `Device authorization flow failed: ${errorMessage}`;
return { success: false, reason: 'error' }; console.error(message);
return { success: false, reason: 'error', message };
} finally { } finally {
// Clean up event listener // Clean up event listener
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler); qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
@@ -852,10 +867,30 @@ async function loadCachedQwenCredentials(
async function cacheQwenCredentials(credentials: QwenCredentials) { async function cacheQwenCredentials(credentials: QwenCredentials) {
const filePath = getQwenCachedCredentialPath(); 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); const credString = JSON.stringify(credentials, null, 2);
await fs.writeFile(filePath, credString); 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.`,
);
}
// 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.`,
);
}
} }
/** /**

View File

@@ -33,6 +33,7 @@ export const EVENT_MALFORMED_JSON_RESPONSE =
export const EVENT_FILE_OPERATION = 'qwen-code.file_operation'; export const EVENT_FILE_OPERATION = 'qwen-code.file_operation';
export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model'; export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution'; export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
export const EVENT_AUTH = 'qwen-code.auth';
// Performance Events // Performance Events
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance'; export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';

View File

@@ -43,6 +43,7 @@ export {
logExtensionUninstall, logExtensionUninstall,
logRipgrepFallback, logRipgrepFallback,
logNextSpeakerCheck, logNextSpeakerCheck,
logAuth,
} from './loggers.js'; } from './loggers.js';
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js'; export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
export { export {
@@ -61,6 +62,7 @@ export {
ToolOutputTruncatedEvent, ToolOutputTruncatedEvent,
RipgrepFallbackEvent, RipgrepFallbackEvent,
NextSpeakerCheckEvent, NextSpeakerCheckEvent,
AuthEvent,
} from './types.js'; } from './types.js';
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js'; export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
export type { TelemetryEvent } from './types.js'; export type { TelemetryEvent } from './types.js';

View File

@@ -37,6 +37,7 @@ import {
EVENT_SUBAGENT_EXECUTION, EVENT_SUBAGENT_EXECUTION,
EVENT_MALFORMED_JSON_RESPONSE, EVENT_MALFORMED_JSON_RESPONSE,
EVENT_INVALID_CHUNK, EVENT_INVALID_CHUNK,
EVENT_AUTH,
} from './constants.js'; } from './constants.js';
import { import {
recordApiErrorMetrics, recordApiErrorMetrics,
@@ -83,6 +84,7 @@ import type {
SubagentExecutionEvent, SubagentExecutionEvent,
MalformedJsonResponseEvent, MalformedJsonResponseEvent,
InvalidChunkEvent, InvalidChunkEvent,
AuthEvent,
} from './types.js'; } from './types.js';
import type { UiEvent } from './uiTelemetry.js'; import type { UiEvent } from './uiTelemetry.js';
import { uiTelemetryService } from './uiTelemetry.js'; import { uiTelemetryService } from './uiTelemetry.js';
@@ -838,3 +840,29 @@ export function logExtensionDisable(
}; };
logger.emit(logRecord); 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);
}

View File

@@ -37,6 +37,7 @@ import type {
ExtensionEnableEvent, ExtensionEnableEvent,
ModelSlashCommandEvent, ModelSlashCommandEvent,
ExtensionDisableEvent, ExtensionDisableEvent,
AuthEvent,
} from '../types.js'; } from '../types.js';
import { EndSessionEvent } from '../types.js'; import { EndSessionEvent } from '../types.js';
import type { import type {
@@ -746,6 +747,25 @@ export class QwenLogger {
this.flushIfNeeded(); this.flushIfNeeded();
} }
logAuthEvent(event: AuthEvent): void {
const snapshots: Record<string, unknown> = {
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 // misc events
logFlashFallbackEvent(event: FlashFallbackEvent): void { logFlashFallbackEvent(event: FlashFallbackEvent): void {
const rumEvent = this.createActionEvent('misc', 'flash_fallback', { const rumEvent = this.createActionEvent('misc', 'flash_fallback', {

View File

@@ -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 = export type TelemetryEvent =
| StartSessionEvent | StartSessionEvent
| EndSessionEvent | EndSessionEvent
@@ -713,7 +736,8 @@ export type TelemetryEvent =
| ExtensionInstallEvent | ExtensionInstallEvent
| ExtensionUninstallEvent | ExtensionUninstallEvent
| ToolOutputTruncatedEvent | ToolOutputTruncatedEvent
| ModelSlashCommandEvent; | ModelSlashCommandEvent
| AuthEvent;
export class ExtensionDisableEvent implements BaseTelemetryEvent { export class ExtensionDisableEvent implements BaseTelemetryEvent {
'event.name': 'extension_disable'; 'event.name': 'extension_disable';

View File

@@ -22,12 +22,12 @@ import type { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import type { ChildProcess } from 'node:child_process'; import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { ensureRipgrepPath } from '../utils/ripgrepUtils.js'; import { getRipgrepCommand } from '../utils/ripgrepUtils.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
// Mock ripgrepUtils // Mock ripgrepUtils
vi.mock('../utils/ripgrepUtils.js', () => ({ vi.mock('../utils/ripgrepUtils.js', () => ({
ensureRipgrepPath: vi.fn(), getRipgrepCommand: vi.fn(),
})); }));
// Mock child_process for ripgrep calls // Mock child_process for ripgrep calls
@@ -109,7 +109,7 @@ describe('RipGrepTool', () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
(ensureRipgrepPath as Mock).mockResolvedValue('/mock/path/to/rg'); (getRipgrepCommand as Mock).mockResolvedValue('/mock/path/to/rg');
mockSpawn.mockReset(); mockSpawn.mockReset();
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-')); tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
fileExclusionsMock = { fileExclusionsMock = {
@@ -588,18 +588,15 @@ describe('RipGrepTool', () => {
}); });
it('should throw an error if ripgrep is not available', async () => { it('should throw an error if ripgrep is not available', async () => {
// Make ensureRipgrepBinary throw (getRipgrepCommand as Mock).mockResolvedValue(null);
(ensureRipgrepPath as Mock).mockRejectedValue(
new Error('Ripgrep binary not found'),
);
const params: RipGrepToolParams = { pattern: 'world' }; const params: RipGrepToolParams = { pattern: 'world' };
const invocation = grepTool.build(params); const invocation = grepTool.build(params);
expect(await invocation.execute(abortSignal)).toStrictEqual({ expect(await invocation.execute(abortSignal)).toStrictEqual({
llmContent: llmContent:
'Error during grep search operation: Ripgrep binary not found', 'Error during grep search operation: ripgrep binary not found.',
returnDisplay: 'Error: Ripgrep binary not found', returnDisplay: 'Error: ripgrep binary not found.',
}); });
}); });
}); });

View File

@@ -6,7 +6,6 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { EOL } from 'node:os';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import type { ToolInvocation, ToolResult } from './tools.js'; import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
@@ -14,7 +13,7 @@ import { ToolNames } from './tool-names.js';
import { resolveAndValidatePath } from '../utils/paths.js'; import { resolveAndValidatePath } from '../utils/paths.js';
import { getErrorMessage } from '../utils/errors.js'; import { getErrorMessage } from '../utils/errors.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { ensureRipgrepPath } from '../utils/ripgrepUtils.js'; import { getRipgrepCommand } from '../utils/ripgrepUtils.js';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
import type { FileFilteringOptions } from '../config/constants.js'; import type { FileFilteringOptions } from '../config/constants.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
@@ -88,7 +87,7 @@ class GrepToolInvocation extends BaseToolInvocation<
} }
// Split into lines and count total matches // Split into lines and count total matches
const allLines = rawOutput.split(EOL).filter((line) => line.trim()); const allLines = rawOutput.split('\n').filter((line) => line.trim());
const totalMatches = allLines.length; const totalMatches = allLines.length;
const matchTerm = totalMatches === 1 ? 'match' : 'matches'; const matchTerm = totalMatches === 1 ? 'match' : 'matches';
@@ -159,7 +158,7 @@ class GrepToolInvocation extends BaseToolInvocation<
returnDisplay: displayMessage, returnDisplay: displayMessage,
}; };
} catch (error) { } catch (error) {
console.error(`Error during GrepLogic execution: ${error}`); console.error(`Error during ripgrep search operation: ${error}`);
const errorMessage = getErrorMessage(error); const errorMessage = getErrorMessage(error);
return { return {
llmContent: `Error during grep search operation: ${errorMessage}`, llmContent: `Error during grep search operation: ${errorMessage}`,
@@ -210,11 +209,15 @@ class GrepToolInvocation extends BaseToolInvocation<
rgArgs.push(absolutePath); rgArgs.push(absolutePath);
try { try {
const rgPath = this.config.getUseBuiltinRipgrep() const rgCommand = await getRipgrepCommand(
? await ensureRipgrepPath() this.config.getUseBuiltinRipgrep(),
: 'rg'; );
if (!rgCommand) {
throw new Error('ripgrep binary not found.');
}
const output = await new Promise<string>((resolve, reject) => { const output = await new Promise<string>((resolve, reject) => {
const child = spawn(rgPath, rgArgs, { const child = spawn(rgCommand, rgArgs, {
windowsHide: true, windowsHide: true,
}); });
@@ -234,7 +237,7 @@ class GrepToolInvocation extends BaseToolInvocation<
child.on('error', (err) => { child.on('error', (err) => {
options.signal.removeEventListener('abort', cleanup); options.signal.removeEventListener('abort', cleanup);
reject(new Error(`Failed to start ripgrep: ${err.message}.`)); reject(new Error(`failed to start ripgrep: ${err.message}.`));
}); });
child.on('close', (code) => { child.on('close', (code) => {
@@ -256,7 +259,7 @@ class GrepToolInvocation extends BaseToolInvocation<
return output; return output;
} catch (error: unknown) { } catch (error: unknown) {
console.error(`GrepLogic: ripgrep failed: ${getErrorMessage(error)}`); console.error(`Ripgrep failed: ${getErrorMessage(error)}`);
throw error; throw error;
} }
} }

View File

@@ -7,8 +7,8 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { import {
canUseRipgrep, canUseRipgrep,
ensureRipgrepPath, getRipgrepCommand,
getRipgrepPath, getBuiltinRipgrep,
} from './ripgrepUtils.js'; } from './ripgrepUtils.js';
import { fileExists } from './fileUtils.js'; import { fileExists } from './fileUtils.js';
import path from 'node:path'; import path from 'node:path';
@@ -27,7 +27,7 @@ describe('ripgrepUtils', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('getRipgrepPath', () => { describe('getBulltinRipgrepPath', () => {
it('should return path with .exe extension on Windows', () => { it('should return path with .exe extension on Windows', () => {
const originalPlatform = process.platform; const originalPlatform = process.platform;
const originalArch = process.arch; const originalArch = process.arch;
@@ -36,7 +36,7 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'platform', { value: 'win32' }); Object.defineProperty(process, 'platform', { value: 'win32' });
Object.defineProperty(process, 'arch', { value: 'x64' }); Object.defineProperty(process, 'arch', { value: 'x64' });
const rgPath = getRipgrepPath(); const rgPath = getBuiltinRipgrep();
expect(rgPath).toContain('x64-win32'); expect(rgPath).toContain('x64-win32');
expect(rgPath).toContain('rg.exe'); expect(rgPath).toContain('rg.exe');
@@ -55,7 +55,7 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' }); Object.defineProperty(process, 'platform', { value: 'darwin' });
Object.defineProperty(process, 'arch', { value: 'arm64' }); Object.defineProperty(process, 'arch', { value: 'arm64' });
const rgPath = getRipgrepPath(); const rgPath = getBuiltinRipgrep();
expect(rgPath).toContain('arm64-darwin'); expect(rgPath).toContain('arm64-darwin');
expect(rgPath).toContain('rg'); expect(rgPath).toContain('rg');
@@ -75,7 +75,7 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'platform', { value: 'linux' }); Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'x64' }); Object.defineProperty(process, 'arch', { value: 'x64' });
const rgPath = getRipgrepPath(); const rgPath = getBuiltinRipgrep();
expect(rgPath).toContain('x64-linux'); expect(rgPath).toContain('x64-linux');
expect(rgPath).toContain('rg'); expect(rgPath).toContain('rg');
@@ -87,7 +87,7 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'arch', { value: originalArch }); Object.defineProperty(process, 'arch', { value: originalArch });
}); });
it('should throw error for unsupported platform', () => { it('should return null for unsupported platform', () => {
const originalPlatform = process.platform; const originalPlatform = process.platform;
const originalArch = process.arch; const originalArch = process.arch;
@@ -95,14 +95,14 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'platform', { value: 'freebsd' }); Object.defineProperty(process, 'platform', { value: 'freebsd' });
Object.defineProperty(process, 'arch', { value: 'x64' }); Object.defineProperty(process, 'arch', { value: 'x64' });
expect(() => getRipgrepPath()).toThrow('Unsupported platform: freebsd'); expect(getBuiltinRipgrep()).toBeNull();
// Restore original values // Restore original values
Object.defineProperty(process, 'platform', { value: originalPlatform }); Object.defineProperty(process, 'platform', { value: originalPlatform });
Object.defineProperty(process, 'arch', { value: originalArch }); Object.defineProperty(process, 'arch', { value: originalArch });
}); });
it('should throw error for unsupported architecture', () => { it('should return null for unsupported architecture', () => {
const originalPlatform = process.platform; const originalPlatform = process.platform;
const originalArch = process.arch; const originalArch = process.arch;
@@ -110,7 +110,7 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' }); Object.defineProperty(process, 'platform', { value: 'darwin' });
Object.defineProperty(process, 'arch', { value: 'ia32' }); Object.defineProperty(process, 'arch', { value: 'ia32' });
expect(() => getRipgrepPath()).toThrow('Unsupported architecture: ia32'); expect(getBuiltinRipgrep()).toBeNull();
// Restore original values // Restore original values
Object.defineProperty(process, 'platform', { value: originalPlatform }); Object.defineProperty(process, 'platform', { value: originalPlatform });
@@ -136,7 +136,7 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'platform', { value: platform }); Object.defineProperty(process, 'platform', { value: platform });
Object.defineProperty(process, 'arch', { value: arch }); Object.defineProperty(process, 'arch', { value: arch });
const rgPath = getRipgrepPath(); const rgPath = getBuiltinRipgrep();
const binaryName = platform === 'win32' ? 'rg.exe' : 'rg'; const binaryName = platform === 'win32' ? 'rg.exe' : 'rg';
const expectedPathSegment = path.join( const expectedPathSegment = path.join(
`${arch}-${platform}`, `${arch}-${platform}`,
@@ -169,107 +169,77 @@ describe('ripgrepUtils', () => {
expect(result).toBe(true); expect(result).toBe(true);
expect(fileExists).toHaveBeenCalledOnce(); expect(fileExists).toHaveBeenCalledOnce();
}); });
it('should fall back to system rg if bundled ripgrep binary does not exist', async () => {
(fileExists as Mock).mockResolvedValue(false);
// When useBuiltin is true but bundled binary doesn't exist,
// it should fall back to checking system rg (which will spawn a process)
// In this test environment, system rg is likely available, so result should be true
// unless spawn fails
const result = await canUseRipgrep();
// The test may pass or fail depending on system rg availability
// Just verify that fileExists was called to check bundled binary first
expect(fileExists).toHaveBeenCalledOnce();
// Result depends on whether system rg is installed
expect(typeof result).toBe('boolean');
});
// Note: Tests for system ripgrep detection (useBuiltin=false) would require mocking
// the child_process spawn function, which is complex in ESM. These cases are tested
// indirectly through integration tests.
it('should return false if platform is unsupported', async () => {
const originalPlatform = process.platform;
// Mock unsupported platform
Object.defineProperty(process, 'platform', { value: 'aix' });
const result = await canUseRipgrep();
expect(result).toBe(false);
expect(fileExists).not.toHaveBeenCalled();
// Restore original value
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should return false if architecture is unsupported', async () => {
const originalArch = process.arch;
// Mock unsupported architecture
Object.defineProperty(process, 'arch', { value: 's390x' });
const result = await canUseRipgrep();
expect(result).toBe(false);
expect(fileExists).not.toHaveBeenCalled();
// Restore original value
Object.defineProperty(process, 'arch', { value: originalArch });
});
}); });
describe('ensureRipgrepBinary', () => { describe('ensureRipgrepPath', () => {
it('should return ripgrep path if binary exists', async () => { it('should return bundled ripgrep path if binary exists (useBuiltin=true)', async () => {
(fileExists as Mock).mockResolvedValue(true); (fileExists as Mock).mockResolvedValue(true);
const rgPath = await ensureRipgrepPath(); const rgPath = await getRipgrepCommand(true);
expect(rgPath).toBeDefined(); expect(rgPath).toBeDefined();
expect(rgPath).toContain('rg'); expect(rgPath).toContain('rg');
expect(rgPath).not.toBe('rg'); // Should be full path, not just 'rg'
expect(fileExists).toHaveBeenCalledOnce(); expect(fileExists).toHaveBeenCalledOnce();
expect(fileExists).toHaveBeenCalledWith(rgPath); expect(fileExists).toHaveBeenCalledWith(rgPath);
}); });
it('should throw error if binary does not exist', async () => { it('should return bundled ripgrep path if binary exists (default)', async () => {
(fileExists as Mock).mockResolvedValue(false); (fileExists as Mock).mockResolvedValue(true);
await expect(ensureRipgrepPath()).rejects.toThrow( const rgPath = await getRipgrepCommand();
/Ripgrep binary not found/,
);
await expect(ensureRipgrepPath()).rejects.toThrow(/Platform:/);
await expect(ensureRipgrepPath()).rejects.toThrow(/Architecture:/);
expect(fileExists).toHaveBeenCalled(); expect(rgPath).toBeDefined();
expect(rgPath).toContain('rg');
expect(fileExists).toHaveBeenCalledOnce();
}); });
it('should throw error with correct path information', async () => { it('should fall back to system rg if bundled binary does not exist', async () => {
(fileExists as Mock).mockResolvedValue(false); (fileExists as Mock).mockResolvedValue(false);
// When useBuiltin is true but bundled binary doesn't exist,
// it should fall back to checking system rg
// The test result depends on whether system rg is actually available
const rgPath = await getRipgrepCommand(true);
expect(fileExists).toHaveBeenCalledOnce();
// If system rg is available, it should return 'rg' (or 'rg.exe' on Windows)
// This test will pass if system ripgrep is installed
expect(rgPath).toBeDefined();
});
it('should use system rg when useBuiltin=false', async () => {
// When useBuiltin is false, should skip bundled check and go straight to system rg
const rgPath = await getRipgrepCommand(false);
// Should not check for bundled binary
expect(fileExists).not.toHaveBeenCalled();
// If system rg is available, it should return 'rg' (or 'rg.exe' on Windows)
expect(rgPath).toBeDefined();
});
it('should throw error if neither bundled nor system ripgrep is available', async () => {
// This test only makes sense in an environment where system rg is not installed
// We'll skip this test in CI/local environments where rg might be available
// Instead, we test the error message format
const originalPlatform = process.platform;
// Use an unsupported platform to trigger the error path
Object.defineProperty(process, 'platform', { value: 'freebsd' });
try { try {
await ensureRipgrepPath(); await getRipgrepCommand();
// Should not reach here // If we get here without error, system rg was available, which is fine
expect(true).toBe(false);
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(Error); expect(error).toBeInstanceOf(Error);
const errorMessage = (error as Error).message; const errorMessage = (error as Error).message;
expect(errorMessage).toContain('Ripgrep binary not found at'); // Should contain helpful error information
expect(errorMessage).toContain(process.platform); expect(
expect(errorMessage).toContain(process.arch); errorMessage.includes('Ripgrep binary not found') ||
errorMessage.includes('Failed to locate ripgrep') ||
errorMessage.includes('Unsupported platform'),
).toBe(true);
} }
});
it('should throw error if platform is unsupported', async () => {
const originalPlatform = process.platform;
// Mock unsupported platform
Object.defineProperty(process, 'platform', { value: 'openbsd' });
await expect(ensureRipgrepPath()).rejects.toThrow(
'Unsupported platform: openbsd',
);
// Restore original value // Restore original value
Object.defineProperty(process, 'platform', { value: originalPlatform }); Object.defineProperty(process, 'platform', { value: originalPlatform });

View File

@@ -18,37 +18,42 @@ type Architecture = 'x64' | 'arm64';
/** /**
* Maps process.platform values to vendor directory names * Maps process.platform values to vendor directory names
*/ */
function getPlatformString(platform: string): Platform { function getPlatformString(platform: string): Platform | undefined {
switch (platform) { switch (platform) {
case 'darwin': case 'darwin':
case 'linux': case 'linux':
case 'win32': case 'win32':
return platform; return platform;
default: default:
throw new Error(`Unsupported platform: ${platform}`); return undefined;
} }
} }
/** /**
* Maps process.arch values to vendor directory names * Maps process.arch values to vendor directory names
*/ */
function getArchitectureString(arch: string): Architecture { function getArchitectureString(arch: string): Architecture | undefined {
switch (arch) { switch (arch) {
case 'x64': case 'x64':
case 'arm64': case 'arm64':
return arch; return arch;
default: default:
throw new Error(`Unsupported architecture: ${arch}`); return undefined;
} }
} }
/** /**
* Returns the path to the bundled ripgrep binary for the current platform * Returns the path to the bundled ripgrep binary for the current platform
* @returns The path to the bundled ripgrep binary, or null if not available
*/ */
export function getRipgrepPath(): string { export function getBuiltinRipgrep(): string | null {
const platform = getPlatformString(process.platform); const platform = getPlatformString(process.platform);
const arch = getArchitectureString(process.arch); const arch = getArchitectureString(process.arch);
if (!platform || !arch) {
return null;
}
// Binary name includes .exe on Windows // Binary name includes .exe on Windows
const binaryName = platform === 'win32' ? 'rg.exe' : 'rg'; const binaryName = platform === 'win32' ? 'rg.exe' : 'rg';
@@ -83,6 +88,51 @@ export function getRipgrepPath(): string {
return vendorPath; return vendorPath;
} }
/**
* Checks if system ripgrep is available and returns the command to use
* @returns The ripgrep command ('rg' or 'rg.exe') if available, or null if not found
*/
export async function getSystemRipgrep(): Promise<string | null> {
try {
const { spawn } = await import('node:child_process');
const rgCommand = process.platform === 'win32' ? 'rg.exe' : 'rg';
const isAvailable = await new Promise<boolean>((resolve) => {
const proc = spawn(rgCommand, ['--version']);
proc.on('error', () => resolve(false));
proc.on('exit', (code) => resolve(code === 0));
});
return isAvailable ? rgCommand : null;
} catch (_error) {
return null;
}
}
/**
* Checks if ripgrep binary exists and returns its path
* @param useBuiltin If true, tries bundled ripgrep first, then falls back to system ripgrep.
* If false, only checks for system ripgrep.
* @returns The path to ripgrep binary ('rg' or 'rg.exe' for system ripgrep, or full path for bundled), or null if not available
*/
export async function getRipgrepCommand(
useBuiltin: boolean = true,
): Promise<string | null> {
try {
if (useBuiltin) {
// Try bundled ripgrep first
const rgPath = getBuiltinRipgrep();
if (rgPath && (await fileExists(rgPath))) {
return rgPath;
}
// Fallback to system rg if bundled binary is not available
}
// Check for system ripgrep
return await getSystemRipgrep();
} catch (_error) {
return null;
}
}
/** /**
* Checks if ripgrep binary is available * Checks if ripgrep binary is available
* @param useBuiltin If true, tries bundled ripgrep first, then falls back to system ripgrep. * @param useBuiltin If true, tries bundled ripgrep first, then falls back to system ripgrep.
@@ -91,42 +141,6 @@ export function getRipgrepPath(): string {
export async function canUseRipgrep( export async function canUseRipgrep(
useBuiltin: boolean = true, useBuiltin: boolean = true,
): Promise<boolean> { ): Promise<boolean> {
try { const rgPath = await getRipgrepCommand(useBuiltin);
if (useBuiltin) { return rgPath !== null;
// Try bundled ripgrep first
const rgPath = getRipgrepPath();
if (await fileExists(rgPath)) {
return true;
}
// Fallback to system rg if bundled binary is not available
}
// Check for system ripgrep by trying to spawn 'rg --version'
const { spawn } = await import('node:child_process');
return await new Promise<boolean>((resolve) => {
const proc = spawn('rg', ['--version']);
proc.on('error', () => resolve(false));
proc.on('exit', (code) => resolve(code === 0));
});
} catch (_error) {
// Unsupported platform/arch or other error
return false;
}
}
/**
* Ensures ripgrep binary exists and returns its path
* @throws Error if ripgrep binary is not available
*/
export async function ensureRipgrepPath(): Promise<string> {
const rgPath = getRipgrepPath();
if (!(await fileExists(rgPath))) {
throw new Error(
`Ripgrep binary not found at ${rgPath}. ` +
`Platform: ${process.platform}, Architecture: ${process.arch}`,
);
}
return rgPath;
} }