mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 21:21:09 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b545258c4c | |||
| 5cac7c10fa | |||
| 41c9260cae | |||
| 8b56d27901 | |||
| 85563dabe8 |
Generated
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -18117,7 +18117,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.19.0",
|
||||
@@ -18246,7 +18246,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
@@ -18394,7 +18394,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -18674,7 +18674,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -18689,7 +18689,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18720,7 +18720,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18752,7 +18752,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.45.0-nightly.20260521.g854f811be"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.45.0-preview.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
+13
-9
@@ -17,17 +17,21 @@ import {
|
||||
|
||||
// --- Global Entry Point ---
|
||||
|
||||
// Suppress known race condition error in node-pty on Windows
|
||||
// Suppress known race condition error in node-pty on Windows and Linux
|
||||
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
|
||||
process.on('uncaughtException', (error) => {
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
error instanceof Error &&
|
||||
error.message === 'Cannot resize a pty that has already exited'
|
||||
) {
|
||||
// This error happens on Windows with node-pty when resizing a pty that has just exited.
|
||||
// It is a race condition in node-pty that we cannot prevent, so we silence it.
|
||||
return;
|
||||
if (error instanceof Error) {
|
||||
const isPtyResizeError =
|
||||
error.message === 'Cannot resize a pty that has already exited';
|
||||
const isEbadfError = error.message.includes('EBADF');
|
||||
const isFromNodePty =
|
||||
error.stack?.includes('node-pty') || error.stack?.includes('PtyResize');
|
||||
|
||||
if ((isPtyResizeError || isEbadfError) && isFromNodePty) {
|
||||
// This error happens with node-pty when resizing a pty that has just exited.
|
||||
// It is a race condition in node-pty that we cannot prevent, so we silence it.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For other errors, we rely on the default behavior, but since we attached a listener,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.45.0-nightly.20260521.g854f811be"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.45.0-preview.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -475,4 +475,258 @@ describe('mcp list command', () => {
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block servers excluded by user settings even if workspace settings override/clear the excluded list', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
user: {
|
||||
path: '/user/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
excluded: ['blocked-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
excluded: ['blocked-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
path: '/workspace/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
excluded: [],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
excluded: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'blocked-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
merged: {
|
||||
mcp: {
|
||||
excluded: [], // workspace has overridden user settings!
|
||||
},
|
||||
mcpServers: {
|
||||
'blocked-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'blocked-server: /test/server (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block servers case-insensitively when excluded', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
user: {
|
||||
path: '/user/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
excluded: ['BLOCKED-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
excluded: ['BLOCKED-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'blocked-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
merged: {
|
||||
mcpServers: {
|
||||
'blocked-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'blocked-server: /test/server (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should restrict allowed servers to the intersection of all defined allowlists', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
user: {
|
||||
path: '/user/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'allowed-server-2'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'allowed-server-2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
path: '/workspace/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'malicious-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'malicious-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'allowed-server-1': { command: '/allowed/1' },
|
||||
'allowed-server-2': { command: '/allowed/2' },
|
||||
'malicious-server': { command: '/malicious' },
|
||||
},
|
||||
isTrusted: true,
|
||||
merged: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'malicious-server'], // workspace overrode user settings!
|
||||
},
|
||||
mcpServers: {
|
||||
'allowed-server-1': { command: '/allowed/1' },
|
||||
'allowed-server-2': { command: '/allowed/2' },
|
||||
'malicious-server': { command: '/malicious' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
// allowed-server-1 is in the intersection, so it should connect
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'allowed-server-1: /allowed/1 (stdio) - Connected',
|
||||
),
|
||||
);
|
||||
// allowed-server-2 and malicious-server are not in the intersection, so they should be Blocked
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'allowed-server-2: /allowed/2 (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'malicious-server: /malicious (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
|
||||
expect(mockedCreateTransport).toHaveBeenCalledTimes(1);
|
||||
expect(mockedCreateTransport).toHaveBeenCalledWith(
|
||||
'allowed-server-1',
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should block all servers if the intersection of user and workspace allowlists is empty (disjoint allowlists)', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
user: {
|
||||
path: '/user/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
allowed: ['user-allowed-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
allowed: ['user-allowed-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
path: '/workspace/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
allowed: ['workspace-allowed-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
allowed: ['workspace-allowed-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'user-allowed-server': { command: '/allowed/user' },
|
||||
'workspace-allowed-server': { command: '/allowed/workspace' },
|
||||
},
|
||||
isTrusted: true,
|
||||
merged: {
|
||||
mcp: {
|
||||
allowed: ['workspace-allowed-server'], // workspace override
|
||||
},
|
||||
mcpServers: {
|
||||
'user-allowed-server': { command: '/allowed/user' },
|
||||
'workspace-allowed-server': { command: '/allowed/workspace' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
// Since the intersection is empty ([]), both servers should be Blocked!
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'user-allowed-server: /allowed/user (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'workspace-allowed-server: /allowed/workspace (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block all servers if allowlist is configured as empty array []', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
mcp: {
|
||||
allowed: [], // empty allowlist configured!
|
||||
},
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-server: /test/server (stdio) - Blocked'),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,12 +159,16 @@ async function getServerStatus(
|
||||
server: MCPServerConfig,
|
||||
isTrusted: boolean,
|
||||
activeSettings: MergedSettings,
|
||||
consolidatedExcluded: string[],
|
||||
consolidatedAllowed: string[] | undefined,
|
||||
): Promise<MCPServerStatus> {
|
||||
const mcpEnablementManager = McpServerEnablementManager.getInstance();
|
||||
|
||||
const loadResult = await canLoadServer(serverName, {
|
||||
adminMcpEnabled: activeSettings.admin?.mcp?.enabled ?? true,
|
||||
allowedList: activeSettings.mcp?.allowed,
|
||||
excludedList: activeSettings.mcp?.excluded,
|
||||
allowedList: consolidatedAllowed,
|
||||
excludedList:
|
||||
consolidatedExcluded.length > 0 ? consolidatedExcluded : undefined,
|
||||
enablement: mcpEnablementManager.getEnablementCallbacks(),
|
||||
});
|
||||
|
||||
@@ -227,6 +231,10 @@ export async function listMcpServers(
|
||||
);
|
||||
}
|
||||
|
||||
const consolidatedExcluded =
|
||||
loadedSettings.getConsolidatedExcludedMcpServers();
|
||||
const consolidatedAllowed = loadedSettings.getConsolidatedAllowedMcpServers();
|
||||
|
||||
debugLogger.log('Configured MCP servers:\n');
|
||||
|
||||
for (const serverName of serverNames) {
|
||||
@@ -237,6 +245,8 @@ export async function listMcpServers(
|
||||
server,
|
||||
loadedSettings.isTrusted,
|
||||
activeSettings,
|
||||
consolidatedExcluded,
|
||||
consolidatedAllowed,
|
||||
);
|
||||
|
||||
let statusIndicator = '';
|
||||
|
||||
@@ -576,6 +576,7 @@ export interface LoadCliConfigOptions {
|
||||
};
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
skipExtensions?: boolean;
|
||||
loadedSettings?: LoadedSettings;
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
@@ -584,7 +585,12 @@ export async function loadCliConfig(
|
||||
argv: CliArgs,
|
||||
options: LoadCliConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
const { cwd = process.cwd(), projectHooks, skipExtensions = false } = options;
|
||||
const {
|
||||
cwd = process.cwd(),
|
||||
projectHooks,
|
||||
skipExtensions = false,
|
||||
loadedSettings,
|
||||
} = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const worktreeSettings =
|
||||
@@ -985,12 +991,17 @@ export async function loadCliConfig(
|
||||
agents: settings.agents,
|
||||
adminSkillsEnabled,
|
||||
allowedMcpServers: mcpEnabled
|
||||
? (argv.allowedMcpServerNames ?? settings.mcp?.allowed)
|
||||
? (argv.allowedMcpServerNames ??
|
||||
(loadedSettings
|
||||
? loadedSettings.getConsolidatedAllowedMcpServers()
|
||||
: settings.mcp?.allowed))
|
||||
: undefined,
|
||||
blockedMcpServers: mcpEnabled
|
||||
? argv.allowedMcpServerNames
|
||||
? undefined
|
||||
: settings.mcp?.excluded
|
||||
: loadedSettings
|
||||
? loadedSettings.getConsolidatedExcludedMcpServers()
|
||||
: settings.mcp?.excluded
|
||||
: undefined,
|
||||
blockedEnvironmentVariables:
|
||||
settings.security?.environmentVariableRedaction?.blocked,
|
||||
|
||||
@@ -119,7 +119,7 @@ export async function canLoadServer(
|
||||
}
|
||||
|
||||
// 2. Allowlist check
|
||||
if (config.allowedList && config.allowedList.length > 0) {
|
||||
if (config.allowedList !== undefined) {
|
||||
const { found, deprecationWarning } = isInSettingsList(
|
||||
normalizedId,
|
||||
config.allowedList,
|
||||
|
||||
@@ -1109,6 +1109,77 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoadedSettings MCP consolidation', () => {
|
||||
it('should consolidate mcp excluded list across all scopes', () => {
|
||||
const loaded = new LoadedSettings(
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { excluded: ['system-excluded'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { excluded: ['defaults-excluded'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { excluded: ['user-excluded'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { excluded: ['workspace-excluded'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(loaded.getConsolidatedExcludedMcpServers()).toEqual([
|
||||
'system-excluded',
|
||||
'defaults-excluded',
|
||||
'user-excluded',
|
||||
'workspace-excluded',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should consolidate allowed mcp list via case-insensitive intersection', () => {
|
||||
const loaded = new LoadedSettings(
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { allowed: ['Server-A', 'Server-B'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { allowed: ['server-a', 'Server-C'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
{ path: '', settings: {}, originalSettings: {} }, // no allowlist in user
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { allowed: ['SERVER-A', 'Server-D'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(loaded.getConsolidatedAllowedMcpServers()).toEqual(['Server-A']);
|
||||
});
|
||||
|
||||
it('should return undefined allowed list if no scopes define one', () => {
|
||||
const loaded = new LoadedSettings(
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
true,
|
||||
);
|
||||
|
||||
expect(loaded.getConsolidatedAllowedMcpServers()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compressionThreshold settings', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -509,6 +509,51 @@ export class LoadedSettings {
|
||||
this._remoteAdminSettings = { admin };
|
||||
this._merged = this.computeMergedSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a consolidated list of excluded MCP servers across all settings files.
|
||||
*/
|
||||
getConsolidatedExcludedMcpServers(): string[] {
|
||||
const scopes = [
|
||||
this.system,
|
||||
this.systemDefaults,
|
||||
this.user,
|
||||
this.workspace,
|
||||
];
|
||||
return scopes.flatMap((scope) => {
|
||||
const excluded = scope?.settings?.mcp?.excluded;
|
||||
return Array.isArray(excluded) ? excluded : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a consolidated list of allowed MCP servers (via intersection of all defined lists).
|
||||
*/
|
||||
getConsolidatedAllowedMcpServers(): string[] | undefined {
|
||||
const scopes = [
|
||||
this.system,
|
||||
this.systemDefaults,
|
||||
this.user,
|
||||
this.workspace,
|
||||
];
|
||||
const definedAllowlists = scopes.flatMap((scope) => {
|
||||
const allowed = scope?.settings?.mcp?.allowed;
|
||||
return Array.isArray(allowed) ? [allowed] : [];
|
||||
});
|
||||
|
||||
if (definedAllowlists.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return definedAllowlists.reduce((acc, current) => {
|
||||
const normalizedCurrent = new Set(
|
||||
current.map((item) => item.toLowerCase().trim()),
|
||||
);
|
||||
return acc.filter((item) =>
|
||||
normalizedCurrent.has(item.toLowerCase().trim()),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function findEnvFile(
|
||||
|
||||
@@ -499,6 +499,7 @@ export async function main() {
|
||||
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
skipExtensions: true,
|
||||
loadedSettings: settings,
|
||||
});
|
||||
|
||||
adminControlsListner.setConfig(partialConfig);
|
||||
@@ -627,6 +628,7 @@ export async function main() {
|
||||
config = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
worktreeSettings: worktreeInfo,
|
||||
loadedSettings: settings,
|
||||
});
|
||||
loadConfigHandle?.end();
|
||||
|
||||
|
||||
@@ -81,4 +81,24 @@ describe('useVim passthrough', () => {
|
||||
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['H', 'M', 'Q', 'm'])(
|
||||
'should ignore unmapped printable key %s in NORMAL mode',
|
||||
async (sequence) => {
|
||||
mockVimContext.vimMode = 'NORMAL';
|
||||
const { result } = await renderHook(() =>
|
||||
useVim(mockBuffer as TextBuffer),
|
||||
);
|
||||
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = result.current.handleInput(
|
||||
createKey({ name: sequence, sequence, insertable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1486,8 +1486,14 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
// Unknown command, clear count and pending states
|
||||
dispatch({ type: 'CLEAR_PENDING_STATES' });
|
||||
|
||||
// Ignore any Insertable key in Normal Mode
|
||||
if (normalizedKey.insertable) {
|
||||
// Ignore unmapped Insertable keys in Normal Mode, but let
|
||||
// modifier-key chords (ctrl/alt/cmd) fall through to other handlers.
|
||||
if (
|
||||
normalizedKey.insertable &&
|
||||
!normalizedKey.ctrl &&
|
||||
!normalizedKey.alt &&
|
||||
!normalizedKey.cmd
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -386,6 +386,97 @@ describe('ClassifierStrategy', () => {
|
||||
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return null (bypass classifier) if history is only tool turns and request is a function response', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ functionResponse: { name: 'tool', response: { ok: true } } }],
|
||||
},
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool2' } }] },
|
||||
];
|
||||
mockContext.history = history;
|
||||
mockContext.request = [
|
||||
{ functionResponse: { name: 'tool2', response: { ok: true } } },
|
||||
];
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
expect(decision).toBeNull();
|
||||
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null (bypass classifier) if history has text turns and request is a function response', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'some task' }] },
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
|
||||
];
|
||||
mockContext.history = history;
|
||||
mockContext.request = [
|
||||
{ functionResponse: { name: 'tool', response: { ok: true } } },
|
||||
];
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
expect(decision).toBeNull();
|
||||
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still route if history is only tool turns but request is text', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ functionResponse: { name: 'tool', response: { ok: true } } }],
|
||||
},
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool2' } }] },
|
||||
];
|
||||
mockContext.history = history;
|
||||
mockContext.request = [{ text: 'simple task' }];
|
||||
|
||||
const mockApiResponse = {
|
||||
reasoning: 'Simple.',
|
||||
model_choice: 'flash',
|
||||
};
|
||||
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
|
||||
mockApiResponse,
|
||||
);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
expect(decision).not.toBeNull();
|
||||
expect(mockBaseLlmClient.generateJson).toHaveBeenCalled();
|
||||
|
||||
const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock
|
||||
.calls[0][0];
|
||||
const contents = generateJsonCall.contents;
|
||||
|
||||
// History should be empty because all turns were tool turns and stripped.
|
||||
// Request should be present.
|
||||
const expectedContents = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'simple task' }],
|
||||
},
|
||||
];
|
||||
expect(contents).toEqual(expectedContents);
|
||||
});
|
||||
|
||||
describe('Gemini 3.1 and Custom Tools Routing', () => {
|
||||
it('should route to PREVIEW_GEMINI_3_1_MODEL when Gemini 3.1 is launched', async () => {
|
||||
vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true);
|
||||
|
||||
@@ -145,12 +145,22 @@ export class ClassifierStrategy implements RoutingStrategy {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO - Consider using function req/res if they help accuracy.
|
||||
// Bypass the classifier if the request is a function response.
|
||||
// Since we prune all tool turns from history, sending a function response
|
||||
// request would result in an invalid payload (missing the preceding function call).
|
||||
if (isFunctionResponse(createUserContent(context.request))) {
|
||||
debugLogger.log(
|
||||
'[Routing] Bypassing Classifier: request is FunctionResponse.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const promptId = getPromptIdWithFallback('classifier-router');
|
||||
|
||||
const historySlice = context.history.slice(-HISTORY_SEARCH_WINDOW);
|
||||
|
||||
// Filter out tool-related turns.
|
||||
// TODO - Consider using function req/res if they help accuracy.
|
||||
const cleanHistory = historySlice.filter(
|
||||
(content) => !isFunctionCall(content) && !isFunctionResponse(content),
|
||||
);
|
||||
|
||||
@@ -475,6 +475,105 @@ describe('NumericalClassifierStrategy', () => {
|
||||
expect(contents).toEqual(expectedContents);
|
||||
});
|
||||
|
||||
it('should return null (bypass classifier) if history is only tool turns and request is a function response', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ functionResponse: { name: 'tool', response: { ok: true } } }],
|
||||
},
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool2' } }] },
|
||||
];
|
||||
mockContext.history = history;
|
||||
mockContext.request = [
|
||||
{ functionResponse: { name: 'tool2', response: { ok: true } } },
|
||||
];
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
expect(decision).toBeNull();
|
||||
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still route if history is only tool turns but request is text', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ functionResponse: { name: 'tool', response: { ok: true } } }],
|
||||
},
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool2' } }] },
|
||||
];
|
||||
mockContext.history = history;
|
||||
mockContext.request = [{ text: 'simple task' }];
|
||||
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Simple.',
|
||||
complexity_score: 10,
|
||||
};
|
||||
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
|
||||
mockApiResponse,
|
||||
);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
expect(decision).not.toBeNull();
|
||||
expect(mockBaseLlmClient.generateJson).toHaveBeenCalled();
|
||||
|
||||
const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock
|
||||
.calls[0][0];
|
||||
const contents = generateJsonCall.contents;
|
||||
|
||||
// History should be empty because all turns were tool turns and stripped.
|
||||
// Request should be present.
|
||||
const expectedContents = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'simple task' }],
|
||||
},
|
||||
];
|
||||
expect(contents).toEqual(expectedContents);
|
||||
});
|
||||
|
||||
it('should still route if history has text turns and request is a function response', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'some task' }] },
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
|
||||
];
|
||||
mockContext.history = history;
|
||||
mockContext.request = [
|
||||
{ functionResponse: { name: 'tool', response: { ok: true } } },
|
||||
];
|
||||
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Simple.',
|
||||
complexity_score: 10,
|
||||
};
|
||||
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
|
||||
mockApiResponse,
|
||||
);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
expect(decision).not.toBeNull();
|
||||
expect(mockBaseLlmClient.generateJson).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve tool turns when they appear after a non-tool turn in the middle of history', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'turn 0 (before)' }] },
|
||||
|
||||
@@ -142,6 +142,19 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
|
||||
? context.request
|
||||
: [context.request];
|
||||
|
||||
// Bypass the classifier if the request is a function response and history is empty.
|
||||
// Since we prune leading tool turns, if the history becomes empty, sending a
|
||||
// function response request would result in an invalid payload (starts with function response).
|
||||
if (
|
||||
finalHistory.length === 0 &&
|
||||
isFunctionResponse(createUserContent(context.request))
|
||||
) {
|
||||
debugLogger.log(
|
||||
'[Routing] Bypassing NumericalClassifier: request is FunctionResponse but history is empty after slicing.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const sanitizedRequest = requestParts.map((part) => {
|
||||
if (typeof part === 'string') {
|
||||
return { text: part };
|
||||
|
||||
@@ -1517,12 +1517,13 @@ export class ShellExecutionService {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const err = e as { code?: string; message?: string };
|
||||
const isEsrch = err.code === 'ESRCH';
|
||||
const isEbadf = err.code === 'EBADF' || err.message?.includes('EBADF');
|
||||
const isWindowsPtyError = err.message?.includes(
|
||||
'Cannot resize a pty that has already exited',
|
||||
);
|
||||
|
||||
if (isEsrch || isWindowsPtyError) {
|
||||
// On Unix, we get an ESRCH error.
|
||||
if (isEsrch || isEbadf || isWindowsPtyError) {
|
||||
// On Unix, we get an ESRCH or EBADF error.
|
||||
// On Windows, we get a message-based error.
|
||||
// In both cases, it's safe to ignore.
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.45.0-nightly.20260521.g854f811be",
|
||||
"version": "0.45.0-preview.0",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user