diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 20a8afcf..b80b6dd0 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -317,6 +317,7 @@ export async function loadCliConfig( name: e.config.name, version: e.config.version, })), + noBrowser: !!process.env.NO_BROWSER, }); } diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 84a3da62..8b58c46a 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -35,6 +35,7 @@ import { sessionId, logUserPrompt, AuthType, + getOauthClient, } from '@google/gemini-cli-core'; import { validateAuthMethod } from './config/auth.js'; import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; @@ -165,6 +166,15 @@ export async function main() { } } } + + if ( + settings.merged.selectedAuthType === AuthType.LOGIN_WITH_GOOGLE && + config.getNoBrowser() + ) { + // Do oauth before app renders to make copying the link possible. + await getOauthClient(settings.merged.selectedAuthType, config); + } + let input = config.getQuestion(); const startupWarnings = [ ...(await getStartupWarnings()), diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index e3a5eb55..2a6bf088 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -728,13 +728,29 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { /> ) : isAuthenticating ? ( - { - setAuthError('Authentication timed out. Please try again.'); - cancelAuthentication(); - openAuthDialog(); - }} - /> + <> + { + setAuthError('Authentication timed out. Please try again.'); + cancelAuthentication(); + openAuthDialog(); + }} + /> + {showErrorDetails && ( + + + + + + + )} + ) : isAuthDialogOpen ? ( { if ( authType === AuthType.LOGIN_WITH_GOOGLE || authType === AuthType.CLOUD_SHELL ) { - const authClient = await getOauthClient(authType); + const authClient = await getOauthClient(authType, config); const projectId = await setupUser(authClient); return new CodeAssistServer(authClient, projectId, httpOptions, sessionId); } diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index 76d43726..d8cd525b 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -14,6 +14,7 @@ import open from 'open'; import crypto from 'crypto'; import * as os from 'os'; import { AuthType } from '../core/contentGenerator.js'; +import { Config } from '../config/config.js'; vi.mock('os', async (importOriginal) => { const os = await importOriginal(); @@ -28,6 +29,10 @@ vi.mock('http'); vi.mock('open'); vi.mock('crypto'); +const mockConfig = { + getNoBrowser: () => false, +} as unknown as Config; + // Mock fetch globally global.fetch = vi.fn(); @@ -136,7 +141,10 @@ describe('oauth2', () => { return mockHttpServer as unknown as http.Server; }); - const clientPromise = getOauthClient(AuthType.LOGIN_WITH_GOOGLE); + const clientPromise = getOauthClient( + AuthType.LOGIN_WITH_GOOGLE, + mockConfig, + ); // wait for server to start listening. await serverListeningPromise; @@ -214,7 +222,7 @@ describe('oauth2', () => { () => mockClient as unknown as OAuth2Client, ); - await getOauthClient(AuthType.LOGIN_WITH_GOOGLE); + await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); expect(fs.promises.readFile).toHaveBeenCalledWith( '/user/home/.gemini/oauth_creds.json', @@ -227,7 +235,7 @@ describe('oauth2', () => { }); it('should use Compute to get a client if no cached credentials exist', async () => { - await getOauthClient(AuthType.CLOUD_SHELL); + await getOauthClient(AuthType.CLOUD_SHELL, mockConfig); expect(Compute).toHaveBeenCalledWith({}); expect(mockGetAccessToken).toHaveBeenCalled(); @@ -238,13 +246,13 @@ describe('oauth2', () => { mockComputeClient.credentials = newCredentials; mockGetAccessToken.mockResolvedValue({ token: 'new-adc-token' }); - await getOauthClient(AuthType.CLOUD_SHELL); + await getOauthClient(AuthType.CLOUD_SHELL, mockConfig); expect(fs.promises.writeFile).not.toHaveBeenCalled(); }); it('should return the Compute client on successful ADC authentication', async () => { - const client = await getOauthClient(AuthType.CLOUD_SHELL); + const client = await getOauthClient(AuthType.CLOUD_SHELL, mockConfig); expect(client).toBe(mockComputeClient); }); @@ -252,7 +260,9 @@ describe('oauth2', () => { const testError = new Error('ADC Failed'); mockGetAccessToken.mockRejectedValue(testError); - await expect(getOauthClient(AuthType.CLOUD_SHELL)).rejects.toThrow( + await expect( + getOauthClient(AuthType.CLOUD_SHELL, mockConfig), + ).rejects.toThrow( 'Could not authenticate using Cloud Shell credentials. Please select a different authentication method or ensure you are in a properly configured environment. Error: ADC Failed', ); }); diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 93d0e28b..2d3c04d0 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { OAuth2Client, Credentials, Compute } from 'google-auth-library'; +import { + OAuth2Client, + Credentials, + Compute, + CodeChallengeMethod, +} from 'google-auth-library'; import * as http from 'http'; import url from 'url'; import crypto from 'crypto'; @@ -13,8 +18,10 @@ import open from 'open'; import path from 'node:path'; import { promises as fs, existsSync, readFileSync } from 'node:fs'; import * as os from 'os'; +import { Config } from '../config/config.js'; import { getErrorMessage } from '../utils/errors.js'; import { AuthType } from '../core/contentGenerator.js'; +import readline from 'node:readline'; // OAuth Client ID used to initiate OAuth2Client class. const OAUTH_CLIENT_ID = @@ -57,6 +64,7 @@ export interface OauthWebLogin { export async function getOauthClient( authType: AuthType, + config: Config, ): Promise { const client = new OAuth2Client({ clientId: OAUTH_CLIENT_ID, @@ -109,27 +117,93 @@ export async function getOauthClient( } } - // Otherwise, obtain creds using standard web flow - const webLogin = await authWithWeb(client); + if (config.getNoBrowser()) { + let success = false; + const maxRetries = 2; + for (let i = 0; !success && i < maxRetries; i++) { + success = await authWithUserCode(client); + if (!success) { + console.error( + '\nFailed to authenticate with user code.', + i === maxRetries - 1 ? '' : 'Retrying...\n', + ); + } + } + if (!success) { + process.exit(1); + } + } else { + const webLogin = await authWithWeb(client); - console.log( - `\n\nCode Assist login required.\n` + - `Attempting to open authentication page in your browser.\n` + - `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n`, - ); - await open(webLogin.authUrl); - console.log('Waiting for authentication...'); + // This does basically nothing, as it isn't show to the user. + console.log( + `\n\nCode Assist login required.\n` + + `Attempting to open authentication page in your browser.\n` + + `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n`, + ); + await open(webLogin.authUrl); + console.log('Waiting for authentication...'); - await webLogin.loginCompletePromise; + await webLogin.loginCompletePromise; + } return client; } +async function authWithUserCode(client: OAuth2Client): Promise { + const redirectUri = 'https://sdk.cloud.google.com/authcode_cloudcode.html'; + 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, + }); + console.error('Please visit the following URL to authorize the application:'); + console.error(''); + console.error(authUrl); + console.error(''); + + const code = await new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question('Enter the authorization code: ', (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); + + if (!code) { + console.error('Authorization code is required.'); + return false; + } else { + console.error(`Received authorization code: "${code}"`); + } + + try { + const response = await client.getToken({ + code, + codeVerifier: codeVerifier.codeVerifier, + redirect_uri: redirectUri, + }); + client.setCredentials(response.tokens); + } catch (_error) { + // Consider logging the error. + return false; + } + return true; +} + async function authWithWeb(client: OAuth2Client): Promise { const port = await getAvailablePort(); const redirectUri = `http://localhost:${port}/oauth2callback`; const state = crypto.randomBytes(32).toString('hex'); - const authUrl: string = client.generateAuthUrl({ + const authUrl = client.generateAuthUrl({ redirect_uri: redirectUri, access_type: 'offline', scope: OAUTH_SCOPE, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 51915fc8..15e9e73b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -141,6 +141,7 @@ export interface ConfigParameters { extensionContextFilePaths?: string[]; listExtensions?: boolean; activeExtensions?: ActiveExtension[]; + noBrowser?: boolean; } export class Config { @@ -179,6 +180,7 @@ export class Config { private readonly bugCommand: BugCommandSettings | undefined; private readonly model: string; private readonly extensionContextFilePaths: string[]; + private readonly noBrowser: boolean; private modelSwitchedDuringSession: boolean = false; private readonly listExtensions: boolean; private readonly _activeExtensions: ActiveExtension[]; @@ -227,6 +229,7 @@ export class Config { this.extensionContextFilePaths = params.extensionContextFilePaths ?? []; this.listExtensions = params.listExtensions ?? false; this._activeExtensions = params.activeExtensions ?? []; + this.noBrowser = params.noBrowser ?? false; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); @@ -475,6 +478,10 @@ export class Config { return this._activeExtensions; } + getNoBrowser(): boolean { + return this.noBrowser; + } + async getGitService(): Promise { if (!this.gitService) { this.gitService = new GitService(this.targetDir); diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 44828a74..2769e1b0 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -180,6 +180,7 @@ describe('Gemini Client (client.ts)', () => { getFileService: vi.fn().mockReturnValue(fileService), getQuotaErrorOccurred: vi.fn().mockReturnValue(false), setQuotaErrorOccurred: vi.fn(), + getNoBrowser: vi.fn().mockReturnValue(false), }; return mock as unknown as Config; }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index eee52cb4..5d9ac0cb 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -109,6 +109,7 @@ export class GeminiClient { async initialize(contentGeneratorConfig: ContentGeneratorConfig) { this.contentGenerator = await createContentGenerator( contentGeneratorConfig, + this.config, this.config.getSessionId(), ); this.chat = await this.startChat(); diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index eb480710..92144aa4 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -12,20 +12,26 @@ import { } from './contentGenerator.js'; import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js'; import { GoogleGenAI } from '@google/genai'; +import { Config } from '../config/config.js'; vi.mock('../code_assist/codeAssist.js'); vi.mock('@google/genai'); +const mockConfig = {} as unknown as Config; + describe('createContentGenerator', () => { it('should create a CodeAssistContentGenerator', async () => { const mockGenerator = {} as unknown; vi.mocked(createCodeAssistContentGenerator).mockResolvedValue( mockGenerator as never, ); - const generator = await createContentGenerator({ - model: 'test-model', - authType: AuthType.LOGIN_WITH_GOOGLE, - }); + const generator = await createContentGenerator( + { + model: 'test-model', + authType: AuthType.LOGIN_WITH_GOOGLE, + }, + mockConfig, + ); expect(createCodeAssistContentGenerator).toHaveBeenCalled(); expect(generator).toBe(mockGenerator); }); @@ -35,11 +41,14 @@ describe('createContentGenerator', () => { models: {}, } as unknown; vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); - const generator = await createContentGenerator({ - model: 'test-model', - apiKey: 'test-api-key', - authType: AuthType.USE_GEMINI, - }); + const generator = await createContentGenerator( + { + model: 'test-model', + apiKey: 'test-api-key', + authType: AuthType.USE_GEMINI, + }, + mockConfig, + ); expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: 'test-api-key', vertexai: undefined, diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index e9e1138f..fee10fad 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -15,6 +15,7 @@ import { } from '@google/genai'; import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js'; import { DEFAULT_GEMINI_MODEL } from '../config/models.js'; +import { Config } from '../config/config.js'; import { getEffectiveModel } from './modelCheck.js'; /** @@ -99,6 +100,7 @@ export async function createContentGeneratorConfig( export async function createContentGenerator( config: ContentGeneratorConfig, + gcConfig: Config, sessionId?: string, ): Promise { const version = process.env.CLI_VERSION || process.version; @@ -114,6 +116,7 @@ export async function createContentGenerator( return createCodeAssistContentGenerator( httpOptions, config.authType, + gcConfig, sessionId, ); }