Final Changes for stable release (#8105)

Co-authored-by: gemini-cli-robot <gemini-cli-robot@google.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: christine betts <chrstn@uw.edu>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
Co-authored-by: anthony bushong <agmsb@users.noreply.github.com>
Co-authored-by: Shreya Keshive <skeshive@gmail.com>
Co-authored-by: Taylor Mullen <ntaylormullen@google.com>
Co-authored-by: Arya Gummadi <aryagummadi@google.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
Co-authored-by: Pascal Birchler <pascalb@google.com>
Co-authored-by: Victor May <mayvic@google.com>
Co-authored-by: silvio junior <silviojr.dcc@gmail.com>
This commit is contained in:
matt korwel
2025-09-09 13:55:27 -07:00
committed by GitHub
parent c173f77052
commit 89213699bf
26 changed files with 443 additions and 291 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.4.0-preview",
"version": "0.4.0-preview.2",
"description": "Gemini CLI",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.4.0-preview"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.4.0-preview.2"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",

View File

@@ -5,9 +5,8 @@
*/
import { describe, it, expect } from 'vitest';
import { installCommand, handleInstall } from './install.js';
import { installCommand } from './install.js';
import yargs from 'yargs';
import * as extension from '../../config/extension.js';
vi.mock('../../config/extension.js', () => ({
installExtension: vi.fn(),
@@ -17,33 +16,14 @@ describe('extensions install command', () => {
it('should fail if no source is provided', () => {
const validationParser = yargs([]).command(installCommand).fail(false);
expect(() => validationParser.parse('install')).toThrow(
'Either --source or --path must be provided.',
'Either source or --path must be provided.',
);
});
it('should fail if both git source and local path are provided', () => {
const validationParser = yargs([]).command(installCommand).fail(false);
expect(() =>
validationParser.parse('install --source some-url --path /some/path'),
validationParser.parse('install some-url --path /some/path'),
).toThrow('Arguments source and path are mutually exclusive');
});
});
describe('extensions install with org/repo', () => {
it('should call installExtension with the correct git URL', async () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const installExtensionSpy = vi
.spyOn(extension, 'installExtension')
.mockResolvedValue('test-extension');
await handleInstall({ source: 'test-org/test-repo' });
expect(installExtensionSpy).toHaveBeenCalledWith({
source: 'https://github.com/test-org/test-repo.git',
type: 'git',
});
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" installed successfully and enabled.',
);
});
});

View File

@@ -17,8 +17,6 @@ interface InstallArgs {
path?: string;
}
const ORG_REPO_REGEX = /^[a-zA-Z0-9-]+\/[\w.-]+$/;
export async function handleInstall(args: InstallArgs) {
try {
let installMetadata: ExtensionInstallMetadata;
@@ -34,15 +32,8 @@ export async function handleInstall(args: InstallArgs) {
source,
type: 'git',
};
} else if (ORG_REPO_REGEX.test(source)) {
installMetadata = {
source: `https://github.com/${source}.git`,
type: 'git',
};
} else {
throw new Error(
`The source "${source}" is not a valid URL or "org/repo" format.`,
);
throw new Error(`The source "${source}" is not a valid URL format.`);
}
} else if (args.path) {
installMetadata = {
@@ -54,10 +45,8 @@ export async function handleInstall(args: InstallArgs) {
throw new Error('Either --source or --path must be provided.');
}
const extensionName = await installExtension(installMetadata);
console.log(
`Extension "${extensionName}" installed successfully and enabled.`,
);
const name = await installExtension(installMetadata);
console.log(`Extension "${name}" installed successfully and enabled.`);
} catch (error) {
console.error(getErrorMessage(error));
process.exit(1);
@@ -65,13 +54,12 @@ export async function handleInstall(args: InstallArgs) {
}
export const installCommand: CommandModule = {
command: 'install [--source | --path ]',
describe:
'Installs an extension from a git repository (URL or "org/repo") or a local path.',
command: 'install [source]',
describe: 'Installs an extension from a git repository URL or a local path.',
builder: (yargs) =>
yargs
.option('source', {
describe: 'The git URL or "org/repo" of the extension to install.',
.positional('source', {
describe: 'The github URL of the extension to install.',
type: 'string',
})
.option('path', {
@@ -81,7 +69,7 @@ export const installCommand: CommandModule = {
.conflicts('source', 'path')
.check((argv) => {
if (!argv.source && !argv.path) {
throw new Error('Either --source or --path must be provided.');
throw new Error('Either source or --path must be provided.');
}
return true;
}),

View File

@@ -305,7 +305,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
// Register MCP subcommands
.command(mcpCommand);
if (settings?.experimental?.extensionManagement ?? false) {
if (settings?.experimental?.extensionManagement ?? true) {
yargsInstance.command(extensionsCommand);
}

View File

@@ -26,6 +26,8 @@ import {
GEMINI_DIR,
type GeminiCLIExtension,
type MCPServerConfig,
ClearcutLogger,
type Config,
} from '@google/gemini-cli-core';
import { execSync } from 'node:child_process';
import { SettingScope, loadSettings } from './settings.js';
@@ -52,6 +54,22 @@ vi.mock('./trustedFolders.js', async (importOriginal) => {
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const mockLogExtensionInstallEvent = vi.fn();
return {
...actual,
ClearcutLogger: {
getInstance: vi.fn(() => ({
logExtensionInstallEvent: mockLogExtensionInstallEvent,
})),
},
Config: vi.fn(),
ExtensionInstallEvent: vi.fn(),
};
});
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
@@ -63,59 +81,36 @@ vi.mock('child_process', async (importOriginal) => {
const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
describe('loadExtensions', () => {
let tempWorkspaceDir: string;
let tempHomeDir: string;
let workspaceExtensionsDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
fs.mkdirSync(userExtensionsDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
fs.rmSync(tempHomeDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('ignores extensions in untrusted workspaces', () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue(false);
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true });
createExtension({
extensionsDir: workspaceExtensionsDir,
name: 'ext1',
version: '1.0.0',
addContextFile: true,
});
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions.length).toBe(0);
});
it('should include extension path in loaded extension', () => {
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
const extensionDir = path.join(userExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true });
createExtension({
extensionsDir: workspaceExtensionsDir,
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
});
const extensions = loadExtensions(tempWorkspaceDir);
const extensions = loadExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].path).toBe(extensionDir);
expect(extensions[0].config.name).toBe('test-extension');
@@ -123,70 +118,70 @@ describe('loadExtensions', () => {
it('should load context file path when GEMINI.md is present', () => {
createExtension({
extensionsDir: workspaceExtensionsDir,
extensionsDir: userExtensionsDir,
name: 'ext1',
version: '1.0.0',
addContextFile: true,
});
createExtension({
extensionsDir: workspaceExtensionsDir,
extensionsDir: userExtensionsDir,
name: 'ext2',
version: '2.0.0',
});
const extensions = loadExtensions(tempWorkspaceDir);
const extensions = loadExtensions();
expect(extensions).toHaveLength(2);
const ext1 = extensions.find((e) => e.config.name === 'ext1');
const ext2 = extensions.find((e) => e.config.name === 'ext2');
expect(ext1?.contextFiles).toEqual([
path.join(workspaceExtensionsDir, 'ext1', 'GEMINI.md'),
path.join(userExtensionsDir, 'ext1', 'GEMINI.md'),
]);
expect(ext2?.contextFiles).toEqual([]);
});
it('should load context file path from the extension config', () => {
createExtension({
extensionsDir: workspaceExtensionsDir,
extensionsDir: userExtensionsDir,
name: 'ext1',
version: '1.0.0',
addContextFile: false,
contextFileName: 'my-context-file.md',
});
const extensions = loadExtensions(tempWorkspaceDir);
const extensions = loadExtensions();
expect(extensions).toHaveLength(1);
const ext1 = extensions.find((e) => e.config.name === 'ext1');
expect(ext1?.contextFiles).toEqual([
path.join(workspaceExtensionsDir, 'ext1', 'my-context-file.md'),
path.join(userExtensionsDir, 'ext1', 'my-context-file.md'),
]);
});
it('should filter out disabled extensions', () => {
createExtension({
extensionsDir: workspaceExtensionsDir,
extensionsDir: userExtensionsDir,
name: 'ext1',
version: '1.0.0',
});
createExtension({
extensionsDir: workspaceExtensionsDir,
extensionsDir: userExtensionsDir,
name: 'ext2',
version: '2.0.0',
});
const settingsDir = path.join(tempWorkspaceDir, GEMINI_DIR);
const settingsDir = path.join(tempHomeDir, GEMINI_DIR);
fs.mkdirSync(settingsDir, { recursive: true });
fs.writeFileSync(
path.join(settingsDir, 'settings.json'),
JSON.stringify({ extensions: { disabled: ['ext1'] } }),
);
const extensions = loadExtensions(tempWorkspaceDir);
const extensions = loadExtensions();
const activeExtensions = annotateActiveExtensions(
extensions,
[],
tempWorkspaceDir,
tempHomeDir,
).filter((e) => e.isActive);
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext2');
@@ -194,7 +189,7 @@ describe('loadExtensions', () => {
it('should hydrate variables', () => {
createExtension({
extensionsDir: workspaceExtensionsDir,
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
addContextFile: false,
@@ -206,11 +201,11 @@ describe('loadExtensions', () => {
},
});
const extensions = loadExtensions(tempWorkspaceDir);
const extensions = loadExtensions();
expect(extensions).toHaveLength(1);
const loadedConfig = extensions[0].config;
const expectedCwd = path.join(
workspaceExtensionsDir,
userExtensionsDir,
'test-extension',
'server',
);
@@ -218,6 +213,9 @@ describe('loadExtensions', () => {
});
it('should load a linked extension correctly', async () => {
const tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
const sourceExtDir = createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension',
@@ -231,7 +229,7 @@ describe('loadExtensions', () => {
type: 'link',
});
expect(extensionName).toEqual('my-linked-extension');
const extensions = loadExtensions(tempHomeDir);
const extensions = loadExtensions();
expect(extensions).toHaveLength(1);
const linkedExt = extensions[0];
@@ -252,13 +250,13 @@ describe('loadExtensions', () => {
process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb';
try {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
const userExtensionsDir = path.join(
tempHomeDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
const extDir = path.join(workspaceExtensionsDir, 'test-extension');
const extDir = path.join(userExtensionsDir, 'test-extension');
fs.mkdirSync(extDir);
// Write config to a separate file for clarity and good practices
@@ -280,7 +278,7 @@ describe('loadExtensions', () => {
};
fs.writeFileSync(configPath, JSON.stringify(extensionConfig));
const extensions = loadExtensions(tempWorkspaceDir);
const extensions = loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
@@ -302,13 +300,10 @@ describe('loadExtensions', () => {
});
it('should handle missing environment variables gracefully', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
fs.mkdirSync(userExtensionsDir, { recursive: true });
const extDir = path.join(workspaceExtensionsDir, 'test-extension');
const extDir = path.join(userExtensionsDir, 'test-extension');
fs.mkdirSync(extDir);
const extensionConfig = {
@@ -331,7 +326,7 @@ describe('loadExtensions', () => {
JSON.stringify(extensionConfig),
);
const extensions = loadExtensions(tempWorkspaceDir);
const extensions = loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
@@ -542,6 +537,19 @@ describe('installExtension', () => {
});
fs.rmSync(targetExtDir, { recursive: true, force: true });
});
it('should log to clearcut on successful install', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
await installExtension({ source: sourceExtDir, type: 'local' });
const logger = ClearcutLogger.getInstance({} as Config);
expect(logger?.logExtensionInstallEvent).toHaveBeenCalled();
});
});
describe('uninstallExtension', () => {
@@ -592,7 +600,7 @@ describe('uninstallExtension', () => {
await uninstallExtension('my-local-extension');
expect(fs.existsSync(sourceExtDir)).toBe(false);
expect(loadExtensions(tempHomeDir)).toHaveLength(1);
expect(loadExtensions()).toHaveLength(1);
expect(fs.existsSync(otherExtDir)).toBe(true);
});
@@ -675,7 +683,7 @@ describe('performWorkspaceExtensionMigration', () => {
});
await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]);
const extensions = loadExtensions(tempWorkspaceDir);
const extensions = loadExtensions();
expect(extensions).toEqual([]);
});
@@ -703,7 +711,7 @@ describe('performWorkspaceExtensionMigration', () => {
const userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
const userExt1Path = path.join(userExtensionsDir, 'ext1');
const extensions = loadExtensions(tempWorkspaceDir);
const extensions = loadExtensions();
expect(extensions).toHaveLength(2);
const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME);
@@ -912,7 +920,7 @@ describe('enableExtension', () => {
});
const getActiveExtensions = (): GeminiCLIExtension[] => {
const extensions = loadExtensions(tempWorkspaceDir);
const extensions = loadExtensions();
const activeExtensions = annotateActiveExtensions(
extensions,
[],

View File

@@ -8,7 +8,13 @@ import type {
MCPServerConfig,
GeminiCLIExtension,
} from '@google/gemini-cli-core';
import { GEMINI_DIR, Storage } from '@google/gemini-cli-core';
import {
GEMINI_DIR,
Storage,
ClearcutLogger,
Config,
ExtensionInstallEvent,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
@@ -18,6 +24,7 @@ import { getErrorMessage } from '../utils/errors.js';
import { recursivelyHydrateStrings } from './extensions/variables.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { randomUUID } from 'node:crypto';
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
@@ -119,7 +126,8 @@ export function loadExtensions(
if (
(isWorkspaceTrusted(settings) ?? true) &&
!settings.experimental?.extensionManagement
// Default management setting to true
!(settings.experimental?.extensionManagement ?? true)
) {
allExtensions.push(...getWorkspaceExtensions(workspaceDir));
}
@@ -345,83 +353,120 @@ export async function installExtension(
installMetadata: ExtensionInstallMetadata,
cwd: string = process.cwd(),
): Promise<string> {
const settings = loadSettings(cwd).merged;
if (!isWorkspaceTrusted(settings)) {
throw new Error(
`Could not install extension from untrusted folder at ${installMetadata.source}`,
);
}
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
await fs.promises.mkdir(extensionsDir, { recursive: true });
// Convert relative paths to absolute paths for the metadata file.
if (
!path.isAbsolute(installMetadata.source) &&
(installMetadata.type === 'local' || installMetadata.type === 'link')
) {
installMetadata.source = path.resolve(cwd, installMetadata.source);
}
let localSourcePath: string;
let tempDir: string | undefined;
let newExtensionName: string | undefined;
if (installMetadata.type === 'git') {
tempDir = await ExtensionStorage.createTmpDir();
await cloneFromGit(installMetadata.source, tempDir);
localSourcePath = tempDir;
} else if (
installMetadata.type === 'local' ||
installMetadata.type === 'link'
) {
localSourcePath = installMetadata.source;
} else {
throw new Error(`Unsupported install type: ${installMetadata.type}`);
}
const config = new Config({
sessionId: randomUUID(),
targetDir: process.cwd(),
cwd: process.cwd(),
model: '',
debugMode: false,
});
const logger = ClearcutLogger.getInstance(config);
let newExtensionConfig: ExtensionConfig | null = null;
let localSourcePath: string | undefined;
try {
const newExtensionConfig = await loadExtensionConfig(localSourcePath);
if (!newExtensionConfig) {
const settings = loadSettings(cwd).merged;
if (!isWorkspaceTrusted(settings)) {
throw new Error(
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
`Could not install extension from untrusted folder at ${installMetadata.source}`,
);
}
newExtensionName = newExtensionConfig.name;
const extensionStorage = new ExtensionStorage(newExtensionName);
const destinationPath = extensionStorage.getExtensionDir();
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
await fs.promises.mkdir(extensionsDir, { recursive: true });
const installedExtensions = loadUserExtensions();
if (
installedExtensions.some(
(installed) => installed.config.name === newExtensionName,
)
!path.isAbsolute(installMetadata.source) &&
(installMetadata.type === 'local' || installMetadata.type === 'link')
) {
throw new Error(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
installMetadata.source = path.resolve(cwd, installMetadata.source);
}
let tempDir: string | undefined;
if (installMetadata.type === 'git') {
tempDir = await ExtensionStorage.createTmpDir();
await cloneFromGit(installMetadata.source, tempDir);
localSourcePath = tempDir;
} else if (
installMetadata.type === 'local' ||
installMetadata.type === 'link'
) {
localSourcePath = installMetadata.source;
} else {
throw new Error(`Unsupported install type: ${installMetadata.type}`);
}
try {
newExtensionConfig = await loadExtensionConfig(localSourcePath);
if (!newExtensionConfig) {
throw new Error(
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
);
}
const newExtensionName = newExtensionConfig.name;
const extensionStorage = new ExtensionStorage(newExtensionName);
const destinationPath = extensionStorage.getExtensionDir();
const installedExtensions = loadUserExtensions();
if (
installedExtensions.some(
(installed) => installed.config.name === newExtensionName,
)
) {
throw new Error(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
);
}
await fs.promises.mkdir(destinationPath, { recursive: true });
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
await copyExtension(localSourcePath, destinationPath);
}
const metadataString = JSON.stringify(installMetadata, null, 2);
const metadataPath = path.join(
destinationPath,
INSTALL_METADATA_FILENAME,
);
await fs.promises.writeFile(metadataPath, metadataString);
} finally {
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
}
await fs.promises.mkdir(destinationPath, { recursive: true });
logger?.logExtensionInstallEvent(
new ExtensionInstallEvent(
newExtensionConfig!.name,
newExtensionConfig!.version,
installMetadata.source,
'success',
),
);
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
await copyExtension(localSourcePath, destinationPath);
}
const metadataString = JSON.stringify(installMetadata, null, 2);
const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME);
await fs.promises.writeFile(metadataPath, metadataString);
} finally {
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
return newExtensionConfig!.name;
} catch (error) {
// Attempt to load config from the source path even if installation fails
// to get the name and version for logging.
if (!newExtensionConfig && localSourcePath) {
newExtensionConfig = await loadExtensionConfig(localSourcePath);
}
logger?.logExtensionInstallEvent(
new ExtensionInstallEvent(
newExtensionConfig?.name ?? '',
newExtensionConfig?.version ?? '',
installMetadata.source,
'error',
),
);
throw error;
}
return newExtensionName;
}
async function loadExtensionConfig(
export async function loadExtensionConfig(
extensionDir: string,
): Promise<ExtensionConfig | null> {
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);

View File

@@ -521,7 +521,7 @@ describe('Settings Loading and Merging', () => {
});
});
it('should ignore folderTrust from workspace settings', () => {
it('should use folderTrust from workspace settings when trusted', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
security: {
@@ -533,7 +533,7 @@ describe('Settings Loading and Merging', () => {
const workspaceSettingsContent = {
security: {
folderTrust: {
enabled: false, // This should be ignored
enabled: false, // This should be used
},
},
};
@@ -554,7 +554,7 @@ describe('Settings Loading and Merging', () => {
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.security?.folderTrust?.enabled).toBe(true); // User setting should be used
expect(settings.merged.security?.folderTrust?.enabled).toBe(false); // Workspace setting should be used
});
it('should use system folderTrust over user setting', () => {

View File

@@ -76,7 +76,7 @@ const MIGRATION_MAP: Record<string, string> = {
excludeTools: 'tools.exclude',
excludeMCPServers: 'mcp.excluded',
excludedProjectEnvVars: 'advanced.excludedEnvVars',
extensionManagement: 'advanced.extensionManagement',
extensionManagement: 'experimental.extensionManagement',
extensions: 'extensions',
fileFiltering: 'context.fileFiltering',
folderTrustFeature: 'security.folderTrust.featureEnabled',
@@ -329,18 +329,6 @@ function mergeSettings(
): Settings {
const safeWorkspace = isTrusted ? workspace : ({} as Settings);
// folderTrust is not supported at workspace level.
const { security, ...restOfWorkspace } = safeWorkspace;
const safeWorkspaceWithoutFolderTrust = security
? {
...restOfWorkspace,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
security: (({ folderTrust, ...rest }) => rest)(security),
}
: {
...restOfWorkspace,
};
// Settings are merged with the following precedence (last one wins for
// single values):
// 1. System Defaults
@@ -352,7 +340,7 @@ function mergeSettings(
{}, // Start with an empty object
systemDefaults,
user,
safeWorkspaceWithoutFolderTrust,
safeWorkspace,
system,
) as Settings;
}

View File

@@ -838,7 +838,7 @@ export const SETTINGS_SCHEMA = {
label: 'Extension Management',
category: 'Experimental',
requiresRestart: true,
default: false,
default: true,
description: 'Enable extension management features.',
showInDialog: false,
},

View File

@@ -20,7 +20,8 @@ export function useWorkspaceMigration(settings: LoadedSettings) {
);
useEffect(() => {
if (!settings.merged.experimental?.extensionManagement) {
// Default to true if not set.
if (!(settings.merged.experimental?.extensionManagement ?? true)) {
return;
}
const cwd = process.cwd();