feat: better error messages (#20577)

Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
This commit is contained in:
Gaurav
2026-02-27 10:18:16 -08:00
committed by GitHub
parent b2d6844f9b
commit ea48bd9414
17 changed files with 668 additions and 19 deletions

View File

@@ -17,7 +17,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>(); await importOriginal<typeof import('@google/gemini-cli-core')>();
return { return {
...actual, ...actual,
getErrorMessage: (e: unknown) => (e as Error).message,
}; };
}); });
@@ -32,7 +31,7 @@ describe('auth', () => {
it('should return null if authType is undefined', async () => { it('should return null if authType is undefined', async () => {
const result = await performInitialAuth(mockConfig, undefined); const result = await performInitialAuth(mockConfig, undefined);
expect(result).toBeNull(); expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); expect(mockConfig.refreshAuth).not.toHaveBeenCalled();
}); });
@@ -41,7 +40,7 @@ describe('auth', () => {
mockConfig, mockConfig,
AuthType.LOGIN_WITH_GOOGLE, AuthType.LOGIN_WITH_GOOGLE,
); );
expect(result).toBeNull(); expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
expect(mockConfig.refreshAuth).toHaveBeenCalledWith( expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE, AuthType.LOGIN_WITH_GOOGLE,
); );
@@ -54,7 +53,10 @@ describe('auth', () => {
mockConfig, mockConfig,
AuthType.LOGIN_WITH_GOOGLE, 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( expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE, AuthType.LOGIN_WITH_GOOGLE,
); );
@@ -68,7 +70,48 @@ describe('auth', () => {
mockConfig, mockConfig,
AuthType.LOGIN_WITH_GOOGLE, 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( expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE, AuthType.LOGIN_WITH_GOOGLE,
); );

View File

@@ -9,20 +9,28 @@ import {
type Config, type Config,
getErrorMessage, getErrorMessage,
ValidationRequiredError, ValidationRequiredError,
isAccountSuspendedError,
} from '@google/gemini-cli-core'; } 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. * Handles the initial authentication flow.
* @param config The application config. * @param config The application config.
* @param authType The selected auth type. * @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( export async function performInitialAuth(
config: Config, config: Config,
authType: AuthType | undefined, authType: AuthType | undefined,
): Promise<string | null> { ): Promise<InitialAuthResult> {
if (!authType) { if (!authType) {
return null; return { authError: null, accountSuspensionInfo: null };
} }
try { try {
@@ -33,10 +41,24 @@ export async function performInitialAuth(
if (e instanceof ValidationRequiredError) { if (e instanceof ValidationRequiredError) {
// Don't treat validation required as a fatal auth error during startup. // Don't treat validation required as a fatal auth error during startup.
// This allows the React UI to load and show the ValidationDialog. // 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 };
} }

View File

@@ -72,7 +72,10 @@ describe('initializer', () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue( vi.mocked(IdeClient.getInstance).mockResolvedValue(
mockIdeClient as unknown as IdeClient, mockIdeClient as unknown as IdeClient,
); );
vi.mocked(performInitialAuth).mockResolvedValue(null); vi.mocked(performInitialAuth).mockResolvedValue({
authError: null,
accountSuspensionInfo: null,
});
vi.mocked(validateTheme).mockReturnValue(null); vi.mocked(validateTheme).mockReturnValue(null);
}); });
@@ -84,6 +87,7 @@ describe('initializer', () => {
expect(result).toEqual({ expect(result).toEqual({
authError: null, authError: null,
accountSuspensionInfo: null,
themeError: null, themeError: null,
shouldOpenAuthDialog: false, shouldOpenAuthDialog: false,
geminiMdFileCount: 5, geminiMdFileCount: 5,
@@ -103,6 +107,7 @@ describe('initializer', () => {
expect(result).toEqual({ expect(result).toEqual({
authError: null, authError: null,
accountSuspensionInfo: null,
themeError: null, themeError: null,
shouldOpenAuthDialog: false, shouldOpenAuthDialog: false,
geminiMdFileCount: 5, geminiMdFileCount: 5,
@@ -116,7 +121,10 @@ describe('initializer', () => {
}); });
it('should handle auth error', async () => { 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( const result = await initializeApp(
mockConfig as unknown as Config, mockConfig as unknown as Config,
mockSettings, mockSettings,

View File

@@ -17,9 +17,11 @@ import {
import { type LoadedSettings } from '../config/settings.js'; import { type LoadedSettings } from '../config/settings.js';
import { performInitialAuth } from './auth.js'; import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js'; import { validateTheme } from './theme.js';
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
export interface InitializationResult { export interface InitializationResult {
authError: string | null; authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
themeError: string | null; themeError: string | null;
shouldOpenAuthDialog: boolean; shouldOpenAuthDialog: boolean;
geminiMdFileCount: number; geminiMdFileCount: number;
@@ -37,7 +39,7 @@ export async function initializeApp(
settings: LoadedSettings, settings: LoadedSettings,
): Promise<InitializationResult> { ): Promise<InitializationResult> {
const authHandle = startupProfiler.start('authenticate'); const authHandle = startupProfiler.start('authenticate');
const authError = await performInitialAuth( const { authError, accountSuspensionInfo } = await performInitialAuth(
config, config,
settings.merged.security.auth.selectedType, settings.merged.security.auth.selectedType,
); );
@@ -60,6 +62,7 @@ export async function initializeApp(
return { return {
authError, authError,
accountSuspensionInfo,
themeError, themeError,
shouldOpenAuthDialog, shouldOpenAuthDialog,
geminiMdFileCount: config.getGeminiMdFileCount(), geminiMdFileCount: config.getGeminiMdFileCount(),

View File

@@ -1202,6 +1202,7 @@ describe('startInteractiveUI', () => {
const mockWorkspaceRoot = '/root'; const mockWorkspaceRoot = '/root';
const mockInitializationResult = { const mockInitializationResult = {
authError: null, authError: null,
accountSuspensionInfo: null,
themeError: null, themeError: null,
shouldOpenAuthDialog: false, shouldOpenAuthDialog: false,
geminiMdFileCount: 0, geminiMdFileCount: 0,

View File

@@ -104,6 +104,8 @@ vi.mock('../ui/auth/useAuth.js', () => ({
onAuthError: vi.fn(), onAuthError: vi.fn(),
apiKeyDefaultValue: 'test-api-key', apiKeyDefaultValue: 'test-api-key',
reloadApiKey: vi.fn().mockResolvedValue('test-api-key'), reloadApiKey: vi.fn().mockResolvedValue('test-api-key'),
accountSuspensionInfo: null,
setAccountSuspensionInfo: vi.fn(),
}), }),
validateAuthMethodWithSettings: () => null, validateAuthMethodWithSettings: () => null,
})); }));
@@ -387,6 +389,7 @@ export class AppRig {
version="test-version" version="test-version"
initializationResult={{ initializationResult={{
authError: null, authError: null,
accountSuspensionInfo: null,
themeError: null, themeError: null,
shouldOpenAuthDialog: false, shouldOpenAuthDialog: false,
geminiMdFileCount: 0, geminiMdFileCount: 0,

View File

@@ -615,6 +615,7 @@ const mockUIActions: UIActions = {
handleRestart: vi.fn(), handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(), handleNewAgentsSelect: vi.fn(),
getPreferredEditor: vi.fn(), getPreferredEditor: vi.fn(),
clearAccountSuspension: vi.fn(),
}; };
let capturedOverflowState: OverflowState | undefined; let capturedOverflowState: OverflowState | undefined;

View File

@@ -675,7 +675,14 @@ export const AppContainer = (props: AppContainerProps) => {
onAuthError, onAuthError,
apiKeyDefaultValue, apiKeyDefaultValue,
reloadApiKey, reloadApiKey,
} = useAuthCommand(settings, config, initializationResult.authError); accountSuspensionInfo,
setAccountSuspensionInfo,
} = useAuthCommand(
settings,
config,
initializationResult.authError,
initializationResult.accountSuspensionInfo,
);
const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>( const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>(
{}, {},
); );
@@ -2197,6 +2204,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isAuthenticating, isAuthenticating,
isConfigInitialized, isConfigInitialized,
authError, authError,
accountSuspensionInfo,
isAuthDialogOpen, isAuthDialogOpen,
isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput, isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput,
apiKeyDefaultValue, apiKeyDefaultValue,
@@ -2328,6 +2336,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isAuthenticating, isAuthenticating,
isConfigInitialized, isConfigInitialized,
authError, authError,
accountSuspensionInfo,
isAuthDialogOpen, isAuthDialogOpen,
editorError, editorError,
isEditorDialogOpen, isEditorDialogOpen,
@@ -2537,6 +2546,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
setNewAgents(null); setNewAgents(null);
}, },
getPreferredEditor, getPreferredEditor,
clearAccountSuspension: () => {
setAccountSuspensionInfo(null);
setAuthState(AuthState.Updating);
},
}), }),
[ [
handleThemeSelect, handleThemeSelect,
@@ -2587,6 +2600,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
setActiveBackgroundShellPid, setActiveBackgroundShellPid,
setIsBackgroundShellListOpen, setIsBackgroundShellListOpen,
setAuthContext, setAuthContext,
setAccountSuspensionInfo,
newAgents, newAgents,
config, config,
historyManager, historyManager,

View File

@@ -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<typeof import('@google/gemini-cli-core')>();
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 }) => (
<Text key={item.value}>{item.label}</Text>
))}
</>
)),
}));
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={infoWithoutUrl}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={infoWithoutLinkText}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});

View File

@@ -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<string | null>(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 (
<Box flexDirection="column" padding={1}>
<Text bold color={theme.status.error}>
Error: Account Suspended
</Text>
<Box marginTop={1}>
<Text>{accountSuspensionInfo.message}</Text>
</Box>
{appealUrl && (
<>
<Box marginTop={1}>
<Text>Appeal URL:</Text>
</Box>
<Box>
<Text color={theme.text.link}>[{appealUrl}]</Text>
</Box>
</>
)}
{errorMessage && (
<Box marginTop={1}>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<RadioButtonSelect
items={items}
onSelect={(choice) => void handleSelect(choice)}
/>
</Box>
<Box marginTop={1}>
<Text dimColor>Escape to exit</Text>
</Box>
</Box>
);
}

View File

@@ -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
"
`;

View File

@@ -11,6 +11,7 @@ import {
type Config, type Config,
loadApiKey, loadApiKey,
debugLogger, debugLogger,
isAccountSuspendedError,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { getErrorMessage } from '@google/gemini-cli-core'; import { getErrorMessage } from '@google/gemini-cli-core';
import { AuthState } from '../types.js'; import { AuthState } from '../types.js';
@@ -34,16 +35,21 @@ export function validateAuthMethodWithSettings(
return validateAuthMethod(authType); return validateAuthMethod(authType);
} }
import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js';
export const useAuthCommand = ( export const useAuthCommand = (
settings: LoadedSettings, settings: LoadedSettings,
config: Config, config: Config,
initialAuthError: string | null = null, initialAuthError: string | null = null,
initialAccountSuspensionInfo: AccountSuspensionInfo | null = null,
) => { ) => {
const [authState, setAuthState] = useState<AuthState>( const [authState, setAuthState] = useState<AuthState>(
initialAuthError ? AuthState.Updating : AuthState.Unauthenticated, initialAuthError ? AuthState.Updating : AuthState.Unauthenticated,
); );
const [authError, setAuthError] = useState<string | null>(initialAuthError); const [authError, setAuthError] = useState<string | null>(initialAuthError);
const [accountSuspensionInfo, setAccountSuspensionInfo] =
useState<AccountSuspensionInfo | null>(initialAccountSuspensionInfo);
const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState< const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState<
string | undefined string | undefined
>(undefined); >(undefined);
@@ -130,7 +136,16 @@ export const useAuthCommand = (
setAuthError(null); setAuthError(null);
setAuthState(AuthState.Authenticated); setAuthState(AuthState.Authenticated);
} catch (e) { } 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, onAuthError,
apiKeyDefaultValue, apiKeyDefaultValue,
reloadApiKey, reloadApiKey,
accountSuspensionInfo,
setAccountSuspensionInfo,
}; };
}; };

View File

@@ -13,6 +13,7 @@ import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js'; import { SettingsDialog } from './SettingsDialog.js';
import { AuthInProgress } from '../auth/AuthInProgress.js'; import { AuthInProgress } from '../auth/AuthInProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js'; import { AuthDialog } from '../auth/AuthDialog.js';
import { BannedAccountDialog } from '../auth/BannedAccountDialog.js';
import { ApiAuthDialog } from '../auth/ApiAuthDialog.js'; import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PrivacyNotice } from '../privacy/PrivacyNotice.js'; import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
@@ -320,6 +321,21 @@ export const DialogManager = ({
</Box> </Box>
); );
} }
if (uiState.accountSuspensionInfo) {
return (
<Box flexDirection="column">
<BannedAccountDialog
accountSuspensionInfo={uiState.accountSuspensionInfo}
onExit={() => {
process.exit(1);
}}
onChangeAuth={() => {
uiActions.clearAccountSuspension();
}}
/>
</Box>
);
}
if (uiState.isAuthenticating) { if (uiState.isAuthenticating) {
return ( return (
<AuthInProgress <AuthInProgress
@@ -342,6 +358,7 @@ export const DialogManager = ({
</Box> </Box>
); );
} }
if (uiState.isAuthDialogOpen) { if (uiState.isAuthDialogOpen) {
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">

View File

@@ -91,6 +91,7 @@ export interface UIActions {
handleRestart: () => void; handleRestart: () => void;
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>; handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
getPreferredEditor: () => EditorType | undefined; getPreferredEditor: () => EditorType | undefined;
clearAccountSuspension: () => void;
} }
export const UIActionsContext = createContext<UIActions | null>(null); export const UIActionsContext = createContext<UIActions | null>(null);

View File

@@ -97,6 +97,12 @@ export interface QuotaState {
emptyWalletRequest: EmptyWalletDialogRequest | null; emptyWalletRequest: EmptyWalletDialogRequest | null;
} }
export interface AccountSuspensionInfo {
message: string;
appealUrl?: string;
appealLinkText?: string;
}
export interface UIState { export interface UIState {
history: HistoryItem[]; history: HistoryItem[];
historyManager: UseHistoryManagerReturn; historyManager: UseHistoryManagerReturn;
@@ -107,6 +113,7 @@ export interface UIState {
isAuthenticating: boolean; isAuthenticating: boolean;
isConfigInitialized: boolean; isConfigInitialized: boolean;
authError: string | null; authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
isAuthDialogOpen: boolean; isAuthDialogOpen: boolean;
isAwaitingApiKeyInput: boolean; isAwaitingApiKeyInput: boolean;
apiKeyDefaultValue?: string; apiKeyDefaultValue?: string;

View File

@@ -11,6 +11,7 @@ import {
toFriendlyError, toFriendlyError,
BadRequestError, BadRequestError,
ForbiddenError, ForbiddenError,
AccountSuspendedError,
getErrorMessage, getErrorMessage,
getErrorType, getErrorType,
FatalAuthenticationError, FatalAuthenticationError,
@@ -127,9 +128,86 @@ describe('toFriendlyError', () => {
}; };
const result = toFriendlyError(error); const result = toFriendlyError(error);
expect(result).toBeInstanceOf(ForbiddenError); expect(result).toBeInstanceOf(ForbiddenError);
expect(result).not.toBeInstanceOf(AccountSuspendedError);
expect((result as ForbiddenError).message).toBe('Forbidden'); 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', () => { it('should parse stringified JSON data', () => {
const error = { const error = {
response: { response: {
@@ -236,6 +314,9 @@ describe('getErrorType', () => {
'FatalCancellationError', 'FatalCancellationError',
); );
expect(getErrorType(new ForbiddenError('test'))).toBe('ForbiddenError'); expect(getErrorType(new ForbiddenError('test'))).toBe('ForbiddenError');
expect(getErrorType(new AccountSuspendedError('test'))).toBe(
'AccountSuspendedError',
);
expect(getErrorType(new UnauthorizedError('test'))).toBe( expect(getErrorType(new UnauthorizedError('test'))).toBe(
'UnauthorizedError', 'UnauthorizedError',
); );

View File

@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { parseGoogleApiError, type ErrorInfo } from './googleErrors.js';
interface GaxiosError { interface GaxiosError {
response?: { response?: {
data?: unknown; data?: unknown;
@@ -107,6 +109,17 @@ export class CanceledError extends Error {
} }
export class ForbiddenError extends Error {} export class ForbiddenError extends Error {}
export class AccountSuspendedError extends ForbiddenError {
readonly appealUrl?: string;
readonly appealLinkText?: string;
constructor(message: string, metadata?: Record<string, string>) {
super(message);
this.name = 'AccountSuspendedError';
this.appealUrl = metadata?.['appeal_url'];
this.appealLinkText = metadata?.['appeal_url_link_text'];
}
}
export class UnauthorizedError extends Error {} export class UnauthorizedError extends Error {}
export class BadRequestError extends Error {} export class BadRequestError extends Error {}
@@ -157,6 +170,24 @@ function isResponseData(data: unknown): data is ResponseData {
} }
export function toFriendlyError(error: unknown): unknown { 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)) { if (isGaxiosError(error)) {
const data = parseResponseData(error); const data = parseResponseData(error);
if (data && data.error && data.error.message && data.error.code) { if (data && data.error && data.error.message && data.error.code) {
@@ -166,9 +197,6 @@ export function toFriendlyError(error: unknown): unknown {
case 401: case 401:
return new UnauthorizedError(data.error.message); return new UnauthorizedError(data.error.message);
case 403: 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); return new ForbiddenError(data.error.message);
default: default:
} }
@@ -177,6 +205,13 @@ export function toFriendlyError(error: unknown): unknown {
return error; 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 { function parseResponseData(error: GaxiosError): ResponseData | undefined {
let data = error.response?.data; let data = error.response?.data;
// Inexplicably, Gaxios sometimes doesn't JSONify the response data. // Inexplicably, Gaxios sometimes doesn't JSONify the response data.