diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts index 3619d84e3d65fb..296e6ed99ec021 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts @@ -33,6 +33,7 @@ import { RenameToolId } from '../../tools/renameTool.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { IWorkspaceAgentPluginActivationService } from '../../workspaceAgentPluginActivation.js'; import { AgentCustomizationSyncProvider } from './agentCustomizationSyncProvider.js'; import { type ILocalCustomizationSyncOptions, resolveCustomizationRefs, resolveLocalCustomAgents } from './agentHostLocalCustomizations.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; @@ -121,6 +122,7 @@ class AgentCustomizationScope extends Disposable { @IFileService private readonly _fileService: IFileService, @IPromptsService private readonly _promptsService: IPromptsService, @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, + @IWorkspaceAgentPluginActivationService private readonly _workspaceAgentPluginActivationService: IWorkspaceAgentPluginActivationService, @IInstantiationService instantiationService: IInstantiationService, @IMcpService private readonly _mcpService: IMcpService, @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService, @@ -133,6 +135,7 @@ class AgentCustomizationScope extends Disposable { const seq = ++this._updateSeq; let completedInitialResolution = false; try { + await this._workspaceAgentPluginActivationService.reconcile(this._roots.slice(0, 1)); const [refs, agents] = await Promise.all([ resolveCustomizationRefs( this._fileService, @@ -147,7 +150,7 @@ class AgentCustomizationScope extends Disposable { this._roots, this._windowRemoteAuthority, ), - resolveLocalCustomAgents(this._fileService, this._promptsService, this._syncProvider, this._agentPluginService, this._sessionType, this._options), + resolveLocalCustomAgents(this._fileService, this._promptsService, this._syncProvider, this._agentPluginService, this._sessionType, this._options, this._roots), ]); if (seq !== this._updateSeq) { return; @@ -186,6 +189,7 @@ class AgentCustomizationScope extends Disposable { this._promptsService.onDidChangeInstructions, )(() => scheduleUpdate())); this._register(autorun(reader => { + const workspaceFolder = this._roots[0]; for (const plugin of this._agentPluginService.plugins.read(reader)) { plugin.enablement.read(reader); plugin.hooks.read(reader); @@ -194,6 +198,9 @@ class AgentCustomizationScope extends Disposable { plugin.agents.read(reader); plugin.instructions.read(reader); plugin.mcpServerDefinitions.read(reader); + if (workspaceFolder) { + this._agentPluginService.getWorkspaceConfiguredEnablement(plugin, workspaceFolder, reader); + } } scheduleUpdate(); })); @@ -300,9 +307,11 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo } acquireScope(sessionType: string, roots: readonly URI[]): IAgentCustomizationScope { + const orderedRoots = deduplicateRoots(roots, this._uriIdentityService.extUri); const normalizedRoots = normalizeRoots(roots, this._uriIdentityService.extUri); const scopeKey = getScopeKey(normalizedRoots, this._uriIdentityService.extUri); - const serviceScopeKey = getServiceScopeKey(sessionType, scopeKey); + const primaryRootKey = orderedRoots[0] ? this._uriIdentityService.extUri.getComparisonKey(orderedRoots[0]) : undefined; + const serviceScopeKey = getServiceScopeKey(sessionType, primaryRootKey, scopeKey); let scope = this._scopes.get(serviceScopeKey); if (!scope) { // A host that does not share the client's filesystem needs user storage shipped over the wire. @@ -310,7 +319,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo const createdScope: AgentCustomizationScope = this._instantiationService.createInstance( AgentCustomizationScope, sessionType, - normalizedRoots, + orderedRoots, scopeKey, this.getSyncProvider(sessionType), options, @@ -451,33 +460,40 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo } function normalizeRoots(roots: readonly URI[], extUri: IExtUri): readonly URI[] { - const rootsByUri = new ResourceMap(root => extUri.getComparisonKey(root)); - for (const root of roots) { - rootsByUri.set(root, root); - } - // Ordinal (not locale) ordering: this order feeds `getScopeKey`, whose hash - // becomes an on-disk plugin cache directory name on the agent host side. - return [...rootsByUri.values()].sort((a, b) => { + return [...deduplicateRoots(roots, extUri)].sort((a, b) => { const left = extUri.getComparisonKey(a); const right = extUri.getComparisonKey(b); return left < right ? -1 : left > right ? 1 : 0; }); } +function deduplicateRoots(roots: readonly URI[], extUri: IExtUri): readonly URI[] { + const rootsByUri = new ResourceMap(root => extUri.getComparisonKey(root)); + for (const root of roots) { + rootsByUri.set(root, root); + } + return [...rootsByUri.values()]; +} + /** Returns whether two working-directory sets describe the same customization scope. */ export function areCustomizationScopeRootsEqual(first: readonly URI[] | undefined, second: readonly URI[], extUri: IExtUri): boolean { const toComparisonKey = (root: URI) => extUri.getComparisonKey(root); const firstRoots = new ResourceSet(first ?? [], toComparisonKey); const secondRoots = new ResourceSet(second, toComparisonKey); - return firstRoots.size === secondRoots.size && [...firstRoots].every(root => secondRoots.has(root)); + const firstPrimary = first?.[0]; + const secondPrimary = second[0]; + const samePrimary = firstPrimary === undefined + ? secondPrimary === undefined + : secondPrimary !== undefined && extUri.isEqual(firstPrimary, secondPrimary); + return samePrimary && firstRoots.size === secondRoots.size && [...firstRoots].every(root => secondRoots.has(root)); } function getScopeKey(roots: readonly URI[], extUri: IExtUri): string { return roots.map(root => extUri.getComparisonKey(root)).join('\n'); } -function getServiceScopeKey(sessionType: string, scopeKey: string): string { - return JSON.stringify([sessionType, scopeKey]); +function getServiceScopeKey(sessionType: string, primaryRootKey: string | undefined, scopeKey: string): string { + return JSON.stringify([sessionType, primaryRootKey, scopeKey]); } function createScopeAuthority(sessionType: string, scopeKey: string): string { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index 8d3acb8d395567..95d8d4e5e3b46a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -146,6 +146,7 @@ export async function resolveLocalCustomAgents( agentPluginService: IAgentPluginService, sessionType: string, options: ILocalCustomizationSyncOptions | undefined, + workingDirectories: readonly URI[] = [], ): Promise { const plugins = agentPluginService.plugins.get(); const result: AgentCustomization[] = []; @@ -154,7 +155,7 @@ export async function resolveLocalCustomAgents( const enumerated = await enumerateLocalCustomizationsForHarness(promptsService, syncProvider, sessionType, CancellationToken.None, options); for (const agent of enumerated) { - if (agent.type !== PromptsType.agent || agent.disabled) { + if (agent.type !== PromptsType.agent) { continue; } const plugin = agent.source === AICustomizationSources.plugin @@ -163,6 +164,12 @@ export async function resolveLocalCustomAgents( if (agent.source === AICustomizationSources.plugin && !plugin) { continue; } + const workspaceConfiguredEnablement = plugin && workingDirectories[0] + ? agentPluginService.getWorkspaceConfiguredEnablement(plugin, workingDirectories[0]) + : undefined; + if (workspaceConfiguredEnablement === false || (agent.disabled && workspaceConfiguredEnablement !== true)) { + continue; + } const pluginAgent = plugin?.agents.get().find(candidate => candidate.uri.toString() === agent.uri.toString()); pending.push((async () => { let name = pluginAgent?.name ?? basename(agent.uri, '.agent.md'); @@ -255,15 +262,28 @@ export async function resolveCustomizationRefs( // ignored, sync will probably fail later though... } + let enablement = withCustomizationEnablement(undefined, CustomizationEnablementKind.Global, { + kind: CustomizationEnablementKind.Global, + enabled: agentPluginService.enablementModel.readProfileEnabled(key), + }); + const workspaceFolder = workingDirectories[0]; + const workspaceConfiguredEnablement = workspaceFolder + ? agentPluginService.getWorkspaceConfiguredEnablement(plugin, workspaceFolder) + : undefined; + if (workspaceConfiguredEnablement !== undefined && workspaceFolder) { + enablement = withCustomizationEnablement(enablement, CustomizationEnablementKind.Workspace, { + kind: CustomizationEnablementKind.Workspace, + uri: workspaceFolder.toString() as ProtocolURI, + enabled: workspaceConfiguredEnablement, + }); + } + const ref: ClientPluginCustomization = { type: CustomizationType.Plugin, id: customizationId(key), uri: key as ProtocolURI, name: plugin.label, - enablement: withCustomizationEnablement(undefined, CustomizationEnablementKind.Global, { - kind: CustomizationEnablementKind.Global, - enabled: agentPluginService.enablementModel.readProfileEnabled(key), - }), + enablement, }; if (nonce !== undefined) { ref.nonce = nonce.toString(16); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 31a3b77b8a057e..1451b61f9d76b6 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -229,7 +229,7 @@ import { ChatStatusBarEntry } from './chatStatus/chatStatusEntry.js'; import { CodexStatusBarEntry } from './chatStatus/codexStatusEntry.js'; import { ChatTipService, IChatTipService } from './chatTipService.js'; import { ChatWindowNotifier } from './chatWindowNotifier.js'; -import { AgentPluginRecommendations } from './claudePluginRecommendations.js'; +import { IWorkspaceAgentPluginActivationService, WorkspaceAgentPluginActivation, WorkspaceAgentPluginActivationService } from './workspaceAgentPluginActivation.js'; import { ChatCodeBlockContextProviderService } from './codeBlockContextProviderService.js'; import { ExploreAgentDefaultModel } from './exploreAgentDefaultModel.js'; import { HasByokModelsContribution } from './hasByokModelsContribution.js'; @@ -3365,7 +3365,7 @@ registerWorkbenchContribution2(UserToolSetsContributions.ID, UserToolSetsContrib registerWorkbenchContribution2(PromptLanguageFeaturesProvider.ID, PromptLanguageFeaturesProvider, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatWindowNotifier.ID, ChatWindowNotifier, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatRepoInfoContribution.ID, ChatRepoInfoContribution, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(AgentPluginRecommendations.ID, AgentPluginRecommendations, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(WorkspaceAgentPluginActivation.ID, WorkspaceAgentPluginActivation, WorkbenchPhase.Eventually); registerWorkbenchContribution2(AgentPluginCommandsContribution.ID, AgentPluginCommandsContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(PluginAutoUpdate.ID, PluginAutoUpdate, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatReferenceAttachmentWidgetContribution.ID, ChatReferenceAttachmentWidgetContribution, WorkbenchPhase.AfterRestored); @@ -3439,6 +3439,7 @@ registerSingleton(IChatVariablesService, ChatVariablesService, InstantiationType registerSingleton(IAgentPluginService, AgentPluginService, InstantiationType.Delayed); registerSingleton(IPluginMarketplaceService, PluginMarketplaceService, InstantiationType.Delayed); registerSingleton(IWorkspacePluginSettingsService, WorkspacePluginSettingsService, InstantiationType.Delayed); +registerSingleton(IWorkspaceAgentPluginActivationService, WorkspaceAgentPluginActivationService, InstantiationType.Delayed); registerSingleton(IAgentPluginRepositoryService, AgentPluginRepositoryService, InstantiationType.Delayed); registerSingleton(IPluginGitService, BrowserPluginGitCommandService, InstantiationType.Delayed); registerSingleton(IPluginInstallService, PluginInstallService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/claudePluginRecommendations.ts b/src/vs/workbench/contrib/chat/browser/claudePluginRecommendations.ts deleted file mode 100644 index fdee276812b15a..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/claudePluginRecommendations.ts +++ /dev/null @@ -1,99 +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 { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { localize } from '../../../../nls.js'; -import { INotificationService, NeverShowAgainScope, Severity } from '../../../../platform/notification/common/notification.js'; -import { IWorkbenchContribution } from '../../../common/contributions.js'; -import { IExtensionsWorkbenchService } from '../../extensions/common/extensions.js'; -import { IChatService } from '../common/chatService/chatService.js'; -import { IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; - -export class AgentPluginRecommendations extends Disposable implements IWorkbenchContribution { - static readonly ID = 'workbench.contrib.agentPluginRecommendations'; - - private _hasNotified = false; - - constructor( - @IChatService private readonly _chatService: IChatService, - @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, - @INotificationService private readonly _notificationService: INotificationService, - @IExtensionsWorkbenchService private readonly _extensionsWorkbenchService: IExtensionsWorkbenchService, - ) { - super(); - - this._register(this._chatService.onDidSubmitRequest(() => { - if (!this._hasNotified) { - this._hasNotified = true; - this._checkForRecommendedPlugins(); - } - })); - } - - private async _checkForRecommendedPlugins(): Promise { - const recommended = this._pluginMarketplaceService.recommendedPlugins.get(); - if (recommended.size === 0) { - return; - } - - // Build a set of installed plugin keys ("name@marketplace") from - // storage without triggering any network fetch. - const installedKeys = new Set(); - for (const entry of this._pluginMarketplaceService.installedPlugins.get()) { - const key = `${entry.plugin.name}@${entry.plugin.marketplace}`; - installedKeys.add(key); - } - - // Only count recommendations that resolve to a known marketplace - // plugin. Otherwise the @recommended search would land on an empty - // list (see microsoft/vscode#315347). Fall back to a fresh fetch - // when the cache hasn't been populated yet so first-run sessions - // with valid recommendations still notify. - let fetched: readonly IMarketplacePlugin[] = this._pluginMarketplaceService.lastFetchedPlugins.get(); - if (fetched.length === 0) { - try { - fetched = await this._pluginMarketplaceService.fetchMarketplacePlugins(CancellationToken.None); - } catch { - return; - } - } - const knownKeys = new Set(); - for (const plugin of fetched) { - knownKeys.add(`${plugin.name}@${plugin.marketplace}`); - } - - let uninstalledCount = 0; - for (const key of recommended) { - if (!installedKeys.has(key) && knownKeys.has(key)) { - uninstalledCount++; - } - } - - if (uninstalledCount === 0) { - return; - } - - this._notificationService.prompt( - Severity.Info, - uninstalledCount === 1 - ? localize('agentPluginRecommendation.one', "This workspace recommends 1 agent plugin.") - : localize('agentPluginRecommendation.many', "This workspace recommends {0} agent plugins.", uninstalledCount), - [{ - label: localize('showPlugins', "Show Plugins"), - run: () => { - this._extensionsWorkbenchService.openSearch('@agentPlugins @recommended'); - } - }], - { - neverShowAgain: { - id: 'agentPluginRecommendations.dismissed', - scope: NeverShowAgainScope.WORKSPACE, - isSecondary: true, - } - } - ); - } -} diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index 6ce5744b20be5f..489482d43fc70e 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts @@ -23,7 +23,7 @@ import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickin import { IPathService } from '../../../services/path/common/pathService.js'; import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; import { ChatConfiguration } from '../common/constants.js'; -import { IPluginInstallService, IInstallPluginFromSourceOptions, IInstallPluginFromSourceResult, IUpdateAllPluginsOptions, IUpdateAllPluginsResult } from '../common/plugins/pluginInstallService.js'; +import { IPluginInstallService, IInstallMarketplacePluginOptions, IInstallPluginFromSourceOptions, IInstallPluginFromSourceResult, IUpdateAllPluginsOptions, IUpdateAllPluginsResult } from '../common/plugins/pluginInstallService.js'; import { IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, MarketplaceReferenceKind, MarketplaceType, hasSourceChanged, parseMarketplaceReference, parseMarketplaceReferences, PluginSourceKind, readConfiguredMarketplaces } from '../common/plugins/pluginMarketplaceService.js'; const maxPluginSubdirectoryLength = 8192; @@ -45,9 +45,9 @@ export class PluginInstallService implements IPluginInstallService { @IPathService private readonly _pathService: IPathService, ) { } - async installPlugin(plugin: IMarketplacePlugin, token: CancellationToken = CancellationToken.None): Promise { + async installPlugin(plugin: IMarketplacePlugin, token: CancellationToken = CancellationToken.None, options?: IInstallMarketplacePluginOptions): Promise { this.throwIfCancelled(token); - if (!await this._ensureMarketplaceTrusted(plugin, token)) { + if (!options?.skipTrust && !await this._ensureMarketplaceTrusted(plugin, token)) { throw new CancellationError(); } this.throwIfCancelled(token); diff --git a/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts b/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts new file mode 100644 index 00000000000000..9a0f3735c109a0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { getErrorMessage, isCancellationError } from '../../../../base/common/errors.js'; +import { Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; +import { ContributionEnablementState } from '../common/enablement.js'; +import { IAgentPluginService } from '../common/plugins/agentPluginService.js'; +import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; +import { areMarketplacePluginsEqual, IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; +import { IWorkspacePluginSettings, IWorkspacePluginSettingsService } from '../common/plugins/workspacePluginSettingsService.js'; + +export const IWorkspaceAgentPluginActivationService = createDecorator('workspaceAgentPluginActivationService'); + +export interface IWorkspaceAgentPluginActivationService { + readonly _serviceBrand: undefined; + /** Reconciles repository plugin installs for the requested workspace folders, or all folders when omitted. */ + reconcile(workspaceFolders?: readonly URI[]): Promise; +} + +export class WorkspaceAgentPluginActivationService extends Disposable implements IWorkspaceAgentPluginActivationService { + declare readonly _serviceBrand: undefined; + + static readonly ID = 'workbench.contrib.workspaceAgentPluginActivation'; + + private readonly _cancellation = this._register(new CancellationTokenSource()); + private readonly _requestedWorkspaceFolders: URI[] = []; + private _allWorkspaceFoldersRequested = false; + private _reconcilePromise: Promise | undefined; + + constructor( + @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, + @IPluginInstallService private readonly _pluginInstallService: IPluginInstallService, + @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, + @IWorkspacePluginSettingsService private readonly _workspacePluginSettingsService: IWorkspacePluginSettingsService, + @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._register(Event.any( + Event.fromObservableLight(this._workspacePluginSettingsService.workspaceSettings), + Event.fromObservableLight(this._pluginMarketplaceService.installedPlugins), + this._pluginMarketplaceService.onDidChangeMarketplaces, + this._chatEntitlementService.onDidChangeSentiment, + )(() => { + void this.reconcile(); + })); + void this.reconcile(); + } + + override dispose(): void { + this._cancellation.cancel(); + super.dispose(); + } + + reconcile(workspaceFolders?: readonly URI[]): Promise { + if (workspaceFolders === undefined) { + this._allWorkspaceFoldersRequested = true; + this._requestedWorkspaceFolders.length = 0; + } else if (!this._allWorkspaceFoldersRequested) { + this._requestedWorkspaceFolders.push(...workspaceFolders); + } + + if (!this._reconcilePromise) { + const reconcilePromise = this._runReconcile().finally(() => { + if (this._reconcilePromise === reconcilePromise) { + this._reconcilePromise = undefined; + } + }); + this._reconcilePromise = reconcilePromise; + } + return this._reconcilePromise; + } + + private async _runReconcile(): Promise { + while ((this._allWorkspaceFoldersRequested || this._requestedWorkspaceFolders.length > 0) + && !this._store.isDisposed + && !this._cancellation.token.isCancellationRequested) { + const workspaceFolders = this._allWorkspaceFoldersRequested ? undefined : this._requestedWorkspaceFolders.splice(0); + this._allWorkspaceFoldersRequested = false; + try { + await this._installConfiguredPlugins(workspaceFolders); + } catch (error) { + if (!isCancellationError(error)) { + this._logService.error('[WorkspaceAgentPluginActivation] Failed to reconcile workspace plugins', error); + } + } + } + } + + private async _installConfiguredPlugins(workspaceFolders: readonly URI[] | undefined): Promise { + await this._workspacePluginSettingsService.whenSettled(); + if (this._chatEntitlementService.sentiment.hidden) { + return; + } + + const workspaceSettings = this._getWorkspaceSettings(workspaceFolders); + const hasConfiguredEnablement = workspaceSettings.some(settings => settings.enabledPlugins.size > 0); + if (!hasConfiguredEnablement) { + return; + } + + await this._pluginMarketplaceService.whenInstalledPluginsReady; + if (this._cancellation.token.isCancellationRequested || this._chatEntitlementService.sentiment.hidden) { + return; + } + + let refreshPluginDiscovery = false; + for (const settings of workspaceSettings) { + const configuredPluginIds = new Set([...settings.enabledPlugins].filter(([, enabled]) => enabled).map(([pluginId]) => pluginId)); + if (configuredPluginIds.size === 0) { + continue; + } + + const marketplacePlugins = await this._pluginMarketplaceService.fetchMarketplacePlugins( + this._cancellation.token, + undefined, + { + workspaceFolder: settings.workspaceFolder, + onMarketplaceError: (marketplace, error) => { + this._logService.warn(`[WorkspaceAgentPluginActivation] Could not read marketplace '${marketplace.displayLabel}': ${getErrorMessage(error)}`); + }, + }, + ); + const pluginsById = new Map(); + for (const plugin of marketplacePlugins) { + const pluginId = getMarketplacePluginId(plugin); + if (!pluginsById.has(pluginId)) { + pluginsById.set(pluginId, plugin); + } + } + + for (const pluginId of configuredPluginIds) { + if (this._cancellation.token.isCancellationRequested || this._chatEntitlementService.sentiment.hidden) { + return; + } + if (this._workspacePluginSettingsService.getWorkspaceSettings(settings.workspaceFolder)?.enabledPlugins.get(pluginId) !== true) { + continue; + } + + const plugin = pluginsById.get(pluginId); + if (!plugin) { + this._logService.warn(`[WorkspaceAgentPluginActivation] Workspace plugin '${pluginId}' was not found in the configured marketplaces`); + continue; + } + + const installUri = this._pluginInstallService.getPluginInstallUri(plugin); + if (this._pluginMarketplaceService.isPluginInstalled(installUri)) { + const installedMetadata = this._pluginMarketplaceService.getMarketplacePluginMetadata(installUri); + if (!areMarketplacePluginsEqual(installedMetadata, plugin)) { + this._pluginMarketplaceService.addInstalledPlugin(installUri, plugin); + refreshPluginDiscovery = true; + } + continue; + } + + try { + await this._pluginInstallService.installPlugin(plugin, this._cancellation.token, { skipTrust: true }); + } catch (error) { + if (!isCancellationError(error)) { + this._logService.error(`[WorkspaceAgentPluginActivation] Failed to install workspace plugin '${pluginId}'`, error); + } + continue; + } + + if (!this._pluginMarketplaceService.isPluginInstalled(installUri)) { + this._logService.warn(`[WorkspaceAgentPluginActivation] Workspace plugin '${pluginId}' was not installed`); + continue; + } + + this._agentPluginService.enablementModel.setEnabled(installUri.toString(), ContributionEnablementState.DisabledProfile); + refreshPluginDiscovery = true; + } + } + + await this._agentPluginService.whenReady; + if (refreshPluginDiscovery && !this._cancellation.token.isCancellationRequested) { + await this._agentPluginService.refresh(); + } + } + + private _getWorkspaceSettings(workspaceFolders: readonly URI[] | undefined): readonly IWorkspacePluginSettings[] { + if (workspaceFolders === undefined) { + return this._workspacePluginSettingsService.workspaceSettings.get(); + } + const result: IWorkspacePluginSettings[] = []; + for (const workspaceFolder of workspaceFolders) { + const settings = this._workspacePluginSettingsService.getWorkspaceSettings(workspaceFolder); + if (settings && !result.some(candidate => isEqual(candidate.workspaceFolder, settings.workspaceFolder))) { + result.push(settings); + } + } + return result; + } +} + +export class WorkspaceAgentPluginActivation implements IWorkbenchContribution { + static readonly ID = WorkspaceAgentPluginActivationService.ID; + + constructor( + @IWorkspaceAgentPluginActivationService _workspaceAgentPluginActivationService: IWorkspaceAgentPluginActivationService, + ) { } +} + +function getMarketplacePluginId(plugin: IMarketplacePlugin): string { + return `${plugin.name}@${plugin.marketplace}`; +} diff --git a/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md b/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md index 482191fc9ac73d..9a7dfc4e2ccde3 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md +++ b/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md @@ -140,7 +140,7 @@ Manages the catalog of available and installed plugins: - **Fetch** — reads `chat.plugins.marketplaces` config (GitHub shorthand, Git URLs, or file URIs), fetches `marketplace.json` from each, and returns parsed `IMarketplacePlugin` entries. - **Installed storage** — persists installed plugins in application-scoped storage (`chat.plugins.installed.v1`). Each entry tracks `{ pluginUri, plugin, enabled }`. - **Trust** — marketplace canonical IDs must be explicitly trusted before install proceeds (`chat.plugins.trustedMarketplaces.v1`). -- **Auto-update** — checks eligible installed marketplaces approximately every 24 hours and reports their canonical IDs through `marketplacesWithUpdates`. Managed `extraKnownMarketplaces..autoUpdate` values override `extensions.autoUpdate` for that marketplace; undefined entries inherit the global setting. Checks and updates are restricted to enabled marketplaces and still enforce `strictKnownMarketplaces`. +- **Auto-update** — checks eligible installed marketplaces approximately every 24 hours and reports their canonical IDs through `marketplacesWithUpdates`. Managed `extraKnownMarketplaces..autoUpdate` values override `extensions.autoUpdate` for that marketplace; undefined entries inherit the global setting. Repository settings cannot authorize silent updates to the shared plugin cache. Checks and updates are restricted to enabled marketplaces and still enforce `strictKnownMarketplaces`. - **GitHub caching** — caches raw GitHub API responses with an 8-hour TTL to avoid repeated fetches. ### Marketplace Definition Files @@ -180,6 +180,10 @@ Each `PluginSourceKind` has a strategy that knows how to compute cache paths, pr | `chat.pluginLocations` | `Record` | `{}` | Local plugin directories to discover | | `chat.pluginMarketplaces` | `string[]` | `[]` | Marketplace references to fetch from | +### Repository-scoped activation + +Trusted workspace settings in `.claude/settings(.local).json` and `.github/copilot/settings(.local).json` can define `extraKnownMarketplaces` and `enabledPlugins`. Repository layers follow Copilot CLI precedence: Copilot local, Copilot shared, Claude local, then Claude shared. In multi-root windows, each folder retains its own marketplace aliases and enablement map; an agent-host session receives only the decision for its primary working directory. An `enabledPlugins` entry set to `true` installs the plugin from the repository's exact marketplace source without another confirmation and overlays workspace enablement only onto that source; `false` overlays workspace disablement; an absent entry leaves the stored user state unchanged. Repository-installed plugins receive a disabled profile baseline so that leaving the workspace does not activate them globally, and they participate in automatic updates only when authorized by user or managed settings. Enterprise policy remains authoritative, and none of these workspace decisions are exposed while the workspace is untrusted. + ### Enterprise customization lockdown The managed customization controls are complementary: diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts index 9732cd178dcefa..b6fa01f497aaaa 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts @@ -7,6 +7,7 @@ import { IObservable, IReader, ITransaction } from '../../../../../base/common/o import { AgentPluginDiscoveryPriority, IAgentPlugin } from './agentPluginService.js'; import { IGitHubPluginSource, IGitUrlPluginSource, IMarketplacePlugin, INpmPluginSource, IPipPluginSource, PluginSourceKind } from './pluginMarketplaceService.js'; import { type IMarketplaceReference } from './marketplaceReference.js'; +import { type IWorkspaceMarketplaceEntry } from './workspacePluginSettingsService.js'; import { CollisionEnablementModel, ContributionEnablementState, IEnablementModel, isContributionEnabled } from '../enablement.js'; export interface IDiscoveredAgentPlugins { @@ -28,37 +29,41 @@ interface IAgentPluginCandidate { */ const COPILOT_CLI_INSTALL_PATH_FRAGMENT = '/.copilot/installed-plugins/'; -class AgentPluginPolicyEnablementModel implements IEnablementModel { +class AgentPluginConfiguredEnablementModel implements IEnablementModel { constructor( private readonly base: IEnablementModel, - private readonly policyEnablement?: IObservable>, + private readonly configuredEnablement?: IObservable>, ) { } readEnabled(key: string, reader?: IReader): ContributionEnablementState { - const policyValue = this.policyEnablement?.read(reader).get(key); - return policyValue === true - ? ContributionEnablementState.EnabledProfile - : policyValue === false - ? ContributionEnablementState.DisabledProfile - : this.base.readEnabled(key, reader); + return this.configuredEnablement?.read(reader).get(key) ?? this.base.readEnabled(key, reader); } readProfileEnabled(key: string, reader?: IReader): boolean { - return this.policyEnablement?.read(reader).get(key) ?? this.base.readProfileEnabled(key, reader); + const configuredState = this.configuredEnablement?.read(reader).get(key); + if (configuredState === ContributionEnablementState.EnabledProfile) { + return true; + } + if (configuredState === ContributionEnablementState.DisabledProfile) { + return false; + } + return this.base.readProfileEnabled(key, reader); } setEnabled(key: string, state: ContributionEnablementState, tx?: ITransaction): void { - const policy = this.policyEnablement?.get(); - if (policy?.has(key)) { + const configuredState = this.configuredEnablement?.get().get(key); + if (configuredState === ContributionEnablementState.EnabledProfile || configuredState === ContributionEnablementState.DisabledProfile) { return; } this.base.setEnabled(key, state, tx); } remove(key: string): void { - if (!this.policyEnablement?.get().has(key)) { - this.base.remove(key); + const configuredState = this.configuredEnablement?.get().get(key); + if (configuredState === ContributionEnablementState.EnabledProfile || configuredState === ContributionEnablementState.DisabledProfile) { + return; } + this.base.remove(key); } } @@ -66,14 +71,17 @@ export class AgentPluginCollisionEnablementModel extends CollisionEnablementMode constructor( base: IEnablementModel, private readonly collisionGroups: IObservable>, - private readonly policyEnablement?: IObservable>, + private readonly configuredEnablement?: IObservable>, ) { - super(new AgentPluginPolicyEnablementModel(base, policyEnablement), collisionGroups); + super(new AgentPluginConfiguredEnablementModel(base, configuredEnablement), collisionGroups); } override setEnabled(key: string, state: ContributionEnablementState, tx?: ITransaction): void { const group = isContributionEnabled(state) ? this.collisionGroups.get().get(key) : undefined; - if (group?.some(otherId => otherId !== key && this.policyEnablement?.get().get(otherId) === true)) { + if (group?.some(otherId => { + const configuredState = this.configuredEnablement?.get().get(otherId); + return otherId !== key && configuredState !== undefined && isContributionEnabled(configuredState); + })) { return; } super.setEnabled(key, state, tx); @@ -148,6 +156,60 @@ export function getAgentPluginPolicyEnablement( return pluginId === undefined ? undefined : enabledPluginsPolicy?.[pluginId]; } +export function getAgentPluginConfiguredEnablement( + plugin: IAgentPlugin, + enabledPluginsPolicy: Record | undefined, + workspaceEnabledPlugins: ReadonlyMap | undefined, + workspaceMarketplaces?: readonly IWorkspaceMarketplaceEntry[], +): ContributionEnablementState | undefined { + const policyEnablement = getAgentPluginPolicyEnablement(plugin, enabledPluginsPolicy); + if (policyEnablement !== undefined) { + return policyEnablement ? ContributionEnablementState.EnabledProfile : ContributionEnablementState.DisabledProfile; + } + + const workspaceEnablement = getAgentPluginWorkspaceEnablement(plugin, workspaceEnabledPlugins, workspaceMarketplaces); + if (workspaceEnablement !== undefined) { + return workspaceEnablement ? ContributionEnablementState.EnabledWorkspace : ContributionEnablementState.DisabledWorkspace; + } + + return undefined; +} + +export function getAgentPluginWorkspaceEnablement( + plugin: IAgentPlugin, + workspaceEnabledPlugins: ReadonlyMap | undefined, + workspaceMarketplaces?: readonly IWorkspaceMarketplaceEntry[], +): boolean | undefined { + const identity = getPolicyIdentity(plugin); + if (!identity || !workspaceEnabledPlugins) { + return undefined; + } + + const pluginId = `${identity.name}@${identity.marketplace}`; + const workspaceMarketplace = workspaceMarketplaces?.find(entry => entry.name === identity.marketplace); + const matchesWorkspaceMarketplace = workspaceMarketplace === undefined + || identity.marketplaceReference?.canonicalId === workspaceMarketplace.reference.canonicalId; + const directEnablement = matchesWorkspaceMarketplace ? workspaceEnabledPlugins.get(pluginId) : undefined; + if (directEnablement !== undefined) { + return directEnablement; + } + + if (!identity.marketplaceReference) { + return undefined; + } + for (const [configuredPluginId, enabled] of workspaceEnabledPlugins) { + const separator = configuredPluginId.lastIndexOf('@'); + if (separator <= 0 || configuredPluginId.slice(0, separator) !== identity.name) { + continue; + } + const configuredMarketplace = workspaceMarketplaces?.find(entry => entry.name === configuredPluginId.slice(separator + 1)); + if (configuredMarketplace?.reference.canonicalId === identity.marketplaceReference.canonicalId) { + return enabled; + } + } + return undefined; +} + export function getAgentPluginPolicyId(plugin: IAgentPlugin): string | undefined { const identity = getPolicyIdentity(plugin); return identity ? `${identity.name}@${identity.marketplace}` : undefined; diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 7f318ebd913ff2..ecdf751b78ca9c 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { IObservable } from '../../../../../base/common/observable.js'; +import { IObservable, IReader } from '../../../../../base/common/observable.js'; import { basename } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { SyncDescriptor0 } from '../../../../../platform/instantiation/common/descriptors.js'; @@ -64,11 +64,18 @@ export interface IAgentPluginService { readonly _serviceBrand: undefined; readonly plugins: IObservable; readonly enablementModel: IEnablementModel; + /** Resolves after every discovery source has completed its initial scan. */ + readonly whenReady: Promise; + /** Re-runs all plugin discovery sources and resolves after their results are published. */ + refresh(): Promise; + /** Returns the repository's workspace decision after enterprise-policy precedence. */ + getWorkspaceConfiguredEnablement(plugin: IAgentPlugin, workspaceFolder: URI, reader?: IReader): boolean | undefined; } export interface IAgentPluginDiscovery extends IDisposable { readonly plugins: IObservable; start(enablementModel: IEnablementModel): void; + refresh(): Promise; } export const enum AgentPluginDiscoveryPriority { diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 2d2134da85552b..9ef8e682c82f09 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { RunOnceScheduler, Sequencer } from '../../../../../base/common/async.js'; import { encodeHex, VSBuffer } from '../../../../../base/common/buffer.js'; import { Event } from '../../../../../base/common/event.js'; import { Iterable } from '../../../../../base/common/iterator.js'; @@ -51,14 +51,15 @@ import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { ChatConfiguration } from '../constants.js'; -import { EnablementModel, IEnablementModel } from '../enablement.js'; +import { ContributionEnablementState, EnablementModel, IEnablementModel, isContributionDisabled, isContributionEnabled } from '../enablement.js'; import { AUTOMATION_BLUEPRINT_FILE_SUFFIX, parseAutomationBlueprint } from '../automations/automationBlueprint.js'; import { HookType } from '../promptSyntax/hookTypes.js'; -import { AgentPluginCollisionEnablementModel, getAgentPluginPolicyEnablement, getAgentPluginPolicyId, getCanonicalAgentPluginCollisionGroups, getSortedAgentPlugins, IDiscoveredAgentPlugins, isAgentPluginBlockedByPolicy, isAgentPluginForceEnabledByPolicy } from './agentPluginEnablement.js'; +import { AgentPluginCollisionEnablementModel, getAgentPluginConfiguredEnablement, getAgentPluginPolicyEnablement, getAgentPluginPolicyId, getAgentPluginWorkspaceEnablement, getCanonicalAgentPluginCollisionGroups, getSortedAgentPlugins, IDiscoveredAgentPlugins } from './agentPluginEnablement.js'; import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js'; import { AgentPluginDiscoveryPriority, agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginAutomation, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginService } from './agentPluginService.js'; import { IPluginInstallService } from './pluginInstallService.js'; import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js'; +import { IWorkspacePluginSettingsService } from './workspacePluginSettingsService.js'; // Re-export shared helpers so existing consumers (including tests) continue to work. export { shellQuotePluginRootInCommand, resolveMcpServersMap, convertBareEnvVarsToVsCodeSyntax } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; @@ -97,44 +98,47 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic public readonly plugins: IObservable; public readonly enablementModel: IEnablementModel; + public readonly whenReady: Promise; + private readonly _discoveries: IAgentPluginDiscoveryWithPriority[] = []; + private readonly _collisionGroups: IObservable>; constructor( @IInstantiationService instantiationService: IInstantiationService, - @IConfigurationService configurationService: IConfigurationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, @IStorageService storageService: IStorageService, @ILogService logService: ILogService, + @IWorkspacePluginSettingsService private readonly _workspacePluginSettingsService: IWorkspacePluginSettingsService, ) { super(); const baseEnablementModel = this._register(new EnablementModel('agentPlugins.enablement', storageService)); - const pluginsEnabled = observableConfigValue(ChatConfiguration.PluginsEnabled, true, configurationService); + const pluginsEnabled = observableConfigValue(ChatConfiguration.PluginsEnabled, true, this._configurationService); - const discoveries: IAgentPluginDiscoveryWithPriority[] = []; for (const registration of agentPluginDiscoveryRegistry.getAll()) { const discovery = instantiationService.createInstance(registration.descriptor); this._register(discovery); - discoveries.push({ discovery, priority: registration.priority, order: registration.order }); + this._discoveries.push({ discovery, priority: registration.priority, order: registration.order }); } // Policy-driven enforcement, applied after discovery so that enterprise // policy is honored regardless of which discovery source surfaces a // plugin (local paths, marketplace, CLI install dir). const enabledPluginsPolicy = observableFromEvent(this, - Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.EnabledPlugins)), - () => configurationService.inspect>(ChatConfiguration.EnabledPlugins).policyValue, + Event.filter(this._configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.EnabledPlugins)), + () => this._configurationService.inspect>(ChatConfiguration.EnabledPlugins).policyValue, ); - const policyEnablement = derived(reader => { - const discoveredPlugins = readDiscoveredAgentPlugins(discoveries, reader); + const configuredEnablement = derived(reader => { + const discoveredPlugins = readDiscoveredAgentPlugins(this._discoveries, reader); const policy = enabledPluginsPolicy.read(reader); - const result = new Map(); - if (discoveredPlugins && policy) { + const result = new Map(); + if (discoveredPlugins) { for (const { plugins } of discoveredPlugins) { for (const plugin of plugins) { - const policyValue = getAgentPluginPolicyEnablement(plugin, policy); - if (policyValue !== undefined) { - result.set(plugin.uri.toString(), policyValue); + const configuredState = getAgentPluginConfiguredEnablement(plugin, policy, undefined); + if (configuredState !== undefined) { + result.set(plugin.uri.toString(), configuredState); } } } @@ -142,25 +146,31 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic return result; }); - const collisionGroups = derived(reader => { + this._collisionGroups = derived(reader => { if (!pluginsEnabled.read(reader)) { return new Map(); } - const discoveredPlugins = readDiscoveredAgentPlugins(discoveries, reader); + const discoveredPlugins = readDiscoveredAgentPlugins(this._discoveries, reader); if (!discoveredPlugins) { return new Map(); } - const policy = enabledPluginsPolicy.read(reader); + const configured = configuredEnablement.read(reader); return getCanonicalAgentPluginCollisionGroups( discoveredPlugins, - plugin => isAgentPluginBlockedByPolicy(plugin, policy), - plugin => isAgentPluginForceEnabledByPolicy(plugin, policy), + plugin => { + const state = configured.get(plugin.uri.toString()); + return state !== undefined && isContributionDisabled(state); + }, + plugin => { + const state = configured.get(plugin.uri.toString()); + return state !== undefined && isContributionEnabled(state); + }, ); }); - this.enablementModel = new AgentPluginCollisionEnablementModel(baseEnablementModel, collisionGroups, policyEnablement); + this.enablementModel = new AgentPluginCollisionEnablementModel(baseEnablementModel, this._collisionGroups, configuredEnablement); - for (const { discovery } of discoveries) { + for (const { discovery } of this._discoveries) { discovery.start(this.enablementModel); } @@ -168,12 +178,13 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic if (!pluginsEnabled.read(read)) { return []; } - const discoveredPlugins = readDiscoveredAgentPlugins(discoveries, read); + const discoveredPlugins = readDiscoveredAgentPlugins(this._discoveries, read); if (!discoveredPlugins) { return []; } return getSortedAgentPlugins(discoveredPlugins); }); + this.whenReady = this.refresh(); this._register(autorun(reader => { const plugins = this.plugins.read(reader); @@ -188,6 +199,43 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic }); })); } + + async refresh(): Promise { + await Promise.all(this._discoveries.map(({ discovery }) => discovery.refresh())); + } + + getWorkspaceConfiguredEnablement(plugin: IAgentPlugin, workspaceFolder: URI, reader?: IReader): boolean | undefined { + const policy = this._configurationService.inspect>(ChatConfiguration.EnabledPlugins).policyValue; + if (getAgentPluginPolicyEnablement(plugin, policy) !== undefined) { + return undefined; + } + const settings = this._workspacePluginSettingsService.getWorkspaceSettings(workspaceFolder, reader); + if (!settings) { + return undefined; + } + const configured = getAgentPluginWorkspaceEnablement(plugin, settings.enabledPlugins, settings.extraMarketplaces); + if (configured !== undefined) { + return configured; + } + + const collisionGroup = this._collisionGroups.read(reader).get(plugin.uri.toString()); + if (!collisionGroup) { + return undefined; + } + const pluginsByUri = new Map(this.plugins.read(reader).map(candidate => [candidate.uri.toString(), candidate])); + for (const candidateUri of collisionGroup) { + if (candidateUri === plugin.uri.toString()) { + continue; + } + const candidate = pluginsByUri.get(candidateUri); + if (candidate + && getAgentPluginPolicyEnablement(candidate, policy) === undefined + && getAgentPluginWorkspaceEnablement(candidate, settings.enabledPlugins, settings.extraMarketplaces) === true) { + return false; + } + } + return undefined; + } } interface IAgentPluginDiscoveryWithPriority { @@ -273,6 +321,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements private readonly _plugins = observableValue('discoveredAgentPlugins', undefined); public readonly plugins: IObservable = this._plugins; + private readonly _refreshSequencer = new Sequencer(); private _discoverVersion = 0; protected _enablementModel!: IEnablementModel; @@ -287,6 +336,10 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements public abstract start(enablementModel: IEnablementModel): void; + public refresh(): Promise { + return this._refreshSequencer.queue(() => this._refreshPlugins()); + } + protected async _refreshPlugins(): Promise { const version = ++this._discoverVersion; const plugins = await this._discoverAndBuildPlugins(version); @@ -459,7 +512,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements } const latestFormat = await detectPluginFormat(uri, this._fileService); if (latestFormat.format !== format.format) { - await this._refreshPlugins(); + await this.refresh(); return; } manifest.set(await readPluginManifest(uri, format, this._fileService), undefined); @@ -670,7 +723,7 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery public override start(enablementModel: IEnablementModel): void { this._enablementModel = enablementModel; - const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); + const scheduler = this._register(new RunOnceScheduler(() => this.refresh(), 0)); this._register(autorun(reader => { this._pluginLocationsConfig.read(reader); scheduler.schedule(); @@ -801,7 +854,7 @@ export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscover public override start(enablementModel: IEnablementModel): void { this._enablementModel = enablementModel; - const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); + const scheduler = this._register(new RunOnceScheduler(() => this.refresh(), 0)); this._register(autorun(reader => { const installed = this._pluginMarketplaceService.installedPlugins.read(reader); const watchedParents: URI[] = []; @@ -1139,7 +1192,7 @@ export class CopilotCliAgentPluginDiscovery extends AbstractAgentPluginDiscovery public override start(enablementModel: IEnablementModel): void { this._enablementModel = enablementModel; - const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 200)); + const scheduler = this._register(new RunOnceScheduler(() => this.refresh(), 200)); this._refreshScheduler = scheduler; scheduler.schedule(0); } @@ -1231,7 +1284,7 @@ export class ExtensionAgentPluginDiscovery extends AbstractAgentPluginDiscovery public override start(enablementModel: IEnablementModel): void { this._enablementModel = enablementModel; - const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); + const scheduler = this._register(new RunOnceScheduler(() => this.refresh(), 0)); this._register(this._contextKeyService.onDidChangeContext(e => { if (e.affectsSome(this._whenKeys)) { scheduler.schedule(); diff --git a/src/vs/workbench/contrib/chat/common/plugins/fileBackedInstalledPluginsStore.ts b/src/vs/workbench/contrib/chat/common/plugins/fileBackedInstalledPluginsStore.ts index 4d51b9d789fcae..c38091c8d12f15 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/fileBackedInstalledPluginsStore.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/fileBackedInstalledPluginsStore.ts @@ -83,6 +83,7 @@ export class FileBackedInstalledPluginsStore extends Disposable { private _initialized = false; readonly value: IObservable = this._installed; + readonly whenInitialized: Promise; constructor( private readonly _agentPluginsHome: URI, @@ -94,7 +95,7 @@ export class FileBackedInstalledPluginsStore extends Disposable { super(); this._fileUri = joinPath(_agentPluginsHome, INSTALLED_JSON_FILENAME); this._writeDelayer = this._register(new ThrottledDelayer(100)); - void this._initialize(); + this.whenInitialized = this._initialize(); } get(): readonly IStoredInstalledPlugin[] { diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts index 710eb1a668fc8f..6035fdd2eb0eab 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts @@ -57,6 +57,11 @@ export interface IInstallPluginFromSourceResult { readonly matchedPlugin?: IMarketplacePlugin; } +export interface IInstallMarketplacePluginOptions { + /** Skips the trust prompt after trusted workspace configuration established the marketplace source. */ + readonly skipTrust?: boolean; +} + export interface IPluginInstallService { readonly _serviceBrand: undefined; @@ -64,7 +69,7 @@ export interface IPluginInstallService { * Clones the marketplace repository (if not already cached) and registers * the plugin in the marketplace service's installed plugins storage. */ - installPlugin(plugin: IMarketplacePlugin, token?: CancellationToken): Promise; + installPlugin(plugin: IMarketplacePlugin, token?: CancellationToken, options?: IInstallMarketplacePluginOptions): Promise; /** * Removes the exact installed plugin entry and performs best-effort source cleanup. diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 574afb2e6c86a4..a3e7a4321ae54e 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -13,7 +13,8 @@ import { Lazy } from '../../../../../base/common/lazy.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { LRUCache } from '../../../../../base/common/map.js'; import { revive } from '../../../../../base/common/marshalling.js'; -import { autorun, derived, IObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { equals as objectsEqual } from '../../../../../base/common/objects.js'; import { isEqual, isEqualOrParent, joinPath, normalizePath, relativePath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; @@ -33,7 +34,6 @@ import { ChatConfiguration } from '../constants.js'; import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js'; import { FileBackedInstalledPluginsStore, IStoredInstalledPlugin } from './fileBackedInstalledPluginsStore.js'; import { IWorkspacePluginSettingsService } from './workspacePluginSettingsService.js'; -import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { readAgentPluginManifest } from '../../../../../platform/agentPlugins/common/agentPluginParser.js'; import { type IMarketplaceReference, deduplicateMarketplaceReferences, MarketplaceReferenceKind, parseMarketplaceObjectEntry, parseMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; import { getStrictKnownMarketplaces, isMarketplaceReferenceAllowed } from './strictKnownMarketplaces.js'; @@ -154,6 +154,8 @@ export interface IMarketplaceInstalledPlugin { export interface IFetchMarketplacePluginsOptions { /** Bypass the marketplace caches (HTTP TTL cache and cloned-repository TTL) and re-read from the remote. */ readonly refresh?: boolean; + /** Resolve repository-configured marketplace aliases for this workspace folder only. */ + readonly workspaceFolder?: URI; /** * Called for each marketplace that could not be read. Individual failures * are otherwise swallowed so that one bad marketplace cannot fail the @@ -185,6 +187,8 @@ export interface IPluginMarketplaceService { readonly onDidChangeMarketplaces: Event; /** Installed marketplace plugins, backed by storage. */ readonly installedPlugins: IObservable; + /** Resolves after the installed-plugin inventory has been loaded or migrated. */ + readonly whenInstalledPluginsReady: Promise; /** Canonical IDs of marketplaces with updates detected by the periodic check. */ readonly marketplacesWithUpdates: IObservable>; /** @@ -194,19 +198,20 @@ export interface IPluginMarketplaceService { */ readonly lastFetchedPlugins: IObservable; /** - * Set of recommended plugin keys (`"pluginName@marketplaceName"`) aggregated - * from workspace-defined settings (e.g. `.claude/settings.json`). Providers - * may be added over time; consumers should not assume a specific source. + * Set of repository-enabled plugin keys (`"pluginName@marketplaceName"`) + * aggregated from trusted workspace settings. Also backs the legacy + * `@recommended` marketplace filter. */ readonly recommendedPlugins: IObservable>; /** Clears all reported marketplaces, or only the provided canonical IDs. */ clearUpdatesAvailable(marketplaceIds?: ReadonlySet): void; /** Returns the effective, policy-filtered marketplace references in query order. */ - getMarketplaceReferences(): readonly IMarketplaceReference[]; + getMarketplaceReferences(workspaceFolder?: URI): readonly IMarketplaceReference[]; /** Queries a stable, opaque page over selected existing Plugin marketplaces. */ queryMarketplacePlugins(options: IPluginMarketplaceQuery, token: CancellationToken): Promise; fetchMarketplacePlugins(token: CancellationToken, marketplaceIds?: ReadonlySet, options?: IFetchMarketplacePluginsOptions): Promise; getMarketplacePluginMetadata(pluginUri: URI): IMarketplacePlugin | undefined; + isPluginInstalled(pluginUri: URI): boolean; addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void; /** Removes the exact durable installed entry, including when its metadata is not hydrated. */ removeInstalledPlugin(pluginUri: URI): boolean; @@ -365,6 +370,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke readonly onDidChangeMarketplaces: Event; readonly installedPlugins: IObservable; + readonly whenInstalledPluginsReady: Promise; readonly marketplacesWithUpdates: IObservable> = this._marketplacesWithUpdates; readonly lastFetchedPlugins: IObservable; readonly recommendedPlugins: IObservable>; @@ -378,7 +384,6 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke @ILogService private readonly _logService: ILogService, @IStorageService private readonly _storageService: IStorageService, @IWorkspacePluginSettingsService private readonly _workspacePluginSettingsService: IWorkspacePluginSettingsService, - @IWorkspaceTrustManagementService private readonly _workspaceTrustService: IWorkspaceTrustManagementService, @IExtensionsWorkbenchService private readonly _extensionsWorkbenchService: IExtensionsWorkbenchService, @IMeteredConnectionService private readonly _meteredConnectionService: IMeteredConnectionService, ) { @@ -396,6 +401,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke _storageService, ) ); + this.whenInstalledPluginsReady = this._installedPluginsStore.whenInitialized; this._trustedMarketplacesStore = this._register( trustedMarketplacesMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, _storageService) @@ -421,15 +427,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke return result; }); - // Aggregate recommended plugin keys from all providers. - // Currently sourced from Claude workspace settings; more providers can be - // added here via additional observables in the derived computation. - // Only expose recommendations when the workspace is trusted. - const workspaceTrusted = observableFromEvent(this, this._workspaceTrustService.onDidChangeTrust, () => this._workspaceTrustService.isWorkspaceTrusted()); this.recommendedPlugins = derived(reader => { - if (!workspaceTrusted.read(reader)) { - return new Set(); - } const enabledMap = this._workspacePluginSettingsService.enabledPlugins.read(reader); const keys = new Set(); for (const [key, value] of enabledMap) { @@ -440,6 +438,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke return keys; }); + const onDidChangeWorkspaceMarketplaces = Event.fromObservableLight(this._workspacePluginSettingsService.extraMarketplaces); this.onDidChangeMarketplaces = Event.any( Event.filter( _configurationService.onDidChangeConfiguration, @@ -447,8 +446,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke e.affectsConfiguration(ChatConfiguration.PluginMarketplaces) || e.affectsConfiguration(ChatConfiguration.ExtraMarketplaces), ) as Event as Event, - Event.fromObservableLight(this._workspacePluginSettingsService.extraMarketplaces), - Event.map(this._workspaceTrustService.onDidChangeTrust, () => { }), + onDidChangeWorkspaceMarketplaces, ); this._register(this.onDidChangeMarketplaces(() => this._invalidateQueries())); this._register(Event.filter( @@ -504,8 +502,8 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } } - getMarketplaceReferences(): readonly IMarketplaceReference[] { - return this._getConfiguredMarketplaceReferences().filter(reference => this._isMarketplaceAllowedByStrictPolicy(reference)); + getMarketplaceReferences(workspaceFolder?: URI): readonly IMarketplaceReference[] { + return this._getConfiguredMarketplaceReferences(workspaceFolder).filter(reference => this._isMarketplaceAllowedByStrictPolicy(reference)); } async queryMarketplacePlugins(options: IPluginMarketplaceQuery, token: CancellationToken): Promise { @@ -584,7 +582,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } } - const refsToFetch = this.getMarketplaceReferences().filter(ref => !marketplaceIds || marketplaceIds.has(ref.canonicalId)); + const refsToFetch = this.getMarketplaceReferences(options?.workspaceFolder).filter(ref => !marketplaceIds || marketplaceIds.has(ref.canonicalId)); const results = await Promise.all( refsToFetch.map(ref => { if (ref.kind === MarketplaceReferenceKind.GitHubShorthand && ref.githubRepo) { @@ -601,20 +599,20 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke return plugins; } - const storedPlugins = marketplaceIds - ? [...this.lastFetchedPlugins.get().filter(plugin => !marketplaceIds.has(plugin.marketplaceReference.canonicalId)), ...plugins] + const replacedMarketplaceIds = marketplaceIds ?? (options?.workspaceFolder ? new Set(refsToFetch.map(reference => reference.canonicalId)) : undefined); + const storedPlugins = replacedMarketplaceIds + ? [...this.lastFetchedPlugins.get().filter(plugin => !replacedMarketplaceIds.has(plugin.marketplaceReference.canonicalId)), ...plugins] : plugins; this._lastFetchedPluginsStore.set({ plugins: storedPlugins, fetchedAt: Date.now() }, undefined); return plugins; } - private _getConfiguredMarketplaceReferences(): readonly IMarketplaceReference[] { + private _getConfiguredMarketplaceReferences(workspaceFolder?: URI): readonly IMarketplaceReference[] { const { effectiveValues } = readConfiguredMarketplaces(this._configurationService); const configured = parseMarketplaceReferences(effectiveValues); - if (!this._workspaceTrustService.isWorkspaceTrusted()) { - return configured; - } - const workspaceEntries = this._workspacePluginSettingsService.extraMarketplaces.get(); + const workspaceEntries = workspaceFolder + ? this._workspacePluginSettingsService.getWorkspaceSettings(workspaceFolder)?.extraMarketplaces ?? [] + : this._workspacePluginSettingsService.extraMarketplaces.get(); return deduplicateMarketplaceReferences(workspaceEntries.map(entry => entry.reference), configured); } @@ -791,8 +789,14 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke ?? [...this._pluginMetadata.entries()].find(([key]) => isEqualOrParent(pluginUri, URI.parse(key)))?.[1]; } + isPluginInstalled(pluginUri: URI): boolean { + return this._installedPluginsStore.get().some(entry => isEqual(entry.pluginUri, pluginUri)); + } + addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void { - this._pluginMetadata.set(pluginUri.toString(), plugin); + const key = pluginUri.toString(); + const metadataChanged = !areMarketplacePluginsEqual(this._pluginMetadata.get(key), plugin); + this._pluginMetadata.set(key, plugin); const entry: IStoredInstalledPlugin = { pluginUri, marketplace: plugin.marketplaceReference.rawValue, @@ -801,8 +805,9 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke const current = this._installedPluginsStore.get(); const existing = current.find(e => isEqual(e.pluginUri, pluginUri)); if (existing) { - // Still update to trigger watchers to re-check, something might have happened that we want to know about - this._installedPluginsStore.set(current.map(c => c === existing ? entry : c), undefined); + if (metadataChanged || existing.marketplace !== entry.marketplace || existing.name !== entry.name) { + this._installedPluginsStore.set(current.map(candidate => candidate === existing ? entry : candidate), undefined); + } } else { this._installedPluginsStore.set([...current, entry], undefined); } @@ -838,7 +843,10 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke isMarketplaceAutoUpdateEnabled(ref: IMarketplaceReference): boolean { const { extraValues } = readConfiguredMarketplaces(this._configurationService); const managedRef = parseMarketplaceReferences(extraValues).find(candidate => candidate.canonicalId === ref.canonicalId); - return managedRef?.autoUpdate ?? this._extensionsWorkbenchService.getAutoUpdateValue() !== 'off'; + if (managedRef?.autoUpdate !== undefined) { + return managedRef.autoUpdate; + } + return this._extensionsWorkbenchService.getAutoUpdateValue() !== 'off' && ref.autoUpdate !== false; } private _isMarketplaceAllowedByStrictPolicy(ref: IMarketplaceReference): boolean { @@ -1191,6 +1199,36 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } } +export function areMarketplacePluginsEqual(first: IMarketplacePlugin | undefined, second: IMarketplacePlugin): boolean { + if (!first) { + return false; + } + const firstReference = first.marketplaceReference; + const secondReference = second.marketplaceReference; + return first.name === second.name + && first.description === second.description + && first.version === second.version + && first.source === second.source + && objectsEqual(first.sourceDescriptor, second.sourceDescriptor) + && first.marketplace === second.marketplace + && first.marketplaceType === second.marketplaceType + && firstReference.rawValue === secondReference.rawValue + && firstReference.displayLabel === secondReference.displayLabel + && firstReference.cloneUrl === secondReference.cloneUrl + && firstReference.canonicalId === secondReference.canonicalId + && objectsEqual(firstReference.cacheSegments, secondReference.cacheSegments) + && firstReference.kind === secondReference.kind + && firstReference.ref === secondReference.ref + && firstReference.githubRepo === secondReference.githubRepo + && firstReference.autoUpdate === secondReference.autoUpdate + && areOptionalUrisEqual(firstReference.localRepositoryUri, secondReference.localRepositoryUri) + && areOptionalUrisEqual(first.readmeUri, second.readmeUri); +} + +function areOptionalUrisEqual(first: URI | undefined, second: URI | undefined): boolean { + return first === undefined ? second === undefined : second !== undefined && isEqual(first, second); +} + function normalizeMarketplacePath(value: string): string { let normalized = value.trim().replace(/\\/g, '/'); normalized = normalized.replace(/^\.?\/+/, '').replace(/\/+$/g, ''); diff --git a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts index 083f59053fbef5..15a5cda35626b0 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts @@ -6,13 +6,14 @@ import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; -import { joinPath } from '../../../../../base/common/resources.js'; +import { autorun, derived, IObservable, IReader, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { isEqual, joinPath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { CLAUDE_CONFIG_FOLDER } from '../promptSyntax/config/promptFileLocations.js'; import { IMarketplaceReference, parseMarketplaceObjectEntry } from './marketplaceReference.js'; @@ -32,21 +33,35 @@ export interface IWorkspaceMarketplaceEntry { export const IWorkspacePluginSettingsService = createDecorator('workspacePluginSettingsService'); +export interface IWorkspacePluginSettings { + readonly workspaceFolder: URI; + readonly extraMarketplaces: readonly IWorkspaceMarketplaceEntry[]; + readonly enabledPlugins: ReadonlyMap; +} + export interface IWorkspacePluginSettingsService { readonly _serviceBrand: undefined; + /** Repository plugin settings keyed by their source workspace folder. */ + readonly workspaceSettings: IObservable; + /** - * Marketplace references parsed from `extraKnownMarketplaces` in workspace - * settings files (`.claude/settings.json`, `.github/copilot/settings.json`). + * Marketplace references parsed from `extraKnownMarketplaces` in trusted + * workspace settings files (`.claude/settings.json`, `.github/copilot/settings.json`). */ readonly extraMarketplaces: IObservable; /** - * Plugin recommendation map parsed from `enabledPlugins` in workspace - * settings files. - * Keys are `"pluginName@marketplaceName"`, values indicate recommendation. + * Repository-scoped plugin activation map parsed from `enabledPlugins` in + * trusted workspace settings files. Keys are `"pluginName@marketplaceName"`. */ readonly enabledPlugins: IObservable>; + + /** Resolves after the current workspace settings reads have completed. */ + whenSettled(): Promise; + + /** Returns settings for the workspace folder containing `resource`. */ + getWorkspaceSettings(resource: URI, reader?: IReader): IWorkspacePluginSettings | undefined; } // --- Parsing helpers --------------------------------------------------------- @@ -103,11 +118,15 @@ function parseExtraMarketplaces(json: unknown, logPrefix: string, logService: IL // --- Settings reader (reusable per config folder) ---------------------------- interface IWorkspaceSettingsData { + readonly workspaceFolder: URI; readonly marketplaces: readonly IWorkspaceMarketplaceEntry[]; readonly enabledPlugins: ReadonlyMap; } -const EMPTY_DATA: IWorkspaceSettingsData = { marketplaces: [], enabledPlugins: new Map() }; +interface IWorkspaceSettingsDirectory { + readonly workspaceFolder: URI; + readonly settingsDirectory: URI; +} /** * Reads `enabledPlugins` and `extraKnownMarketplaces` from a pair of @@ -117,8 +136,10 @@ const EMPTY_DATA: IWorkspaceSettingsData = { marketplaces: [], enabledPlugins: n */ class WorkspaceSettingsReader extends Disposable { - private readonly _data = observableValue('data', EMPTY_DATA); - readonly data: IObservable = this._data; + private readonly _data = observableValue('data', []); + private _readVersion = 0; + private _pendingRead: Promise = Promise.resolve(); + readonly data: IObservable = this._data; constructor( /** Workspace-relative config folder (e.g. `.claude`). */ @@ -133,7 +154,10 @@ class WorkspaceSettingsReader extends Disposable { const settingsDirs = observableFromEvent( this, workspaceContextService.onDidChangeWorkspaceFolders, - () => workspaceContextService.getWorkspace().folders.map(f => f.uri.path ? joinPath(f.uri, configFolder) : joinPath(f.uri.with({ path: '/' }), configFolder)), + () => workspaceContextService.getWorkspace().folders.map(folder => ({ + workspaceFolder: folder.uri, + settingsDirectory: folder.uri.path ? joinPath(folder.uri, configFolder) : joinPath(folder.uri.with({ path: '/' }), configFolder), + })), ); const watcherStore = this._register(new DisposableStore()); @@ -141,32 +165,45 @@ class WorkspaceSettingsReader extends Disposable { const dirs = settingsDirs.read(reader); watcherStore.clear(); - // Coalesce rapid file-change events into a single read. - const scheduler = new RunOnceScheduler(() => this._readSettings(dirs, logPrefix, fileService), 100); + const scheduler = new RunOnceScheduler(() => { + this._pendingRead = this._readSettings(dirs, logPrefix, fileService); + }, 100); watcherStore.add(scheduler); - for (const dir of dirs) { - const watcher = fileService.createWatcher(dir, { recursive: false, excludes: [] }); + for (const { settingsDirectory } of dirs) { + const watcher = fileService.createWatcher(settingsDirectory, { recursive: false, excludes: [] }); watcherStore.add(watcher); watcherStore.add(watcher.onDidChange(e => { - if (e.affects(joinPath(dir, SETTINGS_FILENAME)) || e.affects(joinPath(dir, SETTINGS_LOCAL_FILENAME))) { + if (e.affects(joinPath(settingsDirectory, SETTINGS_FILENAME)) || e.affects(joinPath(settingsDirectory, SETTINGS_LOCAL_FILENAME))) { scheduler.schedule(); } })); } - // Perform initial read immediately. - this._readSettings(dirs, logPrefix, fileService); + this._data.set([], undefined); + this._pendingRead = this._readSettings(dirs, logPrefix, fileService); })); } - private async _readSettings(dirs: readonly URI[], logPrefix: string, fileService: IFileService): Promise { - const allMarketplaces: IWorkspaceMarketplaceEntry[] = []; - const mergedEnabled = new Map(); + async whenSettled(): Promise { + while (true) { + const pendingRead = this._pendingRead; + await pendingRead; + if (pendingRead === this._pendingRead) { + return; + } + } + } + + private async _readSettings(dirs: readonly IWorkspaceSettingsDirectory[], logPrefix: string, fileService: IFileService): Promise { + const readVersion = ++this._readVersion; + const result: IWorkspaceSettingsData[] = []; - for (const dir of dirs) { - const sharedUri = joinPath(dir, SETTINGS_FILENAME); - const localUri = joinPath(dir, SETTINGS_LOCAL_FILENAME); + for (const { workspaceFolder, settingsDirectory } of dirs) { + const mergedMarketplaces = new Map(); + const mergedEnabled = new Map(); + const sharedUri = joinPath(settingsDirectory, SETTINGS_FILENAME); + const localUri = joinPath(settingsDirectory, SETTINGS_LOCAL_FILENAME); for (const uri of [sharedUri, localUri]) { try { @@ -181,9 +218,7 @@ class WorkspaceSettingsReader extends Disposable { const marketplaces = parseExtraMarketplaces(root.extraKnownMarketplaces, logPrefix, this._logService); for (const entry of marketplaces) { - if (!allMarketplaces.some(e => e.reference.canonicalId === entry.reference.canonicalId)) { - allMarketplaces.push(entry); - } + mergedMarketplaces.set(entry.name, entry); } const enabled = parseEnabledPlugins(root.enabledPlugins); @@ -194,9 +229,17 @@ class WorkspaceSettingsReader extends Disposable { this._logService.debug(`${logPrefix} Could not read ${uri.toString()}`); } } + + result.push({ + workspaceFolder, + marketplaces: [...mergedMarketplaces.values()], + enabledPlugins: mergedEnabled, + }); } - this._data.set({ marketplaces: allMarketplaces, enabledPlugins: mergedEnabled }, undefined); + if (readVersion === this._readVersion) { + this._data.set(result, undefined); + } } } @@ -205,54 +248,102 @@ class WorkspaceSettingsReader extends Disposable { export class WorkspacePluginSettingsService extends Disposable implements IWorkspacePluginSettingsService { declare readonly _serviceBrand: undefined; + readonly workspaceSettings: IObservable; readonly extraMarketplaces: IObservable; readonly enabledPlugins: IObservable>; constructor( @IFileService fileService: IFileService, - @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ILogService logService: ILogService, + @IWorkspaceTrustManagementService workspaceTrustService: IWorkspaceTrustManagementService, ) { super(); const claudeReader = this._register(new WorkspaceSettingsReader( CLAUDE_CONFIG_FOLDER, '[ClaudePluginSettings]', - fileService, workspaceContextService, logService, + fileService, this._workspaceContextService, logService, )); const copilotReader = this._register(new WorkspaceSettingsReader( COPILOT_CONFIG_FOLDER, '[CopilotPluginSettings]', - fileService, workspaceContextService, logService, + fileService, this._workspaceContextService, logService, )); - // Merge marketplaces from all readers, deduplicating by canonical ID. + const workspaceTrusted = observableFromEvent(this, workspaceTrustService.onDidChangeTrust, () => workspaceTrustService.isWorkspaceTrusted()); + + this.workspaceSettings = derived(reader => { + if (!workspaceTrusted.read(reader)) { + return []; + } + + const claudeSettings = claudeReader.data.read(reader); + const copilotSettings = copilotReader.data.read(reader); + return copilotSettings.map(copilot => { + const claude = claudeSettings.find(candidate => isEqual(candidate.workspaceFolder, copilot.workspaceFolder)); + const extraMarketplaces: IWorkspaceMarketplaceEntry[] = []; + const seenNames = new Set(); + const seenCanonicalIds = new Set(); + for (const entry of [...copilot.marketplaces, ...(claude?.marketplaces ?? [])]) { + if (!seenNames.has(entry.name) && !seenCanonicalIds.has(entry.reference.canonicalId)) { + extraMarketplaces.push(entry); + seenNames.add(entry.name); + seenCanonicalIds.add(entry.reference.canonicalId); + } + } + + const enabledPlugins = new Map(copilot.enabledPlugins); + for (const [key, value] of claude?.enabledPlugins ?? []) { + if (!enabledPlugins.has(key)) { + enabledPlugins.set(key, value); + } + } + + return { + workspaceFolder: copilot.workspaceFolder, + extraMarketplaces, + enabledPlugins, + }; + }); + }); + this.extraMarketplaces = derived(reader => { - const claude = claudeReader.data.read(reader).marketplaces; - const copilot = copilotReader.data.read(reader).marketplaces; - const byCanonicalId = new Map(); - for (const entry of [...claude, ...copilot]) { - if (!byCanonicalId.has(entry.reference.canonicalId)) { - byCanonicalId.set(entry.reference.canonicalId, entry); + const result: IWorkspaceMarketplaceEntry[] = []; + const seenCanonicalIds = new Set(); + for (const settings of this.workspaceSettings.read(reader)) { + for (const entry of settings.extraMarketplaces) { + if (!seenCanonicalIds.has(entry.reference.canonicalId)) { + result.push(entry); + seenCanonicalIds.add(entry.reference.canonicalId); + } } } - return [...byCanonicalId.values()]; + return result; }); - // Merge enabledPlugins from all readers. Claude entries take - // precedence for keys that exist in both (first-writer wins). this.enabledPlugins = derived(reader => { - const claude = claudeReader.data.read(reader).enabledPlugins; - const copilot = copilotReader.data.read(reader).enabledPlugins; const merged = new Map(); - for (const [key, value] of claude) { - merged.set(key, value); - } - for (const [key, value] of copilot) { - if (!merged.has(key)) { - merged.set(key, value); + for (const settings of this.workspaceSettings.read(reader)) { + for (const [key, value] of settings.enabledPlugins) { + merged.set(key, value || merged.get(key) === true); } } return merged; }); + + this.whenSettled = async () => { + await Promise.all([claudeReader.whenSettled(), copilotReader.whenSettled()]); + }; + } + + readonly whenSettled: () => Promise; + + getWorkspaceSettings(resource: URI, reader?: IReader): IWorkspacePluginSettings | undefined { + const workspaceFolder = this._workspaceContextService.getWorkspaceFolder(resource); + if (!workspaceFolder) { + return undefined; + } + const settings = reader ? this.workspaceSettings.read(reader) : this.workspaceSettings.get(); + return settings.find(candidate => isEqual(candidate.workspaceFolder, workspaceFolder.uri)); } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts index 1e000457e7d987..c06ddee004f454 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts @@ -182,6 +182,14 @@ suite('AgentHostUntitledProvisionalSessionService', () => { ), false); }); + test('keeps scopes with different primary workspace roots separate', () => { + const extUri = new ExtUri(() => false); + const first = URI.file('/workspace-a'); + const second = URI.file('/workspace-b'); + + assert.strictEqual(areCustomizationScopeRootsEqual([first, second], [second, first], extUri), false); + }); + let agentHost: MockAgentHostService; let sessionResolutions: ResourceMap; let onDidChangeSessionResolution: Emitter; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts index 14f39304587f13..89ac62825c840f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts @@ -85,11 +85,18 @@ function globalEnablement(enabled: boolean): CustomizationEnablement[] { return [{ kind: CustomizationEnablementKind.Global, enabled }]; } -function makeAgentPluginService(plugins: readonly IAgentPlugin[] = [], profileEnablement = new Map()): IAgentPluginService { +function makeAgentPluginService( + plugins: readonly IAgentPlugin[] = [], + profileEnablement = new Map(), + workspaceConfiguredEnablement = new Map(), +): IAgentPluginService { return { _serviceBrand: undefined, plugins: observableValue('plugins', plugins), enablementModel: { readProfileEnabled: (key: string) => profileEnablement.get(key) ?? true }, + getWorkspaceConfiguredEnablement: (plugin: IAgentPlugin, workspaceFolder: URI) => + workspaceConfiguredEnablement.get(`${workspaceFolder.toString()}\0${plugin.uri.toString()}`) + ?? workspaceConfiguredEnablement.get(plugin.uri.toString()), } as unknown as IAgentPluginService; } @@ -738,6 +745,81 @@ suite('resolveCustomizationRefs - built-in skills', () => { assert.deepStrictEqual(bundler.receivedMcp[0].map(entry => entry.enablement), [globalEnablement(true)]); }); + test('publishes repository enablement for the agent host workspace', async () => { + const enabledUri = URI.file('/plugins/repository-enabled'); + const disabledUri = URI.file('/plugins/repository-disabled'); + const workingDirectory = URI.file('/workspace'); + const refs = await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService( + [makePlugin(enabledUri, { mcpServers: 1 }), makePlugin(disabledUri, { mcpServers: 1 })], + new Map([[enabledUri.toString(), false], [disabledUri.toString(), true]]), + new Map([[enabledUri.toString(), true], [disabledUri.toString(), false]]), + ), + makeMcpService(), + makeConfigurationResolverService(), + new FakeBundler() as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + undefined, + [workingDirectory], + ); + + assert.deepStrictEqual(refs.map(ref => ref.enablement), [ + [ + { kind: CustomizationEnablementKind.Workspace, uri: workingDirectory.toString(), enabled: true }, + { kind: CustomizationEnablementKind.Global, enabled: false }, + ], + [ + { kind: CustomizationEnablementKind.Workspace, uri: workingDirectory.toString(), enabled: false }, + { kind: CustomizationEnablementKind.Global, enabled: true }, + ], + ]); + }); + + test('publishes only the target workspace folder enablement in a multi-root window', async () => { + const pluginUri = URI.file('/plugins/repository-scoped'); + const firstWorkspace = URI.file('/workspace-a'); + const secondWorkspace = URI.file('/workspace-b'); + const pluginService = makeAgentPluginService( + [makePlugin(pluginUri, { mcpServers: 1 })], + new Map([[pluginUri.toString(), false]]), + new Map([ + [`${firstWorkspace.toString()}\0${pluginUri.toString()}`, true], + [`${secondWorkspace.toString()}\0${pluginUri.toString()}`, false], + ]), + ); + const resolveFor = (workspaceFolder: URI) => resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + pluginService, + makeMcpService(), + makeConfigurationResolverService(), + new FakeBundler() as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + undefined, + [workspaceFolder], + ); + + const [first, second] = await Promise.all([resolveFor(firstWorkspace), resolveFor(secondWorkspace)]); + + assert.deepStrictEqual({ + first: first[0].enablement, + second: second[0].enablement, + }, { + first: [ + { kind: CustomizationEnablementKind.Workspace, uri: firstWorkspace.toString(), enabled: true }, + { kind: CustomizationEnablementKind.Global, enabled: false }, + ], + second: [ + { kind: CustomizationEnablementKind.Workspace, uri: secondWorkspace.toString(), enabled: false }, + { kind: CustomizationEnablementKind.Global, enabled: false }, + ], + }); + }); + test('excludes workspace-discovered `.mcp.json` servers (the agent host discovers those itself)', async () => { const bundler = new FakeBundler(); const mcpService = makeMcpService([ 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 e223bc25e4f92d..4688cea8ad481f 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 @@ -72,6 +72,7 @@ suite('PluginInstallService', () => { /** Whether the terminal resolves the command completion at all */ terminalCompletes: boolean; pullRepositoryCalls: { marketplace: IMarketplaceReference; options?: IPullRepositoryOptions }[]; + pullRepositoryChanged: boolean; updatePluginSourceCalls: { plugin: IMarketplacePlugin; options?: IPullRepositoryOptions }[]; /** Whether the marketplace is already trusted */ marketplaceTrusted: boolean; @@ -128,6 +129,7 @@ suite('PluginInstallService', () => { terminalExitCode: 0, terminalCompletes: true, pullRepositoryCalls: [], + pullRepositoryChanged: false, updatePluginSourceCalls: [], marketplaceTrusted: true, strictMarketplacePolicyActive: false, @@ -304,6 +306,7 @@ suite('PluginInstallService', () => { }, pullRepository: async (marketplace: IMarketplaceReference, options?: IPullRepositoryOptions) => { state.pullRepositoryCalls.push({ marketplace, options }); + return state.pullRepositoryChanged; }, getPluginSourceInstallUri: (descriptor: IPluginSourceDescriptor) => { const key = descriptor.kind; @@ -1001,6 +1004,36 @@ suite('PluginInstallService', () => { }); }); + test('applies a simulated repository marketplace update automatically', async () => { + const installed = installedPlugin('repository-plugin', 'microsoft/repository-plugins'); + const updated = { ...installed.plugin, version: '2.0.0', description: 'updated' }; + const { service, state } = createService({ + installedPlugins: [installed], + fetchedMarketplacePlugins: [updated], + pullRepositoryChanged: true, + autoUpdateByMarketplace: new Map([[installed.plugin.marketplaceReference.canonicalId, true]]), + }); + + const result = await service.updateAllPlugins({ + silent: true, + automatic: true, + marketplaceIds: new Set([installed.plugin.marketplaceReference.canonicalId]), + }, CancellationToken.None); + + assert.deepStrictEqual({ + result, + pulled: state.pullRepositoryCalls.map(call => call.marketplace.canonicalId), + fetched: state.fetchMarketplaceCalls, + }, { + result: { + updatedNames: [installed.plugin.marketplaceReference.displayLabel], + failedNames: [], + }, + pulled: [installed.plugin.marketplaceReference.canonicalId], + fetched: [[installed.plugin.marketplaceReference.canonicalId]], + }); + }); + test('rechecks managed auto-update policy before an automatic update', async () => { const installed = installedPlugin('blocked', 'microsoft/blocked'); const { service, state } = createService({ @@ -1091,6 +1124,24 @@ suite('PluginInstallService', () => { assert.strictEqual(state.trustedMarketplaces.length, 0, 'should not re-trust'); }); + test('skips trust prompt when a trusted repository established the marketplace source', 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, CancellationToken.None, { skipTrust: true }); + + assert.deepStrictEqual({ + installed: state.addedPlugins.length, + trusted: state.trustedMarketplaces.length, + }, { + installed: 1, + trusted: 0, + }); + }); + test('shows trust prompt and installs when user confirms', async () => { const { service, state } = createService({ marketplaceTrusted: false, dialogConfirmResult: true }); const plugin = createPlugin({ diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/workspaceAgentPluginActivation.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/workspaceAgentPluginActivation.test.ts new file mode 100644 index 00000000000000..0f29622d630f27 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/workspaceAgentPluginActivation.test.ts @@ -0,0 +1,302 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { PluginFormat } from '../../../../../../platform/agentPlugins/common/pluginParsers.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { WorkspaceAgentPluginActivationService } from '../../../browser/workspaceAgentPluginActivation.js'; +import { ContributionEnablementState, IEnablementModel } from '../../../common/enablement.js'; +import { IAgentPlugin, IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; +import { IInstallMarketplacePluginOptions, IPluginInstallService } from '../../../common/plugins/pluginInstallService.js'; +import { IMarketplaceInstalledPlugin, IMarketplacePlugin, IPluginMarketplaceService, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; +import { IWorkspacePluginSettings, IWorkspacePluginSettingsService } from '../../../common/plugins/workspacePluginSettingsService.js'; + +suite('WorkspaceAgentPluginActivation', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const installUri = URI.file('/agent-plugins/owner/marketplace/example-plugin'); + const workspaceFolder = URI.file('/workspace'); + + function createPlugin(): IMarketplacePlugin { + const marketplaceReference = parseMarketplaceReference('owner/marketplace'); + assert.ok(marketplaceReference); + return { + name: 'example-plugin', + description: '', + version: '1.0.0', + source: 'plugins/example-plugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/example-plugin' }, + marketplace: 'owner/marketplace', + marketplaceReference, + marketplaceType: MarketplaceType.Copilot, + }; + } + + function createEnablementModel(onSet?: (state: ContributionEnablementState) => void): { model: IEnablementModel; states: Map } { + const states = new Map(); + return { + states, + model: { + readEnabled: key => states.get(key) ?? ContributionEnablementState.EnabledProfile, + readProfileEnabled: key => (states.get(key) ?? ContributionEnablementState.EnabledProfile) === ContributionEnablementState.EnabledProfile, + setEnabled: (key, state) => { + states.set(key, state); + onSet?.(state); + }, + remove: key => states.delete(key), + }, + }; + } + + function createContribution(options?: { readonly installed?: boolean; readonly hidden?: boolean; readonly installSucceeds?: boolean; readonly discoveredPlugin?: IAgentPlugin; readonly installedPlugin?: IMarketplacePlugin }) { + const plugin = createPlugin(); + const pluginId = `${plugin.name}@${plugin.marketplace}`; + let installedMetadata = options?.installedPlugin ?? (options?.installed ? plugin : undefined); + const installedPlugins = observableValue('installedPlugins', installedMetadata ? [{ pluginUri: installUri, plugin: installedMetadata }] : []); + const recommendedPlugins = observableValue>('recommendedPlugins', new Set([pluginId])); + const workspaceSettings: IWorkspacePluginSettings = { + workspaceFolder, + extraMarketplaces: [{ name: plugin.marketplace, reference: plugin.marketplaceReference }], + enabledPlugins: new Map([[pluginId, true]]), + }; + const installCalled = new DeferredPromise(); + const baselineSet = new DeferredPromise(); + let installCount = 0; + let fetchCount = 0; + let metadataRefreshCount = 0; + let discoveryRefreshCount = 0; + let installOptions: IInstallMarketplacePluginOptions | undefined; + const { model, states } = createEnablementModel(state => { + if (state === ContributionEnablementState.DisabledProfile) { + baselineSet.complete(); + } + }); + + const marketplaceService = new class extends mock() { + override readonly onDidChangeMarketplaces = Event.None; + override readonly installedPlugins = installedPlugins; + override readonly whenInstalledPluginsReady = Promise.resolve(); + override readonly recommendedPlugins = recommendedPlugins; + override getMarketplacePluginMetadata(): IMarketplacePlugin | undefined { + return installedMetadata; + } + override isPluginInstalled(pluginUri: URI): boolean { + return installedPlugins.get().some(entry => entry.pluginUri.toString() === pluginUri.toString()); + } + override addInstalledPlugin(pluginUri: URI, installedPlugin: IMarketplacePlugin): void { + metadataRefreshCount++; + installedMetadata = installedPlugin; + installedPlugins.set([{ pluginUri, plugin: installedPlugin }], undefined); + } + override async fetchMarketplacePlugins(): Promise { + fetchCount++; + return [plugin]; + } + }(); + const installService = new class extends mock() { + override async installPlugin(_plugin: IMarketplacePlugin, _token?: CancellationToken, installOptionsArgument?: IInstallMarketplacePluginOptions): Promise { + installCount++; + installOptions = installOptionsArgument; + if (options?.installSucceeds !== false) { + marketplaceService.addInstalledPlugin(installUri, plugin); + } + installCalled.complete(); + } + override getPluginInstallUri(): URI { + return installUri; + } + }(); + const agentPluginService = new class extends mock() { + override readonly plugins = observableValue('plugins', options?.discoveredPlugin ? [options.discoveredPlugin] : []); + override readonly enablementModel = model; + override readonly whenReady = Promise.resolve(); + override async refresh(): Promise { + discoveryRefreshCount++; + } + }(); + const workspacePluginSettingsService = new class extends mock() { + override readonly workspaceSettings = observableValue('workspaceSettings', [workspaceSettings]); + override readonly extraMarketplaces = observableValue('extraMarketplaces', workspaceSettings.extraMarketplaces); + override readonly enabledPlugins = observableValue('enabledPlugins', workspaceSettings.enabledPlugins); + override async whenSettled(): Promise { } + override getWorkspaceSettings(): IWorkspacePluginSettings { + return workspaceSettings; + } + }(); + const entitlementService = new class extends mock() { + override readonly onDidChangeSentiment = Event.None; + override readonly sentiment = { hidden: options?.hidden }; + }(); + + const service = store.add(new WorkspaceAgentPluginActivationService( + marketplaceService, + installService, + agentPluginService, + workspacePluginSettingsService, + entitlementService, + new NullLogService(), + )); + + return { + baselineSet, + installCalled, + installedPlugins, + states, + reconcile: () => service.reconcile([workspaceFolder]), + get installOptions() { return installOptions; }, + get metadataRefreshCount() { return metadataRefreshCount; }, + get discoveryRefreshCount() { return discoveryRefreshCount; }, + get fetchCount() { return fetchCount; }, + get installCount() { return installCount; }, + }; + } + + test('installs a configured plugin with a disabled profile baseline', async () => { + const harness = createContribution(); + + await harness.reconcile(); + + assert.deepStrictEqual({ + installCount: harness.installCount, + discoveryRefreshCount: harness.discoveryRefreshCount, + skipTrust: harness.installOptions?.skipTrust, + profileState: harness.states.get(installUri.toString()), + }, { + installCount: 1, + discoveryRefreshCount: 1, + skipTrust: true, + profileState: ContributionEnablementState.DisabledProfile, + }); + }); + + test('preserves enablement when the configured plugin is already installed', async () => { + const harness = createContribution({ installed: true }); + + await harness.reconcile(); + + assert.deepStrictEqual({ + installCount: harness.installCount, + metadataRefreshCount: harness.metadataRefreshCount, + states: [...harness.states], + }, { + installCount: 0, + metadataRefreshCount: 0, + states: [], + }); + }); + + test('refreshes installed metadata from the repository marketplace before resolving', async () => { + const currentPlugin = createPlugin(); + const installedPlugin = { + ...currentPlugin, + marketplace: 'stale-marketplace-name', + marketplaceReference: { + ...currentPlugin.marketplaceReference, + displayLabel: 'stale-marketplace-name', + autoUpdate: undefined, + }, + }; + const harness = createContribution({ installedPlugin }); + + await harness.reconcile(); + + assert.deepStrictEqual({ + installCount: harness.installCount, + metadataRefreshCount: harness.metadataRefreshCount, + discoveryRefreshCount: harness.discoveryRefreshCount, + }, { + installCount: 0, + metadataRefreshCount: 1, + discoveryRefreshCount: 1, + }); + }); + + test('reinstalls a configured plugin after it is removed', async () => { + const harness = createContribution({ installed: true }); + await timeout(0); + + harness.installedPlugins.set([], undefined); + await harness.baselineSet.p; + + assert.deepStrictEqual({ + installCount: harness.installCount, + profileState: harness.states.get(installUri.toString()), + }, { + installCount: 1, + profileState: ContributionEnablementState.DisabledProfile, + }); + }); + + test('installs the configured source when a same-named plugin from another source is discovered', async () => { + const otherMarketplace = createPlugin(); + const otherReference = parseMarketplaceReference('owner/other-marketplace'); + assert.ok(otherReference); + const harness = createContribution({ + discoveredPlugin: { + uri: URI.file('/agent-plugins/owner/other-marketplace/example-plugin'), + format: PluginFormat.Copilot, + label: otherMarketplace.name, + enablement: observableValue('otherPluginEnablement', ContributionEnablementState.EnabledProfile), + hooks: observableValue('otherPluginHooks', []), + commands: observableValue('otherPluginCommands', []), + skills: observableValue('otherPluginSkills', []), + agents: observableValue('otherPluginAgents', []), + instructions: observableValue('otherPluginInstructions', []), + mcpServerDefinitions: observableValue('otherPluginMcpServers', []), + automations: observableValue('otherPluginAutomations', []), + fromMarketplace: { + ...otherMarketplace, + marketplaceReference: { ...otherReference, displayLabel: otherMarketplace.marketplace }, + }, + }, + }); + + await harness.baselineSet.p; + + assert.deepStrictEqual({ + installCount: harness.installCount, + profileState: harness.states.get(installUri.toString()), + }, { + installCount: 1, + profileState: ContributionEnablementState.DisabledProfile, + }); + }); + + test('does not change profile enablement when installation fails', async () => { + const harness = createContribution({ installSucceeds: false }); + + await harness.installCalled.p; + await timeout(0); + + assert.deepStrictEqual({ + installCount: harness.installCount, + states: [...harness.states], + }, { + installCount: 1, + states: [], + }); + }); + + test('does not fetch or install plugins when AI features are hidden', async () => { + const harness = createContribution({ hidden: true }); + + await timeout(0); + + assert.deepStrictEqual({ + fetchCount: harness.fetchCount, + installCount: harness.installCount, + }, { + fetchCount: 0, + installCount: 0, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginEnablement.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginEnablement.test.ts index 0153ea04182798..adafa866f2b921 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginEnablement.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginEnablement.test.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { PluginFormat } from '../../../../../../platform/agentPlugins/common/pluginParsers.js'; import { ContributionEnablementState, IEnablementModel, isContributionEnabled } from '../../../common/enablement.js'; -import { AgentPluginCollisionEnablementModel, getCanonicalAgentPluginCollisionGroups, getSortedAgentPlugins, IDiscoveredAgentPlugins, isAgentPluginBlockedByPolicy, isAgentPluginForceEnabledByPolicy } from '../../../common/plugins/agentPluginEnablement.js'; +import { AgentPluginCollisionEnablementModel, getAgentPluginConfiguredEnablement, getCanonicalAgentPluginCollisionGroups, getSortedAgentPlugins, IDiscoveredAgentPlugins, isAgentPluginBlockedByPolicy, isAgentPluginForceEnabledByPolicy } from '../../../common/plugins/agentPluginEnablement.js'; import { AgentPluginDiscoveryPriority, IAgentPlugin } from '../../../common/plugins/agentPluginService.js'; import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; @@ -135,9 +135,9 @@ suite('AgentPlugin enablement', () => { setEnabled: (key, value) => stored.set(key, value), remove: key => stored.delete(key), }; - const policy = observableValue>('managedPluginEnablement', new Map([ - [required, true], - [blocked, false], + const policy = observableValue>('managedPluginEnablement', new Map([ + [required, ContributionEnablementState.EnabledProfile], + [blocked, ContributionEnablementState.DisabledProfile], ])); const enablementModel = new AgentPluginCollisionEnablementModel(base, observableValue('emptyCollisionGroups', new Map()), policy); @@ -174,6 +174,51 @@ suite('AgentPlugin enablement', () => { }); }); + test('workspace configuration overlays effective state without replacing profile state', () => { + const enabledForWorkspace = URI.file('/plugins/workspace-enabled').toString(); + const disabledForWorkspace = URI.file('/plugins/workspace-disabled').toString(); + const stored = new Map([ + [enabledForWorkspace, ContributionEnablementState.DisabledProfile], + [disabledForWorkspace, ContributionEnablementState.EnabledProfile], + ]); + const base: IEnablementModel = { + readEnabled: key => stored.get(key) ?? ContributionEnablementState.EnabledProfile, + readProfileEnabled: key => (stored.get(key) ?? ContributionEnablementState.EnabledProfile) === ContributionEnablementState.EnabledProfile, + setEnabled: (key, value) => stored.set(key, value), + remove: key => stored.delete(key), + }; + const workspace = observableValue>('workspacePluginEnablement', new Map([ + [enabledForWorkspace, ContributionEnablementState.EnabledWorkspace], + [disabledForWorkspace, ContributionEnablementState.DisabledWorkspace], + ])); + const enablementModel = new AgentPluginCollisionEnablementModel(base, observableValue('emptyCollisionGroups', new Map()), workspace); + + enablementModel.setEnabled(enabledForWorkspace, ContributionEnablementState.EnabledProfile); + enablementModel.setEnabled(disabledForWorkspace, ContributionEnablementState.DisabledProfile); + + assert.deepStrictEqual({ + enabled: enablementModel.readEnabled(enabledForWorkspace), + enabledProfile: enablementModel.readProfileEnabled(enabledForWorkspace), + disabled: enablementModel.readEnabled(disabledForWorkspace), + disabledProfile: enablementModel.readProfileEnabled(disabledForWorkspace), + }, { + enabled: ContributionEnablementState.EnabledWorkspace, + enabledProfile: true, + disabled: ContributionEnablementState.DisabledWorkspace, + disabledProfile: false, + }); + + workspace.set(new Map(), undefined); + + assert.deepStrictEqual({ + enabled: enablementModel.readEnabled(enabledForWorkspace), + disabled: enablementModel.readEnabled(disabledForWorkspace), + }, { + enabled: ContributionEnablementState.EnabledProfile, + disabled: ContributionEnablementState.DisabledProfile, + }); + }); + test('force-enabled duplicate wins collision priority', () => { const marketplaceUri = URI.file('/Users/test/.vscode-insiders/agent-plugins/github.com/microsoft/vscode-team-kit/model-council'); const copilotCliDirectUri = URI.file('/Users/test/.copilot/installed-plugins/_direct/microsoft--vscode-team-kit--model-council'); @@ -214,7 +259,7 @@ suite('AgentPlugin enablement', () => { setEnabled: (key, value) => stored.set(key, value), remove: key => stored.delete(key), }; - const policy = observableValue>('managedPluginEnablement', new Map([[required, true]])); + const policy = observableValue>('managedPluginEnablement', new Map([[required, ContributionEnablementState.EnabledProfile]])); const groups = observableValue>('collisionGroups', new Map([ [required, [required, unmanaged]], [unmanaged, [required, unmanaged]], @@ -266,6 +311,49 @@ suite('AgentPlugin enablement', () => { return makePlugin(uri, 'model-council', makeMarketplacePlugin()); } + test('enterprise policy takes precedence over workspace configuration', () => { + const plugin = makeMarketplacePluginForPolicy(); + const workspaceEnabled = new Map([[policyId, true]]); + const workspaceDisabled = new Map([[policyId, false]]); + + assert.deepStrictEqual({ + policyBlocked: getAgentPluginConfiguredEnablement(plugin, { [policyId]: false }, workspaceEnabled), + policyEnabled: getAgentPluginConfiguredEnablement(plugin, { [policyId]: true }, workspaceDisabled), + workspaceEnabled: getAgentPluginConfiguredEnablement(plugin, undefined, workspaceEnabled), + workspaceDisabled: getAgentPluginConfiguredEnablement(plugin, undefined, workspaceDisabled), + }, { + policyBlocked: ContributionEnablementState.DisabledProfile, + policyEnabled: ContributionEnablementState.EnabledProfile, + workspaceEnabled: ContributionEnablementState.EnabledWorkspace, + workspaceDisabled: ContributionEnablementState.DisabledWorkspace, + }); + }); + + test('workspace configuration applies only to the repository marketplace source', () => { + const currentReference = { ...parseMarketplaceReference('owner/current')!, displayLabel: 'shared-marketplace' }; + const otherReference = { ...parseMarketplaceReference('owner/other')!, displayLabel: 'shared-marketplace' }; + const currentPlugin = makePlugin( + URI.file('/plugins/current/model-council'), + 'model-council', + { ...makeMarketplacePlugin(), marketplace: 'shared-marketplace', marketplaceReference: currentReference }, + ); + const otherPlugin = makePlugin( + URI.file('/plugins/other/model-council'), + 'model-council', + { ...makeMarketplacePlugin(), marketplace: 'shared-marketplace', marketplaceReference: otherReference }, + ); + const workspaceEnabled = new Map([['model-council@shared-marketplace', true]]); + const workspaceMarketplaces = [{ name: 'shared-marketplace', reference: currentReference }]; + + assert.deepStrictEqual({ + current: getAgentPluginConfiguredEnablement(currentPlugin, undefined, workspaceEnabled, workspaceMarketplaces), + other: getAgentPluginConfiguredEnablement(otherPlugin, undefined, workspaceEnabled, workspaceMarketplaces), + }, { + current: ContributionEnablementState.EnabledWorkspace, + other: undefined, + }); + }); + test('no policy set: nothing is blocked', () => { const plugin = makeMarketplacePluginForPolicy(); assert.strictEqual(isAgentPluginBlockedByPolicy(plugin, undefined), false); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/fileBackedInstalledPluginsStore.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/fileBackedInstalledPluginsStore.test.ts index 44282db9dc8c38..a5ae6c75fb3f45 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/fileBackedInstalledPluginsStore.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/fileBackedInstalledPluginsStore.test.ts @@ -102,6 +102,37 @@ suite('FileBackedInstalledPluginsStore', () => { assert.fail('Condition not met in time'); } + test('signals when the installed plugin inventory is ready', async () => { + const storageService = store.add(new InMemoryStorageService()); + const fileService = new TestFileService(); + const agentPluginsHome = URI.file('/home/user/.vscode/agent-plugins'); + const pluginUri = URI.file('/home/user/.vscode/agent-plugins/github.com/owner/repo/plugin'); + fileService.setFile(URI.joinPath(agentPluginsHome, 'installed.json'), JSON.stringify({ + version: 1, + installed: [{ pluginUri: pluginUri.toString(), marketplace: 'owner/repo', name: 'plugin' }], + })); + + const pluginsStore = store.add(new FileBackedInstalledPluginsStore( + agentPluginsHome, + undefined, + fileService as unknown as IFileService, + new NullLogService(), + storageService, + )); + + await pluginsStore.whenInitialized; + + assert.deepStrictEqual(pluginsStore.get().map(entry => ({ + pluginUri: entry.pluginUri.toString(), + marketplace: entry.marketplace, + name: entry.name, + })), [{ + pluginUri: pluginUri.toString(), + marketplace: 'owner/repo', + name: 'plugin', + }]); + }); + test('migrates legacy storage to installed.json and removes legacy keys', async () => { const storageService = store.add(new InMemoryStorageService()); const fileService = new TestFileService(); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index b0973f62175055..51a6d3b87650f9 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -643,7 +643,10 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { const marketplaceRef = parseMarketplaceReference('microsoft/plugins')!; - function createService(autoUpdate: AutoUpdateConfigurationValue = 'on', extraMarketplaces: Record = {}): PluginMarketplaceService { + function createService( + autoUpdate: AutoUpdateConfigurationValue = 'on', + extraMarketplaces: Record = {}, + ): PluginMarketplaceService { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IConfigurationService, new TestConfigurationService({ @@ -724,6 +727,20 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { unmanaged: false, }); }); + + test('repository marketplace autoUpdate does not override the global setting', () => { + const repositoryMarketplace = parseMarketplaceReference('microsoft/repository-plugins')!; + + assert.deepStrictEqual({ + enabledWithGlobalOff: createService('off').isMarketplaceAutoUpdateEnabled({ ...repositoryMarketplace, autoUpdate: true }), + enabledWithGlobalOn: createService('on').isMarketplaceAutoUpdateEnabled({ ...repositoryMarketplace, autoUpdate: true }), + disabledWithGlobalOn: createService('on').isMarketplaceAutoUpdateEnabled({ ...repositoryMarketplace, autoUpdate: false }), + }, { + enabledWithGlobalOff: false, + enabledWithGlobalOn: true, + disabledWithGlobalOn: false, + }); + }); }); suite('PluginMarketplaceService - installed plugins lifecycle', () => { @@ -748,6 +765,8 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { configurationService?: TestConfigurationService; meteredConnectionService?: IMeteredConnectionService; pluginRepositoryService?: Partial; + workspaceExtraMarketplaces?: IWorkspacePluginSettingsService['extraMarketplaces']; + autoUpdate?: AutoUpdateConfigurationValue; }): PluginMarketplaceService { const instantiationService = store.add(new TestInstantiationService()); @@ -765,7 +784,7 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { instantiationService.stub(IRequestService, {} as unknown as IRequestService); instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); instantiationService.stub(IWorkspacePluginSettingsService, { - extraMarketplaces: observableValue('test.extraMarketplaces', []), + extraMarketplaces: options?.workspaceExtraMarketplaces ?? observableValue('test.extraMarketplaces', []), enabledPlugins: observableValue('test.enabledPlugins', new Map()), } as Partial as IWorkspacePluginSettingsService); instantiationService.stub(IWorkspaceTrustManagementService, { @@ -773,7 +792,7 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { onDidChangeTrust: Event.None, } as Partial as IWorkspaceTrustManagementService); instantiationService.stub(IExtensionsWorkbenchService, { - getAutoUpdateValue: () => 'on', + getAutoUpdateValue: () => options?.autoUpdate ?? 'on', } as Partial as IExtensionsWorkbenchService); stubMeteredConnectionService(instantiationService, options?.meteredConnectionService); @@ -913,6 +932,49 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { assert.strictEqual(fetchCount, 1); }); + test('repository marketplace changes do not schedule update checks when global auto-update is off', async () => { + let runIdle: ((idle: IdleDeadline) => void) | undefined; + store.add(installFakeRunWhenIdle((_target, runner) => { + runIdle = runner; + return Disposable.None; + })); + const installedRepositoryRef = { ...marketplaceRef, autoUpdate: false }; + const configuredRepositoryRef = { ...marketplaceRef, autoUpdate: true }; + const workspaceExtraMarketplaces = observableValue('test.extraMarketplaces', []); + let fetchCount = 0; + const service = createService({ + autoUpdate: 'off', + workspaceExtraMarketplaces, + pluginRepositoryService: { + fetchRepository: async () => { + fetchCount++; + return false; + }, + }, + }); + service.addInstalledPlugin( + URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin'), + makePlugin('my-plugin', 'my-plugin', installedRepositoryRef), + ); + + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + await timeout(0); + const beforeRepositoryConfiguration = fetchCount; + + workspaceExtraMarketplaces.set([{ name: 'repository', reference: configuredRepositoryRef }], undefined); + await timeout(0); + await timeout(0); + + assert.deepStrictEqual({ + beforeRepositoryConfiguration, + afterRepositoryConfiguration: fetchCount, + }, { + beforeRepositoryConfiguration: 0, + afterRepositoryConfiguration: 0, + }); + }); + test('defers an overdue check until queued updates are acknowledged', async () => { const updateCheckInterval = 24 * 60 * 60 * 1000; const clock = sinon.useFakeTimers({ now: updateCheckInterval + 1 }); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/workspacePluginSettingsService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/workspacePluginSettingsService.test.ts index f72810ad59f693..b8eb0e70775b5e 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/workspacePluginSettingsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/workspacePluginSettingsService.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { Emitter } from '../../../../../../base/common/event.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -14,6 +15,7 @@ import { FileService } from '../../../../../../platform/files/common/fileService import { InMemoryFileSystemProvider } from '../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { TestContextService } from '../../../../../test/common/workbenchTestServices.js'; +import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { testWorkspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; import { WorkspacePluginSettingsService } from '../../../common/plugins/workspacePluginSettingsService.js'; @@ -23,10 +25,14 @@ suite('WorkspacePluginSettingsService', () => { let fileService: FileService; let workspaceContextService: TestContextService; + let workspaceTrusted: boolean; + let trustChanged: Emitter; const workspaceRoot = URI.from({ scheme: Schemas.inMemory, path: '/workspace' }); setup(() => { workspaceContextService = new TestContextService(testWorkspace(workspaceRoot)); + workspaceTrusted = true; + trustChanged = store.add(new Emitter()); fileService = store.add(new FileService(logService)); store.add(fileService.registerProvider(Schemas.inMemory, store.add(new InMemoryFileSystemProvider()))); }); @@ -36,6 +42,10 @@ suite('WorkspacePluginSettingsService', () => { fileService, workspaceContextService, logService, + { + isWorkspaceTrusted: () => workspaceTrusted, + onDidChangeTrust: trustChanged.event, + } as Partial as IWorkspaceTrustManagementService, )); } @@ -54,8 +64,44 @@ suite('WorkspacePluginSettingsService', () => { await fileService.writeFile(uri, VSBuffer.fromString(content)); } + async function writeCopilotLocalSettings(content: string): Promise { + const uri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/.github/copilot/settings.local.json' }); + await fileService.writeFile(uri, VSBuffer.fromString(content)); + } + // --- enabledPlugins parsing --- + test('ignores workspace plugin settings until the workspace is trusted', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + workspaceTrusted = false; + await writeCopilotSettings(JSON.stringify({ + extraKnownMarketplaces: { + 'my-marketplace': { source: 'github', repo: 'owner/repo' } + }, + enabledPlugins: { 'my-plugin@my-marketplace': true } + })); + + const service = createService(); + assert.deepStrictEqual({ + marketplaces: service.extraMarketplaces.get(), + enabledPlugins: [...service.enabledPlugins.get()], + }, { + marketplaces: [], + enabledPlugins: [], + }); + + workspaceTrusted = true; + trustChanged.fire(true); + await waitForState(service.enabledPlugins, value => value.size > 0); + + assert.deepStrictEqual({ + marketplaces: service.extraMarketplaces.get().map(entry => entry.name), + enabledPlugins: [...service.enabledPlugins.get()], + }, { + marketplaces: ['my-marketplace'], + enabledPlugins: [['my-plugin@my-marketplace', true]], + }); + })); + test('parses enabledPlugins from Claude settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { await writeClaudeSettings(JSON.stringify({ enabledPlugins: { @@ -110,7 +156,7 @@ suite('WorkspacePluginSettingsService', () => { assert.strictEqual(enabled.get('from-copilot@mp'), true); })); - test('Claude enabledPlugins take precedence over Copilot for same key', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('Copilot enabledPlugins take precedence over Claude for same key', () => runWithFakedTimers({ useFakeTimers: true }, async () => { await writeClaudeSettings(JSON.stringify({ enabledPlugins: { 'shared-plugin@mp': false } })); @@ -122,7 +168,63 @@ suite('WorkspacePluginSettingsService', () => { await waitForState(service.enabledPlugins, v => v.size > 0); const enabled = service.enabledPlugins.get(); - assert.strictEqual(enabled.get('shared-plugin@mp'), false, 'Claude should win'); + assert.strictEqual(enabled.get('shared-plugin@mp'), true, 'Copilot should win'); + })); + + test('Copilot local enabledPlugins take precedence over all other repository layers', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await writeClaudeSettings(JSON.stringify({ enabledPlugins: { 'shared-plugin@mp': false } })); + await writeClaudeLocalSettings(JSON.stringify({ enabledPlugins: { 'shared-plugin@mp': false } })); + await writeCopilotSettings(JSON.stringify({ enabledPlugins: { 'shared-plugin@mp': false } })); + await writeCopilotLocalSettings(JSON.stringify({ enabledPlugins: { 'shared-plugin@mp': true } })); + + const service = createService(); + await waitForState(service.enabledPlugins, v => v.size > 0); + + assert.strictEqual(service.enabledPlugins.get().get('shared-plugin@mp'), true); + })); + + test('keeps plugin settings scoped to their source workspace folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const secondWorkspaceRoot = URI.from({ scheme: Schemas.inMemory, path: '/workspace-b' }); + workspaceContextService = new TestContextService(testWorkspace(workspaceRoot, secondWorkspaceRoot)); + await fileService.writeFile( + URI.joinPath(workspaceRoot, '.github', 'copilot', 'settings.json'), + VSBuffer.fromString(JSON.stringify({ + extraKnownMarketplaces: { shared: { source: 'github', repo: 'owner/marketplace-a' } }, + enabledPlugins: { 'shared-plugin@shared': true }, + })), + ); + await fileService.writeFile( + URI.joinPath(secondWorkspaceRoot, '.github', 'copilot', 'settings.json'), + VSBuffer.fromString(JSON.stringify({ + extraKnownMarketplaces: { shared: { source: 'github', repo: 'owner/marketplace-b' } }, + enabledPlugins: { 'shared-plugin@shared': false }, + })), + ); + + const service = createService(); + await waitForState(service.workspaceSettings, settings => settings.length === 2 && settings.every(entry => entry.enabledPlugins.size === 1)); + + const first = service.getWorkspaceSettings(workspaceRoot); + const second = service.getWorkspaceSettings(secondWorkspaceRoot); + assert.deepStrictEqual({ + first: { + enabled: first?.enabledPlugins.get('shared-plugin@shared'), + marketplace: first?.extraMarketplaces[0].reference.canonicalId, + }, + second: { + enabled: second?.enabledPlugins.get('shared-plugin@shared'), + marketplace: second?.extraMarketplaces[0].reference.canonicalId, + }, + }, { + first: { + enabled: true, + marketplace: 'github:owner/marketplace-a', + }, + second: { + enabled: false, + marketplace: 'github:owner/marketplace-b', + }, + }); })); // --- extraKnownMarketplaces parsing --- @@ -170,6 +272,7 @@ suite('WorkspacePluginSettingsService', () => { await writeClaudeSettings(JSON.stringify({ extraKnownMarketplaces: { 'nested-mp': { + autoUpdate: true, source: { source: 'github', repo: 'nested-owner/nested-repo', @@ -185,6 +288,31 @@ suite('WorkspacePluginSettingsService', () => { assert.strictEqual(marketplaces.length, 1); assert.strictEqual(marketplaces[0].reference.githubRepo, 'nested-owner/nested-repo'); assert.strictEqual(marketplaces[0].reference.displayLabel, 'nested-mp'); + assert.strictEqual(marketplaces[0].reference.autoUpdate, true); + })); + + test('settings.local.json overrides settings.json for a same-named marketplace', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await writeClaudeSettings(JSON.stringify({ + extraKnownMarketplaces: { + 'shared-name': { source: 'github', repo: 'owner/shared' } + } + })); + await writeClaudeLocalSettings(JSON.stringify({ + extraKnownMarketplaces: { + 'shared-name': { source: 'github', repo: 'owner/local' } + } + })); + + const service = createService(); + await waitForState(service.extraMarketplaces, v => v.length > 0); + + assert.deepStrictEqual(service.extraMarketplaces.get().map(entry => ({ + name: entry.name, + repo: entry.reference.githubRepo, + })), [{ + name: 'shared-name', + repo: 'owner/local', + }]); })); test('deduplicates marketplaces across Claude and Copilot by canonical ID', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -204,7 +332,31 @@ suite('WorkspacePluginSettingsService', () => { const marketplaces = service.extraMarketplaces.get(); assert.strictEqual(marketplaces.length, 1, 'should deduplicate by canonical ID'); - assert.strictEqual(marketplaces[0].name, 'claude-name', 'Claude entry should win'); + assert.strictEqual(marketplaces[0].name, 'copilot-name', 'Copilot entry should win'); + })); + + test('Copilot marketplace takes precedence over a same-named Claude marketplace', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await writeClaudeSettings(JSON.stringify({ + extraKnownMarketplaces: { + 'shared-name': { source: 'github', repo: 'owner/claude' } + } + })); + await writeCopilotSettings(JSON.stringify({ + extraKnownMarketplaces: { + 'shared-name': { source: 'github', repo: 'owner/copilot' } + } + })); + + const service = createService(); + await waitForState(service.extraMarketplaces, v => v.length > 0); + + assert.deepStrictEqual(service.extraMarketplaces.get().map(entry => ({ + name: entry.name, + repo: entry.reference.githubRepo, + })), [{ + name: 'shared-name', + repo: 'owner/copilot', + }]); })); // --- Invalid input handling --- diff --git a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts index 8602fefd54cbc8..b25e71cf3e3854 100644 --- a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts @@ -55,6 +55,9 @@ suite('PluginMcpDiscovery', () => { _serviceBrand: undefined, plugins, enablementModel: new class extends mock() { }, + whenReady: Promise.resolve(), + refresh: async () => { }, + getWorkspaceConfiguredEnablement: () => undefined, }, registry)); discovery.start();