diff --git a/packages/cli/src/acp/acpRpcDispatcher.test.ts b/packages/cli/src/acp/acpRpcDispatcher.test.ts index a677c5631b..f5f08a5741 100644 --- a/packages/cli/src/acp/acpRpcDispatcher.test.ts +++ b/packages/cli/src/acp/acpRpcDispatcher.test.ts @@ -10,11 +10,13 @@ import { expect, vi, beforeEach, + afterEach, type Mock, type Mocked, } from 'vitest'; import { GeminiAgent } from './acpRpcDispatcher.js'; import * as acp from '@agentclientprotocol/sdk'; +import { promises as fs } from 'node:fs'; import { AuthType, type Config, @@ -25,6 +27,14 @@ import type { LoadedSettings } from '../config/settings.js'; import { loadCliConfig, type CliArgs } from '../config/config.js'; import { loadSettings, SettingScope } from '../config/settings.js'; +const { mockGetAccessToken, mockGetTokenInfo, mockLoadApiKey } = vi.hoisted( + () => ({ + mockGetAccessToken: vi.fn(), + mockGetTokenInfo: vi.fn(), + mockLoadApiKey: vi.fn(), + }), +); + vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn(), })); @@ -37,6 +47,35 @@ vi.mock('../config/settings.js', async (importOriginal) => { }; }); +vi.mock('node:fs', () => ({ + promises: { + readFile: vi.fn(), + }, +})); + +vi.mock('google-auth-library', () => { + class MockOAuth2Client { + setCredentials = vi.fn(); + getAccessToken = mockGetAccessToken; + getTokenInfo = mockGetTokenInfo; + } + return { + OAuth2Client: MockOAuth2Client, + Compute: MockOAuth2Client, + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadApiKey: mockLoadApiKey, + OAUTH_CLIENT_ID: 'test-client-id', + OAUTH_CLIENT_SECRET: 'test-client-secret', + }; +}); + describe('GeminiAgent - RPC Dispatcher', () => { let mockConfig: Mocked; let mockSettings: Mocked; @@ -52,6 +91,7 @@ describe('GeminiAgent - RPC Dispatcher', () => { getFileSystemService: vi.fn(), setFileSystemService: vi.fn(), getContentGeneratorConfig: vi.fn(), + getClientName: vi.fn().mockReturnValue('xcode'), getActiveModel: vi.fn().mockReturnValue('gemini-pro'), getModel: vi.fn().mockReturnValue('gemini-pro'), getGeminiClient: vi.fn().mockReturnValue({ @@ -335,4 +375,193 @@ describe('GeminiAgent - RPC Dispatcher', () => { }), ).rejects.toThrow('Session not found: unknown'); }); + + describe('extMethod - auth/status', () => { + beforeEach(() => { + vi.stubEnv('GEMINI_API_KEY', ''); + vi.stubEnv('GOOGLE_API_KEY', ''); + vi.stubEnv('GOOGLE_CLOUD_PROJECT', ''); + vi.stubEnv('GOOGLE_CLOUD_LOCATION', ''); + vi.stubEnv('XCODE_VERSION_ACTUAL', '1500'); // Default to Xcode for auth/status tests + mockConfig.getClientName.mockReturnValue('xcode'); + mockLoadApiKey.mockReset(); + mockGetAccessToken.mockReset(); + mockGetTokenInfo.mockReset(); + vi.mocked(fs.readFile).mockReset(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should throw RequestError for unknown custom method', async () => { + await expect(agent.extMethod('unknown/method', {})).rejects.toThrow( + 'Method not found: unknown/method', + ); + }); + + it('should throw RequestError when called from a non-Xcode client', async () => { + vi.stubEnv('XCODE_VERSION_ACTUAL', ''); + mockConfig.getClientName.mockReturnValue('vscode'); + + await expect(agent.extMethod('auth/status', {})).rejects.toThrow( + 'Method not found: auth/status', + ); + }); + + it('should return Unauthorized when gemini-api-key is missing or empty', async () => { + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_GEMINI, + apiKey: '', + }); + mockLoadApiKey.mockResolvedValue(null); + + const result = await agent.extMethod('auth/status', {}); + + expect(result).toEqual({ + status: 'Unauthorized', + methodId: null, + }); + }); + + it('should return Authorized when API key is present in process.env', async () => { + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_GEMINI, + }); + vi.stubEnv('GEMINI_API_KEY', 'env-api-key'); + + const result = await agent.extMethod('auth/status', {}); + + expect(result).toEqual({ + status: 'Authorized', + methodId: AuthType.USE_GEMINI, + }); + }); + + it('should return Authorized when API key is loaded from keychain cache', async () => { + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_GEMINI, + }); + mockLoadApiKey.mockResolvedValue('keychain-api-key'); + + const result = await agent.extMethod('auth/status', {}); + + expect(result).toEqual({ + status: 'Authorized', + methodId: AuthType.USE_GEMINI, + }); + }); + + it('should return Authorized for valid oauth-personal token info', async () => { + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }); + + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ refresh_token: 'valid-token' }), + ); + mockGetAccessToken.mockResolvedValue({ token: 'access-token' }); + mockGetTokenInfo.mockResolvedValue({ scopes: [] }); + + const result = await agent.extMethod('auth/status', {}); + + expect(result).toEqual({ + status: 'Authorized', + methodId: AuthType.LOGIN_WITH_GOOGLE, + }); + }); + + it('should return Unauthorized for expired/invalid oauth-personal refresh', async () => { + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }); + + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ refresh_token: 'invalid-token' }), + ); + mockGetAccessToken.mockRejectedValue(new Error('invalid grant')); + + const result = await agent.extMethod('auth/status', {}); + + expect(result).toEqual({ + status: 'Unauthorized', + methodId: null, + }); + }); + + it('should return Authorized for Vertex AI when env variables are configured', async () => { + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_VERTEX_AI, + }); + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-project'); + vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1'); + + const result = await agent.extMethod('auth/status', {}); + + expect(result).toEqual({ + status: 'Authorized', + methodId: AuthType.USE_VERTEX_AI, + }); + }); + + it('should throw RequestError when credentials store file contains malformed JSON', async () => { + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }); + + vi.mocked(fs.readFile).mockResolvedValue('{ malformed: json '); + + await expect(agent.extMethod('auth/status', {})).rejects.toThrow( + /Corrupted credentials store file/, + ); + }); + + it('should return Authorized for COMPUTE_ADC when process.env.GOOGLE_APPLICATION_CREDENTIALS is valid', async () => { + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.COMPUTE_ADC, + }); + vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', '/path/to/adc.json'); + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ type: 'service_account' }), + ); + + const result = await agent.extMethod('auth/status', {}); + + expect(result).toEqual({ + status: 'Authorized', + methodId: AuthType.COMPUTE_ADC, + }); + }); + + it('should return Authorized for COMPUTE_ADC when GCE metadata server responds successfully', async () => { + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.COMPUTE_ADC, + }); + vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', ''); + mockGetAccessToken.mockResolvedValue({ token: 'compute-access-token' }); + + const result = await agent.extMethod('auth/status', {}); + + expect(result).toEqual({ + status: 'Authorized', + methodId: AuthType.COMPUTE_ADC, + }); + }); + + it('should return Unauthorized for COMPUTE_ADC when environment is unconfigured or check fails', async () => { + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.COMPUTE_ADC, + }); + vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', ''); + mockGetAccessToken.mockRejectedValue(new Error('Not GCE env')); + + const result = await agent.extMethod('auth/status', {}); + + expect(result).toEqual({ + status: 'Unauthorized', + methodId: null, + }); + }); + }); }); diff --git a/packages/cli/src/acp/acpRpcDispatcher.ts b/packages/cli/src/acp/acpRpcDispatcher.ts index a7d7d26e61..313d68e23d 100644 --- a/packages/cli/src/acp/acpRpcDispatcher.ts +++ b/packages/cli/src/acp/acpRpcDispatcher.ts @@ -9,8 +9,14 @@ import { AuthType, clearCachedCredentialFile, getVersion, + loadApiKey, + OAUTH_CLIENT_ID, + OAUTH_CLIENT_SECRET, + Storage, } from '@google/gemini-cli-core'; import * as acp from '@agentclientprotocol/sdk'; +import { OAuth2Client, Compute } from 'google-auth-library'; +import { promises as fs } from 'node:fs'; import { z } from 'zod'; import { SettingScope, type LoadedSettings } from '../config/settings.js'; import type { CliArgs } from '../config/config.js'; @@ -233,4 +239,163 @@ export class GeminiAgent { } return session.setModel(params.modelId); } + + async extMethod( + method: string, + _params: unknown, + ): Promise> { + if (method === 'auth/status') { + const clientName = this.context.config.getClientName()?.toLowerCase(); + const isXcode = + clientName?.includes('xcode') || !!process.env['XCODE_VERSION_ACTUAL']; + if (!isXcode) { + throw new acp.RequestError(-32601, `Method not found: ${method}`); + } + return this.handleAuthStatus(); + } + throw new acp.RequestError(-32601, `Method not found: ${method}`); + } + + private async handleAuthStatus(): Promise<{ + status: string; + methodId: string | null; + }> { + const currentConfig = this.context.config.getContentGeneratorConfig(); + const authType = + currentConfig?.authType || + this.settings.merged.security.auth.selectedType || + AuthType.USE_GEMINI; + + let isAuth = false; + + if (authType === AuthType.USE_GEMINI) { + const apiKey = + this.apiKey || + currentConfig?.apiKey || + process.env['GEMINI_API_KEY'] || + (await loadApiKey()); + isAuth = !!apiKey && apiKey.trim() !== ''; + } else if (authType === AuthType.LOGIN_WITH_GOOGLE) { + isAuth = await this.checkOAuthValid(); + } else if (authType === AuthType.USE_VERTEX_AI) { + const googleApiKey = process.env['GOOGLE_API_KEY']; + const googleCloudProject = + process.env['GOOGLE_CLOUD_PROJECT'] || + process.env['GOOGLE_CLOUD_PROJECT_ID']; + const googleCloudLocation = process.env['GOOGLE_CLOUD_LOCATION']; + isAuth = !!googleApiKey || !!(googleCloudProject && googleCloudLocation); + } else if (authType === AuthType.GATEWAY) { + const apiKey = + this.apiKey || currentConfig?.apiKey || process.env['GEMINI_API_KEY']; + isAuth = !!apiKey || !!this.baseUrl || !!currentConfig?.baseUrl; + } else if (authType === AuthType.COMPUTE_ADC) { + isAuth = await this.checkADCValid(); + } + + return { + status: isAuth ? 'Authorized' : 'Unauthorized', + methodId: isAuth ? authType : null, + }; + } + + private async checkOAuthValid(): Promise { + let fileContent: string; + try { + const filePath = Storage.getOAuthCredsPath(); + fileContent = await fs.readFile(filePath, 'utf-8'); + } catch (e) { + if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') { + return false; + } + return false; + } + + let credentials: OAuthCredentialsPayload; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + credentials = JSON.parse(fileContent) as OAuthCredentialsPayload; + } catch (err) { + if (err instanceof SyntaxError) { + throw new acp.RequestError( + -32603, + `Internal error: Corrupted credentials store file: ${err.message}`, + ); + } + return false; + } + + if ( + !credentials || + (!credentials.refresh_token && !credentials.access_token) + ) { + return false; + } + + try { + const client = new OAuth2Client({ + clientId: OAUTH_CLIENT_ID, + clientSecret: OAUTH_CLIENT_SECRET, + }); + + client.setCredentials(credentials); + + const { token } = await client.getAccessToken(); + if (!token) { + return false; + } + + await client.getTokenInfo(token); + return true; + } catch { + return false; + } + } + + private async checkADCValid(): Promise { + try { + const envAdcPath = process.env['GOOGLE_APPLICATION_CREDENTIALS']; + if (envAdcPath) { + try { + const content = await fs.readFile(envAdcPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const parsed = JSON.parse(content) as Record; + if ( + parsed && + (parsed['type'] === 'service_account' || + parsed['type'] === 'authorized_user') + ) { + return true; + } + } catch (e) { + if (e instanceof SyntaxError) { + throw new acp.RequestError( + -32603, + `Internal error: Corrupted ADC environment credentials file: ${e.message}`, + ); + } + return false; + } + } + + const computeClient = new Compute(); + const token = await Promise.race([ + computeClient.getAccessToken(), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('ADC metadata check timeout')), + 1000, + ), + ), + ]); + return !!token; + } catch { + return false; + } + } +} + +interface OAuthCredentialsPayload { + refresh_token?: string; + access_token?: string; + type?: string; } diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 8ea83e5270..4d6de9683f 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -69,7 +69,7 @@ async function triggerPostAuthCallbacks(tokens: Credentials) { const userAccountManager = new UserAccountManager(); // OAuth Client ID used to initiate OAuth2Client class. -const OAUTH_CLIENT_ID = +export const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; // OAuth Secret value used to initiate OAuth2Client class. @@ -78,7 +78,7 @@ const OAUTH_CLIENT_ID = // "The process results in a client ID and, in some cases, a client secret, // which you embed in the source code of your application. (In this context, // the client secret is obviously not treated as a secret.)" -const OAUTH_CLIENT_SECRET = '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'; +export const OAUTH_CLIENT_SECRET = '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'; // OAuth Scopes for Cloud Code authorization. const OAUTH_SCOPE = [