mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 13:41:05 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b378111cd3 |
@@ -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<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
loadApiKey: mockLoadApiKey,
|
||||
OAUTH_CLIENT_ID: 'test-client-id',
|
||||
OAUTH_CLIENT_SECRET: 'test-client-secret',
|
||||
};
|
||||
});
|
||||
|
||||
describe('GeminiAgent - RPC Dispatcher', () => {
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockSettings: Mocked<LoadedSettings>;
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<string, unknown>;
|
||||
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<null>((_, 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;
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user