diff --git a/eslint.config.js b/eslint.config.js index ec5efb7c5fc94..9714d00e8bc0f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2015,6 +2015,7 @@ export default tseslint.config( 'vs/editor/contrib/*/~', 'vs/workbench/~', 'vs/workbench/services/*/~', + 'vs/sessions/services/*/~', { 'when': 'test', 'pattern': 'vs/workbench/contrib/*/~' diff --git a/extensions/git/esbuild.mts b/extensions/git/esbuild.mts index 35c8f6c63f0da..1b397880bc6ca 100644 --- a/extensions/git/esbuild.mts +++ b/extensions/git/esbuild.mts @@ -2,12 +2,27 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { run } from '../esbuild-extension-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); +async function copyNonTsFiles(outDir: string): Promise { + const entries = await fs.readdir(srcDir, { withFileTypes: true, recursive: true }); + for (const entry of entries) { + if (!entry.isFile() || entry.name.endsWith('.ts')) { + continue; + } + const srcPath = path.join(entry.parentPath, entry.name); + const relativePath = path.relative(srcDir, srcPath); + const destPath = path.join(outDir, relativePath); + await fs.mkdir(path.dirname(destPath), { recursive: true }); + await fs.copyFile(srcPath, destPath); + } +} + run({ platform: 'node', entryPoints: { @@ -17,4 +32,4 @@ run({ }, srcDir, outdir: outDir, -}, process.argv); +}, process.argv, copyNonTsFiles); diff --git a/extensions/github/package.json b/extensions/github/package.json index 815c645270632..78577f2192d15 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -184,18 +184,6 @@ } ], "chat/input/editing/sessionApplyActions": [ - { - "command": "github.createPullRequest", - "group": "navigation", - "order": 1, - "when": "isSessionsWindow && agentSessionHasChanges && chatSessionType == copilotcli && !github.hasOpenPullRequest" - }, - { - "command": "github.openPullRequest", - "group": "navigation", - "order": 1, - "when": "isSessionsWindow && agentSessionHasChanges && chatSessionType == copilotcli && github.hasOpenPullRequest" - } ] }, "configuration": [ diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changesView/browser/changesView.ts index 1bed45ff7c6e6..1ef5586817f44 100644 --- a/src/vs/sessions/contrib/changesView/browser/changesView.ts +++ b/src/vs/sessions/contrib/changesView/browser/changesView.ts @@ -54,7 +54,6 @@ import { createFileIconThemableTreeContainerScope } from '../../../../workbench/ import { IActivityService, NumberBadge } from '../../../../workbench/services/activity/common/activity.js'; import { IEditorService, MODAL_GROUP, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; @@ -254,7 +253,6 @@ export class ChangesViewPane extends ViewPane { @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @ILabelService private readonly labelService: ILabelService, @IStorageService private readonly storageService: IStorageService, - @ICommandService private readonly commandService: ICommandService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -561,18 +559,23 @@ export class ChangesViewPane extends ViewPane { return files > 0; })); - // Check if a PR exists when the active session changes + // Set context key for PR state from session metadata + const hasOpenPullRequestKey = scopedContextKeyService.createKey('sessions.hasOpenPullRequest', false); this.renderDisposables.add(autorun(reader => { const sessionResource = activeSessionResource.read(reader); + sessionsChangedSignal.read(reader); if (sessionResource) { const metadata = this.agentSessionsService.getSession(sessionResource)?.metadata; - this.commandService.executeCommand('github.checkOpenPullRequest', sessionResource, metadata).catch(() => { /* ignore */ }); + hasOpenPullRequestKey.set(!!metadata?.pullRequestUrl); + } else { + hasOpenPullRequestKey.set(false); } })); this.renderDisposables.add(autorun(reader => { const { isSessionMenu, added, removed } = topLevelStats.read(reader); const sessionResource = activeSessionResource.read(reader); + sessionsChangedSignal.read(reader); // Re-evaluate when session metadata changes (e.g. pullRequestUrl) const menuId = isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar; reader.store.add(scopedInstantiationService.createInstance( @@ -581,6 +584,7 @@ export class ChangesViewPane extends ViewPane { menuId, { telemetrySource: 'changesView', + disableWhileRunning: isSessionMenu, menuOptions: isSessionMenu && sessionResource ? { args: [sessionResource, this.agentSessionsService.getSession(sessionResource)?.metadata] } : { shouldForwardArgs: true }, @@ -592,9 +596,6 @@ export class ChangesViewPane extends ViewPane { ); return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'working-set-diff-stats', customLabel: diffStatsLabel }; } - if (action.id === 'github.createPullRequest' || action.id === 'github.openPullRequest') { - return { showIcon: true, showLabel: true, isSecondary: true }; - } if (action.id === 'chatEditing.synchronizeChanges') { return { showIcon: true, showLabel: true, isSecondary: true }; } diff --git a/src/vs/sessions/contrib/changesView/browser/media/changesView.css b/src/vs/sessions/contrib/changesView/browser/media/changesView.css index 4f6d74f525c6a..21cddd20fd875 100644 --- a/src/vs/sessions/contrib/changesView/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changesView/browser/media/changesView.css @@ -104,7 +104,6 @@ .changes-view-body .chat-editing-session-actions.outside-card .monaco-button { height: 26px; padding: 4px 14px; - border-radius: 4px; font-size: 12px; line-height: 18px; } diff --git a/src/vs/sessions/contrib/markdownPreview/browser/markdownPreview.contribution.ts b/src/vs/sessions/contrib/markdownPreview/browser/markdownPreview.contribution.ts deleted file mode 100644 index f186d71637d78..0000000000000 --- a/src/vs/sessions/contrib/markdownPreview/browser/markdownPreview.contribution.ts +++ /dev/null @@ -1,24 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { localize } from '../../../../nls.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; -import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; -import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; - -// Show a floating "Open Preview" button in the editor content -// area when editing markdown or related prompt/instructions/chatagent/skill -// language content in the sessions window. -MenuRegistry.appendMenuItem(MenuId.EditorContent, { - command: { - id: 'markdown.showPreviewToSide', - title: localize('openPreview', "Open Preview"), - }, - when: ContextKeyExpr.and( - IsSessionsWindowContext, - ContextKeyExpr.regex(EditorContextKeys.languageId.key, /^(markdown|prompt|instructions|chatagent|skill)$/), - ), -}); diff --git a/src/vs/sessions/electron-browser/sessions.main.ts b/src/vs/sessions/electron-browser/sessions.main.ts index 3a5ed7dff30e0..2dc3a764678b1 100644 --- a/src/vs/sessions/electron-browser/sessions.main.ts +++ b/src/vs/sessions/electron-browser/sessions.main.ts @@ -352,14 +352,15 @@ export class SessionsMain extends Disposable { logService: ILogService, policyService: IPolicyService ): Promise<{ configurationService: ConfigurationService; workspaceContextService: SessionsWorkspaceContextService }> { - const configurationService = new ConfigurationService(userDataProfileService.currentProfile.settingsResource, fileService, policyService, logService); + const workspaceContextService = new SessionsWorkspaceContextService(workspaceIdentifier, uriIdentityService); + const configurationService = new ConfigurationService(userDataProfileService, workspaceContextService, uriIdentityService, fileService, policyService, logService); try { await configurationService.initialize(); } catch (error) { onUnexpectedError(error); } - const workspaceContextService = new SessionsWorkspaceContextService(workspaceIdentifier, uriIdentityService, configurationService); + workspaceContextService.setConfigurationService(configurationService); return { configurationService, workspaceContextService }; } diff --git a/src/vs/sessions/services/configuration/browser/configurationService.ts b/src/vs/sessions/services/configuration/browser/configurationService.ts index 3c145277fa4c6..01fb00152a6a1 100644 --- a/src/vs/sessions/services/configuration/browser/configurationService.ts +++ b/src/vs/sessions/services/configuration/browser/configurationService.ts @@ -3,25 +3,375 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event } from '../../../../base/common/event.js'; -import { Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { ConfigurationService as BaseConfigurationService } from '../../../../platform/configuration/common/configurationService.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Queue } from '../../../../base/common/async.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { JSONPath, ParseError, parse } from '../../../../base/common/json.js'; +import { applyEdits, setProperty } from '../../../../base/common/jsonEdit.js'; +import { Edit, FormattingOptions } from '../../../../base/common/jsonFormatter.js'; +import { equals } from '../../../../base/common/objects.js'; +import { distinct, equals as arrayEquals } from '../../../../base/common/arrays.js'; +import { OS, OperatingSystem } from '../../../../base/common/platform.js'; +import { IConfigurationChange, IConfigurationChangeEvent, IConfigurationData, IConfigurationOverrides, IConfigurationUpdateOptions, IConfigurationUpdateOverrides, IConfigurationValue, ConfigurationTarget, isConfigurationOverrides, isConfigurationUpdateOverrides } from '../../../../platform/configuration/common/configuration.js'; +import { ConfigurationChangeEvent, ConfigurationModel } from '../../../../platform/configuration/common/configurationModels.js'; +import { DefaultConfiguration, IPolicyConfiguration, NullPolicyConfiguration, PolicyConfiguration } from '../../../../platform/configuration/common/configurations.js'; +import { Extensions, IConfigurationRegistry, keyFromOverrideIdentifiers } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { IFileService, FileOperationError, FileOperationResult } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IPolicyService, NullPolicyService } from '../../../../platform/policy/common/policy.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { APPLICATION_SCOPES, APPLY_ALL_PROFILES_SETTING, IWorkbenchConfigurationService, RestrictedSettings } from '../../../../workbench/services/configuration/common/configuration.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IWorkspaceContextService, IWorkspaceFoldersChangeEvent, IWorkspaceFolder, WorkbenchState, Workspace } from '../../../../platform/workspace/common/workspace.js'; +import { FolderConfiguration, UserConfiguration } from '../../../../workbench/services/configuration/browser/configuration.js'; +import { APPLICATION_SCOPES, APPLY_ALL_PROFILES_SETTING, FOLDER_CONFIG_FOLDER_NAME, FOLDER_SETTINGS_PATH, IWorkbenchConfigurationService, RestrictedSettings } from '../../../../workbench/services/configuration/common/configuration.js'; +import { Configuration } from '../../../../workbench/services/configuration/common/configurationModels.js'; +import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js'; -// Import to register contributions +// Import to register configuration contributions import '../../../../workbench/services/configuration/browser/configurationService.js'; -export class ConfigurationService extends BaseConfigurationService implements IWorkbenchConfigurationService { - readonly restrictedSettings: RestrictedSettings = { default: [] }; +export class ConfigurationService extends Disposable implements IWorkbenchConfigurationService { + + declare readonly _serviceBrand: undefined; + + private _configuration: Configuration; + private readonly defaultConfiguration: DefaultConfiguration; + private readonly policyConfiguration: IPolicyConfiguration; + private readonly userConfiguration: UserConfiguration; + private readonly cachedFolderConfigs = this._register(new DisposableMap(new ResourceMap())); + + private readonly _onDidChangeConfiguration = this._register(new Emitter()); + readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event; + readonly onDidChangeRestrictedSettings = Event.None; + readonly restrictedSettings: RestrictedSettings = { default: [] }; + + private readonly configurationRegistry = Registry.as(Extensions.Configuration); + + private readonly settingsResource: URI; + private readonly configurationEditing: ConfigurationEditing; + + constructor( + userDataProfileService: IUserDataProfileService, + private readonly workspaceService: IWorkspaceContextService, + private readonly uriIdentityService: IUriIdentityService, + private readonly fileService: IFileService, + policyService: IPolicyService, + private readonly logService: ILogService, + ) { + super(); + + this.settingsResource = userDataProfileService.currentProfile.settingsResource; + this.defaultConfiguration = this._register(new DefaultConfiguration(logService)); + this.policyConfiguration = policyService instanceof NullPolicyService ? new NullPolicyConfiguration() : this._register(new PolicyConfiguration(this.defaultConfiguration, policyService, logService)); + this.userConfiguration = this._register(new UserConfiguration(userDataProfileService.currentProfile.settingsResource, userDataProfileService.currentProfile.tasksResource, userDataProfileService.currentProfile.mcpResource, {}, fileService, uriIdentityService, logService)); + this.configurationEditing = new ConfigurationEditing(fileService, this); + + this._configuration = new Configuration( + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + new ResourceMap(), + ConfigurationModel.createEmptyModel(logService), + new ResourceMap(), + this.workspaceService.getWorkspace() as Workspace, + this.logService + ); + + this._register(this.defaultConfiguration.onDidChangeConfiguration(({ defaults, properties }) => this.onDefaultConfigurationChanged(defaults, properties))); + this._register(this.policyConfiguration.onDidChangeConfiguration(configurationModel => this.onPolicyConfigurationChanged(configurationModel))); + this._register(this.userConfiguration.onDidChangeConfiguration(userConfiguration => this.onUserConfigurationChanged(userConfiguration))); + this._register(this.workspaceService.onWillChangeWorkspaceFolders(e => e.join(this.loadFolderConfigurations(e.changes.added)))); + this._register(this.workspaceService.onDidChangeWorkspaceFolders(e => this.onWorkspaceFoldersChanged(e))); + } + + async initialize(): Promise { + const [defaultModel, policyModel, userModel] = await Promise.all([ + this.defaultConfiguration.initialize(), + this.policyConfiguration.initialize(), + this.userConfiguration.initialize() + ]); + const workspace = this.workspaceService.getWorkspace() as Workspace; + this._configuration = new Configuration( + defaultModel, + policyModel, + ConfigurationModel.createEmptyModel(this.logService), + userModel, + ConfigurationModel.createEmptyModel(this.logService), + ConfigurationModel.createEmptyModel(this.logService), + new ResourceMap(), + ConfigurationModel.createEmptyModel(this.logService), + new ResourceMap(), + workspace, + this.logService + ); + await this.loadFolderConfigurations(workspace.folders); + } + + // #region IWorkbenchConfigurationService + + getConfigurationData(): IConfigurationData { + return this._configuration.toData(); + } + + getValue(): T; + getValue(section: string): T; + getValue(overrides: IConfigurationOverrides): T; + getValue(section: string, overrides: IConfigurationOverrides): T; + getValue(arg1?: unknown, arg2?: unknown): unknown { + const section = typeof arg1 === 'string' ? arg1 : undefined; + const overrides = isConfigurationOverrides(arg1) ? arg1 : isConfigurationOverrides(arg2) ? arg2 : undefined; + return this._configuration.getValue(section, overrides); + } + + updateValue(key: string, value: unknown): Promise; + updateValue(key: string, value: unknown, overrides: IConfigurationOverrides | IConfigurationUpdateOverrides): Promise; + updateValue(key: string, value: unknown, target: ConfigurationTarget): Promise; + updateValue(key: string, value: unknown, overrides: IConfigurationOverrides | IConfigurationUpdateOverrides, target: ConfigurationTarget, options?: IConfigurationUpdateOptions): Promise; + async updateValue(key: string, value: unknown, arg3?: unknown, arg4?: unknown, _options?: IConfigurationUpdateOptions): Promise { + const overrides: IConfigurationUpdateOverrides | undefined = isConfigurationUpdateOverrides(arg3) ? arg3 + : isConfigurationOverrides(arg3) ? { resource: arg3.resource, overrideIdentifiers: arg3.overrideIdentifier ? [arg3.overrideIdentifier] : undefined } : undefined; + const target: ConfigurationTarget | undefined = (overrides ? arg4 : arg3) as ConfigurationTarget | undefined; + + if (overrides?.overrideIdentifiers) { + overrides.overrideIdentifiers = distinct(overrides.overrideIdentifiers); + overrides.overrideIdentifiers = overrides.overrideIdentifiers.length ? overrides.overrideIdentifiers : undefined; + } + + const inspect = this.inspect(key, { resource: overrides?.resource, overrideIdentifier: overrides?.overrideIdentifiers ? overrides.overrideIdentifiers[0] : undefined }); + if (inspect.policyValue !== undefined) { + throw new Error(`Unable to write ${key} because it is configured in system policy.`); + } + + // Remove the setting, if the value is same as default value + if (equals(value, inspect.defaultValue)) { + value = undefined; + } + + if (overrides?.overrideIdentifiers?.length && overrides.overrideIdentifiers.length > 1) { + const overrideIdentifiers = overrides.overrideIdentifiers.sort(); + const existingOverrides = this._configuration.localUserConfiguration.overrides.find(override => arrayEquals([...override.identifiers].sort(), overrideIdentifiers)); + if (existingOverrides) { + overrides.overrideIdentifiers = existingOverrides.identifiers; + } + } + + const path = overrides?.overrideIdentifiers?.length ? [keyFromOverrideIdentifiers(overrides.overrideIdentifiers), key] : [key]; + + const settingsResource = this.getSettingsResource(target, overrides?.resource ?? undefined); + await this.configurationEditing.write(settingsResource, path, value); + await this.reloadConfiguration(); + } + + private getSettingsResource(target: ConfigurationTarget | undefined, resource: URI | undefined): URI { + if (target === ConfigurationTarget.WORKSPACE_FOLDER || target === ConfigurationTarget.WORKSPACE) { + if (resource) { + const folder = this.workspaceService.getWorkspaceFolder(resource); + if (folder) { + return this.uriIdentityService.extUri.joinPath(folder.uri, FOLDER_SETTINGS_PATH); + } + } + } + return this.settingsResource; + } + + inspect(key: string, overrides?: IConfigurationOverrides): IConfigurationValue { + return this._configuration.inspect(key, overrides); + } + + keys(): { default: string[]; policy: string[]; user: string[]; workspace: string[]; workspaceFolder: string[] } { + return this._configuration.keys(); + } + + async reloadConfiguration(_target?: ConfigurationTarget | IWorkspaceFolder): Promise { + const userModel = await this.userConfiguration.initialize(); + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdateLocalUserConfiguration(userModel); + + // Reload folder configurations + for (const folder of this.workspaceService.getWorkspace().folders) { + const folderConfiguration = this.cachedFolderConfigs.get(folder.uri); + if (folderConfiguration) { + const folderModel = await folderConfiguration.loadConfiguration(); + const folderChange = this._configuration.compareAndUpdateFolderConfiguration(folder.uri, folderModel); + change.keys.push(...folderChange.keys); + change.overrides.push(...folderChange.overrides); + } + } + + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.USER); + } + + hasCachedConfigurationDefaultsOverrides(): boolean { + return false; + } + async whenRemoteConfigurationLoaded(): Promise { } + isSettingAppliedForAllProfiles(key: string): boolean { - const scope = Registry.as(Extensions.Configuration).getConfigurationProperties()[key]?.scope; + const scope = this.configurationRegistry.getConfigurationProperties()[key]?.scope; if (scope && APPLICATION_SCOPES.includes(scope)) { return true; } const allProfilesSettings = this.getValue(APPLY_ALL_PROFILES_SETTING) ?? []; return Array.isArray(allProfilesSettings) && allProfilesSettings.includes(key); } + + // #endregion + + // #region Configuration change handlers + + private onDefaultConfigurationChanged(defaults: ConfigurationModel, properties?: string[]): void { + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdateDefaultConfiguration(defaults, properties); + this._configuration.updateLocalUserConfiguration(this.userConfiguration.reparse()); + for (const folder of this.workspaceService.getWorkspace().folders) { + const folderConfiguration = this.cachedFolderConfigs.get(folder.uri); + if (folderConfiguration) { + this._configuration.updateFolderConfiguration(folder.uri, folderConfiguration.reparse()); + } + } + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.DEFAULT); + } + + private onPolicyConfigurationChanged(policyConfiguration: ConfigurationModel): void { + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdatePolicyConfiguration(policyConfiguration); + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.DEFAULT); + } + + private onUserConfigurationChanged(userConfiguration: ConfigurationModel): void { + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdateLocalUserConfiguration(userConfiguration); + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.USER); + } + + private onWorkspaceFoldersChanged(e: IWorkspaceFoldersChangeEvent): void { + // Remove configurations for removed folders + const previousData = this._configuration.toData(); + const keys: string[] = []; + const overrides: [string, string[]][] = []; + for (const folder of e.removed) { + const change = this._configuration.compareAndDeleteFolderConfiguration(folder.uri); + keys.push(...change.keys); + overrides.push(...change.overrides); + this.cachedFolderConfigs.deleteAndDispose(folder.uri); + } + if (keys.length || overrides.length) { + this.triggerConfigurationChange({ keys, overrides }, previousData, ConfigurationTarget.WORKSPACE_FOLDER); + } + } + + private onWorkspaceFolderConfigurationChanged(folder: IWorkspaceFolder): void { + const folderConfiguration = this.cachedFolderConfigs.get(folder.uri); + if (folderConfiguration) { + folderConfiguration.loadConfiguration().then(configurationModel => { + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdateFolderConfiguration(folder.uri, configurationModel); + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.WORKSPACE_FOLDER); + }, onUnexpectedError); + } + } + + private async loadFolderConfigurations(folders: readonly IWorkspaceFolder[]): Promise { + for (const folder of folders) { + let folderConfiguration = this.cachedFolderConfigs.get(folder.uri); + if (!folderConfiguration) { + folderConfiguration = new FolderConfiguration(false, folder, FOLDER_CONFIG_FOLDER_NAME, WorkbenchState.WORKSPACE, true, this.fileService, this.uriIdentityService, this.logService, { needsCaching: () => false, read: async () => '', write: async () => { }, remove: async () => { } }); + folderConfiguration.addRelated(folderConfiguration.onDidChange(() => this.onWorkspaceFolderConfigurationChanged(folder))); + this.cachedFolderConfigs.set(folder.uri, folderConfiguration); + } + const configurationModel = await folderConfiguration.loadConfiguration(); + this._configuration.updateFolderConfiguration(folder.uri, configurationModel); + } + } + + private triggerConfigurationChange(change: IConfigurationChange, previousData: IConfigurationData, target: ConfigurationTarget): void { + if (change.keys.length) { + const workspace = this.workspaceService.getWorkspace() as Workspace; + const event = new ConfigurationChangeEvent(change, { data: previousData, workspace }, this._configuration, workspace, this.logService); + event.source = target; + this._onDidChangeConfiguration.fire(event); + } + } + + // #endregion +} + +class ConfigurationEditing { + + private readonly queue = new Queue(); + + constructor( + private readonly fileService: IFileService, + private readonly configurationService: ConfigurationService, + ) { } + + write(settingsResource: URI, path: JSONPath, value: unknown): Promise { + return this.queue.queue(() => this.doWriteConfiguration(settingsResource, path, value)); + } + + private async doWriteConfiguration(settingsResource: URI, path: JSONPath, value: unknown): Promise { + let content: string; + try { + const fileContent = await this.fileService.readFile(settingsResource); + content = fileContent.value.toString(); + } catch (error) { + if ((error as FileOperationError).fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { + content = '{}'; + } else { + throw error; + } + } + + const parseErrors: ParseError[] = []; + parse(content, parseErrors, { allowTrailingComma: true, allowEmptyContent: true }); + if (parseErrors.length > 0) { + throw new Error('Unable to write into the settings file. Please open the file to correct errors/warnings in the file and try again.'); + } + + const edits = this.getEdits(content, path, value); + content = applyEdits(content, edits); + + await this.fileService.writeFile(settingsResource, VSBuffer.fromString(content)); + } + + private getEdits(content: string, path: JSONPath, value: unknown): Edit[] { + const { tabSize, insertSpaces, eol } = this.formattingOptions; + + if (!path.length) { + const newContent = JSON.stringify(value, null, insertSpaces ? ' '.repeat(tabSize) : '\t'); + return [{ + content: newContent, + length: content.length, + offset: 0 + }]; + } + + return setProperty(content, path, value, { tabSize, insertSpaces, eol }); + } + + private _formattingOptions: Required | undefined; + private get formattingOptions(): Required { + if (!this._formattingOptions) { + let eol = OS === OperatingSystem.Linux || OS === OperatingSystem.Macintosh ? '\n' : '\r\n'; + const configuredEol = this.configurationService.getValue('files.eol', { overrideIdentifier: 'jsonc' }); + if (configuredEol && typeof configuredEol === 'string' && configuredEol !== 'auto') { + eol = configuredEol; + } + this._formattingOptions = { + eol, + insertSpaces: !!this.configurationService.getValue('editor.insertSpaces', { overrideIdentifier: 'jsonc' }), + tabSize: this.configurationService.getValue('editor.tabSize', { overrideIdentifier: 'jsonc' }) + }; + } + return this._formattingOptions; + } } diff --git a/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts b/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts new file mode 100644 index 0000000000000..fdfd8a4f1e9f4 --- /dev/null +++ b/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts @@ -0,0 +1,339 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; +import { FileService } from '../../../../../platform/files/common/fileService.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { NullPolicyService } from '../../../../../platform/policy/common/policy.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; +import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { joinPath } from '../../../../../base/common/resources.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { UserDataProfilesService } from '../../../../../platform/userDataProfile/common/userDataProfile.js'; +import { UserDataProfileService } from '../../../../../workbench/services/userDataProfile/common/userDataProfileService.js'; +import { FileUserDataProvider } from '../../../../../platform/userData/common/fileUserDataProvider.js'; +import { TestEnvironmentService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ConfigurationService } from '../../browser/configurationService.js'; +import { SessionsWorkspaceContextService } from '../../../workspace/browser/workspaceContextService.js'; +import { getWorkspaceIdentifier } from '../../../../../workbench/services/workspaces/browser/workspaces.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IUserDataProfileService } from '../../../../../workbench/services/userDataProfile/common/userDataProfile.js'; + +const ROOT = URI.file('tests').with({ scheme: 'vscode-tests' }); + +suite('Sessions ConfigurationService', () => { + + let testObject: ConfigurationService; + let workspaceService: SessionsWorkspaceContextService; + let fileService: FileService; + let userDataProfileService: IUserDataProfileService; + const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + suiteSetup(() => { + configurationRegistry.registerConfiguration({ + 'id': '_test_sessions', + 'type': 'object', + 'properties': { + 'sessionsConfigurationService.testSetting': { + 'type': 'string', + 'default': 'defaultValue', + scope: ConfigurationScope.RESOURCE + }, + 'sessionsConfigurationService.machineSetting': { + 'type': 'string', + 'default': 'defaultValue', + scope: ConfigurationScope.MACHINE + }, + 'sessionsConfigurationService.applicationSetting': { + 'type': 'string', + 'default': 'defaultValue', + scope: ConfigurationScope.APPLICATION + }, + } + }); + }); + + setup(async () => { + const logService = new NullLogService(); + fileService = disposables.add(new FileService(logService)); + const fileSystemProvider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(ROOT.scheme, fileSystemProvider)); + + const environmentService = TestEnvironmentService; + const uriIdentityService = disposables.add(new UriIdentityService(fileService)); + const userDataProfilesService = disposables.add(new UserDataProfilesService(environmentService, fileService, uriIdentityService, logService)); + disposables.add(fileService.registerProvider(Schemas.vscodeUserData, disposables.add(new FileUserDataProvider(ROOT.scheme, fileSystemProvider, Schemas.vscodeUserData, userDataProfilesService, uriIdentityService, logService)))); + userDataProfileService = disposables.add(new UserDataProfileService(userDataProfilesService.defaultProfile)); + + const configResource = joinPath(ROOT, 'agent-sessions.code-workspace'); + await fileService.writeFile(configResource, VSBuffer.fromString(JSON.stringify({ folders: [] }))); + + workspaceService = disposables.add(new SessionsWorkspaceContextService(getWorkspaceIdentifier(configResource), uriIdentityService)); + testObject = disposables.add(new ConfigurationService(userDataProfileService, workspaceService, uriIdentityService, fileService, new NullPolicyService(), logService)); + await testObject.initialize(); + }); + + // #region Reading + + test('defaults', () => { + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'defaultValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.machineSetting'), 'defaultValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.applicationSetting'), 'defaultValue'); + }); + + test('user settings override defaults', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "userValue" }')); + await testObject.reloadConfiguration(); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'userValue'); + })); + + test('workspace folder settings override user settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'myFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "userValue" }')); + await testObject.reloadConfiguration(); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + })); + + test('folder settings are read when folders are added', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'addedFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + })); + + test('folder settings are removed when folders are removed', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'removedFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + await workspaceService.removeFolders([folder]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'defaultValue'); + })); + + test('configuration change event is fired when folders with settings are removed', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'removedFolder2'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + + const promise = Event.toPromise(testObject.onDidChangeConfiguration); + await workspaceService.removeFolders([folder]); + const event = await promise; + assert.ok(event.affectsConfiguration('sessionsConfigurationService.testSetting')); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'defaultValue'); + })); + + test('configuration change event is fired on user settings change', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const promise = Event.toPromise(testObject.onDidChangeConfiguration); + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "userValue" }')); + await testObject.reloadConfiguration(); + const event = await promise; + assert.ok(event.affectsConfiguration('sessionsConfigurationService.testSetting')); + })); + + test('inspect returns correct values per layer', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'inspectFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "userValue" }')); + await testObject.reloadConfiguration(); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + + const inspection = testObject.inspect('sessionsConfigurationService.testSetting', { resource: folder }); + assert.strictEqual(inspection.defaultValue, 'defaultValue'); + assert.strictEqual(inspection.userValue, 'userValue'); + assert.strictEqual(inspection.workspaceFolderValue, 'folderValue'); + })); + + test('application settings are not read from workspace folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'appFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.applicationSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.applicationSetting', { resource: folder }), 'defaultValue'); + })); + + test('machine settings are not read from workspace folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'machineFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.machineSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.machineSetting', { resource: folder }), 'defaultValue'); + })); + + test('folder settings change fires configuration change event', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'changeFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "initialValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'initialValue'); + + const promise = Event.toPromise(testObject.onDidChangeConfiguration); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "updatedValue" }')); + const event = await promise; + assert.ok(event.affectsConfiguration('sessionsConfigurationService.testSetting')); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'updatedValue'); + })); + + // #endregion + + // #region Writing + + test('updateValue writes to user settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'writtenValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'writtenValue'); + })); + + test('updateValue persists to settings file', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'persistedValue'); + + const content = (await fileService.readFile(userDataProfileService.currentProfile.settingsResource)).value.toString(); + assert.ok(content.includes('"sessionsConfigurationService.testSetting"')); + assert.ok(content.includes('persistedValue')); + })); + + test('updateValue fires change event', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const promise = Event.toPromise(testObject.onDidChangeConfiguration); + await testObject.updateValue('sessionsConfigurationService.testSetting', 'eventValue'); + const event = await promise; + assert.ok(event.affectsConfiguration('sessionsConfigurationService.testSetting')); + })); + + test('updateValue removes setting when value equals default', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'nonDefault'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'nonDefault'); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'defaultValue'); + const content = (await fileService.readFile(userDataProfileService.currentProfile.settingsResource)).value.toString(); + assert.ok(!content.includes('sessionsConfigurationService.testSetting')); + })); + + test('updateValue can update multiple settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'value1'); + await testObject.updateValue('sessionsConfigurationService.machineSetting', 'value2'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'value1'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.machineSetting'), 'value2'); + })); + + test('updateValue with language override', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'langValue', { overrideIdentifier: 'jsonc' }); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { overrideIdentifier: 'jsonc' }), 'langValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'defaultValue'); + })); + + test('updateValue is reflected in inspect', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'inspectedValue'); + const inspection = testObject.inspect('sessionsConfigurationService.testSetting'); + assert.strictEqual(inspection.defaultValue, 'defaultValue'); + assert.strictEqual(inspection.userValue, 'inspectedValue'); + })); + + // #endregion + + // #region Workspace Folder - Read and Write + + test('read setting from workspace folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'readFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + + await workspaceService.addFolders([{ uri: folder }]); + + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'defaultValue'); + })); + + test('write setting to workspace folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'writeFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'writtenFolderValue', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'writtenFolderValue'); + })); + + test('write setting to workspace folder persists to folder settings file', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'persistFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'persistedFolderValue', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + const content = (await fileService.readFile(joinPath(folder, '.vscode', 'settings.json'))).value.toString(); + assert.ok(content.includes('"sessionsConfigurationService.testSetting"')); + assert.ok(content.includes('persistedFolderValue')); + })); + + test('write setting to workspace folder does not affect user settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'isolateFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'folderOnly', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderOnly'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'defaultValue'); + })); + + test('workspace folder setting overrides user setting for resource', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'overrideFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'userValue'); + await testObject.updateValue('sessionsConfigurationService.testSetting', 'folderValue', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'userValue'); + })); + + test('inspect shows workspace folder value after write', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'inspectWriteFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'userVal'); + await testObject.updateValue('sessionsConfigurationService.testSetting', 'folderVal', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + const inspection = testObject.inspect('sessionsConfigurationService.testSetting', { resource: folder }); + assert.strictEqual(inspection.defaultValue, 'defaultValue'); + assert.strictEqual(inspection.userValue, 'userVal'); + assert.strictEqual(inspection.workspaceFolderValue, 'folderVal'); + })); + + test('removing folder clears its written settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'clearFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + await testObject.updateValue('sessionsConfigurationService.testSetting', 'folderValue', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + + await workspaceService.removeFolders([folder]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'defaultValue'); + })); + + // #endregion +}); diff --git a/src/vs/sessions/services/workspace/browser/workspaceContextService.ts b/src/vs/sessions/services/workspace/browser/workspaceContextService.ts index 9d8b6763daba3..dce935e3f2446 100644 --- a/src/vs/sessions/services/workspace/browser/workspaceContextService.ts +++ b/src/vs/sessions/services/workspace/browser/workspaceContextService.ts @@ -35,7 +35,6 @@ export class SessionsWorkspaceContextService extends Disposable implements IWork constructor( workspaceIdentifier: IWorkspaceIdentifier, private readonly uriIdentityService: IUriIdentityService, - private readonly configurationService: IConfigurationService, ) { super(); this.workspace = new Workspace(workspaceIdentifier.id, [], false, workspaceIdentifier.configPath, uri => uriIdentityService.extUri.ignorePathCasing(uri)); @@ -53,8 +52,13 @@ export class SessionsWorkspaceContextService extends Disposable implements IWork return WorkbenchState.WORKSPACE; } + private _configurationService: IConfigurationService | undefined; + setConfigurationService(configurationService: IConfigurationService) { + this._configurationService = configurationService; + } + hasWorkspaceData(): boolean { - return this.configurationService.getValue('sessions.workspace.sendWorkspaceDataToExtHost') === true; + return this._configurationService?.getValue('sessions.workspace.sendWorkspaceDataToExtHost') === true; } getWorkspaceFolder(resource: URI): IWorkspaceFolder | null { @@ -159,7 +163,8 @@ export class SessionsWorkspaceContextService extends Disposable implements IWork // Update workspace const workspaceIdentifier = getWorkspaceIdentifier(this.workspace.configuration!); - this.workspace = new Workspace(workspaceIdentifier.id, newFolders, false, workspaceIdentifier.configPath, uri => this.uriIdentityService.extUri.ignorePathCasing(uri)); + const workspace = new Workspace(workspaceIdentifier.id, newFolders, false, workspaceIdentifier.configPath, uri => this.uriIdentityService.extUri.ignorePathCasing(uri)); + this.workspace.update(workspace); // Fire did change event this._onDidChangeWorkspaceFolders.fire(changes); diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 9ede9e80baaf1..efe6d190c0dac 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -211,7 +211,6 @@ import './contrib/gitSync/browser/gitSync.contribution.js'; import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js'; import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed import './contrib/configuration/browser/configuration.contribution.js'; -import './contrib/markdownPreview/browser/markdownPreview.contribution.js'; import './contrib/terminal/browser/sessionsTerminalContribution.js'; import './contrib/logs/browser/logs.contribution.js'; diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index 5beccafafbc51..ebe741adaade6 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts @@ -3,8 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Codicon } from '../../../../base/common/codicons.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; @@ -20,10 +22,15 @@ export class PluginInstallService implements IPluginInstallService { @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, @IFileService private readonly _fileService: IFileService, @INotificationService private readonly _notificationService: INotificationService, + @IDialogService private readonly _dialogService: IDialogService, @ILogService private readonly _logService: ILogService, ) { } async installPlugin(plugin: IMarketplacePlugin): Promise { + if (!await this._ensureMarketplaceTrusted(plugin)) { + return; + } + const kind = plugin.sourceDescriptor.kind; if (kind === PluginSourceKind.RelativePath) { @@ -61,6 +68,31 @@ export class PluginInstallService implements IPluginInstallService { return this._pluginRepositoryService.getPluginSourceInstallUri(plugin.sourceDescriptor); } + // --- Trust gate ------------------------------------------------------------- + + private async _ensureMarketplaceTrusted(plugin: IMarketplacePlugin): Promise { + if (this._pluginMarketplaceService.isMarketplaceTrusted(plugin.marketplaceReference)) { + return true; + } + + const { confirmed } = await this._dialogService.confirm({ + type: 'question', + message: localize('trustMarketplace', "Trust Plugins from '{0}'?", plugin.marketplaceReference.displayLabel), + detail: localize('trustMarketplaceDetail', "Plugins can run code on your machine. Only install plugins from sources you trust.\n\nSource: {0}", plugin.marketplaceReference.rawValue), + primaryButton: localize({ key: 'trustAndInstall', comment: ['&& denotes a mnemonic'] }, "&&Trust"), + custom: { + icon: Codicon.shield, + }, + }); + + if (!confirmed) { + return false; + } + + this._pluginMarketplaceService.trustMarketplace(plugin.marketplaceReference); + return true; + } + // --- Relative-path source (existing git-based flow) ----------------------- private async _installRelativePathPlugin(plugin: IMarketplacePlugin): Promise { diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index dbd6e9b7b179b..86054f4845089 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -154,6 +154,10 @@ export interface IPluginMarketplaceService { addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void; removeInstalledPlugin(pluginUri: URI): void; setInstalledPluginEnabled(pluginUri: URI, enabled: boolean): void; + /** Returns whether the given marketplace has been explicitly trusted by the user. */ + isMarketplaceTrusted(ref: IMarketplaceReference): boolean; + /** Records that the user trusts the given marketplace, persisted permanently. */ + trustMarketplace(ref: IMarketplaceReference): void; } /** @@ -209,10 +213,21 @@ const installedPluginsMemento = observableMemento({ + defaultValue: [], + key: 'chat.plugins.trustedMarketplaces.v1', + toStorage: value => JSON.stringify(value), + fromStorage: value => { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + }, +}); + export class PluginMarketplaceService extends Disposable implements IPluginMarketplaceService { declare readonly _serviceBrand: undefined; private readonly _gitHubMarketplaceCache = new Lazy>(() => this._loadPersistedGitHubMarketplaceCache()); private readonly _installedPluginsStore: ObservableMemento; + private readonly _trustedMarketplacesStore: ObservableMemento; readonly onDidChangeMarketplaces: Event; @@ -232,6 +247,10 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke installedPluginsMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, _storageService) ); + this._trustedMarketplacesStore = this._register( + trustedMarketplacesMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, _storageService) + ); + this.installedPlugins = this._installedPluginsStore.map(s => (revive(s) as readonly IMarketplaceInstalledPlugin[]).map(e => ({ ...e, @@ -456,6 +475,17 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke ); } + isMarketplaceTrusted(ref: IMarketplaceReference): boolean { + return this._trustedMarketplacesStore.get().includes(ref.canonicalId); + } + + trustMarketplace(ref: IMarketplaceReference): void { + const current = this._trustedMarketplacesStore.get(); + if (!current.includes(ref.canonicalId)) { + this._trustedMarketplacesStore.set([...current, ref.canonicalId], undefined); + } + } + private async _fetchFromClonedRepo(reference: IMarketplaceReference, token: CancellationToken): Promise { let repoDir: URI; try { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index 3b4151e65ebc6..de33bb91fd0ce 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -21,6 +21,7 @@ import { localize } from '../../../../../../nls.js'; import { formatArrayValue, getQuotePreference } from '../utils/promptEditHelper.js'; import { HOOKS_BY_TARGET, HOOK_METADATA } from '../hookTypes.js'; import { HOOK_COMMAND_FIELD_DESCRIPTIONS } from '../hookSchema.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { PromptsConfig } from '../config/config.js'; @@ -40,6 +41,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, @IChatModeService private readonly chatModeService: IChatModeService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @IConfigurationService private readonly configurationService: IConfigurationService, ) { } @@ -221,8 +223,8 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { if (value.type === 'sequence') { // if the position is inside the tools metadata, we provide tool name completions const getValues = async () => { - if (target === Target.GitHubCopilot) { - // for GitHub Copilot agent files, we only suggest the known set of tools that are supported by GitHub Copilot, instead of all tools that the user has defined, because many tools won't work with GitHub Copilot and it would be frustrating for users to select a tool that doesn't work + if (target === Target.GitHubCopilot || this.environmentService.isSessionsWindow) { + // for GitHub Copilot targets and the Sessions Window, we only suggest the known set of tools that are supported by GitHub Copilot, instead of all tools that the user has defined, because many tools won't work in these contexts and it would be frustrating for users to select a tool that doesn't work return knownGithubCopilotTools; } else if (target === Target.Claude) { return knownClaudeTools; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 5ee6297bb311b..c0675b3765981 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -9,10 +9,10 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; import * as nls from '../../../../../../nls.js'; import { FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js'; import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../config/config.js'; -import { basename, dirname, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; +import { basename, dirname, isEqual, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource, isInClaudeRulesFolder } from '../config/promptFileLocations.js'; +import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; import { PromptsType } from '../promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -26,6 +26,11 @@ import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; +/** + * Maximum recursion depth when traversing subdirectories for instruction files. + */ +const MAX_INSTRUCTIONS_RECURSION_DEPTH = 5; + /** * Utility class to locate prompt files. */ @@ -492,14 +497,23 @@ export class PromptFilesLocator { /** * Uses the file service to resolve the provided location and return either the file at the location of files in the directory. - * For claude rules folders (.claude/rules), this searches recursively to support subdirectories. + * For instruction folders, this searches recursively (up to {@link MAX_INSTRUCTIONS_RECURSION_DEPTH} levels deep) provided + * the location is not a workspace folder root and does not contain wildcards, to support subdirectories while avoiding + * accidentally broad traversal. */ - private async resolveFilesAtLocation(location: URI, type: PromptsType, token: CancellationToken): Promise { + private async resolveFilesAtLocation(location: URI, type: PromptsType, token: CancellationToken, depth: number = 0): Promise { if (type === PromptsType.skill) { return this.findAgentSkillsInFolder(location, token); } - // Claude rules folders support subdirectories, so search recursively - const recursive = type === PromptsType.instructions && isInClaudeRulesFolder(joinPath(location, 'dummy.md')); + // Recurse into subdirectories for instruction folders, but only if: + // - the location is not a workspace folder root (to avoid full workspace traversal) + // - the path does not contain wildcards (already filtered upstream, but guard here too) + // - the recursion depth hasn't exceeded the limit + const isWorkspaceRoot = depth === 0 && this.getWorkspaceFolders().some(f => isEqual(f.uri, location)); + const recursive = type === PromptsType.instructions + && !isWorkspaceRoot + && !hasGlobPattern(location.path) + && depth < MAX_INSTRUCTIONS_RECURSION_DEPTH; try { const info = await this.fileService.resolve(location); if (token.isCancellationRequested) { @@ -513,8 +527,8 @@ export class PromptFilesLocator { if (child.isFile) { result.push(child.resource); } else if (recursive && child.isDirectory) { - // Recursively search subdirectories for claude rules - const subFiles = await this.resolveFilesAtLocation(child.resource, type, token); + // Recursively search subdirectories for instructions + const subFiles = await this.resolveFilesAtLocation(child.resource, type, token, depth + 1); result.push(...subFiles); } } diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts index a0652f4664119..1d8b6862fa3ce 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts @@ -62,6 +62,10 @@ suite('PluginInstallService', () => { terminalCompletes: boolean; pullRepositoryCalls: { marketplace: IMarketplaceReference; options?: IPullRepositoryOptions }[]; updatePluginSourceCalls: { plugin: IMarketplacePlugin; options?: IPullRepositoryOptions }[]; + /** Whether the marketplace is already trusted */ + marketplaceTrusted: boolean; + /** Canonical IDs that were trusted via trustMarketplace() */ + trustedMarketplaces: string[]; } function createDefaults(): MockState { @@ -78,6 +82,8 @@ suite('PluginInstallService', () => { terminalCompletes: true, pullRepositoryCalls: [], updatePluginSourceCalls: [], + marketplaceTrusted: true, + trustedMarketplaces: [], }; } @@ -237,6 +243,10 @@ suite('PluginInstallService', () => { addInstalledPlugin: (uri: URI, plugin: IMarketplacePlugin) => { state.addedPlugins.push({ uri: uri.toString(), plugin }); }, + isMarketplaceTrusted: () => state.marketplaceTrusted, + trustMarketplace: (ref: IMarketplaceReference) => { + state.trustedMarketplaces.push(ref.canonicalId); + }, } as unknown as IPluginMarketplaceService); const service = instantiationService.createInstance(PluginInstallService); @@ -347,6 +357,8 @@ suite('PluginInstallService', () => { instantiationService.stub(IProgressService, { withProgress: async (_o: unknown, cb: () => Promise) => cb() } as unknown as IProgressService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IPluginMarketplaceService, { addInstalledPlugin: () => { } } as unknown as IPluginMarketplaceService); + instantiationService.stub(IPluginMarketplaceService, 'isMarketplaceTrusted', () => true); + instantiationService.stub(IPluginMarketplaceService, 'trustMarketplace', () => { }); const svc = instantiationService.createInstance(PluginInstallService); const plugin = createPlugin({ @@ -671,4 +683,68 @@ suite('PluginInstallService', () => { assert.ok(state.terminalCommands[0].includes('pip')); }); }); + + // ========================================================================= + // installPlugin — marketplace trust + // ========================================================================= + + suite('installPlugin — marketplace trust', () => { + + test('skips trust prompt when marketplace is already trusted', async () => { + const { service, state } = createService({ marketplaceTrusted: true }); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.trustedMarketplaces.length, 0, 'should not re-trust'); + }); + + test('shows trust prompt and installs when user confirms', async () => { + const { service, state } = createService({ marketplaceTrusted: false, dialogConfirmResult: true }); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.trustedMarketplaces.length, 1); + assert.strictEqual(state.addedPlugins.length, 1); + }); + + test('does not install when user declines trust', async () => { + const { service, state } = createService({ marketplaceTrusted: false, dialogConfirmResult: false }); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.trustedMarketplaces.length, 0); + assert.strictEqual(state.addedPlugins.length, 0); + }); + + test('trust prompt applies to all source kinds', async () => { + const { service, state } = createService({ marketplaceTrusted: false, dialogConfirmResult: false }); + + const kinds: IPluginSourceDescriptor[] = [ + { kind: PluginSourceKind.RelativePath, path: 'p' }, + { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + { kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }, + { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + ]; + + for (const sourceDescriptor of kinds) { + await service.installPlugin(createPlugin({ sourceDescriptor })); + } + + assert.strictEqual(state.addedPlugins.length, 0, 'no plugins should be installed when trust is declined'); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts index 5d9664f790735..2dde1dfb0a2f0 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts @@ -2366,6 +2366,69 @@ suite('PromptFilesLocator', () => { }); }); + suite('instructions', () => { + testT('finds instructions files in subdirectories of .github/instructions', async () => { + const locator = await createPromptsLocator( + { + '.github/instructions': true, + '.claude/rules': false, + '~/.copilot/instructions': false, + }, + ['/Users/legomushroom/repos/vscode'], + [ + { + name: '/Users/legomushroom/repos/vscode', + children: [ + { + name: '.github/instructions', + children: [ + { + name: 'root.instructions.md', + contents: 'root instructions', + }, + { + name: 'frontend', + children: [ + { + name: 'react.instructions.md', + contents: 'react instructions', + }, + { + name: 'css.instructions.md', + contents: 'css instructions', + }, + ], + }, + { + name: 'backend', + children: [ + { + name: 'api.instructions.md', + contents: 'api instructions', + }, + ], + }, + ], + }, + ], + }, + ], + ); + + assertOutcome( + await locator.listFiles(PromptsType.instructions, PromptsStorage.local, CancellationToken.None), + [ + '/Users/legomushroom/repos/vscode/.github/instructions/root.instructions.md', + '/Users/legomushroom/repos/vscode/.github/instructions/frontend/react.instructions.md', + '/Users/legomushroom/repos/vscode/.github/instructions/frontend/css.instructions.md', + '/Users/legomushroom/repos/vscode/.github/instructions/backend/api.instructions.md', + ], + 'Must find instructions files recursively in subdirectories of .github/instructions.', + ); + await locator.disposeAsync(); + }); + }); + suite('skills', () => { suite('findAgentSkills', () => { testT('finds skill files in configured locations', async () => { diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesWidgets.ts b/src/vs/workbench/contrib/preferences/browser/preferencesWidgets.ts index 8c40f80dbe2e2..e33e0d1413ded 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesWidgets.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesWidgets.ts @@ -363,7 +363,7 @@ export class SettingsTargetsWidget extends Widget { private async update(): Promise { this.settingsSwitcherBar.domNode.classList.toggle('empty-workbench', this.contextService.getWorkbenchState() === WorkbenchState.EMPTY); this.userRemoteSettings.enabled = !!(this.options.enableRemoteSettings && this.environmentService.remoteAuthority); - this.workspaceSettings.enabled = this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY; + this.workspaceSettings.enabled = this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY && !this.environmentService.isSessionsWindow; this.folderSettings.action.enabled = this.contextService.getWorkbenchState() === WorkbenchState.WORKSPACE && this.contextService.getWorkspace().folders.length > 0; this.workspaceSettings.tooltip = localize('workspaceSettings', "Workspace"); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index b3ce3a196ce0c..3ff42709b5163 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -47,6 +47,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb private _remoteEnvDetails: IRemoteAgentEnvironment | null = null; private _appRoot: string; private _os: OperatingSystem = OS; + private _defaultWritePaths: string[] = ['~/.npm']; constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -163,6 +164,9 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb ? this._configurationService.getValue<{ denyRead?: string[]; allowWrite?: string[]; denyWrite?: string[] }>(TerminalChatAgentToolsSettingId.TerminalSandboxMacFileSystem) ?? {} : {}; const configFileUri = URI.joinPath(this._tempDir, `vscode-sandbox-settings-${this._sandboxSettingsId}.json`); + const defaultAllowWrite = [...this._defaultWritePaths]; + const linuxAllowWrite = [...new Set([...defaultAllowWrite, ...(linuxFileSystemSetting.allowWrite ?? [])])]; + const macAllowWrite = [...new Set([...defaultAllowWrite, ...(macFileSystemSetting.allowWrite ?? [])])]; let allowedDomains = networkSetting.allowedDomains ?? []; if (networkSetting.allowTrustedDomains) { @@ -176,7 +180,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb }, filesystem: { denyRead: this._os === OperatingSystem.Macintosh ? macFileSystemSetting.denyRead : linuxFileSystemSetting.denyRead, - allowWrite: this._os === OperatingSystem.Macintosh ? macFileSystemSetting.allowWrite : linuxFileSystemSetting.allowWrite, + allowWrite: this._os === OperatingSystem.Macintosh ? macAllowWrite : linuxAllowWrite, denyWrite: this._os === OperatingSystem.Macintosh ? macFileSystemSetting.denyWrite : linuxFileSystemSetting.denyWrite, } }; @@ -203,6 +207,9 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb const environmentService = this._environmentService as IEnvironmentService & { tmpDir?: URI }; this._tempDir = environmentService.tmpDir; } + if (this._tempDir) { + this._defaultWritePaths.push(this._tempDir.path); + } if (!this._tempDir) { this._logService.warn('TerminalSandboxService: Cannot create sandbox settings file because no tmpDir is available in this environment'); }