From ea48bd9414a0e1de735933211680dcced3731946 Mon Sep 17 00:00:00 2001 From: Gaurav <39389231+gsquared94@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:18:16 -0800 Subject: [PATCH] feat: better error messages (#20577) Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com> --- packages/cli/src/core/auth.test.ts | 53 +++- packages/cli/src/core/auth.ts | 34 ++- packages/cli/src/core/initializer.test.ts | 12 +- packages/cli/src/core/initializer.ts | 5 +- packages/cli/src/gemini.test.tsx | 1 + packages/cli/src/test-utils/AppRig.tsx | 3 + packages/cli/src/test-utils/render.tsx | 1 + packages/cli/src/ui/AppContainer.tsx | 16 +- .../src/ui/auth/BannedAccountDialog.test.tsx | 241 ++++++++++++++++++ .../cli/src/ui/auth/BannedAccountDialog.tsx | 138 ++++++++++ .../BannedAccountDialog.test.tsx.snap | 17 ++ packages/cli/src/ui/auth/useAuth.ts | 19 +- .../cli/src/ui/components/DialogManager.tsx | 17 ++ .../cli/src/ui/contexts/UIActionsContext.tsx | 1 + .../cli/src/ui/contexts/UIStateContext.tsx | 7 + packages/core/src/utils/errors.test.ts | 81 ++++++ packages/core/src/utils/errors.ts | 41 ++- 17 files changed, 668 insertions(+), 19 deletions(-) create mode 100644 packages/cli/src/ui/auth/BannedAccountDialog.test.tsx create mode 100644 packages/cli/src/ui/auth/BannedAccountDialog.tsx create mode 100644 packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap diff --git a/packages/cli/src/core/auth.test.ts b/packages/cli/src/core/auth.test.ts index c844ee6f93..f28e826f49 100644 --- a/packages/cli/src/core/auth.test.ts +++ b/packages/cli/src/core/auth.test.ts @@ -17,7 +17,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); return { ...actual, - getErrorMessage: (e: unknown) => (e as Error).message, }; }); @@ -32,7 +31,7 @@ describe('auth', () => { it('should return null if authType is undefined', async () => { const result = await performInitialAuth(mockConfig, undefined); - expect(result).toBeNull(); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); }); @@ -41,7 +40,7 @@ describe('auth', () => { mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); - expect(result).toBeNull(); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); @@ -54,7 +53,10 @@ describe('auth', () => { mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); - expect(result).toBe('Failed to login. Message: Auth failed'); + expect(result).toEqual({ + authError: 'Failed to login. Message: Auth failed', + accountSuspensionInfo: null, + }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); @@ -68,7 +70,48 @@ describe('auth', () => { mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); - expect(result).toBeNull(); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should return accountSuspensionInfo for 403 TOS_VIOLATION error', async () => { + vi.mocked(mockConfig.refreshAuth).mockRejectedValue({ + response: { + data: { + error: { + code: 403, + message: + 'This service has been disabled for violation of Terms of Service.', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'TOS_VIOLATION', + domain: 'example.googleapis.com', + metadata: { + appeal_url: 'https://example.com/appeal', + appeal_url_link_text: 'Appeal Here', + }, + }, + ], + }, + }, + }, + }); + const result = await performInitialAuth( + mockConfig, + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result).toEqual({ + authError: null, + accountSuspensionInfo: { + message: + 'This service has been disabled for violation of Terms of Service.', + appealUrl: 'https://example.com/appeal', + appealLinkText: 'Appeal Here', + }, + }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts index 7b1e8c8277..f49fdecf76 100644 --- a/packages/cli/src/core/auth.ts +++ b/packages/cli/src/core/auth.ts @@ -9,20 +9,28 @@ import { type Config, getErrorMessage, ValidationRequiredError, + isAccountSuspendedError, } from '@google/gemini-cli-core'; +import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js'; + +export interface InitialAuthResult { + authError: string | null; + accountSuspensionInfo: AccountSuspensionInfo | null; +} + /** * Handles the initial authentication flow. * @param config The application config. * @param authType The selected auth type. - * @returns An error message if authentication fails, otherwise null. + * @returns The auth result with error message and account suspension status. */ export async function performInitialAuth( config: Config, authType: AuthType | undefined, -): Promise { +): Promise { if (!authType) { - return null; + return { authError: null, accountSuspensionInfo: null }; } try { @@ -33,10 +41,24 @@ export async function performInitialAuth( if (e instanceof ValidationRequiredError) { // Don't treat validation required as a fatal auth error during startup. // This allows the React UI to load and show the ValidationDialog. - return null; + return { authError: null, accountSuspensionInfo: null }; } - return `Failed to login. Message: ${getErrorMessage(e)}`; + const suspendedError = isAccountSuspendedError(e); + if (suspendedError) { + return { + authError: null, + accountSuspensionInfo: { + message: suspendedError.message, + appealUrl: suspendedError.appealUrl, + appealLinkText: suspendedError.appealLinkText, + }, + }; + } + return { + authError: `Failed to login. Message: ${getErrorMessage(e)}`, + accountSuspensionInfo: null, + }; } - return null; + return { authError: null, accountSuspensionInfo: null }; } diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts index 57f1c41551..e4fdb2cba5 100644 --- a/packages/cli/src/core/initializer.test.ts +++ b/packages/cli/src/core/initializer.test.ts @@ -72,7 +72,10 @@ describe('initializer', () => { vi.mocked(IdeClient.getInstance).mockResolvedValue( mockIdeClient as unknown as IdeClient, ); - vi.mocked(performInitialAuth).mockResolvedValue(null); + vi.mocked(performInitialAuth).mockResolvedValue({ + authError: null, + accountSuspensionInfo: null, + }); vi.mocked(validateTheme).mockReturnValue(null); }); @@ -84,6 +87,7 @@ describe('initializer', () => { expect(result).toEqual({ authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 5, @@ -103,6 +107,7 @@ describe('initializer', () => { expect(result).toEqual({ authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 5, @@ -116,7 +121,10 @@ describe('initializer', () => { }); it('should handle auth error', async () => { - vi.mocked(performInitialAuth).mockResolvedValue('Auth failed'); + vi.mocked(performInitialAuth).mockResolvedValue({ + authError: 'Auth failed', + accountSuspensionInfo: null, + }); const result = await initializeApp( mockConfig as unknown as Config, mockSettings, diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index e99efd90f6..f27e9a9511 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -17,9 +17,11 @@ import { import { type LoadedSettings } from '../config/settings.js'; import { performInitialAuth } from './auth.js'; import { validateTheme } from './theme.js'; +import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js'; export interface InitializationResult { authError: string | null; + accountSuspensionInfo: AccountSuspensionInfo | null; themeError: string | null; shouldOpenAuthDialog: boolean; geminiMdFileCount: number; @@ -37,7 +39,7 @@ export async function initializeApp( settings: LoadedSettings, ): Promise { const authHandle = startupProfiler.start('authenticate'); - const authError = await performInitialAuth( + const { authError, accountSuspensionInfo } = await performInitialAuth( config, settings.merged.security.auth.selectedType, ); @@ -60,6 +62,7 @@ export async function initializeApp( return { authError, + accountSuspensionInfo, themeError, shouldOpenAuthDialog, geminiMdFileCount: config.getGeminiMdFileCount(), diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index c2a1d079c5..2784c5694a 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -1202,6 +1202,7 @@ describe('startInteractiveUI', () => { const mockWorkspaceRoot = '/root'; const mockInitializationResult = { authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 0, diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index d953be0ff6..3ff65c4067 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -104,6 +104,8 @@ vi.mock('../ui/auth/useAuth.js', () => ({ onAuthError: vi.fn(), apiKeyDefaultValue: 'test-api-key', reloadApiKey: vi.fn().mockResolvedValue('test-api-key'), + accountSuspensionInfo: null, + setAccountSuspensionInfo: vi.fn(), }), validateAuthMethodWithSettings: () => null, })); @@ -387,6 +389,7 @@ export class AppRig { version="test-version" initializationResult={{ authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 0, diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 921bd3d7dd..6908fd36fb 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -615,6 +615,7 @@ const mockUIActions: UIActions = { handleRestart: vi.fn(), handleNewAgentsSelect: vi.fn(), getPreferredEditor: vi.fn(), + clearAccountSuspension: vi.fn(), }; let capturedOverflowState: OverflowState | undefined; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 1ddee45b0d..447e4ef956 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -675,7 +675,14 @@ export const AppContainer = (props: AppContainerProps) => { onAuthError, apiKeyDefaultValue, reloadApiKey, - } = useAuthCommand(settings, config, initializationResult.authError); + accountSuspensionInfo, + setAccountSuspensionInfo, + } = useAuthCommand( + settings, + config, + initializationResult.authError, + initializationResult.accountSuspensionInfo, + ); const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>( {}, ); @@ -2197,6 +2204,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isAuthenticating, isConfigInitialized, authError, + accountSuspensionInfo, isAuthDialogOpen, isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput, apiKeyDefaultValue, @@ -2328,6 +2336,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isAuthenticating, isConfigInitialized, authError, + accountSuspensionInfo, isAuthDialogOpen, editorError, isEditorDialogOpen, @@ -2537,6 +2546,10 @@ Logging in with Google... Restarting Gemini CLI to continue. setNewAgents(null); }, getPreferredEditor, + clearAccountSuspension: () => { + setAccountSuspensionInfo(null); + setAuthState(AuthState.Updating); + }, }), [ handleThemeSelect, @@ -2587,6 +2600,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setActiveBackgroundShellPid, setIsBackgroundShellListOpen, setAuthContext, + setAccountSuspensionInfo, newAgents, config, historyManager, diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx new file mode 100644 index 0000000000..692b249415 --- /dev/null +++ b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx @@ -0,0 +1,241 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { BannedAccountDialog } from './BannedAccountDialog.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { + openBrowserSecurely, + shouldLaunchBrowser, +} from '@google/gemini-cli-core'; +import { Text } from 'ink'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + openBrowserSecurely: vi.fn(), + shouldLaunchBrowser: vi.fn().mockReturnValue(true), + }; +}); + +vi.mock('../../utils/cleanup.js', () => ({ + runExitCleanup: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../components/shared/RadioButtonSelect.js', () => ({ + RadioButtonSelect: vi.fn(({ items }) => ( + <> + {items.map((item: { value: string; label: string }) => ( + {item.label} + ))} + + )), +})); + +const mockedRadioButtonSelect = RadioButtonSelect as Mock; +const mockedUseKeypress = useKeypress as Mock; +const mockedOpenBrowser = openBrowserSecurely as Mock; +const mockedShouldLaunchBrowser = shouldLaunchBrowser as Mock; +const mockedRunExitCleanup = runExitCleanup as Mock; + +const DEFAULT_SUSPENSION_INFO: AccountSuspensionInfo = { + message: + 'This service has been disabled in this account for violation of Terms of Service. Please submit an appeal to continue using this product.', + appealUrl: 'https://example.com/appeal', + appealLinkText: 'Appeal Here', +}; + +describe('BannedAccountDialog', () => { + let onExit: Mock; + let onChangeAuth: Mock; + + beforeEach(() => { + vi.resetAllMocks(); + mockedShouldLaunchBrowser.mockReturnValue(true); + mockedOpenBrowser.mockResolvedValue(undefined); + mockedRunExitCleanup.mockResolvedValue(undefined); + onExit = vi.fn(); + onChangeAuth = vi.fn(); + }); + + it('renders the suspension message from accountSuspensionInfo', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const frame = lastFrame(); + expect(frame).toContain('Account Suspended'); + expect(frame).toContain('violation of Terms of Service'); + expect(frame).toContain('Escape to exit'); + unmount(); + }); + + it('renders menu options with appeal link text from response', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items).toHaveLength(3); + expect(items[0].label).toBe('Appeal Here'); + expect(items[1].label).toBe('Change authentication'); + expect(items[2].label).toBe('Exit'); + unmount(); + }); + + it('hides form option when no appealUrl is provided', async () => { + const infoWithoutUrl: AccountSuspensionInfo = { + message: 'Account suspended.', + }; + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items).toHaveLength(2); + expect(items[0].label).toBe('Change authentication'); + expect(items[1].label).toBe('Exit'); + unmount(); + }); + + it('uses default label when appealLinkText is not provided', async () => { + const infoWithoutLinkText: AccountSuspensionInfo = { + message: 'Account suspended.', + appealUrl: 'https://example.com/appeal', + }; + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items[0].label).toBe('Open the Google Form'); + unmount(); + }); + + it('opens browser when appeal option is selected', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + await onSelect('open_form'); + expect(mockedOpenBrowser).toHaveBeenCalledWith( + 'https://example.com/appeal', + ); + expect(onExit).not.toHaveBeenCalled(); + unmount(); + }); + + it('shows URL when browser cannot be launched', async () => { + mockedShouldLaunchBrowser.mockReturnValue(false); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + onSelect('open_form'); + await waitFor(() => { + expect(lastFrame()).toContain('Please open this URL in a browser'); + }); + expect(mockedOpenBrowser).not.toHaveBeenCalled(); + unmount(); + }); + + it('calls onExit when "Exit" is selected', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + await onSelect('exit'); + expect(mockedRunExitCleanup).toHaveBeenCalled(); + expect(onExit).toHaveBeenCalled(); + unmount(); + }); + + it('calls onChangeAuth when "Change authentication" is selected', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + onSelect('change_auth'); + expect(onChangeAuth).toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + unmount(); + }); + + it('exits on escape key', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + const result = keypressHandler({ name: 'escape' }); + expect(result).toBe(true); + unmount(); + }); + + it('renders snapshot correctly', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.tsx new file mode 100644 index 0000000000..e051ba082b --- /dev/null +++ b/packages/cli/src/ui/auth/BannedAccountDialog.tsx @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { + openBrowserSecurely, + shouldLaunchBrowser, +} from '@google/gemini-cli-core'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + +interface BannedAccountDialogProps { + accountSuspensionInfo: AccountSuspensionInfo; + onExit: () => void; + onChangeAuth: () => void; +} + +export function BannedAccountDialog({ + accountSuspensionInfo, + onExit, + onChangeAuth, +}: BannedAccountDialogProps): React.JSX.Element { + const [errorMessage, setErrorMessage] = useState(null); + + const appealUrl = accountSuspensionInfo.appealUrl; + const appealLinkText = + accountSuspensionInfo.appealLinkText ?? 'Open the Google Form'; + + const items = useMemo(() => { + const menuItems = []; + if (appealUrl) { + menuItems.push({ + label: appealLinkText, + value: 'open_form' as const, + key: 'open_form', + }); + } + menuItems.push( + { + label: 'Change authentication', + value: 'change_auth' as const, + key: 'change_auth', + }, + { + label: 'Exit', + value: 'exit' as const, + key: 'exit', + }, + ); + return menuItems; + }, [appealUrl, appealLinkText]); + + useKeypress( + (key) => { + if (key.name === 'escape') { + void handleExit(); + return true; + } + return false; + }, + { isActive: true }, + ); + + const handleExit = useCallback(async () => { + await runExitCleanup(); + onExit(); + }, [onExit]); + + const handleSelect = useCallback( + async (choice: string) => { + if (choice === 'open_form' && appealUrl) { + if (!shouldLaunchBrowser()) { + setErrorMessage(`Please open this URL in a browser: ${appealUrl}`); + return; + } + + try { + await openBrowserSecurely(appealUrl); + } catch { + setErrorMessage(`Failed to open browser. Please visit: ${appealUrl}`); + } + } else if (choice === 'change_auth') { + onChangeAuth(); + } else { + await handleExit(); + } + }, + [handleExit, onChangeAuth, appealUrl], + ); + + return ( + + + Error: Account Suspended + + + + {accountSuspensionInfo.message} + + + {appealUrl && ( + <> + + Appeal URL: + + + [{appealUrl}] + + + )} + + {errorMessage && ( + + {errorMessage} + + )} + + + void handleSelect(choice)} + /> + + + + Escape to exit + + + ); +} diff --git a/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap new file mode 100644 index 0000000000..b95994692d --- /dev/null +++ b/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`BannedAccountDialog > renders snapshot correctly 1`] = ` +" + Error: Account Suspended + + This service has been disabled in this account for violation of Terms of Service. Please submit an + appeal to continue using this product. + + Appeal URL: + [https://example.com/appeal] + + Appeal HereChange authenticationExit + + Escape to exit +" +`; diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index effb17cdff..3faec2d5a8 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -11,6 +11,7 @@ import { type Config, loadApiKey, debugLogger, + isAccountSuspendedError, } from '@google/gemini-cli-core'; import { getErrorMessage } from '@google/gemini-cli-core'; import { AuthState } from '../types.js'; @@ -34,16 +35,21 @@ export function validateAuthMethodWithSettings( return validateAuthMethod(authType); } +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + export const useAuthCommand = ( settings: LoadedSettings, config: Config, initialAuthError: string | null = null, + initialAccountSuspensionInfo: AccountSuspensionInfo | null = null, ) => { const [authState, setAuthState] = useState( initialAuthError ? AuthState.Updating : AuthState.Unauthenticated, ); const [authError, setAuthError] = useState(initialAuthError); + const [accountSuspensionInfo, setAccountSuspensionInfo] = + useState(initialAccountSuspensionInfo); const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState< string | undefined >(undefined); @@ -130,7 +136,16 @@ export const useAuthCommand = ( setAuthError(null); setAuthState(AuthState.Authenticated); } catch (e) { - onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`); + const suspendedError = isAccountSuspendedError(e); + if (suspendedError) { + setAccountSuspensionInfo({ + message: suspendedError.message, + appealUrl: suspendedError.appealUrl, + appealLinkText: suspendedError.appealLinkText, + }); + } else { + onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`); + } } })(); }, [ @@ -150,5 +165,7 @@ export const useAuthCommand = ( onAuthError, apiKeyDefaultValue, reloadApiKey, + accountSuspensionInfo, + setAccountSuspensionInfo, }; }; diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 32edbc9d3f..f7f050a53f 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -13,6 +13,7 @@ import { ThemeDialog } from './ThemeDialog.js'; import { SettingsDialog } from './SettingsDialog.js'; import { AuthInProgress } from '../auth/AuthInProgress.js'; import { AuthDialog } from '../auth/AuthDialog.js'; +import { BannedAccountDialog } from '../auth/BannedAccountDialog.js'; import { ApiAuthDialog } from '../auth/ApiAuthDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { PrivacyNotice } from '../privacy/PrivacyNotice.js'; @@ -320,6 +321,21 @@ export const DialogManager = ({ ); } + if (uiState.accountSuspensionInfo) { + return ( + + { + process.exit(1); + }} + onChangeAuth={() => { + uiActions.clearAccountSuspension(); + }} + /> + + ); + } if (uiState.isAuthenticating) { return ( ); } + if (uiState.isAuthDialogOpen) { return ( diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 06052aa8a9..988837df4d 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -91,6 +91,7 @@ export interface UIActions { handleRestart: () => void; handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise; getPreferredEditor: () => EditorType | undefined; + clearAccountSuspension: () => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 442300da92..554cff34f9 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -97,6 +97,12 @@ export interface QuotaState { emptyWalletRequest: EmptyWalletDialogRequest | null; } +export interface AccountSuspensionInfo { + message: string; + appealUrl?: string; + appealLinkText?: string; +} + export interface UIState { history: HistoryItem[]; historyManager: UseHistoryManagerReturn; @@ -107,6 +113,7 @@ export interface UIState { isAuthenticating: boolean; isConfigInitialized: boolean; authError: string | null; + accountSuspensionInfo: AccountSuspensionInfo | null; isAuthDialogOpen: boolean; isAwaitingApiKeyInput: boolean; apiKeyDefaultValue?: string; diff --git a/packages/core/src/utils/errors.test.ts b/packages/core/src/utils/errors.test.ts index b3df89ef93..7ea319e274 100644 --- a/packages/core/src/utils/errors.test.ts +++ b/packages/core/src/utils/errors.test.ts @@ -11,6 +11,7 @@ import { toFriendlyError, BadRequestError, ForbiddenError, + AccountSuspendedError, getErrorMessage, getErrorType, FatalAuthenticationError, @@ -127,9 +128,86 @@ describe('toFriendlyError', () => { }; const result = toFriendlyError(error); expect(result).toBeInstanceOf(ForbiddenError); + expect(result).not.toBeInstanceOf(AccountSuspendedError); expect((result as ForbiddenError).message).toBe('Forbidden'); }); + it('should return AccountSuspendedError for 403 with TOS_VIOLATION reason in details', () => { + const error = { + response: { + data: { + error: { + code: 403, + message: + 'This service has been disabled in this account for violation of Terms of Service.', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'TOS_VIOLATION', + domain: 'example.googleapis.com', + metadata: { + uiMessage: 'true', + appeal_url_link_text: 'Appeal Here', + appeal_url: 'https://example.com/appeal', + }, + }, + ], + }, + }, + }, + }; + const result = toFriendlyError(error); + expect(result).toBeInstanceOf(AccountSuspendedError); + expect(result).toBeInstanceOf(ForbiddenError); + const suspended = result as AccountSuspendedError; + expect(suspended.message).toBe( + 'This service has been disabled in this account for violation of Terms of Service.', + ); + expect(suspended.appealUrl).toBe('https://example.com/appeal'); + expect(suspended.appealLinkText).toBe('Appeal Here'); + }); + + it('should return ForbiddenError for 403 with violation message but no TOS_VIOLATION detail', () => { + const error = { + response: { + data: { + error: { + code: 403, + message: + 'This service has been disabled in this account for violation of Terms of Service.', + }, + }, + }, + }; + const result = toFriendlyError(error); + expect(result).toBeInstanceOf(ForbiddenError); + expect(result).not.toBeInstanceOf(AccountSuspendedError); + }); + + it('should return ForbiddenError for 403 with non-TOS_VIOLATION detail', () => { + const error = { + response: { + data: { + error: { + code: 403, + message: 'Forbidden', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'ACCESS_DENIED', + domain: 'googleapis.com', + metadata: {}, + }, + ], + }, + }, + }, + }; + const result = toFriendlyError(error); + expect(result).toBeInstanceOf(ForbiddenError); + expect(result).not.toBeInstanceOf(AccountSuspendedError); + }); + it('should parse stringified JSON data', () => { const error = { response: { @@ -236,6 +314,9 @@ describe('getErrorType', () => { 'FatalCancellationError', ); expect(getErrorType(new ForbiddenError('test'))).toBe('ForbiddenError'); + expect(getErrorType(new AccountSuspendedError('test'))).toBe( + 'AccountSuspendedError', + ); expect(getErrorType(new UnauthorizedError('test'))).toBe( 'UnauthorizedError', ); diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index 62db5dcbf4..0fd9c1b7c1 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { parseGoogleApiError, type ErrorInfo } from './googleErrors.js'; + interface GaxiosError { response?: { data?: unknown; @@ -107,6 +109,17 @@ export class CanceledError extends Error { } export class ForbiddenError extends Error {} +export class AccountSuspendedError extends ForbiddenError { + readonly appealUrl?: string; + readonly appealLinkText?: string; + + constructor(message: string, metadata?: Record) { + super(message); + this.name = 'AccountSuspendedError'; + this.appealUrl = metadata?.['appeal_url']; + this.appealLinkText = metadata?.['appeal_url_link_text']; + } +} export class UnauthorizedError extends Error {} export class BadRequestError extends Error {} @@ -157,6 +170,24 @@ function isResponseData(data: unknown): data is ResponseData { } export function toFriendlyError(error: unknown): unknown { + // First, try structured parsing for TOS_VIOLATION detection. + const googleApiError = parseGoogleApiError(error); + if (googleApiError && googleApiError.code === 403) { + const tosDetail = googleApiError.details.find( + (d): d is ErrorInfo => + d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo' && + 'reason' in d && + d.reason === 'TOS_VIOLATION', + ); + if (tosDetail) { + return new AccountSuspendedError( + googleApiError.message, + tosDetail.metadata, + ); + } + } + + // Fall back to basic Gaxios error parsing for other HTTP errors. if (isGaxiosError(error)) { const data = parseResponseData(error); if (data && data.error && data.error.message && data.error.code) { @@ -166,9 +197,6 @@ export function toFriendlyError(error: unknown): unknown { case 401: return new UnauthorizedError(data.error.message); case 403: - // It's import to pass the message here since it might - // explain the cause like "the cloud project you're - // using doesn't have code assist enabled". return new ForbiddenError(data.error.message); default: } @@ -177,6 +205,13 @@ export function toFriendlyError(error: unknown): unknown { return error; } +export function isAccountSuspendedError( + error: unknown, +): AccountSuspendedError | null { + const friendly = toFriendlyError(error); + return friendly instanceof AccountSuspendedError ? friendly : null; +} + function parseResponseData(error: GaxiosError): ResponseData | undefined { let data = error.response?.data; // Inexplicably, Gaxios sometimes doesn't JSONify the response data.