diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 8e4ba82328..bb4cd5ca8d 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -166,6 +166,7 @@ const mockUIActions: UIActions = { handleFinalSubmit: vi.fn(), handleClearScreen: vi.fn(), handleProQuotaChoice: vi.fn(), + handleValidationChoice: vi.fn(), setQueueErrorMessage: vi.fn(), popAllMessages: vi.fn(), handleApiKeySubmit: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d84082859b..ee87b7719d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -495,7 +495,12 @@ export const AppContainer = (props: AppContainerProps) => { } }, [authState, authContext, setAuthState]); - const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({ + const { + proQuotaRequest, + handleProQuotaChoice, + validationRequest, + handleValidationChoice, + } = useQuotaAndFallback({ config, historyManager, userTier, @@ -1471,6 +1476,7 @@ Logging in with Google... Restarting Gemini CLI to continue. showPrivacyNotice || showIdeRestartPrompt || !!proQuotaRequest || + !!validationRequest || isSessionBrowserOpen || isAuthDialogOpen || authState === AuthState.AwaitingApiKeyInput; @@ -1588,6 +1594,7 @@ Logging in with Google... Restarting Gemini CLI to continue. currentModel, userTier, proQuotaRequest, + validationRequest, contextFileNames, errorCount, availableTerminalHeight, @@ -1678,6 +1685,7 @@ Logging in with Google... Restarting Gemini CLI to continue. showAutoAcceptIndicator, userTier, proQuotaRequest, + validationRequest, contextFileNames, errorCount, availableTerminalHeight, @@ -1747,6 +1755,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleFinalSubmit, handleClearScreen, handleProQuotaChoice, + handleValidationChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, @@ -1787,6 +1796,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleFinalSubmit, handleClearScreen, handleProQuotaChoice, + handleValidationChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index f915bc7852..badbfde75a 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -17,6 +17,7 @@ import { ApiAuthDialog } from '../auth/ApiAuthDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { PrivacyNotice } from '../privacy/PrivacyNotice.js'; import { ProQuotaDialog } from './ProQuotaDialog.js'; +import { ValidationDialog } from './ValidationDialog.js'; import { runExitCleanup } from '../../utils/cleanup.js'; import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; import { SessionBrowser } from './SessionBrowser.js'; @@ -68,6 +69,16 @@ export const DialogManager = ({ /> ); } + if (uiState.validationRequest) { + return ( + + ); + } if (uiState.shouldShowIdePrompt) { return ( ({ + RadioButtonSelect: vi.fn(), +})); + +vi.mock('./CliSpinner.js', () => ({ + CliSpinner: vi.fn(() => null), +})); + +const mockOpenBrowserSecurely = vi.fn(); +const mockShouldLaunchBrowser = vi.fn(); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + openBrowserSecurely: (...args: unknown[]) => + mockOpenBrowserSecurely(...args), + shouldLaunchBrowser: () => mockShouldLaunchBrowser(), + }; +}); + +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +describe('ValidationDialog', () => { + const mockOnChoice = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockShouldLaunchBrowser.mockReturnValue(true); + mockOpenBrowserSecurely.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial render (choosing state)', () => { + it('should render the main message and two options', () => { + const { lastFrame, unmount } = render( + , + ); + + expect(lastFrame()).toContain( + 'Further action is required to use this service.', + ); + expect(RadioButtonSelect).toHaveBeenCalledWith( + expect.objectContaining({ + items: [ + { + label: 'Verify your account', + value: 'verify', + key: 'verify', + }, + { + label: 'Change authentication', + value: 'change_auth', + key: 'change_auth', + }, + ], + }), + undefined, + ); + unmount(); + }); + + it('should render learn more URL when provided', () => { + const { lastFrame, unmount } = render( + , + ); + + expect(lastFrame()).toContain('Learn more:'); + expect(lastFrame()).toContain('https://example.com/help'); + unmount(); + }); + }); + + describe('onChoice handling', () => { + it('should call onChoice with change_auth when that option is selected', () => { + const { unmount } = render(); + + const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect; + act(() => { + onSelect('change_auth'); + }); + + expect(mockOnChoice).toHaveBeenCalledWith('change_auth'); + unmount(); + }); + + it('should call onChoice with verify when no validation link is provided', () => { + const { unmount } = render(); + + const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect; + act(() => { + onSelect('verify'); + }); + + expect(mockOnChoice).toHaveBeenCalledWith('verify'); + unmount(); + }); + + it('should open browser and transition to waiting state when verify is selected with a link', async () => { + const { lastFrame, unmount } = render( + , + ); + + const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect; + await act(async () => { + await onSelect('verify'); + }); + + expect(mockOpenBrowserSecurely).toHaveBeenCalledWith( + 'https://accounts.google.com/verify', + ); + expect(lastFrame()).toContain('Waiting for verification...'); + unmount(); + }); + }); + + describe('headless mode', () => { + it('should show URL in message when browser cannot be launched', async () => { + mockShouldLaunchBrowser.mockReturnValue(false); + + const { lastFrame, unmount } = render( + , + ); + + const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect; + await act(async () => { + await onSelect('verify'); + }); + + expect(mockOpenBrowserSecurely).not.toHaveBeenCalled(); + expect(lastFrame()).toContain('Please open this URL in a browser:'); + expect(lastFrame()).toContain('https://accounts.google.com/verify'); + unmount(); + }); + }); + + describe('error state', () => { + it('should show error and options when browser fails to open', async () => { + mockOpenBrowserSecurely.mockRejectedValue(new Error('Browser not found')); + + const { lastFrame, unmount } = render( + , + ); + + const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect; + await act(async () => { + await onSelect('verify'); + }); + + expect(lastFrame()).toContain('Browser not found'); + // RadioButtonSelect should be rendered again with options in error state + expect((RadioButtonSelect as Mock).mock.calls.length).toBeGreaterThan(1); + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/ValidationDialog.tsx b/packages/cli/src/ui/components/ValidationDialog.tsx new file mode 100644 index 0000000000..b7ddf2878a --- /dev/null +++ b/packages/cli/src/ui/components/ValidationDialog.tsx @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { theme } from '../semantic-colors.js'; +import { CliSpinner } from './CliSpinner.js'; +import { + openBrowserSecurely, + shouldLaunchBrowser, + type ValidationIntent, +} from '@google/gemini-cli-core'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; + +interface ValidationDialogProps { + validationLink?: string; + validationDescription?: string; + learnMoreUrl?: string; + onChoice: (choice: ValidationIntent) => void; +} + +type DialogState = 'choosing' | 'waiting' | 'complete' | 'error'; + +export function ValidationDialog({ + validationLink, + learnMoreUrl, + onChoice, +}: ValidationDialogProps): React.JSX.Element { + const [state, setState] = useState('choosing'); + const [errorMessage, setErrorMessage] = useState(''); + + const items = [ + { + label: 'Verify your account', + value: 'verify' as const, + key: 'verify', + }, + { + label: 'Change authentication', + value: 'change_auth' as const, + key: 'change_auth', + }, + ]; + + // Handle keypresses during 'waiting' state (ESC to cancel, Enter to confirm completion) + useKeypress( + (key) => { + if (keyMatchers[Command.ESCAPE](key) || keyMatchers[Command.QUIT](key)) { + onChoice('cancel'); + } else if (keyMatchers[Command.RETURN](key)) { + // User confirmed verification is complete - transition to 'complete' state + setState('complete'); + } + }, + { isActive: state === 'waiting' }, + ); + + // When state becomes 'complete', show success message briefly then proceed + useEffect(() => { + if (state === 'complete') { + const timer = setTimeout(() => { + onChoice('verify'); + }, 500); + return () => clearTimeout(timer); + } + return undefined; + }, [state, onChoice]); + + const handleSelect = useCallback( + async (choice: ValidationIntent) => { + if (choice === 'verify') { + if (validationLink) { + // Check if we're in an environment where we can launch a browser + if (!shouldLaunchBrowser()) { + // In headless mode, show the link and wait for user to manually verify + setErrorMessage( + `Please open this URL in a browser: ${validationLink}`, + ); + setState('waiting'); + return; + } + + try { + await openBrowserSecurely(validationLink); + setState('waiting'); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : 'Failed to open browser', + ); + setState('error'); + } + } else { + // No validation link, just retry + onChoice('verify'); + } + } else { + // 'change_auth' or 'cancel' + onChoice(choice); + } + }, + [validationLink, onChoice], + ); + + if (state === 'error') { + return ( + + + {errorMessage || + 'Failed to open verification link. Please try again or change authentication.'} + + + void handleSelect(choice as ValidationIntent)} + /> + + + ); + } + + if (state === 'waiting') { + return ( + + + + + {' '} + Waiting for verification... (Press ESC or CTRL+C to cancel) + + + {errorMessage && ( + + {errorMessage} + + )} + + Press Enter when verification is complete. + + + ); + } + + if (state === 'complete') { + return ( + + Verification complete + + ); + } + + return ( + + + Further action is required to use this service. + + + void handleSelect(choice as ValidationIntent)} + /> + + {learnMoreUrl && ( + + + Learn more: {learnMoreUrl} + + + )} + + ); +} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 6358c26fa7..1ba8c7dfe3 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -46,6 +46,7 @@ export interface UIActions { handleProQuotaChoice: ( choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade', ) => void; + handleValidationChoice: (choice: 'verify' | 'change_auth' | 'cancel') => void; openSessionBrowser: () => void; closeSessionBrowser: () => void; handleResumeSession: (session: SessionInfo) => Promise; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 80db5782ff..e768ef4796 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -23,6 +23,7 @@ import type { UserTierId, IdeInfo, FallbackIntent, + ValidationIntent, } from '@google/gemini-cli-core'; import type { DOMElement } from 'ink'; import type { SessionStatsState } from '../contexts/SessionContext.js'; @@ -38,6 +39,13 @@ export interface ProQuotaDialogRequest { resolve: (intent: FallbackIntent) => void; } +export interface ValidationDialogRequest { + validationLink?: string; + validationDescription?: string; + learnMoreUrl?: string; + resolve: (intent: ValidationIntent) => void; +} + import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js'; @@ -102,6 +110,7 @@ export interface UIState { // Quota-related state userTier: UserTierId | undefined; proQuotaRequest: ProQuotaDialogRequest | null; + validationRequest: ValidationDialogRequest | null; currentModel: string; contextFileNames: string[]; errorCount: number; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 048c7080d8..205e577fd2 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -28,6 +28,7 @@ import { processRestorableToolCalls, recordToolCallInteractions, ToolErrorType, + ValidationRequiredError, coreEvents, CoreEvent, MCPDiscoveryState, @@ -1100,6 +1101,12 @@ export const useGeminiStream = ( spanMetadata.error = error; if (error instanceof UnauthorizedError) { onAuthError('Session expired or is unauthorized.'); + } else if ( + // Suppress ValidationRequiredError if it was marked as handled (e.g. user clicked change_auth or cancelled) + error instanceof ValidationRequiredError && + error.userHandled + ) { + // Error was handled by validation dialog, don't display again } else if (!isNodeError(error) || error.name !== 'AbortError') { addItem( { diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts index a0066becd8..61e53638ec 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts @@ -498,4 +498,186 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`, ); }); }); + + describe('Validation Handler', () => { + let setValidationHandlerSpy: SpyInstance; + + beforeEach(() => { + setValidationHandlerSpy = vi.spyOn(mockConfig, 'setValidationHandler'); + }); + + it('should register a validation handler on initialization', () => { + renderHook(() => + useQuotaAndFallback({ + config: mockConfig, + historyManager: mockHistoryManager, + userTier: UserTierId.FREE, + setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, + }), + ); + + expect(setValidationHandlerSpy).toHaveBeenCalledTimes(1); + expect(setValidationHandlerSpy.mock.calls[0][0]).toBeInstanceOf(Function); + }); + + it('should set a validation request when handler is called', async () => { + const { result } = renderHook(() => + useQuotaAndFallback({ + config: mockConfig, + historyManager: mockHistoryManager, + userTier: UserTierId.FREE, + setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, + }), + ); + + const handler = setValidationHandlerSpy.mock.calls[0][0] as ( + validationLink?: string, + validationDescription?: string, + learnMoreUrl?: string, + ) => Promise<'verify' | 'change_auth' | 'cancel'>; + + let promise: Promise<'verify' | 'change_auth' | 'cancel'>; + act(() => { + promise = handler( + 'https://example.com/verify', + 'Please verify', + 'https://example.com/help', + ); + }); + + const request = result.current.validationRequest; + expect(request).not.toBeNull(); + expect(request?.validationLink).toBe('https://example.com/verify'); + expect(request?.validationDescription).toBe('Please verify'); + expect(request?.learnMoreUrl).toBe('https://example.com/help'); + + // Simulate user choosing verify + act(() => { + result.current.handleValidationChoice('verify'); + }); + + const intent = await promise!; + expect(intent).toBe('verify'); + expect(result.current.validationRequest).toBeNull(); + }); + + it('should handle race conditions by returning cancel for subsequent requests', async () => { + const { result } = renderHook(() => + useQuotaAndFallback({ + config: mockConfig, + historyManager: mockHistoryManager, + userTier: UserTierId.FREE, + setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, + }), + ); + + const handler = setValidationHandlerSpy.mock.calls[0][0] as ( + validationLink?: string, + ) => Promise<'verify' | 'change_auth' | 'cancel'>; + + let promise1: Promise<'verify' | 'change_auth' | 'cancel'>; + act(() => { + promise1 = handler('https://example.com/verify1'); + }); + + const firstRequest = result.current.validationRequest; + expect(firstRequest).not.toBeNull(); + + let result2: 'verify' | 'change_auth' | 'cancel'; + await act(async () => { + result2 = await handler('https://example.com/verify2'); + }); + + // The lock should have stopped the second request + expect(result2!).toBe('cancel'); + expect(result.current.validationRequest).toBe(firstRequest); + + // Complete the first request + act(() => { + result.current.handleValidationChoice('verify'); + }); + + const intent1 = await promise1!; + expect(intent1).toBe('verify'); + expect(result.current.validationRequest).toBeNull(); + }); + + it('should add info message when change_auth is chosen', async () => { + const { result } = renderHook(() => + useQuotaAndFallback({ + config: mockConfig, + historyManager: mockHistoryManager, + userTier: UserTierId.FREE, + setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, + }), + ); + + const handler = setValidationHandlerSpy.mock.calls[0][0] as ( + validationLink?: string, + ) => Promise<'verify' | 'change_auth' | 'cancel'>; + + let promise: Promise<'verify' | 'change_auth' | 'cancel'>; + act(() => { + promise = handler('https://example.com/verify'); + }); + + act(() => { + result.current.handleValidationChoice('change_auth'); + }); + + const intent = await promise!; + expect(intent).toBe('change_auth'); + + expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1); + const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0]; + expect(lastCall.type).toBe(MessageType.INFO); + expect(lastCall.text).toBe('Use /auth to change authentication method.'); + }); + + it('should not add info message when cancel is chosen', async () => { + const { result } = renderHook(() => + useQuotaAndFallback({ + config: mockConfig, + historyManager: mockHistoryManager, + userTier: UserTierId.FREE, + setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, + }), + ); + + const handler = setValidationHandlerSpy.mock.calls[0][0] as ( + validationLink?: string, + ) => Promise<'verify' | 'change_auth' | 'cancel'>; + + let promise: Promise<'verify' | 'change_auth' | 'cancel'>; + act(() => { + promise = handler('https://example.com/verify'); + }); + + act(() => { + result.current.handleValidationChoice('cancel'); + }); + + const intent = await promise!; + expect(intent).toBe('cancel'); + + expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); + }); + + it('should do nothing if handleValidationChoice is called without pending request', () => { + const { result } = renderHook(() => + useQuotaAndFallback({ + config: mockConfig, + historyManager: mockHistoryManager, + userTier: UserTierId.FREE, + setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, + }), + ); + + act(() => { + result.current.handleValidationChoice('verify'); + }); + + expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts index 4922323567..7f8b8d0f0d 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts @@ -9,6 +9,8 @@ import { type Config, type FallbackModelHandler, type FallbackIntent, + type ValidationHandler, + type ValidationIntent, TerminalQuotaError, ModelNotFoundError, type UserTierId, @@ -19,7 +21,10 @@ import { import { useCallback, useEffect, useRef, useState } from 'react'; import { type UseHistoryManagerReturn } from './useHistoryManager.js'; import { MessageType } from '../types.js'; -import { type ProQuotaDialogRequest } from '../contexts/UIStateContext.js'; +import { + type ProQuotaDialogRequest, + type ValidationDialogRequest, +} from '../contexts/UIStateContext.js'; interface UseQuotaAndFallbackArgs { config: Config; @@ -36,7 +41,10 @@ export function useQuotaAndFallback({ }: UseQuotaAndFallbackArgs) { const [proQuotaRequest, setProQuotaRequest] = useState(null); + const [validationRequest, setValidationRequest] = + useState(null); const isDialogPending = useRef(false); + const isValidationPending = useRef(false); // Set up Flash fallback handler useEffect(() => { @@ -120,6 +128,36 @@ export function useQuotaAndFallback({ config.setFallbackModelHandler(fallbackHandler); }, [config, historyManager, userTier, setModelSwitchedFromQuotaError]); + // Set up validation handler for 403 VALIDATION_REQUIRED errors + useEffect(() => { + const validationHandler: ValidationHandler = async ( + validationLink, + validationDescription, + learnMoreUrl, + ): Promise => { + if (isValidationPending.current) { + return 'cancel'; // A validation dialog is already active + } + isValidationPending.current = true; + + const intent: ValidationIntent = await new Promise( + (resolve) => { + // Call setValidationRequest directly - same pattern as proQuotaRequest + setValidationRequest({ + validationLink, + validationDescription, + learnMoreUrl, + resolve, + }); + }, + ); + + return intent; + }; + + config.setValidationHandler(validationHandler); + }, [config]); + const handleProQuotaChoice = useCallback( (choice: FallbackIntent) => { if (!proQuotaRequest) return; @@ -148,9 +186,35 @@ export function useQuotaAndFallback({ [proQuotaRequest, historyManager, config, setModelSwitchedFromQuotaError], ); + const handleValidationChoice = useCallback( + (choice: ValidationIntent) => { + // Guard against double-execution (e.g. rapid clicks) and stale requests + if (!isValidationPending.current || !validationRequest) return; + + // Immediately clear the flag to prevent any subsequent calls from passing the guard + isValidationPending.current = false; + + validationRequest.resolve(choice); + setValidationRequest(null); + + if (choice === 'change_auth') { + historyManager.addItem( + { + type: MessageType.INFO, + text: 'Use /auth to change authentication method.', + }, + Date.now(), + ); + } + }, + [validationRequest, historyManager], + ); + return { proQuotaRequest, handleProQuotaChoice, + validationRequest, + handleValidationChoice, }; } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 579dcf8d3d..2871b9fc30 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -67,7 +67,10 @@ import { ApprovalModeSwitchEvent, ApprovalModeDurationEvent, } from '../telemetry/types.js'; -import type { FallbackModelHandler } from '../fallback/types.js'; +import type { + FallbackModelHandler, + ValidationHandler, +} from '../fallback/types.js'; import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js'; import { ModelRouterService } from '../routing/modelRouterService.js'; import { OutputFormat } from '../output/types.js'; @@ -477,6 +480,7 @@ export class Config { private readonly _enabledExtensions: string[]; private readonly enableExtensionReloading: boolean; fallbackModelHandler?: FallbackModelHandler; + validationHandler?: ValidationHandler; private quotaErrorOccurred: boolean = false; private readonly summarizeToolOutput: | Record @@ -1066,6 +1070,14 @@ export class Config { return this.fallbackModelHandler; } + setValidationHandler(handler: ValidationHandler): void { + this.validationHandler = handler; + } + + getValidationHandler(): ValidationHandler | undefined { + return this.validationHandler; + } + resetTurn(): void { this.modelAvailabilityService.resetTurn(); } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index ba4092ec1a..fdf5e22a4d 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -25,6 +25,7 @@ import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js'; import { reportError } from '../utils/errorReporting.js'; import { GeminiChat } from './geminiChat.js'; import { retryWithBackoff } from '../utils/retry.js'; +import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js'; import { getErrorMessage } from '../utils/errors.js'; import { tokenLimit } from './tokenLimits.js'; import type { @@ -926,8 +927,29 @@ export class GeminiClient { // Pass the captured model to the centralized handler. handleFallback(this.config, currentAttemptModel, authType, error); + const onValidationRequiredCallback = async ( + validationError: ValidationRequiredError, + ) => { + // Suppress validation dialog for background calls (e.g. prompt-completion) + // to prevent the dialog from appearing on startup or during typing. + if (modelConfigKey.model === 'prompt-completion') { + throw validationError; + } + + const handler = this.config.getValidationHandler(); + if (typeof handler !== 'function') { + throw validationError; + } + return handler( + validationError.validationLink, + validationError.validationDescription, + validationError.learnMoreUrl, + ); + }; + const result = await retryWithBackoff(apiCall, { onPersistent429: onPersistent429Callback, + onValidationRequired: onValidationRequiredCallback, authType: this.config.getContentGeneratorConfig()?.authType, maxAttempts: availabilityMaxAttempts, getAvailabilityContext, diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 8b75eedb1b..4a99cf7b7b 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -19,6 +19,7 @@ import type { import { toParts } from '../code_assist/converter.js'; import { createUserContent, FinishReason } from '@google/genai'; import { retryWithBackoff, isRetryableError } from '../utils/retry.js'; +import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js'; import type { Config } from '../config/config.js'; import { resolveModel, @@ -579,8 +580,24 @@ export class GeminiChat { error?: unknown, ) => handleFallback(this.config, lastModelToUse, authType, error); + const onValidationRequiredCallback = async ( + validationError: ValidationRequiredError, + ) => { + const handler = this.config.getValidationHandler(); + if (typeof handler !== 'function') { + // No handler registered, re-throw to show default error message + throw validationError; + } + return handler( + validationError.validationLink, + validationError.validationDescription, + validationError.learnMoreUrl, + ); + }; + const streamResponse = await retryWithBackoff(apiCall, { onPersistent429: onPersistent429Callback, + onValidationRequired: onValidationRequiredCallback, authType: this.config.getContentGeneratorConfig()?.authType, retryFetchErrors: this.config.getRetryFetchErrors(), signal: abortSignal, diff --git a/packages/core/src/fallback/types.ts b/packages/core/src/fallback/types.ts index 223c3abbf5..222c7d72a6 100644 --- a/packages/core/src/fallback/types.ts +++ b/packages/core/src/fallback/types.ts @@ -37,3 +37,21 @@ export type FallbackModelHandler = ( fallbackModel: string, error?: unknown, ) => Promise; + +/** + * Defines the intent returned by the UI layer during a validation required scenario. + */ +export type ValidationIntent = + | 'verify' // User chose to verify, wait for completion then retry. + | 'change_auth' // User chose to change authentication method. + | 'cancel'; // User cancelled the verification process. + +/** + * The interface for the handler provided by the UI layer (e.g., the CLI) + * to interact with the user when validation is required. + */ +export type ValidationHandler = ( + validationLink?: string, + validationDescription?: string, + learnMoreUrl?: string, +) => Promise; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 506e602ebf..bc1fd35f28 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -90,6 +90,7 @@ export * from './utils/extensionLoader.js'; export * from './utils/package.js'; export * from './utils/version.js'; export * from './utils/checkpointUtils.js'; +export * from './utils/secure-browser-launcher.js'; export * from './utils/apiConversionUtils.js'; export * from './utils/channel.js'; diff --git a/packages/core/src/utils/googleQuotaErrors.test.ts b/packages/core/src/utils/googleQuotaErrors.test.ts index 5aaaf16b76..e126589d63 100644 --- a/packages/core/src/utils/googleQuotaErrors.test.ts +++ b/packages/core/src/utils/googleQuotaErrors.test.ts @@ -9,6 +9,7 @@ import { classifyGoogleError, RetryableQuotaError, TerminalQuotaError, + ValidationRequiredError, } from './googleQuotaErrors.js'; import * as errorParser from './googleErrors.js'; import type { GoogleApiError } from './googleErrors.js'; @@ -449,4 +450,190 @@ describe('classifyGoogleError', () => { expect(result.retryDelayMs).toBeUndefined(); } }); + + it('should return ValidationRequiredError for 403 with VALIDATION_REQUIRED from cloudcode-pa domain', () => { + const apiError: GoogleApiError = { + code: 403, + message: 'Validation required to continue.', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'VALIDATION_REQUIRED', + domain: 'cloudcode-pa.googleapis.com', + metadata: { + validation_link: 'https://fallback.example.com/validate', + }, + }, + { + '@type': 'type.googleapis.com/google.rpc.Help', + links: [ + { + description: 'Complete validation to continue', + url: 'https://example.com/validate', + }, + { + description: 'Learn more', + url: 'https://support.google.com/accounts?p=al_alert', + }, + ], + }, + ], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const result = classifyGoogleError(new Error()); + expect(result).toBeInstanceOf(ValidationRequiredError); + expect((result as ValidationRequiredError).validationLink).toBe( + 'https://example.com/validate', + ); + expect((result as ValidationRequiredError).validationDescription).toBe( + 'Complete validation to continue', + ); + expect((result as ValidationRequiredError).learnMoreUrl).toBe( + 'https://support.google.com/accounts?p=al_alert', + ); + expect((result as ValidationRequiredError).cause).toBe(apiError); + }); + + it('should correctly parse Learn more URL when first link description contains "Learn more" text', () => { + // This tests the real API response format where the description of the first + // link contains "Learn more:" text, but we should use the second link's URL + const apiError: GoogleApiError = { + code: 403, + message: 'Validation required to continue.', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'VALIDATION_REQUIRED', + domain: 'cloudcode-pa.googleapis.com', + metadata: {}, + }, + { + '@type': 'type.googleapis.com/google.rpc.Help', + links: [ + { + description: + 'Further action is required to use this service. Navigate to the following URL to complete verification:\n\nhttps://accounts.sandbox.google.com/signin/continue?...\n\nLearn more:\n\nhttps://support.google.com/accounts?p=al_alert\n', + url: 'https://accounts.sandbox.google.com/signin/continue?sarp=1&scc=1&continue=...', + }, + { + description: 'Learn more', + url: 'https://support.google.com/accounts?p=al_alert', + }, + ], + }, + ], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const result = classifyGoogleError(new Error()); + expect(result).toBeInstanceOf(ValidationRequiredError); + // Should get the validation link from the first link + expect((result as ValidationRequiredError).validationLink).toBe( + 'https://accounts.sandbox.google.com/signin/continue?sarp=1&scc=1&continue=...', + ); + // Should get the Learn more URL from the SECOND link, not the first + expect((result as ValidationRequiredError).learnMoreUrl).toBe( + 'https://support.google.com/accounts?p=al_alert', + ); + }); + + it('should fallback to ErrorInfo metadata when Help detail is not present', () => { + const apiError: GoogleApiError = { + code: 403, + message: 'Validation required.', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'VALIDATION_REQUIRED', + domain: 'staging-cloudcode-pa.googleapis.com', + metadata: { + validation_link: 'https://staging.example.com/validate', + }, + }, + ], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const result = classifyGoogleError(new Error()); + expect(result).toBeInstanceOf(ValidationRequiredError); + expect((result as ValidationRequiredError).validationLink).toBe( + 'https://staging.example.com/validate', + ); + expect( + (result as ValidationRequiredError).validationDescription, + ).toBeUndefined(); + expect((result as ValidationRequiredError).learnMoreUrl).toBeUndefined(); + }); + + it('should return original error for 403 with different reason', () => { + const apiError: GoogleApiError = { + code: 403, + message: 'Access denied.', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'ACCESS_DENIED', + domain: 'cloudcode-pa.googleapis.com', + metadata: {}, + }, + ], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const originalError = new Error(); + const result = classifyGoogleError(originalError); + expect(result).toBe(originalError); + expect(result).not.toBeInstanceOf(ValidationRequiredError); + }); + + it('should find learn more link by hostname when description is different', () => { + const apiError: GoogleApiError = { + code: 403, + message: 'Validation required.', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'VALIDATION_REQUIRED', + domain: 'cloudcode-pa.googleapis.com', + metadata: {}, + }, + { + '@type': 'type.googleapis.com/google.rpc.Help', + links: [ + { + description: 'Complete validation', + url: 'https://accounts.google.com/validate', + }, + { + description: 'More information', // Not exactly "Learn more" + url: 'https://support.google.com/accounts?p=al_alert', + }, + ], + }, + ], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const result = classifyGoogleError(new Error()); + expect(result).toBeInstanceOf(ValidationRequiredError); + expect((result as ValidationRequiredError).learnMoreUrl).toBe( + 'https://support.google.com/accounts?p=al_alert', + ); + }); + + it('should return original error for 403 from non-cloudcode domain', () => { + const apiError: GoogleApiError = { + code: 403, + message: 'Forbidden.', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'VALIDATION_REQUIRED', + domain: 'other.googleapis.com', + metadata: {}, + }, + ], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const originalError = new Error(); + const result = classifyGoogleError(originalError); + expect(result).toBe(originalError); + expect(result).not.toBeInstanceOf(ValidationRequiredError); + }); }); diff --git a/packages/core/src/utils/googleQuotaErrors.ts b/packages/core/src/utils/googleQuotaErrors.ts index 4c1234010f..dfd828f41f 100644 --- a/packages/core/src/utils/googleQuotaErrors.ts +++ b/packages/core/src/utils/googleQuotaErrors.ts @@ -7,6 +7,7 @@ import type { ErrorInfo, GoogleApiError, + Help, QuotaFailure, RetryInfo, } from './googleErrors.js'; @@ -51,6 +52,30 @@ export class RetryableQuotaError extends Error { } } +/** + * An error indicating that user validation is required to continue. + */ +export class ValidationRequiredError extends Error { + validationLink?: string; + validationDescription?: string; + learnMoreUrl?: string; + userHandled: boolean = false; + + constructor( + message: string, + override readonly cause: GoogleApiError, + validationLink?: string, + validationDescription?: string, + learnMoreUrl?: string, + ) { + super(message); + this.name = 'ValidationRequiredError'; + this.validationLink = validationLink; + this.validationDescription = validationDescription; + this.learnMoreUrl = learnMoreUrl; + } +} + /** * Parses a duration string (e.g., "34.074824224s", "60s", "900ms") and returns the time in seconds. * @param duration The duration string to parse. @@ -69,18 +94,94 @@ function parseDurationInSeconds(duration: string): number | null { } /** - * Analyzes a caught error and classifies it as a specific quota-related error if applicable. + * Valid Cloud Code API domains for VALIDATION_REQUIRED errors. + */ +const CLOUDCODE_DOMAINS = [ + 'cloudcode-pa.googleapis.com', + 'staging-cloudcode-pa.googleapis.com', + 'autopush-cloudcode-pa.googleapis.com', +]; + +/** + * Checks if a 403 error requires user validation and extracts validation details. * - * It decides whether an error is a `TerminalQuotaError` or a `RetryableQuotaError` based on - * the following logic: - * - If the error indicates a daily limit, it's a `TerminalQuotaError`. - * - If the error suggests a retry delay of more than 2 minutes, it's a `TerminalQuotaError`. - * - If the error suggests a retry delay of 2 minutes or less, it's a `RetryableQuotaError`. - * - If the error indicates a per-minute limit, it's a `RetryableQuotaError`. - * - If the error message contains the phrase "Please retry in X[s|ms]", it's a `RetryableQuotaError`. + * @param googleApiError The parsed Google API error to check. + * @returns A `ValidationRequiredError` if validation is required, otherwise `null`. + */ +function classifyValidationRequiredError( + googleApiError: GoogleApiError, +): ValidationRequiredError | null { + const errorInfo = googleApiError.details.find( + (d): d is ErrorInfo => + d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo', + ); + + if (!errorInfo) { + return null; + } + + if ( + !CLOUDCODE_DOMAINS.includes(errorInfo.domain) || + errorInfo.reason !== 'VALIDATION_REQUIRED' + ) { + return null; + } + + // Try to extract validation info from Help detail first + const helpDetail = googleApiError.details.find( + (d): d is Help => d['@type'] === 'type.googleapis.com/google.rpc.Help', + ); + + let validationLink: string | undefined; + let validationDescription: string | undefined; + let learnMoreUrl: string | undefined; + + if (helpDetail?.links && helpDetail.links.length > 0) { + // First link is the validation link, extract description and URL + const validationLinkInfo = helpDetail.links[0]; + validationLink = validationLinkInfo.url; + validationDescription = validationLinkInfo.description; + + // Look for "Learn more" link - identified by description or support.google.com hostname + const learnMoreLink = helpDetail.links.find((link) => { + if (link.description.toLowerCase().trim() === 'learn more') return true; + const parsed = URL.parse(link.url); + return parsed?.hostname === 'support.google.com'; + }); + if (learnMoreLink) { + learnMoreUrl = learnMoreLink.url; + } + } + + // Fallback to ErrorInfo metadata if Help detail not found + if (!validationLink) { + validationLink = errorInfo.metadata?.['validation_link']; + } + + return new ValidationRequiredError( + googleApiError.message, + googleApiError, + validationLink, + validationDescription, + learnMoreUrl, + ); +} +/** + * Analyzes a caught error and classifies it as a specific error type if applicable. + * + * Classification logic: + * - 404 errors are classified as `ModelNotFoundError`. + * - 403 errors with `VALIDATION_REQUIRED` from cloudcode-pa domains are classified + * as `ValidationRequiredError`. + * - 429 errors are classified as either `TerminalQuotaError` or `RetryableQuotaError`: + * - If the error indicates a daily limit, it's a `TerminalQuotaError`. + * - If the error suggests a retry delay of more than 2 minutes, it's a `TerminalQuotaError`. + * - If the error suggests a retry delay of 2 minutes or less, it's a `RetryableQuotaError`. + * - If the error indicates a per-minute limit, it's a `RetryableQuotaError`. + * - If the error message contains the phrase "Please retry in X[s|ms]", it's a `RetryableQuotaError`. * * @param error The error to classify. - * @returns A `TerminalQuotaError`, `RetryableQuotaError`, or the original `unknown` error. + * @returns A classified error or the original `unknown` error. */ export function classifyGoogleError(error: unknown): unknown { const googleApiError = parseGoogleApiError(error); @@ -93,6 +194,14 @@ export function classifyGoogleError(error: unknown): unknown { return new ModelNotFoundError(message, status); } + // Check for 403 VALIDATION_REQUIRED errors from Cloud Code API + if (status === 403 && googleApiError) { + const validationError = classifyValidationRequiredError(googleApiError); + if (validationError) { + return validationError; + } + } + if ( !googleApiError || googleApiError.code !== 429 || diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index eb9e145c18..cbfa16379f 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -9,6 +9,7 @@ import { ApiError } from '@google/genai'; import { TerminalQuotaError, RetryableQuotaError, + ValidationRequiredError, classifyGoogleError, } from './googleQuotaErrors.js'; import { delay, createAbortError } from './delay.js'; @@ -28,6 +29,9 @@ export interface RetryOptions { authType?: string, error?: unknown, ) => Promise; + onValidationRequired?: ( + error: ValidationRequiredError, + ) => Promise<'verify' | 'change_auth' | 'cancel'>; authType?: string; retryFetchErrors?: boolean; signal?: AbortSignal; @@ -144,6 +148,7 @@ export async function retryWithBackoff( initialDelayMs, maxDelayMs, onPersistent429, + onValidationRequired, authType, shouldRetryOnError, shouldRetryOnContent, @@ -220,6 +225,26 @@ export async function retryWithBackoff( throw classifiedError; // Throw if no fallback or fallback failed. } + // Handle ValidationRequiredError - user needs to verify before proceeding + if (classifiedError instanceof ValidationRequiredError) { + if (onValidationRequired) { + try { + const intent = await onValidationRequired(classifiedError); + if (intent === 'verify') { + // User verified, retry the request + attempt = 0; + currentDelay = initialDelayMs; + continue; + } + // 'change_auth' or 'cancel' - mark as handled and throw + classifiedError.userHandled = true; + } catch (validationError) { + debugLogger.warn('Validation handler failed:', validationError); + } + } + throw classifiedError; + } + const is500 = errorCode !== undefined && errorCode >= 500 && errorCode < 600;