From 5b7b6fe608257381f522df5432d76e9fdf2bc144 Mon Sep 17 00:00:00 2001 From: Marat Boshernitsan Date: Fri, 18 Jul 2025 17:22:50 -0700 Subject: [PATCH] Automatically detect non-interactive environments and fall back to a manual, code-based authentication flow (#4475) --- packages/cli/src/gemini.tsx | 3 +- packages/cli/src/ui/hooks/useAuthCommand.ts | 6 ++- packages/core/src/code_assist/oauth2.test.ts | 7 ++- packages/core/src/code_assist/oauth2.ts | 11 ++-- packages/core/src/index.ts | 1 + packages/core/src/utils/browser.ts | 53 ++++++++++++++++++++ 6 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/utils/browser.ts diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 71e69952..f00dfd45 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -37,6 +37,7 @@ import { logUserPrompt, AuthType, getOauthClient, + shouldAttemptBrowserLaunch, } from '@google/gemini-cli-core'; import { validateAuthMethod } from './config/auth.js'; import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; @@ -184,7 +185,7 @@ export async function main() { if ( settings.merged.selectedAuthType === AuthType.LOGIN_WITH_GOOGLE && - config.getNoBrowser() + (config.getNoBrowser() || !shouldAttemptBrowserLaunch()) ) { // Do oauth before app renders to make copying the link possible. await getOauthClient(settings.merged.selectedAuthType, config); diff --git a/packages/cli/src/ui/hooks/useAuthCommand.ts b/packages/cli/src/ui/hooks/useAuthCommand.ts index afc276c0..e4f1f093 100644 --- a/packages/cli/src/ui/hooks/useAuthCommand.ts +++ b/packages/cli/src/ui/hooks/useAuthCommand.ts @@ -11,6 +11,7 @@ import { Config, clearCachedCredentialFile, getErrorMessage, + shouldAttemptBrowserLaunch, } from '@google/gemini-cli-core'; import { runExitCleanup } from '../../utils/cleanup.js'; @@ -56,7 +57,10 @@ export const useAuthCommand = ( if (authType) { await clearCachedCredentialFile(); settings.setValue(scope, 'selectedAuthType', authType); - if (authType === AuthType.LOGIN_WITH_GOOGLE && config.getNoBrowser()) { + if ( + authType === AuthType.LOGIN_WITH_GOOGLE && + (config.getNoBrowser() || !shouldAttemptBrowserLaunch()) + ) { runExitCleanup(); console.log( ` diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index 9d8fb892..8a1af056 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -31,6 +31,9 @@ vi.mock('http'); vi.mock('open'); vi.mock('crypto'); vi.mock('node:readline'); +vi.mock('../utils/browser.js', () => ({ + shouldAttemptBrowserLaunch: () => true, +})); const mockConfig = { getNoBrowser: () => false, @@ -83,7 +86,7 @@ describe('oauth2', () => { ); vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); - (open as Mock).mockImplementation(async () => ({}) as never); + (open as Mock).mockImplementation(async () => ({ on: vi.fn() }) as never); // Mock the UserInfo API response (global.fetch as Mock).mockResolvedValue({ @@ -236,7 +239,7 @@ describe('oauth2', () => { expect(mockGetToken).toHaveBeenCalledWith({ code: mockCode, codeVerifier: mockCodeVerifier.codeVerifier, - redirect_uri: 'https://sdk.cloud.google.com/authcode_cloudcode.html', + redirect_uri: 'https://codeassist.google.com/authcode', }); expect(mockSetCredentials).toHaveBeenCalledWith(mockTokens); diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index ac45177c..51227086 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -26,6 +26,7 @@ import { clearCachedGoogleAccount, } from '../utils/user_account.js'; import { AuthType } from '../core/contentGenerator.js'; +import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; import readline from 'node:readline'; // OAuth Client ID used to initiate OAuth2Client class. @@ -121,7 +122,7 @@ export async function getOauthClient( } } - if (config.getNoBrowser()) { + if (config.getNoBrowser() || !shouldAttemptBrowserLaunch()) { let success = false; const maxRetries = 2; for (let i = 0; !success && i < maxRetries; i++) { @@ -156,15 +157,17 @@ export async function getOauthClient( // causing the entire Node.js process to crash. childProcess.on('error', (_) => { console.error( - 'Failed to open browser automatically. Please open the URL manually:', + 'Failed to open browser automatically. Please try running again with NO_BROWSER=true set.', ); - console.error(webLogin.authUrl); + process.exit(1); }); } catch (err) { console.error( 'An unexpected error occurred while trying to open the browser:', err, + '\nPlease try running again with NO_BROWSER=true set.', ); + process.exit(1); } console.log('Waiting for authentication...'); @@ -175,7 +178,7 @@ export async function getOauthClient( } async function authWithUserCode(client: OAuth2Client): Promise { - const redirectUri = 'https://sdk.cloud.google.com/authcode_cloudcode.html'; + const redirectUri = 'https://codeassist.google.com/authcode'; const codeVerifier = await client.generateCodeVerifierAsync(); const state = crypto.randomBytes(32).toString('hex'); const authUrl: string = client.generateAuthUrl({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2e85deff..a3d77ddc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -80,3 +80,4 @@ export { OAuthUtils } from './mcp/oauth-utils.js'; // Export telemetry functions export * from './telemetry/index.js'; export { sessionId } from './utils/session.js'; +export * from './utils/browser.js'; diff --git a/packages/core/src/utils/browser.ts b/packages/core/src/utils/browser.ts new file mode 100644 index 00000000..a9b2b013 --- /dev/null +++ b/packages/core/src/utils/browser.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Determines if we should attempt to launch a browser for authentication + * based on the user's environment. + * + * This is an adaptation of the logic from the Google Cloud SDK. + * @returns True if the tool should attempt to launch a browser. + */ +export function shouldAttemptBrowserLaunch(): boolean { + // A list of browser names that indicate we should not attempt to open a + // web browser for the user. + const browserBlocklist = ['www-browser']; + const browserEnv = process.env.BROWSER; + if (browserEnv && browserBlocklist.includes(browserEnv)) { + return false; + } + // Common environment variables used in CI/CD or other non-interactive shells. + if (process.env.CI || process.env.DEBIAN_FRONTEND === 'noninteractive') { + return false; + } + + // The presence of SSH_CONNECTION indicates a remote session. + // We should not attempt to launch a browser unless a display is explicitly available + // (checked below for Linux). + const isSSH = !!process.env.SSH_CONNECTION; + + // On Linux, the presence of a display server is a strong indicator of a GUI. + if (process.platform === 'linux') { + // These are environment variables that can indicate a running compositor on + // Linux. + const displayVariables = ['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET']; + const hasDisplay = displayVariables.some((v) => !!process.env[v]); + if (!hasDisplay) { + return false; + } + } + + // If in an SSH session on a non-Linux OS (e.g., macOS), don't launch browser. + // The Linux case is handled above (it's allowed if DISPLAY is set). + if (isSSH && process.platform !== 'linux') { + return false; + } + + // For non-Linux OSes, we generally assume a GUI is available + // unless other signals (like SSH) suggest otherwise. + // The `open` command's error handling will catch final edge cases. + return true; +}