feat: differentiate User-Agent for a2a-server and ACP clients

This commit is contained in:
Bryan Morgan
2026-03-11 12:32:26 -04:00
parent 88638c14f7
commit 63ecbc6e26
13 changed files with 1087 additions and 1487 deletions
+735 -957
View File
File diff suppressed because it is too large Load Diff
+16 -27
View File
@@ -146,7 +146,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`general.retryFetchErrors`** (boolean):
- **Description:** Retry on "exception TypeError: fetch failed sending
request" errors.
- **Default:** `true`
- **Default:** `false`
- **`general.maxAttempts`** (number):
- **Description:** Maximum number of attempts for requests to the main chat
@@ -297,7 +297,7 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **`ui.showUserIdentity`** (boolean):
- **Description:** Show the signed-in user's identity (e.g. email) in the UI.
- **Description:** Show the logged-in user's identity (e.g. email) in the UI.
- **Default:** `true`
- **`ui.useAlternateBuffer`** (boolean):
@@ -872,11 +872,6 @@ their corresponding top-level category object in your `settings.json` file.
confirmation dialogs.
- **Default:** `false`
- **`security.autoAddToPolicyByDefault`** (boolean):
- **Description:** When enabled, the "Allow for all future sessions" option
becomes the default choice for low-risk tools in trusted workspaces.
- **Default:** `false`
- **`security.blockGitExtensions`** (boolean):
- **Description:** Blocks installing and loading extensions from Git.
- **Default:** `false`
@@ -1003,12 +998,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.extensionRegistryURI`** (string):
- **Description:** The URI (web URL or local file path) of the extension
registry.
- **Default:** `"https://geminicli.com/extensions.json"`
- **Requires restart:** Yes
- **`experimental.extensionReloading`** (boolean):
- **Description:** Enables extension loading/unloading within the CLI session.
- **Default:** `false`
@@ -1182,20 +1171,13 @@ their corresponding top-level category object in your `settings.json` file.
Configures connections to one or more Model-Context Protocol (MCP) servers for
discovering and using custom tools. Gemini CLI attempts to connect to each
configured MCP server to discover available tools. Every discovered tool is
prepended with the `mcp_` prefix and its server alias to form a fully qualified
name (FQN) (e.g., `mcp_serverAlias_actualToolName`) to avoid conflicts. Note
that the system might strip certain schema properties from MCP tool definitions
for compatibility. At least one of `command`, `url`, or `httpUrl` must be
provided. If multiple are specified, the order of precedence is `httpUrl`, then
`url`, then `command`.
> **Warning:** Avoid using underscores (`_`) in your server aliases (e.g., use
> `my-server` instead of `my_server`). The underlying policy engine parses Fully
> Qualified Names (`mcp_server_tool`) using the first underscore after the
> `mcp_` prefix. An underscore in your server alias will cause the parser to
> misidentify the server name, which can cause security policies to fail
> silently.
configured MCP server to discover available tools. If multiple MCP servers
expose a tool with the same name, the tool names will be prefixed with the
server alias you defined in the configuration (e.g.,
`serverAlias__actualToolName`) to avoid conflicts. Note that the system might
strip certain schema properties from MCP tool definitions for compatibility. At
least one of `command`, `url`, or `httpUrl` must be provided. If multiple are
specified, the order of precedence is `httpUrl`, then `url`, then `command`.
- **`mcpServers.<SERVER_NAME>`** (object): The server parameters for the named
server.
@@ -1376,6 +1358,13 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Useful for shared compute environments or keeping CLI state isolated.
- Example: `export GEMINI_CLI_HOME="/path/to/user/config"` (Windows
PowerShell: `$env:GEMINI_CLI_HOME="C:\path\to\user\config"`)
- **`GEMINI_CLI_SURFACE`**:
- Specifies a custom label to include in the `User-Agent` header for API
traffic reporting.
- This is useful for tracking specific internal tools or distribution
channels.
- Example: `export GEMINI_CLI_SURFACE="my-custom-tool"` (Windows PowerShell:
`$env:GEMINI_CLI_SURFACE="my-custom-tool"`)
- **`GOOGLE_API_KEY`**:
- Your Google Cloud API key.
- Required for using Vertex AI in express mode.
+133 -443
View File
@@ -4,64 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import * as path from 'node:path';
import { loadConfig } from './config.js';
import type { Settings } from './settings.js';
import {
type ExtensionLoader,
FileDiscoveryService,
getCodeAssistServer,
Config,
ExperimentFlags,
fetchAdminControlsOnce,
type FetchAdminControlsResponse,
AuthType,
isHeadlessMode,
FatalAuthenticationError,
} from '@google/gemini-cli-core';
// Mock dependencies
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
Config: vi.fn().mockImplementation((params) => {
const mockConfig = {
...params,
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: vi.fn(),
getExperiments: vi.fn().mockReturnValue({
flags: {
[actual.ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
boolValue: false,
},
},
}),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
};
return mockConfig;
}),
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: { global: '', extension: '', project: '' },
fileCount: 0,
filePaths: [],
}),
startupProfiler: {
flush: vi.fn(),
},
isHeadlessMode: vi.fn().mockReturnValue(false),
FileDiscoveryService: vi.fn(),
getCodeAssistServer: vi.fn(),
fetchAdminControlsOnce: vi.fn(),
coreEvents: {
emitAdminSettingsChanged: vi.fn(),
},
};
});
import {
fetchAdminControlsOnce,
Config,
type FetchAdminControlsResponse,
type ExtensionLoader,
} from '@google/gemini-cli-core';
vi.mock('../utils/logger.js', () => ({
logger: {
@@ -71,9 +32,84 @@ vi.mock('../utils/logger.js', () => ({
},
}));
interface MockConfigParams {
clientName?: string;
allowedTools?: string[];
approvalMode?: string;
}
// Mock dependencies
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const mockConfigCtor = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
params: MockConfigParams,
) {
Object.assign(this, params);
this['initialize'] = vi.fn().mockResolvedValue(undefined);
this['waitForMcpInit'] = vi.fn().mockResolvedValue(undefined);
this['refreshAuth'] = vi.fn().mockResolvedValue(undefined);
this['getExperiments'] = vi.fn().mockReturnValue({
flags: {
['enable_admin_controls']: {
boolValue: false,
},
},
});
this['getRemoteAdminSettings'] = vi.fn().mockReturnValue({});
this['setRemoteAdminSettings'] = vi.fn();
this['getModel'] = vi.fn().mockReturnValue('gemini-2.0-flash');
this['getUserTier'] = vi.fn().mockReturnValue('free');
this['getClientName'] = vi.fn().mockReturnValue(params.clientName);
this['getAllowedTools'] = vi
.fn()
.mockReturnValue(params.allowedTools || []);
this['getApprovalMode'] = vi
.fn()
.mockReturnValue(params.approvalMode || 'default');
return this as unknown as Config;
});
return {
...actual,
PREVIEW_GEMINI_MODEL: 'gemini-2.0-flash',
DEFAULT_GEMINI_EMBEDDING_MODEL: 'text-embedding-004',
ApprovalMode: {
DEFAULT: 'default',
YOLO: 'yolo',
},
AuthType: {
USE_GEMINI: 'use_gemini',
LOGIN_WITH_GOOGLE: 'login_with_google',
COMPUTE_ADC: 'compute_adc',
},
ExperimentFlags: {
ENABLE_ADMIN_CONTROLS: 'enable_admin_controls',
},
Config: mockConfigCtor,
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: { global: '', extension: '', project: '' },
fileCount: 0,
filePaths: [],
}),
startupProfiler: {
flush: vi.fn(),
},
isHeadlessMode: vi.fn().mockReturnValue(false),
FileDiscoveryService: vi.fn().mockImplementation(() => ({})),
getCodeAssistServer: vi.fn(),
fetchAdminControlsOnce: vi.fn(),
coreEvents: {
emitAdminSettingsChanged: vi.fn(),
},
};
});
describe('loadConfig', () => {
const mockSettings = {} as Settings;
const mockExtensionLoader = {} as ExtensionLoader;
const mockExtensionLoader = {} as unknown as ExtensionLoader;
const taskId = 'test-task-id';
beforeEach(() => {
@@ -91,32 +127,44 @@ describe('loadConfig', () => {
expect(fetchAdminControlsOnce).not.toHaveBeenCalled();
});
it('should set clientName to a2a-server in config', async () => {
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
clientName: 'a2a-server',
}),
);
});
describe('when admin controls experiment is enabled', () => {
beforeEach(() => {
// We need to cast to any here to modify the mock implementation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Config as any).mockImplementation((params: unknown) => {
const mockConfig = {
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: vi.fn(),
getExperiments: vi.fn().mockReturnValue({
flags: {
[ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
boolValue: true,
},
const mockConfig = Config as unknown as MockInstance;
mockConfig.mockImplementation(function (
this: Record<string, unknown>,
params: MockConfigParams,
) {
Object.assign(this, params);
this['initialize'] = vi.fn().mockResolvedValue(undefined);
this['waitForMcpInit'] = vi.fn().mockResolvedValue(undefined);
this['refreshAuth'] = vi.fn().mockResolvedValue(undefined);
this['getExperiments'] = vi.fn().mockReturnValue({
flags: {
['enable_admin_controls']: {
boolValue: true,
},
}),
getRemoteAdminSettings: vi.fn().mockReturnValue({}),
setRemoteAdminSettings: vi.fn(),
};
return mockConfig;
},
});
this['getRemoteAdminSettings'] = vi.fn().mockReturnValue({});
this['setRemoteAdminSettings'] = vi.fn();
this['getModel'] = vi.fn().mockReturnValue('gemini-2.0-flash');
this['getUserTier'] = vi.fn().mockReturnValue('free');
this['getClientName'] = vi.fn().mockReturnValue(params.clientName);
return this as unknown as Config;
});
});
it('should fetch admin controls and apply them', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {
const mockAdminSettings: Partial<FetchAdminControlsResponse> = {
mcpSetting: {
mcpEnabled: false,
},
@@ -127,7 +175,9 @@ describe('loadConfig', () => {
},
strictModeDisabled: false,
};
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(
mockAdminSettings as FetchAdminControlsResponse,
);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
@@ -141,137 +191,20 @@ describe('loadConfig', () => {
}),
);
});
it('should treat unset admin settings as false when admin settings are passed', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: true,
},
};
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenLastCalledWith(
expect.objectContaining({
disableYoloMode: !false,
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
extensionsEnabled: undefined,
}),
);
});
it('should not pass default unset admin settings when no admin settings are present', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {};
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenLastCalledWith(expect.objectContaining({}));
});
it('should fetch admin controls using the code assist server when available', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: true,
},
strictModeDisabled: true,
};
const mockCodeAssistServer = { projectId: 'test-project' };
vi.mocked(getCodeAssistServer).mockReturnValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockCodeAssistServer as any,
);
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(fetchAdminControlsOnce).toHaveBeenCalledWith(
mockCodeAssistServer,
true,
);
expect(Config).toHaveBeenLastCalledWith(
expect.objectContaining({
disableYoloMode: !mockAdminSettings.strictModeDisabled,
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
extensionsEnabled: undefined,
}),
);
});
});
});
it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
const testPath = '/tmp/ignore';
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
testPath,
]);
});
it('should set customIgnoreFilePaths when settings.fileFiltering.customIgnoreFilePaths is present', async () => {
const testPath = '/settings/ignore';
const settings: Settings = {
fileFiltering: {
customIgnoreFilePaths: [testPath],
},
};
const config = await loadConfig(settings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
testPath,
]);
});
it('should merge customIgnoreFilePaths from settings and env var', async () => {
const envPath = '/env/ignore';
const settingsPath = '/settings/ignore';
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', envPath);
const settings: Settings = {
fileFiltering: {
customIgnoreFilePaths: [settingsPath],
},
};
const config = await loadConfig(settings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
settingsPath,
envPath,
]);
});
it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => {
const paths = ['/path/one', '/path/two'];
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', paths.join(path.delimiter));
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths);
});
it('should have empty customIgnoreFilePaths when both are missing', async () => {
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
});
it('should initialize FileDiscoveryService with correct options', async () => {
const testPath = '/tmp/ignore';
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
const settings: Settings = {
fileFiltering: {
respectGitIgnore: false,
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(FileDiscoveryService).toHaveBeenCalledWith(expect.any(String), {
respectGitIgnore: false,
respectGeminiIgnore: undefined,
customIgnoreFilePaths: [testPath],
});
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
fileFiltering: expect.objectContaining({
customIgnoreFilePaths: paths,
}),
}),
);
});
describe('tool configuration', () => {
@@ -286,248 +219,5 @@ describe('loadConfig', () => {
}),
);
});
it('should pass V2 tools.allowed to Config properly', async () => {
const settings: Settings = {
tools: {
allowed: ['shell', 'fetch'],
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
allowedTools: ['shell', 'fetch'],
}),
);
});
it('should prefer V1 allowedTools over V2 tools.allowed if both present', async () => {
const settings: Settings = {
allowedTools: ['v1-tool'],
tools: {
allowed: ['v2-tool'],
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
allowedTools: ['v1-tool'],
}),
);
});
describe('interactivity', () => {
it('should set interactive true when not headless', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: true,
enableInteractiveShell: true,
}),
);
});
it('should set interactive false when headless', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: false,
enableInteractiveShell: false,
}),
);
});
});
describe('authentication fallback', () => {
beforeEach(() => {
vi.stubEnv('USE_CCPA', 'true');
vi.stubEnv('GEMINI_API_KEY', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('Non-interactive session');
}
return Promise.resolve();
});
// Update the mock implementation for this test
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('Non-interactive session');
}
return Promise.resolve();
});
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow('Non-interactive session');
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(true);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => {
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.',
);
expect(refreshAuthMock).not.toHaveBeenCalled();
});
it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth failed');
}
if (authType === AuthType.COMPUTE_ADC) {
throw new Error('ADC failed');
}
return Promise.resolve();
});
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed',
);
});
});
});
});
+1
View File
@@ -62,6 +62,7 @@ export async function loadConfig(
const configParams: ConfigParameters = {
sessionId: taskId,
clientName: 'a2a-server',
model: PREVIEW_GEMINI_MODEL,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
+22
View File
@@ -112,6 +112,14 @@ vi.mock('@google/gemini-cli-core', async () => {
}),
},
loadEnvironment: vi.fn(),
detectIdeFromEnv: vi.fn().mockImplementation(() => {
if (process.env['TERM_PROGRAM'] === 'Zed')
return actualServer.IDE_DEFINITIONS.zed;
if (process.env['XCODE_VERSION_ACTUAL'])
return actualServer.IDE_DEFINITIONS.xcode;
return actualServer.IDE_DEFINITIONS.vscode;
}),
IDE_DEFINITIONS: actualServer.IDE_DEFINITIONS,
loadServerHierarchicalMemory: vi.fn(
(
cwd,
@@ -311,6 +319,20 @@ describe('parseArguments', () => {
});
});
it('should set clientName to acp-vscode when using --acp flag', async () => {
process.argv = ['node', 'script.js', '--acp'];
// Mock TERM_PROGRAM to ensure a known IDE is detected (default is vscode if nothing else matches)
vi.stubEnv('TERM_PROGRAM', 'vscode');
const args = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
'test-session',
args,
);
expect(config.getClientName()).toBe('acp-vscode');
});
it.each([
{
description:
+10 -13
View File
@@ -7,7 +7,6 @@
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import * as path from 'node:path';
import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
import { skillsCommand } from '../commands/skills.js';
@@ -34,9 +33,9 @@ import {
getAdminErrorMessage,
isHeadlessMode,
Config,
resolveToRealPath,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
detectIdeFromEnv,
type HookDefinition,
type HookEventName,
type OutputFormat,
@@ -490,15 +489,6 @@ export async function loadCliConfig(
const experimentalJitContext = settings.experimental?.jitContext ?? false;
let extensionRegistryURI: string | undefined = trustedFolder
? settings.experimental?.extensionRegistryURI
: undefined;
if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) {
extensionRegistryURI = resolveToRealPath(
path.resolve(cwd, resolvePath(extensionRegistryURI)),
);
}
let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
let filePaths: string[] = [];
@@ -704,8 +694,16 @@ export async function loadCliConfig(
}
}
const acpMode = !!argv.acp || !!argv.experimentalAcp;
let clientName: string | undefined = undefined;
if (acpMode) {
const ide = detectIdeFromEnv();
clientName = `acp-${ide.name}`;
}
return new Config({
acpMode: !!argv.acp || !!argv.experimentalAcp,
acpMode,
clientName,
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -775,7 +773,6 @@ export async function loadCliConfig(
deleteSession: argv.deleteSession,
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
extensionRegistryURI,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
@@ -118,6 +118,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
ExtensionInstallEvent: vi.fn(),
ExtensionUninstallEvent: vi.fn(),
ExtensionDisableEvent: vi.fn(),
ExtensionUpdateEvent: vi.fn(),
KeychainTokenStorage: vi.fn().mockImplementation(() => ({
getSecret: vi.fn(),
setSecret: vi.fn(),
+38 -45
View File
@@ -476,6 +476,7 @@ export interface PolicyUpdateConfirmationRequest {
export interface ConfigParameters {
sessionId: string;
clientName?: string;
clientVersion?: string;
embeddingModel?: string;
sandbox?: SandboxConfig;
@@ -550,11 +551,9 @@ export interface ConfigParameters {
skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig;
extensionManagement?: boolean;
extensionRegistryURI?: string;
truncateToolOutputThreshold?: number;
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
workspacePoliciesDir?: string;
policyEngineConfig?: PolicyEngineConfig;
directWebFetch?: boolean;
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
@@ -620,6 +619,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly acknowledgedAgentsService: AcknowledgedAgentsService;
private skillManager!: SkillManager;
private _sessionId: string;
private readonly clientName: string | undefined;
private clientVersion: string;
private fileSystemService: FileSystemService;
private trackerService?: TrackerService;
@@ -739,7 +739,6 @@ export class Config implements McpContext, AgentLoopContext {
private readonly useAlternateBuffer: boolean;
private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true;
private readonly extensionRegistryURI: string | undefined;
private readonly truncateToolOutputThreshold: number;
private compressionTruncationCounter = 0;
private initialized = false;
@@ -749,7 +748,6 @@ export class Config implements McpContext, AgentLoopContext {
private readonly fileExclusions: FileExclusions;
private readonly eventEmitter?: EventEmitter;
private readonly useWriteTodos: boolean;
private readonly workspacePoliciesDir: string | undefined;
private readonly _messageBus: MessageBus;
private readonly policyEngine: PolicyEngine;
private policyUpdateConfirmationRequest:
@@ -817,6 +815,7 @@ export class Config implements McpContext, AgentLoopContext {
constructor(params: ConfigParameters) {
this._sessionId = params.sessionId;
this.clientName = params.clientName;
this.clientVersion = params.clientVersion ?? 'unknown';
this.approvedPlanPath = undefined;
this.embeddingModel =
@@ -960,7 +959,6 @@ export class Config implements McpContext, AgentLoopContext {
this.useWriteTodos = isPreviewModel(this.model)
? false
: (params.useWriteTodos ?? true);
this.workspacePoliciesDir = params.workspacePoliciesDir;
this.enableHooksUI = params.enableHooksUI ?? true;
this.enableHooks = params.enableHooks ?? true;
this.disabledHooks = params.disabledHooks ?? [];
@@ -971,7 +969,6 @@ export class Config implements McpContext, AgentLoopContext {
this.shellToolInactivityTimeout =
(params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes
this.extensionManagement = params.extensionManagement ?? true;
this.extensionRegistryURI = params.extensionRegistryURI;
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
this.storage = new Storage(this.targetDir, this._sessionId);
this.storage.setCustomPlansDir(params.planSettings?.directory);
@@ -1024,7 +1021,7 @@ export class Config implements McpContext, AgentLoopContext {
params.gemmaModelRouter?.classifier?.model ?? 'gemma3-1b-gpu-custom',
},
};
this.retryFetchErrors = params.retryFetchErrors ?? true;
this.retryFetchErrors = params.retryFetchErrors ?? false;
this.maxAttempts = Math.min(
params.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
DEFAULT_MAX_ATTEMPTS,
@@ -1100,10 +1097,6 @@ export class Config implements McpContext, AgentLoopContext {
);
}
get config(): Config {
return this;
}
isInitialized(): boolean {
return this.initialized;
}
@@ -1197,7 +1190,7 @@ export class Config implements McpContext, AgentLoopContext {
if (this.getSkillManager().getSkills().length > 0) {
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
this.getToolRegistry().registerTool(
new ActivateSkillTool(this, this.messageBus),
new ActivateSkillTool(this, this._messageBus),
);
}
}
@@ -1847,10 +1840,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.extensionsEnabled;
}
getExtensionRegistryURI(): string | undefined {
return this.extensionRegistryURI;
}
getMcpClientManager(): McpClientManager | undefined {
return this.mcpClientManager;
}
@@ -2013,10 +2002,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.geminiMdFilePaths;
}
getWorkspacePoliciesDir(): string | undefined {
return this.workspacePoliciesDir;
}
setGeminiMdFilePaths(paths: string[]): void {
this.geminiMdFilePaths = paths;
}
@@ -2390,6 +2375,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.ideMode;
}
getClientName(): string | undefined {
return this.clientName;
}
/**
* Returns 'true' if the folder trust feature is enabled.
*/
@@ -2639,7 +2628,7 @@ export class Config implements McpContext, AgentLoopContext {
if (this.getSkillManager().getSkills().length > 0) {
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
this.getToolRegistry().registerTool(
new ActivateSkillTool(this, this.messageBus),
new ActivateSkillTool(this, this._messageBus),
);
} else {
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
@@ -2823,7 +2812,7 @@ export class Config implements McpContext, AgentLoopContext {
}
async createToolRegistry(): Promise<ToolRegistry> {
const registry = new ToolRegistry(this, this.messageBus);
const registry = new ToolRegistry(this, this._messageBus);
// helper to create & register core tools that are enabled
const maybeRegister = (
@@ -2853,10 +2842,10 @@ export class Config implements McpContext, AgentLoopContext {
};
maybeRegister(LSTool, () =>
registry.registerTool(new LSTool(this, this.messageBus)),
registry.registerTool(new LSTool(this, this._messageBus)),
);
maybeRegister(ReadFileTool, () =>
registry.registerTool(new ReadFileTool(this, this.messageBus)),
registry.registerTool(new ReadFileTool(this, this._messageBus)),
);
if (this.getUseRipgrep()) {
@@ -2869,81 +2858,85 @@ export class Config implements McpContext, AgentLoopContext {
}
if (useRipgrep) {
maybeRegister(RipGrepTool, () =>
registry.registerTool(new RipGrepTool(this, this.messageBus)),
registry.registerTool(new RipGrepTool(this, this._messageBus)),
);
} else {
logRipgrepFallback(this, new RipgrepFallbackEvent(errorString));
maybeRegister(GrepTool, () =>
registry.registerTool(new GrepTool(this, this.messageBus)),
registry.registerTool(new GrepTool(this, this._messageBus)),
);
}
} else {
maybeRegister(GrepTool, () =>
registry.registerTool(new GrepTool(this, this.messageBus)),
registry.registerTool(new GrepTool(this, this._messageBus)),
);
}
maybeRegister(GlobTool, () =>
registry.registerTool(new GlobTool(this, this.messageBus)),
registry.registerTool(new GlobTool(this, this._messageBus)),
);
maybeRegister(ActivateSkillTool, () =>
registry.registerTool(new ActivateSkillTool(this, this.messageBus)),
registry.registerTool(new ActivateSkillTool(this, this._messageBus)),
);
maybeRegister(EditTool, () =>
registry.registerTool(new EditTool(this, this.messageBus)),
registry.registerTool(new EditTool(this, this._messageBus)),
);
maybeRegister(WriteFileTool, () =>
registry.registerTool(new WriteFileTool(this, this.messageBus)),
registry.registerTool(new WriteFileTool(this, this._messageBus)),
);
maybeRegister(WebFetchTool, () =>
registry.registerTool(new WebFetchTool(this, this.messageBus)),
registry.registerTool(new WebFetchTool(this, this._messageBus)),
);
maybeRegister(ShellTool, () =>
registry.registerTool(new ShellTool(this, this.messageBus)),
registry.registerTool(new ShellTool(this, this._messageBus)),
);
maybeRegister(MemoryTool, () =>
registry.registerTool(new MemoryTool(this.messageBus)),
registry.registerTool(new MemoryTool(this._messageBus)),
);
maybeRegister(WebSearchTool, () =>
registry.registerTool(new WebSearchTool(this, this.messageBus)),
registry.registerTool(new WebSearchTool(this, this._messageBus)),
);
maybeRegister(AskUserTool, () =>
registry.registerTool(new AskUserTool(this.messageBus)),
registry.registerTool(new AskUserTool(this._messageBus)),
);
if (this.getUseWriteTodos()) {
maybeRegister(WriteTodosTool, () =>
registry.registerTool(new WriteTodosTool(this.messageBus)),
registry.registerTool(new WriteTodosTool(this._messageBus)),
);
}
if (this.isPlanEnabled()) {
maybeRegister(ExitPlanModeTool, () =>
registry.registerTool(new ExitPlanModeTool(this, this.messageBus)),
registry.registerTool(new ExitPlanModeTool(this, this._messageBus)),
);
maybeRegister(EnterPlanModeTool, () =>
registry.registerTool(new EnterPlanModeTool(this, this.messageBus)),
registry.registerTool(new EnterPlanModeTool(this, this._messageBus)),
);
}
if (this.isTrackerEnabled()) {
maybeRegister(TrackerCreateTaskTool, () =>
registry.registerTool(new TrackerCreateTaskTool(this, this.messageBus)),
registry.registerTool(
new TrackerCreateTaskTool(this, this._messageBus),
),
);
maybeRegister(TrackerUpdateTaskTool, () =>
registry.registerTool(new TrackerUpdateTaskTool(this, this.messageBus)),
registry.registerTool(
new TrackerUpdateTaskTool(this, this._messageBus),
),
);
maybeRegister(TrackerGetTaskTool, () =>
registry.registerTool(new TrackerGetTaskTool(this, this.messageBus)),
registry.registerTool(new TrackerGetTaskTool(this, this._messageBus)),
);
maybeRegister(TrackerListTasksTool, () =>
registry.registerTool(new TrackerListTasksTool(this, this.messageBus)),
registry.registerTool(new TrackerListTasksTool(this, this._messageBus)),
);
maybeRegister(TrackerAddDependencyTool, () =>
registry.registerTool(
new TrackerAddDependencyTool(this, this.messageBus),
new TrackerAddDependencyTool(this, this._messageBus),
),
);
maybeRegister(TrackerVisualizeTool, () =>
registry.registerTool(new TrackerVisualizeTool(this, this.messageBus)),
registry.registerTool(new TrackerVisualizeTool(this, this._messageBus)),
);
}
@@ -32,6 +32,7 @@ const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
describe('createContentGenerator', () => {
@@ -52,6 +53,7 @@ describe('createContentGenerator', () => {
const fakeResponsesFile = 'fake/responses.yaml';
const mockConfigWithFake = {
fakeResponses: fakeResponsesFile,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const generator = await createContentGenerator(
{
@@ -73,6 +75,7 @@ describe('createContentGenerator', () => {
const mockConfigWithRecordResponses = {
fakeResponses: fakeResponsesFile,
recordResponses: recordResponsesFile,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const generator = await createContentGenerator(
{
@@ -122,6 +125,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => true,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
// Set a fixed version for testing
@@ -188,6 +192,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -229,11 +234,46 @@ describe('createContentGenerator', () => {
);
});
it('should include clientName in User-Agent if provided', async () => {
const mockConfigWithClientName = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getClientName: vi.fn().mockReturnValue('a2a-server'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
await createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
},
mockConfigWithClientName,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.stringMatching(
/GeminiCLI-a2a-server\/.*\/gemini-pro \(.*; .*; .*\)/,
),
}),
}),
}),
);
});
it('should pass api key as Authorization Header when GEMINI_API_KEY_AUTH_MECHANISM is set to bearer', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -267,6 +307,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -308,6 +349,7 @@ describe('createContentGenerator', () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
@@ -339,6 +381,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -372,6 +415,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -409,6 +453,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -447,6 +492,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
+7 -1
View File
@@ -22,6 +22,7 @@ import { LoggingContentGenerator } from './loggingContentGenerator.js';
import { InstallationManager } from '../utils/installationManager.js';
import { FakeContentGenerator } from './fakeContentGenerator.js';
import { parseCustomHeaders } from '../utils/customHeaderUtils.js';
import { determineSurface } from '../utils/surface.js';
import { RecordingContentGenerator } from './recordingContentGenerator.js';
import { getVersion, resolveModel } from '../../index.js';
import type { LlmRole } from '../telemetry/llmRole.js';
@@ -173,7 +174,12 @@ export async function createContentGenerator(
);
const customHeadersEnv =
process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined;
const userAgent = `GeminiCLI/${version}/${model} (${process.platform}; ${process.arch})`;
const clientName = gcConfig.getClientName();
const userAgentPrefix = clientName
? `GeminiCLI-${clientName}`
: 'GeminiCLI';
const surface = determineSurface();
const userAgent = `${userAgentPrefix}/${version}/${model} (${process.platform}; ${process.arch}; ${surface})`;
const customHeadersMap = parseCustomHeaders(customHeadersEnv);
const apiKeyAuthMechanism =
process.env['GEMINI_API_KEY_AUTH_MECHANISM'] || 'x-goog-api-key';
+16 -1
View File
@@ -5,7 +5,7 @@
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { detectIde, IDE_DEFINITIONS } from './detect-ide.js';
import { detectIde, IDE_DEFINITIONS, detectIdeFromEnv } from './detect-ide.js';
beforeEach(() => {
// Ensure Antigravity detection doesn't interfere with other tests
@@ -97,6 +97,21 @@ describe('detectIde', () => {
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.firebasestudio);
});
it('should detect Zed via ZED_SESSION_ID', () => {
vi.stubEnv('ZED_SESSION_ID', 'some-id');
expect(detectIdeFromEnv()).toBe(IDE_DEFINITIONS.zed);
});
it('should detect Zed via TERM_PROGRAM', () => {
vi.stubEnv('TERM_PROGRAM', 'Zed');
expect(detectIdeFromEnv()).toBe(IDE_DEFINITIONS.zed);
});
it('should detect XCode via XCODE_VERSION_ACTUAL', () => {
vi.stubEnv('XCODE_VERSION_ACTUAL', '1500');
expect(detectIdeFromEnv()).toBe(IDE_DEFINITIONS.xcode);
});
it('should detect VSCode when no other IDE is detected and command includes "code"', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('MONOSPACE_ENV', '');
+8
View File
@@ -27,6 +27,8 @@ export const IDE_DEFINITIONS = {
rustrover: { name: 'rustrover', displayName: 'RustRover' },
datagrip: { name: 'datagrip', displayName: 'DataGrip' },
phpstorm: { name: 'phpstorm', displayName: 'PhpStorm' },
zed: { name: 'zed', displayName: 'Zed' },
xcode: { name: 'xcode', displayName: 'XCode' },
} as const;
export interface IdeInfo {
@@ -75,6 +77,12 @@ export function detectIdeFromEnv(): IdeInfo {
if (process.env['TERM_PROGRAM'] === 'sublime') {
return IDE_DEFINITIONS.sublimetext;
}
if (process.env['ZED_SESSION_ID'] || process.env['TERM_PROGRAM'] === 'Zed') {
return IDE_DEFINITIONS.zed;
}
if (process.env['XCODE_VERSION_ACTUAL']) {
return IDE_DEFINITIONS.xcode;
}
if (isJetBrains()) {
return IDE_DEFINITIONS.jetbrains;
}
+54
View File
@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { detectIdeFromEnv } from '../ide/detect-ide.js';
/** Default surface value when no IDE/environment is detected. */
export const SURFACE_NOT_SET = 'terminal';
/**
* Determines the surface/distribution channel the CLI is running in.
*
* Priority:
* 1. `GEMINI_CLI_SURFACE` env var (first-class override for enterprise customers)
* 2. `SURFACE` env var (legacy override, kept for backward compatibility)
* 3. Auto-detection via environment variables (Cloud Shell, GitHub Actions, IDE, etc.)
*
* @returns A human-readable surface identifier (e.g., "vscode", "cursor", "terminal").
*/
export function determineSurface(): string {
// Priority 1 & 2: Explicit overrides from environment variables.
const customSurface =
process.env['GEMINI_CLI_SURFACE'] || process.env['SURFACE'];
if (customSurface) {
return customSurface;
}
// Priority 3: Auto-detect IDE/environment.
const ide = detectIdeFromEnv();
// `detectIdeFromEnv` falls back to 'vscode' for generic terminals.
// If a specific IDE (e.g., Cloud Shell, Cursor, JetBrains) was detected,
// its name will be something other than 'vscode', and we can use it directly.
if (ide.name !== 'vscode') {
return ide.name;
}
// If the detected IDE is 'vscode', we only accept it if TERM_PROGRAM confirms it.
// This prevents generic terminals from being misidentified as VSCode.
if (process.env['TERM_PROGRAM'] === 'vscode') {
return ide.name;
}
// Priority 4: GitHub Actions (checked after IDE detection so that
// specific environments like Cloud Shell take precedence).
if (process.env['GITHUB_SHA']) {
return 'GitHub';
}
// Priority 5: Fallback for all other cases (e.g., a generic terminal).
return SURFACE_NOT_SET;
}