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')>();
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,
);

View File

@@ -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<string | null> {
): Promise<InitialAuthResult> {
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 };
}

View File

@@ -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,

View File

@@ -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<InitializationResult> {
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(),

View File

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

View File

@@ -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,

View File

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

View File

@@ -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,

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,
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<AuthState>(
initialAuthError ? AuthState.Updating : AuthState.Unauthenticated,
);
const [authError, setAuthError] = useState<string | null>(initialAuthError);
const [accountSuspensionInfo, setAccountSuspensionInfo] =
useState<AccountSuspensionInfo | null>(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,
};
};

View File

@@ -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 = ({
</Box>
);
}
if (uiState.accountSuspensionInfo) {
return (
<Box flexDirection="column">
<BannedAccountDialog
accountSuspensionInfo={uiState.accountSuspensionInfo}
onExit={() => {
process.exit(1);
}}
onChangeAuth={() => {
uiActions.clearAccountSuspension();
}}
/>
</Box>
);
}
if (uiState.isAuthenticating) {
return (
<AuthInProgress
@@ -342,6 +358,7 @@ export const DialogManager = ({
</Box>
);
}
if (uiState.isAuthDialogOpen) {
return (
<Box flexDirection="column">

View File

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

View File

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

View File

@@ -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',
);

View File

@@ -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<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 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.