mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-10 11:12:35 -07:00
feat: differentiate User-Agent for a2a-server and ACP clients
This commit is contained in:
+735
-957
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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', '');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user