mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 05:42:54 -07:00
feat(cli): implement quiet custom auth/status endpoint for Xcode ACP client
Adds a custom `auth/status` JSON-RPC method specifically tailored for the Xcode ACP sidecar client. This allows the editor to quietly query and probe the background authentication state to trigger user logins without disruptive browser popups. - Implement the `extMethod` hook on `GeminiAgent` to route custom, non-spec JSON-RPC protocol extensions. - Export core `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` constants from `packages/core` to be shared with the CLI dispatcher, establishing a single source of truth for native desktop credentials. - Implement background personal profile validation via a standalone, quiet `OAuth2Client` instance that checks cached tokens without launching interactive web browsers during status queries. - Implement a robust background environment check for Application Default Credentials (`COMPUTE_ADC`) that reads `process.env.GOOGLE_APPLICATION_CREDENTIALS` paths and uses a standalone `Compute` client gated by a 1-second `Promise.race` timeout to fast-probe GCE metadata servers quietly. - Restrict the endpoint exclusively to Xcode clients via `getClientName()` and `XCODE_VERSION_ACTUAL` checks, safely throwing a `Method not found` error (code -32601) to hide the option from other IDEs. - Define a strict `OAuthCredentialsPayload` interface for type-safe JSON contract parsing, throwing a strict JSON-RPC `Internal error` exception (code -32603) if internal credential stores are corrupted. - Adhere fully to project `strict-development-rules.md` guidelines by refactoring test spies from `// @ts-ignore` to type-safe `vi.mocked()` and injecting `vi.restoreAllMocks()` for robust environment isolation. - Pass all 24 unit tests, lint checks, and TypeScript strict index-signature (TS4111) compilation checks.
This commit is contained in:
@@ -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