Files
gemini-cli/packages/cli/src/config/extension.test.ts
christine betts e17f927a69 Add support for policy engine in extensions (#20049)
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-02-27 03:29:33 +00:00

2336 lines
76 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
type MockedFunction,
describe,
it,
expect,
beforeEach,
afterEach,
afterAll,
} from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
type GeminiCLIExtension,
ExtensionUninstallEvent,
ExtensionDisableEvent,
ExtensionEnableEvent,
KeychainTokenStorage,
loadAgentsFromDirectory,
loadSkillsFromDir,
getRealPath,
} from '@google/gemini-cli-core';
import {
loadSettings,
createTestMergedSettings,
SettingScope,
} from './settings.js';
import {
isWorkspaceTrusted,
resetTrustedFoldersForTesting,
} from './trustedFolders.js';
import { createExtension } from '../test-utils/createExtension.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { join } from 'node:path';
import {
EXTENSIONS_CONFIG_FILENAME,
EXTENSIONS_DIRECTORY_NAME,
INSTALL_METADATA_FILENAME,
} from './extensions/variables.js';
import { hashValue, ExtensionManager } from './extension-manager.js';
import { ExtensionStorage } from './extensions/storage.js';
import { INSTALL_WARNING_MESSAGE } from './extensions/consent.js';
import type { ExtensionSetting } from './extensions/extensionSettings.js';
const mockGit = {
clone: vi.fn(),
getRemotes: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
// Not a part of the actual API, but we need to use this to do the correct
// file system interactions.
path: vi.fn(),
};
const mockDownloadFromGithubRelease = vi.hoisted(() => vi.fn());
vi.mock('./extensions/github.js', async (importOriginal) => {
const original =
await importOriginal<typeof import('./extensions/github.js')>();
return {
...original,
downloadFromGitHubRelease: mockDownloadFromGithubRelease,
};
});
vi.mock('simple-git', () => ({
simpleGit: vi.fn((path: string) => {
mockGit.path.mockReturnValue(path);
return mockGit;
}),
}));
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
return {
...mockedOs,
homedir: mockHomedir,
};
});
vi.mock('./trustedFolders.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./trustedFolders.js')>();
return {
...actual,
isWorkspaceTrusted: vi.fn(),
};
});
const mockLogExtensionEnable = vi.hoisted(() => vi.fn());
const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn());
const mockLogExtensionUninstall = vi.hoisted(() => vi.fn());
const mockLogExtensionUpdateEvent = vi.hoisted(() => vi.fn());
const mockLogExtensionDisable = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
logExtensionEnable: mockLogExtensionEnable,
logExtensionInstallEvent: mockLogExtensionInstallEvent,
logExtensionUninstall: mockLogExtensionUninstall,
logExtensionUpdateEvent: mockLogExtensionUpdateEvent,
logExtensionDisable: mockLogExtensionDisable,
homedir: mockHomedir,
ExtensionEnableEvent: vi.fn(),
ExtensionInstallEvent: vi.fn(),
ExtensionUninstallEvent: vi.fn(),
ExtensionDisableEvent: vi.fn(),
KeychainTokenStorage: vi.fn().mockImplementation(() => ({
getSecret: vi.fn(),
setSecret: vi.fn(),
deleteSecret: vi.fn(),
listSecrets: vi.fn(),
isAvailable: vi.fn().mockResolvedValue(true),
})),
loadAgentsFromDirectory: vi
.fn()
.mockImplementation(async () => ({ agents: [], errors: [] })),
loadSkillsFromDir: vi.fn().mockImplementation(async () => []),
};
});
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
execSync: vi.fn(),
};
});
interface MockKeychainStorage {
getSecret: ReturnType<typeof vi.fn>;
setSecret: ReturnType<typeof vi.fn>;
deleteSecret: ReturnType<typeof vi.fn>;
listSecrets: ReturnType<typeof vi.fn>;
isAvailable: ReturnType<typeof vi.fn>;
}
describe('extension tests', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let userExtensionsDir: string;
let extensionManager: ExtensionManager;
let mockRequestConsent: MockedFunction<(consent: string) => Promise<boolean>>;
let mockPromptForSettings: MockedFunction<
(setting: ExtensionSetting) => Promise<string>
>;
let mockKeychainStorage: MockKeychainStorage;
let keychainData: Record<string, string>;
beforeEach(() => {
vi.clearAllMocks();
keychainData = {};
mockKeychainStorage = {
getSecret: vi
.fn()
.mockImplementation(async (key: string) => keychainData[key] || null),
setSecret: vi
.fn()
.mockImplementation(async (key: string, value: string) => {
keychainData[key] = value;
}),
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
delete keychainData[key];
}),
listSecrets: vi
.fn()
.mockImplementation(async () => Object.keys(keychainData)),
isAvailable: vi.fn().mockResolvedValue(true),
};
(
KeychainTokenStorage as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockKeychainStorage);
vi.mocked(loadAgentsFromDirectory).mockResolvedValue({
agents: [],
errors: [],
});
vi.mocked(loadSkillsFromDir).mockResolvedValue([]);
tempHomeDir = getRealPath(
fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-home-')),
);
tempWorkspaceDir = getRealPath(
fs.mkdtempSync(path.join(tempHomeDir, 'gemini-cli-test-workspace-')),
);
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
mockRequestConsent = vi.fn();
mockRequestConsent.mockResolvedValue(true);
mockPromptForSettings = vi.fn();
mockPromptForSettings.mockResolvedValue('');
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: undefined,
});
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.experimental.extensionConfig = true;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings,
});
resetTrustedFoldersForTesting();
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('loadExtensions', () => {
it('should include extension path in loaded extension', async () => {
const extensionDir = path.join(userExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true });
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].path).toBe(extensionDir);
expect(extensions[0].name).toBe('test-extension');
});
it('should throw an error if a context file path is outside the extension directory', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'traversal-extension',
version: '1.0.0',
contextFileName: '../secret.txt',
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(0);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'traversal-extension: Invalid context file path: "../secret.txt"',
),
);
consoleSpy.mockRestore();
});
it('should load context file path when GEMINI.md is present', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'ext1',
version: '1.0.0',
addContextFile: true,
});
createExtension({
extensionsDir: userExtensionsDir,
name: 'ext2',
version: '2.0.0',
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(2);
const ext1 = extensions.find((e) => e.name === 'ext1');
const ext2 = extensions.find((e) => e.name === 'ext2');
expect(ext1?.contextFiles).toEqual([
path.join(userExtensionsDir, 'ext1', 'GEMINI.md'),
]);
expect(ext2?.contextFiles).toEqual([]);
});
it('should load context file path from the extension config', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'ext1',
version: '1.0.0',
addContextFile: false,
contextFileName: 'my-context-file.md',
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const ext1 = extensions.find((e) => e.name === 'ext1');
expect(ext1?.contextFiles).toEqual([
path.join(userExtensionsDir, 'ext1', 'my-context-file.md'),
]);
});
it('should annotate disabled extensions', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'disabled-extension',
version: '1.0.0',
});
createExtension({
extensionsDir: userExtensionsDir,
name: 'enabled-extension',
version: '2.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension(
'disabled-extension',
SettingScope.User,
);
const extensions = extensionManager.getExtensions();
expect(extensions).toHaveLength(2);
expect(extensions[0].name).toBe('disabled-extension');
expect(extensions[0].isActive).toBe(false);
expect(extensions[1].name).toBe('enabled-extension');
expect(extensions[1].isActive).toBe(true);
});
it('should hydrate variables', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
addContextFile: false,
contextFileName: undefined,
mcpServers: {
'test-server': {
cwd: '${extensionPath}${/}server',
},
},
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const expectedCwd = path.join(
userExtensionsDir,
'test-extension',
'server',
);
expect(extensions[0].mcpServers?.['test-server'].cwd).toBe(expectedCwd);
});
it('should load a linked extension correctly', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension',
version: '1.0.0',
contextFileName: 'context.md',
}),
);
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
await extensionManager.loadExtensions();
const extension = await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'link',
});
expect(extension.name).toEqual('my-linked-extension');
const extensions = extensionManager.getExtensions();
expect(extensions).toHaveLength(1);
const linkedExt = extensions[0];
expect(linkedExt.name).toBe('my-linked-extension');
expect(linkedExt.path).toBe(sourceExtDir);
expect(linkedExt.installMetadata).toEqual({
source: sourceExtDir,
type: 'link',
});
expect(linkedExt.contextFiles).toEqual([
path.join(sourceExtDir, 'context.md'),
]);
});
it('should load extension policies from the policies directory', async () => {
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'policy-extension',
version: '1.0.0',
});
const policiesDir = path.join(extDir, 'policies');
fs.mkdirSync(policiesDir);
const policiesContent = `
[[rule]]
toolName = "deny_tool"
decision = "deny"
priority = 500
[[rule]]
toolName = "ask_tool"
decision = "ask_user"
priority = 100
`;
fs.writeFileSync(
path.join(policiesDir, 'policies.toml'),
policiesContent,
);
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
expect(extension.rules).toBeDefined();
expect(extension.rules).toHaveLength(2);
expect(
extension.rules!.find((r) => r.toolName === 'deny_tool')?.decision,
).toBe('deny');
expect(
extension.rules!.find((r) => r.toolName === 'ask_tool')?.decision,
).toBe('ask_user');
// Verify source is prefixed
expect(extension.rules![0].source).toContain(
'Extension (policy-extension):',
);
});
it('should ignore ALLOW rules and YOLO mode from extension policies for security', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'security-test-extension',
version: '1.0.0',
});
const policiesDir = path.join(extDir, 'policies');
fs.mkdirSync(policiesDir);
const policiesContent = `
[[rule]]
toolName = "allow_tool"
decision = "allow"
priority = 100
[[rule]]
toolName = "yolo_tool"
decision = "ask_user"
priority = 100
modes = ["yolo"]
[[safety_checker]]
toolName = "yolo_check"
priority = 100
modes = ["yolo"]
[safety_checker.checker]
type = "external"
name = "yolo-checker"
`;
fs.writeFileSync(
path.join(policiesDir, 'policies.toml'),
policiesContent,
);
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
// ALLOW rules and YOLO rules/checkers should be filtered out
expect(extension.rules).toBeDefined();
expect(extension.rules).toHaveLength(0);
expect(extension.checkers).toBeDefined();
expect(extension.checkers).toHaveLength(0);
// Should have logged warnings
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('attempted to contribute an ALLOW rule'),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('attempted to contribute a rule for YOLO mode'),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'attempted to contribute a safety checker for YOLO mode',
),
);
consoleSpy.mockRestore();
});
it('should hydrate ${extensionPath} correctly for linked extensions', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension-with-path',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['${extensionPath}${/}server${/}index.js'],
cwd: '${extensionPath}${/}server',
},
},
}),
);
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'link',
});
const extensions = extensionManager.getExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].mcpServers?.['test-server'].cwd).toBe(
path.join(sourceExtDir, 'server'),
);
expect(extensions[0].mcpServers?.['test-server'].args).toEqual([
path.join(sourceExtDir, 'server', 'index.js'),
]);
});
it('should resolve environment variables in extension configuration', async () => {
process.env['TEST_API_KEY'] = 'test-api-key-123';
process.env['TEST_DB_URL'] = 'postgresql://localhost:5432/testdb';
try {
const userExtensionsDir = path.join(
tempHomeDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(userExtensionsDir, { recursive: true });
const extDir = path.join(userExtensionsDir, 'test-extension');
fs.mkdirSync(extDir);
// Write config to a separate file for clarity and good practices
const configPath = path.join(extDir, EXTENSIONS_CONFIG_FILENAME);
const extensionConfig = {
name: 'test-extension',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
env: {
API_KEY: '$TEST_API_KEY',
DATABASE_URL: '${TEST_DB_URL}',
STATIC_VALUE: 'no-substitution',
},
},
},
};
fs.writeFileSync(configPath, JSON.stringify(extensionConfig));
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
expect(extension.name).toBe('test-extension');
expect(extension.mcpServers).toBeDefined();
const serverConfig = extension.mcpServers?.['test-server'];
expect(serverConfig).toBeDefined();
expect(serverConfig?.env).toBeDefined();
expect(serverConfig?.env?.['API_KEY']).toBe('test-api-key-123');
expect(serverConfig?.env?.['DATABASE_URL']).toBe(
'postgresql://localhost:5432/testdb',
);
expect(serverConfig?.env?.['STATIC_VALUE']).toBe('no-substitution');
} finally {
delete process.env['TEST_API_KEY'];
delete process.env['TEST_DB_URL'];
}
});
it('should resolve environment variables from an extension .env file', async () => {
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
env: {
API_KEY: '$MY_API_KEY',
STATIC_VALUE: 'no-substitution',
},
},
},
settings: [
{
name: 'My API Key',
description: 'API key for testing.',
envVar: 'MY_API_KEY',
},
],
});
const envFilePath = path.join(extDir, '.env');
fs.writeFileSync(envFilePath, 'MY_API_KEY=test-key-from-file\n');
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
const serverConfig = extension.mcpServers!['test-server'];
expect(serverConfig.env).toBeDefined();
expect(serverConfig.env!['API_KEY']).toBe('test-key-from-file');
expect(serverConfig.env!['STATIC_VALUE']).toBe('no-substitution');
});
it('should handle missing environment variables gracefully', async () => {
const userExtensionsDir = path.join(
tempHomeDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(userExtensionsDir, { recursive: true });
const extDir = path.join(userExtensionsDir, 'test-extension');
fs.mkdirSync(extDir);
const extensionConfig = {
name: 'test-extension',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
env: {
MISSING_VAR: '$UNDEFINED_ENV_VAR',
MISSING_VAR_BRACES: '${ALSO_UNDEFINED}',
},
},
},
};
fs.writeFileSync(
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify(extensionConfig),
);
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
const serverConfig = extension.mcpServers!['test-server'];
expect(serverConfig.env).toBeDefined();
expect(serverConfig.env!['MISSING_VAR']).toBe('$UNDEFINED_ENV_VAR');
expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}');
});
it('should skip extensions with invalid JSON and log a warning', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Good extension
createExtension({
extensionsDir: userExtensionsDir,
name: 'good-ext',
version: '1.0.0',
});
// Bad extension
const badExtDir = path.join(userExtensionsDir, 'bad-ext');
fs.mkdirSync(badExtDir, { recursive: true });
const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
),
);
consoleSpy.mockRestore();
});
it('should skip extensions with missing name and log a warning', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Good extension
createExtension({
extensionsDir: userExtensionsDir,
name: 'good-ext',
version: '1.0.0',
});
// Bad extension
const badExtDir = path.join(userExtensionsDir, 'bad-ext-no-name');
fs.mkdirSync(badExtDir, { recursive: true });
const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' }));
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
),
);
consoleSpy.mockRestore();
});
it('should filter trust out of mcp servers', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
trust: true,
},
},
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
});
it('should throw an error for invalid extension names', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'bad_name',
version: '1.0.0',
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'bad_name');
expect(extension).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid extension name: "bad_name"'),
);
consoleSpy.mockRestore();
});
it('should not load github extensions if blockGitExtensions is set', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'http://somehost.com/foo/bar',
},
});
const blockGitExtensionsSetting = createTestMergedSettings({
security: { blockGitExtensions: true },
});
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: blockGitExtensionsSetting,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Extensions from remote sources is disallowed by your current settings.',
),
);
consoleSpy.mockRestore();
});
it('should load allowed extensions if the allowlist is set.', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'http://allowed.com/foo/bar',
},
});
const extensionAllowlistSetting = createTestMergedSettings({
security: {
allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
},
});
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: extensionAllowlistSetting,
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('my-ext');
});
it('should not load disallowed extensions if the allowlist is set.', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'http://notallowed.com/foo/bar',
},
});
const extensionAllowlistSetting = createTestMergedSettings({
security: {
allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
},
});
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: extensionAllowlistSetting,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'This extension is not allowed by the "allowedExtensions" security setting',
),
);
consoleSpy.mockRestore();
});
it('should not load any extensions if admin.extensions.enabled is false', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
});
const loadedSettings = loadSettings(tempWorkspaceDir).merged;
loadedSettings.admin.extensions.enabled = false;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: loadedSettings,
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toEqual([]);
});
it('should not load mcpServers if admin.mcp.enabled is false', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
mcpServers: {
'test-server': { command: 'echo', args: ['hello'] },
},
});
const loadedSettings = loadSettings(tempWorkspaceDir).merged;
loadedSettings.admin.mcp.enabled = false;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: loadedSettings,
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].mcpServers).toBeUndefined();
});
it('should load mcpServers if admin.mcp.enabled is true', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
mcpServers: {
'test-server': { command: 'echo', args: ['hello'] },
},
});
const loadedSettings = loadSettings(tempWorkspaceDir).merged;
loadedSettings.admin.mcp.enabled = true;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: loadedSettings,
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].mcpServers).toEqual({
'test-server': { command: 'echo', args: ['hello'] },
});
});
describe('id generation', () => {
it.each([
{
description: 'should generate id from source for non-github git urls',
installMetadata: {
type: 'git' as const,
source: 'http://somehost.com/foo/bar',
},
expectedIdSource: 'http://somehost.com/foo/bar',
},
{
description:
'should generate id from owner/repo for github http urls',
installMetadata: {
type: 'git' as const,
source: 'http://github.com/foo/bar',
},
expectedIdSource: 'https://github.com/foo/bar',
},
{
description: 'should generate id from owner/repo for github ssh urls',
installMetadata: {
type: 'git' as const,
source: 'git@github.com:foo/bar',
},
expectedIdSource: 'https://github.com/foo/bar',
},
{
description:
'should generate id from source for github-release extension',
installMetadata: {
type: 'github-release' as const,
source: 'https://github.com/foo/bar',
},
expectedIdSource: 'https://github.com/foo/bar',
},
{
description:
'should generate id from the original source for local extension',
installMetadata: {
type: 'local' as const,
source: '/some/path',
},
expectedIdSource: '/some/path',
},
])('$description', async ({ installMetadata, expectedIdSource }) => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue(expectedIdSource));
});
it('should generate id from the original source for linked extensions', async () => {
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
const actualExtensionDir = getRealPath(
createExtension({
extensionsDir: extDevelopmentDir,
name: 'link-ext-name',
version: '1.0.0',
}),
);
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
type: 'link',
source: actualExtensionDir,
});
const extension = extensionManager
.getExtensions()
.find((e) => e.name === 'link-ext-name');
expect(extension?.id).toBe(hashValue(actualExtensionDir));
});
it('should generate id from name for extension with no install metadata', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'no-meta-name',
version: '1.0.0',
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'no-meta-name');
expect(extension?.id).toBe(hashValue('no-meta-name'));
});
it('should load extension hooks and hydrate variables', async () => {
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'hook-extension',
version: '1.0.0',
});
const hooksDir = path.join(extDir, 'hooks');
fs.mkdirSync(hooksDir);
const hooksConfig = {
enabled: false,
hooks: {
BeforeTool: [
{
matcher: '.*',
hooks: [
{
type: 'command',
command: 'echo ${extensionPath}',
},
],
},
],
},
};
fs.writeFileSync(
path.join(hooksDir, 'hooks.json'),
JSON.stringify(hooksConfig),
);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.hooksConfig.enabled = true;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings,
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
expect(extension.hooks).toBeDefined();
expect(extension.hooks?.BeforeTool).toHaveLength(1);
expect(extension.hooks?.BeforeTool?.[0].hooks[0].command).toBe(
`echo ${extDir}`,
);
});
it('should not load hooks if hooks.enabled is false', async () => {
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'hook-extension-disabled',
version: '1.0.0',
});
const hooksDir = path.join(extDir, 'hooks');
fs.mkdirSync(hooksDir);
fs.writeFileSync(
path.join(hooksDir, 'hooks.json'),
JSON.stringify({ hooks: { BeforeTool: [] }, enabled: false }),
);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.hooksConfig.enabled = false;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings,
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].hooks).toBeUndefined();
});
it('should warn about hooks during installation', async () => {
const requestConsentSpy = vi.fn().mockResolvedValue(true);
extensionManager.setRequestConsent(requestConsentSpy);
const sourceExtDir = path.join(
tempWorkspaceDir,
'hook-extension-source',
);
fs.mkdirSync(sourceExtDir, { recursive: true });
const hooksDir = path.join(sourceExtDir, 'hooks');
fs.mkdirSync(hooksDir);
fs.writeFileSync(
path.join(hooksDir, 'hooks.json'),
JSON.stringify({ hooks: {} }),
);
fs.writeFileSync(
path.join(sourceExtDir, 'gemini-extension.json'),
JSON.stringify({
name: 'hook-extension-install',
version: '1.0.0',
}),
);
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
expect(requestConsentSpy).toHaveBeenCalledWith(
expect.stringContaining('⚠️ This extension contains Hooks'),
);
});
});
});
describe('installExtension', () => {
it('should install an extension from a local path', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
}),
);
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
expect(fs.existsSync(targetExtDir)).toBe(true);
expect(fs.existsSync(metadataPath)).toBe(true);
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
expect(metadata).toEqual({
source: sourceExtDir,
type: 'local',
});
fs.rmSync(targetExtDir, { recursive: true, force: true });
});
it('should throw an error if the extension already exists', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).rejects.toThrow(
'Extension "my-local-extension" is already installed. Please uninstall it first.',
);
});
it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-extension'));
fs.mkdirSync(sourceExtDir, { recursive: true });
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).rejects.toThrow(`Configuration file not found at ${configPath}`);
const targetExtDir = path.join(userExtensionsDir, 'bad-extension');
expect(fs.existsSync(targetExtDir)).toBe(false);
});
it('should throw an error for invalid JSON in gemini-extension.json', async () => {
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-json-ext'));
fs.mkdirSync(sourceExtDir, { recursive: true });
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).rejects.toThrow(`Failed to load extension config from ${configPath}`);
});
it('should throw an error for missing name in gemini-extension.json', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'missing-name-ext',
version: '1.0.0',
}),
);
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
// Overwrite with invalid config
fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' }));
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).rejects.toThrow(
`Invalid configuration in ${configPath}: missing "name"`,
);
});
it('should install an extension from a git URL', async () => {
const gitUrl = 'https://somehost.com/somerepo.git';
const extensionName = 'some-extension';
const targetExtDir = path.join(userExtensionsDir, extensionName);
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
mockGit.clone.mockImplementation(async (_, destination) => {
fs.mkdirSync(path.join(mockGit.path(), destination), {
recursive: true,
});
fs.writeFileSync(
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.0.0' }),
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
mockDownloadFromGithubRelease.mockResolvedValue({
success: false,
failureReason: 'no release data',
type: 'github-release',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'git',
});
expect(fs.existsSync(targetExtDir)).toBe(true);
expect(fs.existsSync(metadataPath)).toBe(true);
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
expect(metadata).toEqual({
source: gitUrl,
type: 'git',
});
});
it('should install a linked extension', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'my-linked-extension',
version: '1.0.0',
}),
);
const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'link',
});
expect(fs.existsSync(targetExtDir)).toBe(true);
expect(fs.existsSync(metadataPath)).toBe(true);
expect(fs.existsSync(configPath)).toBe(false);
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
expect(metadata).toEqual({
source: sourceExtDir,
type: 'link',
});
fs.rmSync(targetExtDir, { recursive: true, force: true });
});
it('should not install a github extension if blockGitExtensions is set', async () => {
const gitUrl = 'https://somehost.com/somerepo.git';
const blockGitExtensionsSetting = createTestMergedSettings({
security: { blockGitExtensions: true },
});
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: blockGitExtensionsSetting,
});
await extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'git',
}),
).rejects.toThrow(
'Installing extensions from remote sources is disallowed by your current settings.',
);
});
it('should not install a disallowed extension if the allowlist is set', async () => {
const gitUrl = 'https://somehost.com/somerepo.git';
const allowedExtensionsSetting = createTestMergedSettings({
security: {
allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
},
});
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: allowedExtensionsSetting,
});
await extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'git',
}),
).rejects.toThrow(
`Installing extension from source "${gitUrl}" is not allowed by the "allowedExtensions" security setting.`,
);
});
it('should prompt for trust if workspace is not trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: undefined,
});
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
expect(mockRequestConsent).toHaveBeenCalledWith(
`The current workspace at "${tempWorkspaceDir}" is not trusted. Do you want to trust this workspace to install extensions?`,
);
});
it('should not install if user denies trust', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: undefined,
});
mockRequestConsent.mockImplementation(async (message) => {
if (
message.includes(
'is not trusted. Do you want to trust this workspace to install extensions?',
)
) {
return false;
}
return true;
});
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).rejects.toThrow(
`Could not install extension because the current workspace at ${tempWorkspaceDir} is not trusted.`,
);
});
it('should add the workspace to trusted folders if user consents', async () => {
const trustedFoldersPath = path.join(
tempHomeDir,
'.gemini',
'trustedFolders.json',
);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: undefined,
});
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
expect(fs.existsSync(trustedFoldersPath)).toBe(true);
const trustedFolders = JSON.parse(
fs.readFileSync(trustedFoldersPath, 'utf-8'),
);
expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER');
});
describe.each([true, false])(
'with previous extension config: %s',
(isUpdate: boolean) => {
let sourceExtDir: string;
beforeEach(async () => {
sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.1.0',
});
await extensionManager.loadExtensions();
if (isUpdate) {
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
}
// Clears out any calls to mocks from the above function calls.
vi.clearAllMocks();
});
it(`should log an ${isUpdate ? 'update' : 'install'} event to clearcut on success`, async () => {
await extensionManager.installOrUpdateExtension(
{ source: sourceExtDir, type: 'local' },
isUpdate
? {
name: 'my-local-extension',
version: '1.0.0',
}
: undefined,
);
if (isUpdate) {
expect(mockLogExtensionUpdateEvent).toHaveBeenCalled();
expect(mockLogExtensionInstallEvent).not.toHaveBeenCalled();
} else {
expect(mockLogExtensionInstallEvent).toHaveBeenCalled();
expect(mockLogExtensionUpdateEvent).not.toHaveBeenCalled();
}
});
it(`should ${isUpdate ? 'not ' : ''} alter the extension enablement configuration`, async () => {
const enablementManager = new ExtensionEnablementManager();
enablementManager.enable('my-local-extension', true, '/some/scope');
await extensionManager.installOrUpdateExtension(
{ source: sourceExtDir, type: 'local' },
isUpdate
? {
name: 'my-local-extension',
version: '1.0.0',
}
: undefined,
);
const config = enablementManager.readConfig()['my-local-extension'];
if (isUpdate) {
expect(config).not.toBeUndefined();
expect(config.overrides).toContain('/some/scope/*');
} else {
expect(config).not.toContain('/some/scope/*');
}
});
},
);
it('should show users information on their ansi escaped mcp servers when installing', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node dobadthing \u001b[12D\u001b[K',
args: ['server.js'],
description: 'a local mcp server',
},
'test-server-2': {
description: 'a remote mcp server',
httpUrl: 'https://google.com',
},
},
});
await extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).resolves.toMatchObject({
name: 'my-local-extension',
});
expect(mockRequestConsent).toHaveBeenCalledWith(
`Installing extension "my-local-extension".
This extension will run the following MCP servers:
* test-server (local): node dobadthing \\u001b[12D\\u001b[K server.js
* test-server-2 (remote): https://google.com
${INSTALL_WARNING_MESSAGE}`,
);
});
it('should continue installation if user accepts prompt for local extension with mcp servers', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
},
},
});
await extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).resolves.toMatchObject({ name: 'my-local-extension' });
});
it('should cancel installation if user declines prompt for local extension with mcp servers', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
},
},
});
mockRequestConsent.mockResolvedValue(false);
await extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).rejects.toThrow('Installation cancelled for "my-local-extension".');
});
it('should save the autoUpdate flag to the install metadata', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
}),
);
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
autoUpdate: true,
});
expect(fs.existsSync(targetExtDir)).toBe(true);
expect(fs.existsSync(metadataPath)).toBe(true);
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
expect(metadata).toEqual({
source: sourceExtDir,
type: 'local',
autoUpdate: true,
});
fs.rmSync(targetExtDir, { recursive: true, force: true });
});
it('should ignore consent flow if not required', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
},
},
});
await extensionManager.loadExtensions();
// Install it with hard coded consent first.
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
expect(mockRequestConsent).toHaveBeenCalledOnce();
// Now update it without changing anything.
await expect(
extensionManager.installOrUpdateExtension(
{ source: sourceExtDir, type: 'local' },
// Provide its own existing config as the previous config.
await extensionManager.loadExtensionConfig(sourceExtDir),
),
).resolves.toMatchObject({ name: 'my-local-extension' });
// Still only called once
expect(mockRequestConsent).toHaveBeenCalledOnce();
});
it('should prompt for settings if promptForSettings', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
settings: [
{
name: 'API Key',
description: 'Your API key for the service.',
envVar: 'MY_API_KEY',
},
],
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
expect(mockPromptForSettings).toHaveBeenCalled();
});
it('should not prompt for settings if promptForSettings is false', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
settings: [
{
name: 'API Key',
description: 'Your API key for the service.',
envVar: 'MY_API_KEY',
},
],
});
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: null,
settings: loadSettings(tempWorkspaceDir).merged,
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
});
it('should only prompt for new settings on update, and preserve old settings', async () => {
// 1. Create and install the "old" version of the extension.
const oldSourceExtDir = createExtension({
extensionsDir: tempHomeDir, // Create it in a temp location first
name: 'my-local-extension',
version: '1.0.0',
settings: [
{
name: 'API Key',
description: 'Your API key for the service.',
envVar: 'MY_API_KEY',
},
],
});
mockPromptForSettings.mockResolvedValueOnce('old-api-key');
await extensionManager.loadExtensions();
// Install it so it exists in the userExtensionsDir
await extensionManager.installOrUpdateExtension({
source: oldSourceExtDir,
type: 'local',
});
const envPath = new ExtensionStorage(
'my-local-extension',
).getEnvFilePath();
expect(fs.existsSync(envPath)).toBe(true);
let envContent = fs.readFileSync(envPath, 'utf-8');
expect(envContent).toContain('MY_API_KEY=old-api-key');
expect(mockPromptForSettings).toHaveBeenCalledTimes(1);
// 2. Create the "new" version of the extension in a new source directory.
const newSourceExtDir = createExtension({
extensionsDir: path.join(tempHomeDir, 'new-source'), // Another temp location
name: 'my-local-extension', // Same name
version: '1.1.0', // New version
settings: [
{
name: 'API Key',
description: 'Your API key for the service.',
envVar: 'MY_API_KEY',
},
{
name: 'New Setting',
description: 'A new setting.',
envVar: 'NEW_SETTING',
},
],
});
const previousExtensionConfig =
await extensionManager.loadExtensionConfig(
path.join(userExtensionsDir, 'my-local-extension'),
);
mockPromptForSettings.mockResolvedValueOnce('new-setting-value');
// 3. Call installOrUpdateExtension to perform the update.
await extensionManager.installOrUpdateExtension(
{ source: newSourceExtDir, type: 'local' },
previousExtensionConfig,
);
expect(mockPromptForSettings).toHaveBeenCalledTimes(2);
expect(mockPromptForSettings).toHaveBeenCalledWith(
expect.objectContaining({ name: 'New Setting' }),
);
expect(fs.existsSync(envPath)).toBe(true);
envContent = fs.readFileSync(envPath, 'utf-8');
expect(envContent).toContain('MY_API_KEY=old-api-key');
expect(envContent).toContain('NEW_SETTING=new-setting-value');
});
it('should auto-update if settings have changed', async () => {
// 1. Install initial version with autoUpdate: true
const oldSourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-auto-update-ext',
version: '1.0.0',
settings: [
{
name: 'OLD_SETTING',
envVar: 'OLD_SETTING',
description: 'An old setting',
},
],
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: oldSourceExtDir,
type: 'local',
autoUpdate: true,
});
// 2. Create new version with different settings
const extensionDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-auto-update-ext',
version: '1.1.0',
settings: [
{
name: 'NEW_SETTING',
envVar: 'NEW_SETTING',
description: 'A new setting',
},
],
});
const previousExtensionConfig =
await extensionManager.loadExtensionConfig(
path.join(userExtensionsDir, 'my-auto-update-ext'),
);
// 3. Attempt to update and assert it fails
const updatedExtension = await extensionManager.installOrUpdateExtension(
{
source: extensionDir,
type: 'local',
autoUpdate: true,
},
previousExtensionConfig,
);
expect(updatedExtension.version).toBe('1.1.0');
expect(extensionManager.getExtensions()[0].version).toBe('1.1.0');
});
it('should throw an error for invalid extension names', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'bad_name',
version: '1.0.0',
});
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).rejects.toThrow('Invalid extension name: "bad_name"');
});
describe('installing from github', () => {
const gitUrl = 'https://github.com/google/gemini-test-extension.git';
const extensionName = 'gemini-test-extension';
beforeEach(() => {
// Mock the git clone behavior for github installs that fallback to it.
mockGit.clone.mockImplementation(async (_, destination) => {
fs.mkdirSync(path.join(mockGit.path(), destination), {
recursive: true,
});
fs.writeFileSync(
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.0.0' }),
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should install from a github release successfully', async () => {
const targetExtDir = path.join(userExtensionsDir, extensionName);
mockDownloadFromGithubRelease.mockResolvedValue({
success: true,
tagName: 'v1.0.0',
type: 'github-release',
});
const tempDir = path.join(tempHomeDir, 'temp-ext');
fs.mkdirSync(tempDir, { recursive: true });
createExtension({
extensionsDir: tempDir,
name: extensionName,
version: '1.0.0',
});
vi.spyOn(ExtensionStorage, 'createTmpDir').mockResolvedValue(
join(tempDir, extensionName),
);
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'github-release',
});
expect(fs.existsSync(targetExtDir)).toBe(true);
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
expect(fs.existsSync(metadataPath)).toBe(true);
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
expect(metadata).toEqual({
source: gitUrl,
type: 'github-release',
releaseTag: 'v1.0.0',
});
});
it('should fallback to git clone if github release download fails and user consents', async () => {
mockDownloadFromGithubRelease.mockResolvedValue({
success: false,
failureReason: 'failed to download asset',
errorMessage: 'download failed',
type: 'github-release',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension(
{ source: gitUrl, type: 'github-release' }, // Use github-release to force consent
);
// It gets called once to ask for a git clone, and once to consent to
// the actual extension features.
expect(mockRequestConsent).toHaveBeenCalledTimes(2);
expect(mockRequestConsent).toHaveBeenCalledWith(
expect.stringContaining(
'Would you like to attempt to install via "git clone" instead?',
),
);
expect(mockGit.clone).toHaveBeenCalled();
const metadataPath = path.join(
userExtensionsDir,
extensionName,
INSTALL_METADATA_FILENAME,
);
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
expect(metadata.type).toBe('git');
});
it('should throw an error if github release download fails and user denies consent', async () => {
mockDownloadFromGithubRelease.mockResolvedValue({
success: false,
errorMessage: 'download failed',
type: 'github-release',
});
mockRequestConsent.mockResolvedValue(false);
await extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'github-release',
}),
).rejects.toThrow(
`Failed to install extension ${gitUrl}: download failed`,
);
expect(mockRequestConsent).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining(
'Would you like to attempt to install via "git clone" instead?',
),
);
expect(mockGit.clone).not.toHaveBeenCalled();
});
it('should fallback to git clone without consent if no release data is found on first install', async () => {
mockDownloadFromGithubRelease.mockResolvedValue({
success: false,
failureReason: 'no release data',
type: 'github-release',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'git',
});
// We should not see the request to use git clone, this is a repo that
// has no github releases so it is the only install method.
expect(mockRequestConsent).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining(
'Installing extension "gemini-test-extension"',
),
);
expect(mockGit.clone).toHaveBeenCalled();
const metadataPath = path.join(
userExtensionsDir,
extensionName,
INSTALL_METADATA_FILENAME,
);
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
expect(metadata.type).toBe('git');
});
it('should ask for consent if no release data is found for an existing github-release extension', async () => {
mockDownloadFromGithubRelease.mockResolvedValue({
success: false,
failureReason: 'no release data',
errorMessage: 'No release data found',
type: 'github-release',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension(
{ source: gitUrl, type: 'github-release' }, // Note the type
);
expect(mockRequestConsent).toHaveBeenCalledWith(
expect.stringContaining(
'Would you like to attempt to install via "git clone" instead?',
),
);
expect(mockGit.clone).toHaveBeenCalled();
});
});
});
describe('uninstallExtension', () => {
it('should uninstall an extension by name', async () => {
const sourceExtDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'my-local-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.uninstallExtension('my-local-extension', false);
expect(fs.existsSync(sourceExtDir)).toBe(false);
});
it('should uninstall an extension by name and retain existing extensions', async () => {
const sourceExtDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'my-local-extension',
version: '1.0.0',
});
const otherExtDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'other-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.uninstallExtension('my-local-extension', false);
expect(fs.existsSync(sourceExtDir)).toBe(false);
expect(extensionManager.getExtensions()).toHaveLength(1);
expect(fs.existsSync(otherExtDir)).toBe(true);
});
it('should uninstall an extension on non-matching extension directory name', async () => {
// Create an extension with a name that differs from the directory name.
const sourceExtDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'My-Local-Extension',
version: '1.0.0',
});
const newSourceExtDir = path.join(
userExtensionsDir,
'my-local-extension',
);
fs.renameSync(sourceExtDir, newSourceExtDir);
const otherExtDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'other-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.uninstallExtension('my-local-extension', false);
expect(fs.existsSync(sourceExtDir)).toBe(false);
expect(fs.existsSync(newSourceExtDir)).toBe(false);
expect(extensionManager.getExtensions()).toHaveLength(1);
expect(fs.existsSync(otherExtDir)).toBe(true);
});
it('should throw an error if the extension does not exist', async () => {
await extensionManager.loadExtensions();
await expect(
extensionManager.uninstallExtension('nonexistent-extension', false),
).rejects.toThrow('Extension not found.');
});
describe.each([true, false])('with isUpdate: %s', (isUpdate: boolean) => {
it(`should ${isUpdate ? 'not ' : ''}log uninstall event`, async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-local-extension',
version: '1.0.0',
installMetadata: {
source: userExtensionsDir,
type: 'local',
},
});
await extensionManager.loadExtensions();
await extensionManager.uninstallExtension(
'my-local-extension',
isUpdate,
);
if (isUpdate) {
expect(mockLogExtensionUninstall).not.toHaveBeenCalled();
expect(ExtensionUninstallEvent).not.toHaveBeenCalled();
} else {
expect(mockLogExtensionUninstall).toHaveBeenCalled();
expect(ExtensionUninstallEvent).toHaveBeenCalledWith(
'my-local-extension',
hashValue('my-local-extension'),
hashValue(userExtensionsDir),
'success',
);
}
});
it(`should ${isUpdate ? 'not ' : ''} alter the extension enablement configuration`, async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
});
const enablementManager = new ExtensionEnablementManager();
enablementManager.enable('test-extension', true, '/some/scope');
await extensionManager.loadExtensions();
await extensionManager.uninstallExtension('test-extension', isUpdate);
const config = enablementManager.readConfig()['test-extension'];
if (isUpdate) {
expect(config).not.toBeUndefined();
expect(config.overrides).toEqual(['/some/scope/*']);
} else {
expect(config).toBeUndefined();
}
});
});
it('should uninstall an extension by its source URL', async () => {
const gitUrl = 'https://github.com/google/gemini-sql-extension.git';
const sourceExtDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'gemini-sql-extension',
version: '1.0.0',
installMetadata: {
source: gitUrl,
type: 'git',
},
});
await extensionManager.loadExtensions();
await extensionManager.uninstallExtension(gitUrl, false);
expect(fs.existsSync(sourceExtDir)).toBe(false);
expect(mockLogExtensionUninstall).toHaveBeenCalled();
expect(ExtensionUninstallEvent).toHaveBeenCalledWith(
'gemini-sql-extension',
hashValue('gemini-sql-extension'),
hashValue('https://github.com/google/gemini-sql-extension'),
'success',
);
});
it('should fail to uninstall by URL if an extension has no install metadata', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'no-metadata-extension',
version: '1.0.0',
// No installMetadata provided
});
await extensionManager.loadExtensions();
await expect(
extensionManager.uninstallExtension(
'https://github.com/google/no-metadata-extension',
false,
),
).rejects.toThrow('Extension not found.');
});
});
describe('disableExtension', () => {
it('should disable an extension at the user scope', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension(
'my-extension',
SettingScope.User,
);
expect(
isEnabled({
name: 'my-extension',
enabledForPath: tempWorkspaceDir,
}),
).toBe(false);
});
it('should disable an extension at the workspace scope', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension(
'my-extension',
SettingScope.Workspace,
);
expect(
isEnabled({
name: 'my-extension',
enabledForPath: tempHomeDir,
}),
).toBe(true);
expect(
isEnabled({
name: 'my-extension',
enabledForPath: tempWorkspaceDir,
}),
).toBe(false);
});
it('should handle disabling the same extension twice', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension(
'my-extension',
SettingScope.User,
);
await extensionManager.disableExtension(
'my-extension',
SettingScope.User,
);
expect(
isEnabled({
name: 'my-extension',
enabledForPath: tempWorkspaceDir,
}),
).toBe(false);
});
it('should throw an error if you request system scope', async () => {
await expect(async () =>
extensionManager.disableExtension('my-extension', SettingScope.System),
).rejects.toThrow('System and SystemDefaults scopes are not supported.');
});
it('should log a disable event', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'ext1',
version: '1.0.0',
installMetadata: {
source: userExtensionsDir,
type: 'local',
},
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension('ext1', SettingScope.Workspace);
expect(mockLogExtensionDisable).toHaveBeenCalled();
expect(ExtensionDisableEvent).toHaveBeenCalledWith(
'ext1',
hashValue('ext1'),
hashValue(userExtensionsDir),
SettingScope.Workspace,
);
});
});
describe('enableExtension', () => {
afterAll(() => {
vi.restoreAllMocks();
});
const getActiveExtensions = (): GeminiCLIExtension[] => {
const extensions = extensionManager.getExtensions();
return extensions.filter((e) => e.isActive);
};
it('should enable an extension at the user scope', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'ext1',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension('ext1', SettingScope.User);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
await extensionManager.enableExtension('ext1', SettingScope.User);
activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext1');
});
it('should enable an extension at the workspace scope', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'ext1',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension('ext1', SettingScope.Workspace);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
await extensionManager.enableExtension('ext1', SettingScope.Workspace);
activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext1');
});
it('should log an enable event', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'ext1',
version: '1.0.0',
installMetadata: {
source: userExtensionsDir,
type: 'local',
},
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension('ext1', SettingScope.Workspace);
await extensionManager.enableExtension('ext1', SettingScope.Workspace);
expect(mockLogExtensionEnable).toHaveBeenCalled();
expect(ExtensionEnableEvent).toHaveBeenCalledWith(
'ext1',
hashValue('ext1'),
hashValue(userExtensionsDir),
SettingScope.Workspace,
);
});
});
});
function isEnabled(options: { name: string; enabledForPath: string }) {
const manager = new ExtensionEnablementManager();
return manager.isEnabled(options.name, options.enabledForPath);
}