Fix multiple bugs with auth flow including using the implemented but unused restart support. (#13565)

This commit is contained in:
Jacob Richman
2025-11-21 08:31:47 -08:00
committed by GitHub
parent b97661553f
commit 030a5ace97
13 changed files with 307 additions and 137 deletions
+9 -6
View File
@@ -8,23 +8,26 @@
import './src/gemini.js'; import './src/gemini.js';
import { main } from './src/gemini.js'; import { main } from './src/gemini.js';
import { debugLogger, FatalError } from '@google/gemini-cli-core'; import { FatalError, writeToStderr } from '@google/gemini-cli-core';
import { runExitCleanup } from './src/utils/cleanup.js';
// --- Global Entry Point --- // --- Global Entry Point ---
main().catch((error) => { main().catch(async (error) => {
await runExitCleanup();
if (error instanceof FatalError) { if (error instanceof FatalError) {
let errorMessage = error.message; let errorMessage = error.message;
if (!process.env['NO_COLOR']) { if (!process.env['NO_COLOR']) {
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`; errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
} }
debugLogger.error(errorMessage); writeToStderr(errorMessage + '\n');
process.exit(error.exitCode); process.exit(error.exitCode);
} }
debugLogger.error('An unexpected critical error occurred:'); writeToStderr('An unexpected critical error occurred:');
if (error instanceof Error) { if (error instanceof Error) {
debugLogger.error(error.stack); writeToStderr(error.stack + '\n');
} else { } else {
debugLogger.error(String(error)); writeToStderr(String(error) + '\n');
} }
process.exit(1); process.exit(1);
}); });
+21 -12
View File
@@ -33,13 +33,11 @@ import {
runExitCleanup, runExitCleanup,
} from './utils/cleanup.js'; } from './utils/cleanup.js';
import { getCliVersion } from './utils/version.js'; import { getCliVersion } from './utils/version.js';
import type {
Config,
ResumedSessionData,
OutputPayload,
ConsoleLogPayload,
} from '@google/gemini-cli-core';
import { import {
type Config,
type ResumedSessionData,
type OutputPayload,
type ConsoleLogPayload,
sessionId, sessionId,
logUserPrompt, logUserPrompt,
AuthType, AuthType,
@@ -53,6 +51,11 @@ import {
patchStdio, patchStdio,
writeToStdout, writeToStdout,
writeToStderr, writeToStderr,
disableMouseEvents,
enableMouseEvents,
enterAlternateScreen,
disableLineWrapping,
shouldEnterAlternateScreen,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { import {
initializeApp, initializeApp,
@@ -85,9 +88,7 @@ import { deleteSession, listSessions } from './utils/sessions.js';
import { ExtensionManager } from './config/extension-manager.js'; import { ExtensionManager } from './config/extension-manager.js';
import { createPolicyUpdater } from './config/policy.js'; import { createPolicyUpdater } from './config/policy.js';
import { requestConsentNonInteractive } from './config/extensions/consent.js'; import { requestConsentNonInteractive } from './config/extensions/consent.js';
import { disableMouseEvents, enableMouseEvents } from './ui/utils/mouse.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import ansiEscapes from 'ansi-escapes';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { profiler } from './ui/components/DebugProfiler.js'; import { profiler } from './ui/components/DebugProfiler.js';
@@ -176,8 +177,10 @@ export async function startInteractiveUI(
// as there is no benefit of alternate buffer mode when using a screen reader // as there is no benefit of alternate buffer mode when using a screen reader
// and the Ink alternate buffer mode requires line wrapping harmful to // and the Ink alternate buffer mode requires line wrapping harmful to
// screen readers. // screen readers.
const useAlternateBuffer = const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(settings) && !config.getScreenReader(); isAlternateBufferEnabled(settings),
config.getScreenReader(),
);
const mouseEventsEnabled = useAlternateBuffer; const mouseEventsEnabled = useAlternateBuffer;
if (mouseEventsEnabled) { if (mouseEventsEnabled) {
enableMouseEvents(); enableMouseEvents();
@@ -481,8 +484,14 @@ export async function main() {
// input showing up in the output. // input showing up in the output.
process.stdin.setRawMode(true); process.stdin.setRawMode(true);
if (isAlternateBufferEnabled(settings)) { if (
writeToStdout(ansiEscapes.enterAlternativeScreen); shouldEnterAlternateScreen(
isAlternateBufferEnabled(settings),
config.getScreenReader(),
)
) {
enterAlternateScreen();
disableLineWrapping();
// Ink will cleanup so there is no need for us to manually cleanup. // Ink will cleanup so there is no need for us to manually cleanup.
} }
+8 -6
View File
@@ -69,6 +69,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
stdout: process.stdout, stdout: process.stdout,
stderr: process.stderr, stderr: process.stderr,
})), })),
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
}; };
}); });
import type { LoadedSettings } from '../config/settings.js'; import type { LoadedSettings } from '../config/settings.js';
@@ -137,10 +139,6 @@ vi.mock('../utils/events.js');
vi.mock('../utils/handleAutoUpdate.js'); vi.mock('../utils/handleAutoUpdate.js');
vi.mock('./utils/ConsolePatcher.js'); vi.mock('./utils/ConsolePatcher.js');
vi.mock('../utils/cleanup.js'); vi.mock('../utils/cleanup.js');
vi.mock('./utils/mouse.js', () => ({
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
}));
import { useHistory } from './hooks/useHistoryManager.js'; import { useHistory } from './hooks/useHistoryManager.js';
import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useThemeCommand } from './hooks/useThemeCommand.js';
@@ -165,9 +163,13 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useKeypress, type Key } from './hooks/useKeypress.js'; import { useKeypress, type Key } from './hooks/useKeypress.js';
import { measureElement } from 'ink'; import { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useTerminalSize } from './hooks/useTerminalSize.js';
import { ShellExecutionService, writeToStdout } from '@google/gemini-cli-core'; import {
ShellExecutionService,
writeToStdout,
enableMouseEvents,
disableMouseEvents,
} from '@google/gemini-cli-core';
import { type ExtensionManager } from '../config/extension-manager.js'; import { type ExtensionManager } from '../config/extension-manager.js';
import { enableMouseEvents, disableMouseEvents } from './utils/mouse.js';
describe('AppContainer State Management', () => { describe('AppContainer State Management', () => {
let mockConfig: Config; let mockConfig: Config;
+16 -8
View File
@@ -52,6 +52,12 @@ import {
refreshServerHierarchicalMemory, refreshServerHierarchicalMemory,
type ModelChangedPayload, type ModelChangedPayload,
type MemoryChangedPayload, type MemoryChangedPayload,
writeToStdout,
disableMouseEvents,
enterAlternateScreen,
enableMouseEvents,
disableLineWrapping,
shouldEnterAlternateScreen,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js'; import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process'; import process from 'node:process';
@@ -92,6 +98,7 @@ import { appEvents, AppEvent } from '../utils/events.js';
import { type UpdateObject } from './utils/updateCheck.js'; import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useSessionStats } from './contexts/SessionContext.js'; import { useSessionStats } from './contexts/SessionContext.js';
@@ -106,11 +113,9 @@ import { type ExtensionManager } from '../config/extension-manager.js';
import { requestConsentInteractive } from '../config/extensions/consent.js'; import { requestConsentInteractive } from '../config/extensions/consent.js';
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js';
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js'; import { useSettings } from './contexts/SettingsContext.js';
import { enableSupportedProtocol } from './utils/kittyProtocolDetector.js'; import { enableSupportedProtocol } from './utils/kittyProtocolDetector.js';
import { writeToStdout } from '@google/gemini-cli-core';
const WARNING_PROMPT_DURATION_MS = 1000; const WARNING_PROMPT_DURATION_MS = 1000;
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
@@ -372,16 +377,19 @@ export const AppContainer = (props: AppContainerProps) => {
setHistoryRemountKey((prev) => prev + 1); setHistoryRemountKey((prev) => prev + 1);
}, [setHistoryRemountKey, isAlternateBuffer, stdout]); }, [setHistoryRemountKey, isAlternateBuffer, stdout]);
const handleEditorClose = useCallback(() => { const handleEditorClose = useCallback(() => {
if (isAlternateBuffer) { if (
shouldEnterAlternateScreen(isAlternateBuffer, config.getScreenReader())
) {
// The editor may have exited alternate buffer mode so we need to // The editor may have exited alternate buffer mode so we need to
// enter it again to be safe. // enter it again to be safe.
writeToStdout(ansiEscapes.enterAlternativeScreen); enterAlternateScreen();
enableMouseEvents(); enableMouseEvents();
disableLineWrapping();
app.rerender(); app.rerender();
} }
enableSupportedProtocol(); enableSupportedProtocol();
refreshStatic(); refreshStatic();
}, [refreshStatic, isAlternateBuffer, app]); }, [refreshStatic, isAlternateBuffer, app, config]);
useEffect(() => { useEffect(() => {
coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose); coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose);
@@ -458,12 +466,12 @@ export const AppContainer = (props: AppContainerProps) => {
config.isBrowserLaunchSuppressed() config.isBrowserLaunchSuppressed()
) { ) {
await runExitCleanup(); await runExitCleanup();
debugLogger.log(` writeToStdout(`
---------------------------------------------------------------- ----------------------------------------------------------------
Logging in with Google... Please restart Gemini CLI to continue. Logging in with Google... Restarting Gemini CLI to continue.
---------------------------------------------------------------- ----------------------------------------------------------------
`); `);
process.exit(0); process.exit(RELAUNCH_EXIT_CODE);
} }
} }
setAuthState(AuthState.Authenticated); setAuthState(AuthState.Authenticated);
+6 -4
View File
@@ -25,6 +25,7 @@ import { validateAuthMethodWithSettings } from './useAuth.js';
import { runExitCleanup } from '../../utils/cleanup.js'; import { runExitCleanup } from '../../utils/cleanup.js';
import { clearCachedCredentialFile } from '@google/gemini-cli-core'; import { clearCachedCredentialFile } from '@google/gemini-cli-core';
import { Text } from 'ink'; import { Text } from 'ink';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
// Mocks // Mocks
vi.mock('@google/gemini-cli-core', async (importOriginal) => { vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -229,6 +230,7 @@ describe('AuthDialog', () => {
}); });
it('exits process for Login with Google when browser is suppressed', async () => { it('exits process for Login with Google when browser is suppressed', async () => {
vi.useFakeTimers();
const exitSpy = vi const exitSpy = vi
.spyOn(process, 'exit') .spyOn(process, 'exit')
.mockImplementation(() => undefined as never); .mockImplementation(() => undefined as never);
@@ -241,14 +243,14 @@ describe('AuthDialog', () => {
mockedRadioButtonSelect.mock.calls[0][0]; mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
await vi.runAllTimersAsync();
expect(mockedRunExitCleanup).toHaveBeenCalled(); expect(mockedRunExitCleanup).toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith( expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
expect.stringContaining('Please restart Gemini CLI'),
);
expect(exitSpy).toHaveBeenCalledWith(0);
exitSpy.mockRestore(); exitSpy.mockRestore();
logSpy.mockRestore(); logSpy.mockRestore();
vi.useRealTimers();
}); });
}); });
+30 -12
View File
@@ -5,7 +5,7 @@
*/ */
import type React from 'react'; import type React from 'react';
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
@@ -17,13 +17,13 @@ import { SettingScope } from '../../config/settings.js';
import { import {
AuthType, AuthType,
clearCachedCredentialFile, clearCachedCredentialFile,
debugLogger,
type Config, type Config,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import { AuthState } from '../types.js'; import { AuthState } from '../types.js';
import { runExitCleanup } from '../../utils/cleanup.js'; import { runExitCleanup } from '../../utils/cleanup.js';
import { validateAuthMethodWithSettings } from './useAuth.js'; import { validateAuthMethodWithSettings } from './useAuth.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
interface AuthDialogProps { interface AuthDialogProps {
config: Config; config: Config;
@@ -40,6 +40,7 @@ export function AuthDialog({
authError, authError,
onAuthError, onAuthError,
}: AuthDialogProps): React.JSX.Element { }: AuthDialogProps): React.JSX.Element {
const [exiting, setExiting] = useState(false);
let items = [ let items = [
{ {
label: 'Login with Google', label: 'Login with Google',
@@ -111,6 +112,9 @@ export function AuthDialog({
const onSelect = useCallback( const onSelect = useCallback(
async (authType: AuthType | undefined, scope: LoadableSettingScope) => { async (authType: AuthType | undefined, scope: LoadableSettingScope) => {
if (exiting) {
return;
}
if (authType) { if (authType) {
await clearCachedCredentialFile(); await clearCachedCredentialFile();
@@ -119,15 +123,12 @@ export function AuthDialog({
authType === AuthType.LOGIN_WITH_GOOGLE && authType === AuthType.LOGIN_WITH_GOOGLE &&
config.isBrowserLaunchSuppressed() config.isBrowserLaunchSuppressed()
) { ) {
runExitCleanup(); setExiting(true);
debugLogger.log( setTimeout(async () => {
` await runExitCleanup();
---------------------------------------------------------------- process.exit(RELAUNCH_EXIT_CODE);
Logging in with Google... Please restart Gemini CLI to continue. }, 100);
---------------------------------------------------------------- return;
`,
);
process.exit(0);
} }
} }
if (authType === AuthType.USE_GEMINI) { if (authType === AuthType.USE_GEMINI) {
@@ -136,7 +137,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
} }
setAuthState(AuthState.Unauthenticated); setAuthState(AuthState.Unauthenticated);
}, },
[settings, config, setAuthState], [settings, config, setAuthState, exiting],
); );
const handleAuthSelect = (authMethod: AuthType) => { const handleAuthSelect = (authMethod: AuthType) => {
@@ -169,6 +170,23 @@ Logging in with Google... Please restart Gemini CLI to continue.
{ isActive: true }, { isActive: true },
); );
if (exiting) {
return (
<Box
borderStyle="round"
borderColor={theme.border.focused}
flexDirection="row"
padding={1}
width="100%"
alignItems="flex-start"
>
<Text color={theme.text.primary}>
Logging in with Google... Restarting Gemini CLI to continue.
</Text>
</Box>
);
}
return ( return (
<Box <Box
borderStyle="round" borderStyle="round"
@@ -18,6 +18,8 @@ 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';
import { ProQuotaDialog } from './ProQuotaDialog.js'; import { ProQuotaDialog } from './ProQuotaDialog.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js'; import { ModelDialog } from './ModelDialog.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
@@ -137,7 +139,10 @@ export const DialogManager = ({
<SettingsDialog <SettingsDialog
settings={settings} settings={settings}
onSelect={() => uiActions.closeSettingsDialog()} onSelect={() => uiActions.closeSettingsDialog()}
onRestartRequest={() => process.exit(0)} onRestartRequest={async () => {
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
}}
availableTerminalHeight={terminalHeight - staticExtraHeight} availableTerminalHeight={terminalHeight - staticExtraHeight}
config={config} config={config}
/> />
@@ -97,6 +97,11 @@ export async function detectAndEnableKittyProtocol(): Promise<void> {
}); });
} }
import {
enableKittyKeyboardProtocol,
disableKittyKeyboardProtocol,
} from '@google/gemini-cli-core';
export function isKittyProtocolEnabled(): boolean { export function isKittyProtocolEnabled(): boolean {
return kittyEnabled; return kittyEnabled;
} }
@@ -104,8 +109,7 @@ export function isKittyProtocolEnabled(): boolean {
function disableAllProtocols() { function disableAllProtocols() {
try { try {
if (kittyEnabled) { if (kittyEnabled) {
// use writeSync to avoid race conditions disableKittyKeyboardProtocol();
fs.writeSync(process.stdout.fd, '\x1b[<u');
kittyEnabled = false; kittyEnabled = false;
} }
} catch { } catch {
@@ -120,8 +124,7 @@ function disableAllProtocols() {
export function enableSupportedProtocol(): void { export function enableSupportedProtocol(): void {
try { try {
if (kittySupported) { if (kittySupported) {
// use writeSync to avoid race conditions enableKittyKeyboardProtocol();
fs.writeSync(process.stdout.fd, '\x1b[>1u');
kittyEnabled = true; kittyEnabled = true;
} }
} catch { } catch {
+2 -12
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { writeToStdout } from '@google/gemini-cli-core'; import { enableMouseEvents, disableMouseEvents } from '@google/gemini-cli-core';
import { import {
SGR_MOUSE_REGEX, SGR_MOUSE_REGEX,
X11_MOUSE_REGEX, X11_MOUSE_REGEX,
@@ -230,14 +230,4 @@ export function isIncompleteMouseSequence(buffer: string): boolean {
return true; return true;
} }
export function enableMouseEvents() { export { enableMouseEvents, disableMouseEvents };
// Enable mouse tracking with SGR format
// ?1002h = button event tracking (clicks + drags + scroll wheel)
// ?1006h = SGR extended mouse mode (better coordinate handling)
writeToStdout('\u001b[?1002h\u001b[?1006h');
}
export function disableMouseEvents() {
// Disable mouse tracking with SGR format
writeToStdout('\u001b[?1006l\u001b[?1002l');
}
+17 -7
View File
@@ -27,6 +27,7 @@ import readline from 'node:readline';
import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js'; import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js';
import { GEMINI_DIR } from '../utils/paths.js'; import { GEMINI_DIR } from '../utils/paths.js';
import { debugLogger } from '../utils/debugLogger.js'; import { debugLogger } from '../utils/debugLogger.js';
import { writeToStdout } from '../utils/stdio.js';
vi.mock('os', async (importOriginal) => { vi.mock('os', async (importOriginal) => {
const os = await importOriginal<typeof import('os')>(); const os = await importOriginal<typeof import('os')>();
@@ -44,6 +45,19 @@ vi.mock('node:readline');
vi.mock('../utils/browser.js', () => ({ vi.mock('../utils/browser.js', () => ({
shouldAttemptBrowserLaunch: () => true, shouldAttemptBrowserLaunch: () => true,
})); }));
vi.mock('../utils/stdio.js', () => ({
writeToStdout: vi.fn(),
writeToStderr: vi.fn(),
createInkStdio: vi.fn(() => ({
stdout: process.stdout,
stderr: process.stderr,
})),
enterAlternateScreen: vi.fn(),
exitAlternateScreen: vi.fn(),
enableLineWrapping: vi.fn(),
disableMouseEvents: vi.fn(),
disableKittyKeyboardProtocol: vi.fn(),
}));
vi.mock('./oauth-credential-storage.js', () => ({ vi.mock('./oauth-credential-storage.js', () => ({
OAuthCredentialStorage: { OAuthCredentialStorage: {
@@ -238,13 +252,10 @@ describe('oauth2', () => {
const mockReadline = { const mockReadline = {
question: vi.fn((_query, callback) => callback(mockCode)), question: vi.fn((_query, callback) => callback(mockCode)),
close: vi.fn(), close: vi.fn(),
on: vi.fn(),
}; };
(readline.createInterface as Mock).mockReturnValue(mockReadline); (readline.createInterface as Mock).mockReturnValue(mockReadline);
const consoleLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
const client = await getOauthClient( const client = await getOauthClient(
AuthType.LOGIN_WITH_GOOGLE, AuthType.LOGIN_WITH_GOOGLE,
mockConfigWithNoBrowser, mockConfigWithNoBrowser,
@@ -255,7 +266,7 @@ describe('oauth2', () => {
// Verify the auth flow // Verify the auth flow
expect(mockGenerateCodeVerifierAsync).toHaveBeenCalled(); expect(mockGenerateCodeVerifierAsync).toHaveBeenCalled();
expect(mockGenerateAuthUrl).toHaveBeenCalled(); expect(mockGenerateAuthUrl).toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith( expect(vi.mocked(writeToStdout)).toHaveBeenCalledWith(
expect.stringContaining(mockAuthUrl), expect.stringContaining(mockAuthUrl),
); );
expect(mockReadline.question).toHaveBeenCalledWith( expect(mockReadline.question).toHaveBeenCalledWith(
@@ -268,8 +279,6 @@ describe('oauth2', () => {
redirect_uri: 'https://codeassist.google.com/authcode', redirect_uri: 'https://codeassist.google.com/authcode',
}); });
expect(mockSetCredentials).toHaveBeenCalledWith(mockTokens); expect(mockSetCredentials).toHaveBeenCalledWith(mockTokens);
consoleLogSpy.mockRestore();
}); });
describe('in Cloud Shell', () => { describe('in Cloud Shell', () => {
@@ -932,6 +941,7 @@ describe('oauth2', () => {
const mockReadline = { const mockReadline = {
question: vi.fn((_query, callback) => callback('invalid-code')), question: vi.fn((_query, callback) => callback('invalid-code')),
close: vi.fn(), close: vi.fn(),
on: vi.fn(),
}; };
(readline.createInterface as Mock).mockReturnValue(mockReadline); (readline.createInterface as Mock).mockReturnValue(mockReadline);
+134 -65
View File
@@ -19,7 +19,11 @@ import open from 'open';
import path from 'node:path'; import path from 'node:path';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { getErrorMessage, FatalAuthenticationError } from '../utils/errors.js'; import {
getErrorMessage,
FatalAuthenticationError,
FatalCancellationError,
} from '../utils/errors.js';
import { UserAccountManager } from '../utils/userAccountManager.js'; import { UserAccountManager } from '../utils/userAccountManager.js';
import { AuthType } from '../core/contentGenerator.js'; import { AuthType } from '../core/contentGenerator.js';
import readline from 'node:readline'; import readline from 'node:readline';
@@ -27,6 +31,19 @@ import { Storage } from '../config/storage.js';
import { OAuthCredentialStorage } from './oauth-credential-storage.js'; import { OAuthCredentialStorage } from './oauth-credential-storage.js';
import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js'; import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js';
import { debugLogger } from '../utils/debugLogger.js'; import { debugLogger } from '../utils/debugLogger.js';
import {
writeToStdout,
createInkStdio,
writeToStderr,
} from '../utils/stdio.js';
import {
enableLineWrapping,
disableMouseEvents,
disableKittyKeyboardProtocol,
enterAlternateScreen,
exitAlternateScreen,
} from '../utils/terminal.js';
import { coreEvents, CoreEvent } from '../utils/events.js';
const userAccountManager = new UserAccountManager(); const userAccountManager = new UserAccountManager();
@@ -185,16 +202,34 @@ async function initOauthClient(
if (config.isBrowserLaunchSuppressed()) { if (config.isBrowserLaunchSuppressed()) {
let success = false; let success = false;
const maxRetries = 2; const maxRetries = 2;
for (let i = 0; !success && i < maxRetries; i++) { // Enter alternate buffer
success = await authWithUserCode(client); enterAlternateScreen();
if (!success) { // Clear screen and move cursor to top-left.
debugLogger.error( writeToStdout('\u001B[2J\u001B[H');
'\nFailed to authenticate with user code.', disableMouseEvents();
i === maxRetries - 1 ? '' : 'Retrying...\n', disableKittyKeyboardProtocol();
); enableLineWrapping();
try {
for (let i = 0; !success && i < maxRetries; i++) {
success = await authWithUserCode(client);
if (!success) {
writeToStderr(
'\nFailed to authenticate with user code.' +
(i === maxRetries - 1 ? '' : ' Retrying...\n'),
);
}
} }
} finally {
exitAlternateScreen();
// If this was triggered from an active Gemini CLI TUI this event ensures
// the TUI will re-initialize the terminal state just like it will when
// another editor like VIM may have modified the buffer of settings.
coreEvents.emit(CoreEvent.ExternalEditorClosed);
} }
if (!success) { if (!success) {
writeToStderr('Failed to authenticate with user code.\n');
throw new FatalAuthenticationError( throw new FatalAuthenticationError(
'Failed to authenticate with user code.', 'Failed to authenticate with user code.',
); );
@@ -202,11 +237,13 @@ async function initOauthClient(
} else { } else {
const webLogin = await authWithWeb(client); const webLogin = await authWithWeb(client);
debugLogger.log( coreEvents.emit(CoreEvent.UserFeedback, {
`\n\nCode Assist login required.\n` + severity: 'info',
message:
`\n\nCode Assist login required.\n` +
`Attempting to open authentication page in your browser.\n` + `Attempting to open authentication page in your browser.\n` +
`Otherwise navigate to:\n\n${webLogin.authUrl}\n\n`, `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n\n`,
); });
try { try {
// Attempt to open the authentication URL in the default browser. // Attempt to open the authentication URL in the default browser.
// We do not use the `wait` option here because the main script's execution // We do not use the `wait` option here because the main script's execution
@@ -218,23 +255,28 @@ async function initOauthClient(
// in a minimal Docker container), it will emit an unhandled 'error' event, // in a minimal Docker container), it will emit an unhandled 'error' event,
// causing the entire Node.js process to crash. // causing the entire Node.js process to crash.
childProcess.on('error', (error) => { childProcess.on('error', (error) => {
debugLogger.error( coreEvents.emit(CoreEvent.UserFeedback, {
`Failed to open browser with error:`, severity: 'error',
getErrorMessage(error), message:
`\nPlease try running again with NO_BROWSER=true set.`, `Failed to open browser with error: ${getErrorMessage(error)}\n` +
); `Please try running again with NO_BROWSER=true set.`,
});
}); });
} catch (err) { } catch (err) {
debugLogger.error( coreEvents.emit(CoreEvent.UserFeedback, {
`Failed to open browser with error:`, severity: 'error',
getErrorMessage(err), message:
`\nPlease try running again with NO_BROWSER=true set.`, `Failed to open browser with error: ${getErrorMessage(err)}\n` +
); `Please try running again with NO_BROWSER=true set.`,
});
throw new FatalAuthenticationError( throw new FatalAuthenticationError(
`Failed to open browser: ${getErrorMessage(err)}`, `Failed to open browser: ${getErrorMessage(err)}`,
); );
} }
debugLogger.log('Waiting for authentication...'); coreEvents.emit(CoreEvent.UserFeedback, {
severity: 'info',
message: 'Waiting for authentication...\n',
});
// Add timeout to prevent infinite waiting when browser tab gets stuck // Add timeout to prevent infinite waiting when browser tab gets stuck
const authTimeout = 5 * 60 * 1000; // 5 minutes timeout const authTimeout = 5 * 60 * 1000; // 5 minutes timeout
@@ -250,6 +292,11 @@ async function initOauthClient(
}); });
await Promise.race([webLogin.loginCompletePromise, timeoutPromise]); await Promise.race([webLogin.loginCompletePromise, timeoutPromise]);
coreEvents.emit(CoreEvent.UserFeedback, {
severity: 'info',
message: 'Authentication succeeded\n',
});
} }
return client; return client;
@@ -266,55 +313,77 @@ export async function getOauthClient(
} }
async function authWithUserCode(client: OAuth2Client): Promise<boolean> { async function authWithUserCode(client: OAuth2Client): Promise<boolean> {
const redirectUri = 'https://codeassist.google.com/authcode';
const codeVerifier = await client.generateCodeVerifierAsync();
const state = crypto.randomBytes(32).toString('hex');
const authUrl: string = client.generateAuthUrl({
redirect_uri: redirectUri,
access_type: 'offline',
scope: OAUTH_SCOPE,
code_challenge_method: CodeChallengeMethod.S256,
code_challenge: codeVerifier.codeChallenge,
state,
});
debugLogger.log(
'Please visit the following URL to authorize the application:',
);
debugLogger.log('');
debugLogger.log(authUrl);
debugLogger.log('');
const code = await new Promise<string>((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question('Enter the authorization code: ', (code) => {
rl.close();
resolve(code.trim());
});
});
if (!code) {
debugLogger.error('Authorization code is required.');
return false;
}
try { try {
const { tokens } = await client.getToken({ const redirectUri = 'https://codeassist.google.com/authcode';
code, const codeVerifier = await client.generateCodeVerifierAsync();
codeVerifier: codeVerifier.codeVerifier, const state = crypto.randomBytes(32).toString('hex');
const authUrl: string = client.generateAuthUrl({
redirect_uri: redirectUri, redirect_uri: redirectUri,
access_type: 'offline',
scope: OAUTH_SCOPE,
code_challenge_method: CodeChallengeMethod.S256,
code_challenge: codeVerifier.codeChallenge,
state,
}); });
client.setCredentials(tokens); writeToStdout(
} catch (error) { 'Please visit the following URL to authorize the application:\n\n' +
authUrl +
'\n\n',
);
const code = await new Promise<string>((resolve, _) => {
const rl = readline.createInterface({
input: process.stdin,
output: createInkStdio().stdout,
terminal: true,
});
rl.question('Enter the authorization code: ', (code) => {
rl.close();
resolve(code.trim());
});
});
if (!code) {
writeToStderr('Authorization code is required.\n');
debugLogger.error('Authorization code is required.');
return false;
}
try {
const { tokens } = await client.getToken({
code,
codeVerifier: codeVerifier.codeVerifier,
redirect_uri: redirectUri,
});
client.setCredentials(tokens);
} catch (error) {
writeToStderr(
'Failed to authenticate with authorization code:' +
getErrorMessage(error) +
'\n',
);
debugLogger.error(
'Failed to authenticate with authorization code:',
getErrorMessage(error),
);
return false;
}
return true;
} catch (err) {
if (err instanceof FatalCancellationError) {
throw err;
}
writeToStderr(
'Failed to authenticate with user code:' + getErrorMessage(err) + '\n',
);
debugLogger.error( debugLogger.error(
'Failed to authenticate with authorization code:', 'Failed to authenticate with user code:',
getErrorMessage(error), getErrorMessage(err),
); );
return false; return false;
} }
return true;
} }
async function authWithWeb(client: OAuth2Client): Promise<OauthWebLogin> { async function authWithWeb(client: OAuth2Client): Promise<OauthWebLogin> {
+1
View File
@@ -147,3 +147,4 @@ export * from './hooks/types.js';
// Export stdio utils // Export stdio utils
export * from './utils/stdio.js'; export * from './utils/stdio.js';
export * from './utils/terminal.js';
+50
View File
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { writeToStdout } from './stdio.js';
export function enableMouseEvents() {
// Enable mouse tracking with SGR format
// ?1002h = button event tracking (clicks + drags + scroll wheel)
// ?1006h = SGR extended mouse mode (better coordinate handling)
writeToStdout('\u001b[?1002h\u001b[?1006h');
}
export function disableMouseEvents() {
// Disable mouse tracking with SGR format
writeToStdout('\u001b[?1006l\u001b[?1002l');
}
export function enableKittyKeyboardProtocol() {
writeToStdout('\x1b[>1u');
}
export function disableKittyKeyboardProtocol() {
writeToStdout('\x1b[<u');
}
export function enableLineWrapping() {
writeToStdout('\x1b[?7h');
}
export function disableLineWrapping() {
writeToStdout('\x1b[?7l');
}
export function enterAlternateScreen() {
writeToStdout('\x1b[?1049h');
}
export function exitAlternateScreen() {
writeToStdout('\x1b[?1049l');
}
export function shouldEnterAlternateScreen(
useAlternateBuffer: boolean,
isScreenReader: boolean,
): boolean {
return useAlternateBuffer && !isScreenReader;
}