From f0f8c3d49d4f3cc848840d9864e7376bfa596e2f Mon Sep 17 00:00:00 2001 From: Paul Wang <8560030+pwang347@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:53:15 -0700 Subject: [PATCH 1/9] chat: activate repository-enabled plugins Honor trusted workspace enabledPlugins as workspace-scoped activation overlays and install missing configured plugins without enabling them globally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chat.shared.contribution.ts | 4 +- .../browser/claudePluginRecommendations.ts | 99 --------- .../browser/workspaceAgentPluginActivation.ts | 152 +++++++++++++ .../chat/common/plugins/AGENTS_PLUGINS.md | 4 + .../common/plugins/agentPluginEnablement.ts | 58 +++-- .../common/plugins/agentPluginServiceImpl.ts | 33 +-- .../fileBackedInstalledPluginsStore.ts | 3 +- .../plugins/pluginMarketplaceService.ts | 31 ++- .../plugins/workspacePluginSettingsService.ts | 29 ++- .../workspaceAgentPluginActivation.test.ts | 200 ++++++++++++++++++ .../plugins/agentPluginEnablement.test.ts | 73 ++++++- .../fileBackedInstalledPluginsStore.test.ts | 31 +++ .../workspacePluginSettingsService.test.ts | 41 ++++ 13 files changed, 597 insertions(+), 161 deletions(-) delete mode 100644 src/vs/workbench/contrib/chat/browser/claudePluginRecommendations.ts create mode 100644 src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/plugins/workspaceAgentPluginActivation.test.ts 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 dc94284383a6c7..47e33b4f2e01ba 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -228,7 +228,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 { WorkspaceAgentPluginActivation } 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); 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/workspaceAgentPluginActivation.ts b/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts new file mode 100644 index 00000000000000..956621675a6849 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { getAgentPluginPolicyId } from '../common/plugins/agentPluginEnablement.js'; +import { IAgentPluginService } from '../common/plugins/agentPluginService.js'; +import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; +import { IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; + +export class WorkspaceAgentPluginActivation extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.workspaceAgentPluginActivation'; + + private readonly _cancellation = this._register(new CancellationTokenSource()); + private _reconcileRequested = false; + private _reconcileRunning = false; + + constructor( + @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, + @IPluginInstallService private readonly _pluginInstallService: IPluginInstallService, + @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, + @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._register(Event.any( + Event.fromObservableLight(this._pluginMarketplaceService.recommendedPlugins), + Event.fromObservableLight(this._pluginMarketplaceService.installedPlugins), + this._pluginMarketplaceService.onDidChangeMarketplaces, + this._chatEntitlementService.onDidChangeSentiment, + )(() => this._requestReconcile())); + this._requestReconcile(); + } + + override dispose(): void { + this._cancellation.cancel(); + super.dispose(); + } + + private _requestReconcile(): void { + this._reconcileRequested = true; + if (!this._reconcileRunning) { + void this._reconcile(); + } + } + + private async _reconcile(): Promise { + this._reconcileRunning = true; + try { + while (this._reconcileRequested && !this._store.isDisposed && !this._cancellation.token.isCancellationRequested) { + this._reconcileRequested = false; + await this._installConfiguredPlugins(); + } + } catch (error) { + if (!isCancellationError(error)) { + this._logService.error('[WorkspaceAgentPluginActivation] Failed to reconcile workspace plugins', error); + } + } finally { + this._reconcileRunning = false; + if (this._reconcileRequested && !this._store.isDisposed && !this._cancellation.token.isCancellationRequested) { + void this._reconcile(); + } + } + } + + private async _installConfiguredPlugins(): Promise { + let configuredPluginIds = this._pluginMarketplaceService.recommendedPlugins.get(); + if (configuredPluginIds.size === 0 || this._chatEntitlementService.sentiment.hidden) { + return; + } + + await this._pluginMarketplaceService.whenInstalledPluginsReady; + configuredPluginIds = this._pluginMarketplaceService.recommendedPlugins.get(); + if (configuredPluginIds.size === 0 || this._cancellation.token.isCancellationRequested || this._chatEntitlementService.sentiment.hidden) { + return; + } + + const marketplacePlugins = await this._pluginMarketplaceService.fetchMarketplacePlugins( + this._cancellation.token, + undefined, + { + 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); + } + } + + const discoveredPluginIds = new Set(); + for (const plugin of this._agentPluginService.plugins.get()) { + const pluginId = getAgentPluginPolicyId(plugin); + if (pluginId !== undefined) { + discoveredPluginIds.add(pluginId); + } + } + + for (const pluginId of configuredPluginIds) { + if (this._cancellation.token.isCancellationRequested || this._chatEntitlementService.sentiment.hidden) { + return; + } + if (!this._pluginMarketplaceService.recommendedPlugins.get().has(pluginId) || discoveredPluginIds.has(pluginId)) { + 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)) { + continue; + } + + try { + await this._pluginInstallService.installPlugin(plugin, this._cancellation.token); + } 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); + } + } +} + +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 7faa5bfbbe2ff0..682b09b1f9c6b8 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md +++ b/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md @@ -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`. An `enabledPlugins` entry set to `true` installs a missing plugin and overlays workspace enablement; `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. 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..9074abf47a4e72 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts @@ -28,37 +28,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 +70,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 +155,25 @@ export function getAgentPluginPolicyEnablement( return pluginId === undefined ? undefined : enabledPluginsPolicy?.[pluginId]; } +export function getAgentPluginConfiguredEnablement( + plugin: IAgentPlugin, + enabledPluginsPolicy: Record | undefined, + workspaceEnabledPlugins: ReadonlyMap | undefined, +): ContributionEnablementState | undefined { + const policyEnablement = getAgentPluginPolicyEnablement(plugin, enabledPluginsPolicy); + if (policyEnablement !== undefined) { + return policyEnablement ? ContributionEnablementState.EnabledProfile : ContributionEnablementState.DisabledProfile; + } + + const pluginId = getAgentPluginPolicyId(plugin); + const workspaceEnablement = pluginId === undefined ? undefined : workspaceEnabledPlugins?.get(pluginId); + if (workspaceEnablement !== undefined) { + return workspaceEnablement ? ContributionEnablementState.EnabledWorkspace : ContributionEnablementState.DisabledWorkspace; + } + + 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/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 1ffd39d0c32f78..f7d1be728a5455 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -48,13 +48,14 @@ 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, getCanonicalAgentPluginCollisionGroups, getSortedAgentPlugins, IDiscoveredAgentPlugins } from './agentPluginEnablement.js'; import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js'; import { AgentPluginDiscoveryPriority, agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginAutomation, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginService } from './agentPluginService.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'; @@ -99,6 +100,7 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic @IConfigurationService configurationService: IConfigurationService, @IStorageService storageService: IStorageService, @ILogService logService: ILogService, + @IWorkspacePluginSettingsService workspacePluginSettingsService: IWorkspacePluginSettingsService, ) { super(); @@ -121,16 +123,17 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic () => configurationService.inspect>(ChatConfiguration.EnabledPlugins).policyValue, ); - const policyEnablement = derived(reader => { + const configuredEnablement = derived(reader => { const discoveredPlugins = readDiscoveredAgentPlugins(discoveries, reader); const policy = enabledPluginsPolicy.read(reader); - const result = new Map(); - if (discoveredPlugins && policy) { + const workspaceEnabledPlugins = workspacePluginSettingsService.enabledPlugins.read(reader); + 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, workspaceEnabledPlugins); + if (configuredState !== undefined) { + result.set(plugin.uri.toString(), configuredState); } } } @@ -146,15 +149,21 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic 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, collisionGroups, configuredEnablement); for (const { discovery } of discoveries) { discovery.start(this.enablementModel); 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/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index aba7cd34be6743..a2f11d89a475d0 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -13,7 +13,7 @@ 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 { 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 +33,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'; @@ -185,6 +184,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,9 +195,9 @@ 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. */ @@ -207,6 +208,7 @@ export interface IPluginMarketplaceService { 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; removeInstalledPlugin(pluginUri: URI): void; /** Returns whether the given marketplace is trusted — either explicitly trusted by the user, or allowed by the enterprise allowlist when strict mode is active. */ @@ -364,6 +366,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>; @@ -377,7 +380,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, ) { @@ -395,6 +397,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke _storageService, ) ); + this.whenInstalledPluginsReady = this._installedPluginsStore.whenInitialized; this._trustedMarketplacesStore = this._register( trustedMarketplacesMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, _storageService) @@ -420,15 +423,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) { @@ -447,7 +442,6 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke e.affectsConfiguration(ChatConfiguration.ExtraMarketplaces), ) as Event as Event, Event.fromObservableLight(this._workspacePluginSettingsService.extraMarketplaces), - Event.map(this._workspaceTrustService.onDidChangeTrust, () => { }), ); this._register(this.onDidChangeMarketplaces(() => this._invalidateQueries())); this._register(Event.filter( @@ -610,9 +604,6 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke private _getConfiguredMarketplaceReferences(): readonly IMarketplaceReference[] { const { effectiveValues } = readConfiguredMarketplaces(this._configurationService); const configured = parseMarketplaceReferences(effectiveValues); - if (!this._workspaceTrustService.isWorkspaceTrusted()) { - return configured; - } const workspaceEntries = this._workspacePluginSettingsService.extraMarketplaces.get(); return deduplicateMarketplaceReferences(workspaceEntries.map(entry => entry.reference), configured); } @@ -790,6 +781,10 @@ 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 entry: IStoredInstalledPlugin = { diff --git a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts index 083f59053fbef5..d5211693707b69 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts @@ -13,6 +13,7 @@ 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'; @@ -36,15 +37,14 @@ export interface IWorkspacePluginSettingsService { readonly _serviceBrand: undefined; /** - * 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>; } @@ -118,6 +118,7 @@ const EMPTY_DATA: IWorkspaceSettingsData = { marketplaces: [], enabledPlugins: n class WorkspaceSettingsReader extends Disposable { private readonly _data = observableValue('data', EMPTY_DATA); + private _readVersion = 0; readonly data: IObservable = this._data; constructor( @@ -155,12 +156,13 @@ class WorkspaceSettingsReader extends Disposable { })); } - // Perform initial read immediately. - this._readSettings(dirs, logPrefix, fileService); + this._data.set(EMPTY_DATA, undefined); + void this._readSettings(dirs, logPrefix, fileService); })); } private async _readSettings(dirs: readonly URI[], logPrefix: string, fileService: IFileService): Promise { + const readVersion = ++this._readVersion; const allMarketplaces: IWorkspaceMarketplaceEntry[] = []; const mergedEnabled = new Map(); @@ -196,7 +198,9 @@ class WorkspaceSettingsReader extends Disposable { } } - this._data.set({ marketplaces: allMarketplaces, enabledPlugins: mergedEnabled }, undefined); + if (readVersion === this._readVersion) { + this._data.set({ marketplaces: allMarketplaces, enabledPlugins: mergedEnabled }, undefined); + } } } @@ -212,6 +216,7 @@ export class WorkspacePluginSettingsService extends Disposable implements IWorks @IFileService fileService: IFileService, @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, @ILogService logService: ILogService, + @IWorkspaceTrustManagementService workspaceTrustService: IWorkspaceTrustManagementService, ) { super(); @@ -225,8 +230,13 @@ export class WorkspacePluginSettingsService extends Disposable implements IWorks fileService, workspaceContextService, logService, )); + const workspaceTrusted = observableFromEvent(this, workspaceTrustService.onDidChangeTrust, () => workspaceTrustService.isWorkspaceTrusted()); + // Merge marketplaces from all readers, deduplicating by canonical ID. this.extraMarketplaces = derived(reader => { + if (!workspaceTrusted.read(reader)) { + return []; + } const claude = claudeReader.data.read(reader).marketplaces; const copilot = copilotReader.data.read(reader).marketplaces; const byCanonicalId = new Map(); @@ -241,6 +251,9 @@ export class WorkspacePluginSettingsService extends Disposable implements IWorks // Merge enabledPlugins from all readers. Claude entries take // precedence for keys that exist in both (first-writer wins). this.enabledPlugins = derived(reader => { + if (!workspaceTrusted.read(reader)) { + return new Map(); + } const claude = claudeReader.data.read(reader).enabledPlugins; const copilot = copilotReader.data.read(reader).enabledPlugins; const merged = new Map(); 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..a9fac60025d221 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/workspaceAgentPluginActivation.test.ts @@ -0,0 +1,200 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { WorkspaceAgentPluginActivation } from '../../../browser/workspaceAgentPluginActivation.js'; +import { ContributionEnablementState, IEnablementModel } from '../../../common/enablement.js'; +import { IAgentPlugin, IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; +import { IPluginInstallService } from '../../../common/plugins/pluginInstallService.js'; +import { IMarketplaceInstalledPlugin, IMarketplacePlugin, IPluginMarketplaceService, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; + +suite('WorkspaceAgentPluginActivation', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const installUri = URI.file('/agent-plugins/owner/marketplace/example-plugin'); + + 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 }) { + const plugin = createPlugin(); + const pluginId = `${plugin.name}@${plugin.marketplace}`; + const installedPlugins = observableValue('installedPlugins', options?.installed ? [{ pluginUri: installUri, plugin }] : []); + const recommendedPlugins = observableValue>('recommendedPlugins', new Set([pluginId])); + const installCalled = new DeferredPromise(); + const baselineSet = new DeferredPromise(); + let installCount = 0; + let fetchCount = 0; + 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 isPluginInstalled(pluginUri: URI): boolean { + return installedPlugins.get().some(entry => entry.pluginUri.toString() === pluginUri.toString()); + } + override async fetchMarketplacePlugins(): Promise { + fetchCount++; + return [plugin]; + } + }(); + const installService = new class extends mock() { + override async installPlugin(_plugin: IMarketplacePlugin, _token?: CancellationToken): Promise { + installCount++; + if (options?.installSucceeds !== false) { + installedPlugins.set([{ pluginUri: installUri, plugin }], undefined); + } + installCalled.complete(); + } + override getPluginInstallUri(): URI { + return installUri; + } + }(); + const agentPluginService = new class extends mock() { + override readonly plugins = observableValue('plugins', []); + override readonly enablementModel = model; + }(); + const entitlementService = new class extends mock() { + override readonly onDidChangeSentiment = Event.None; + override readonly sentiment = { hidden: options?.hidden }; + }(); + + store.add(new WorkspaceAgentPluginActivation( + marketplaceService, + installService, + agentPluginService, + entitlementService, + new NullLogService(), + )); + + return { + baselineSet, + installCalled, + installedPlugins, + states, + get fetchCount() { return fetchCount; }, + get installCount() { return installCount; }, + }; + } + + test('installs a configured plugin with a disabled profile baseline', async () => { + const harness = createContribution(); + + await harness.baselineSet.p; + + assert.deepStrictEqual({ + fetchCount: harness.fetchCount, + installCount: harness.installCount, + profileState: harness.states.get(installUri.toString()), + }, { + fetchCount: 1, + installCount: 1, + profileState: ContributionEnablementState.DisabledProfile, + }); + }); + + test('preserves enablement when the configured plugin is already installed', async () => { + const harness = createContribution({ installed: true }); + + await timeout(0); + + assert.deepStrictEqual({ + fetchCount: harness.fetchCount, + installCount: harness.installCount, + states: [...harness.states], + }, { + fetchCount: 1, + installCount: 0, + states: [], + }); + }); + + 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('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..1aa4eb479a5788 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,24 @@ 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('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/workspacePluginSettingsService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/workspacePluginSettingsService.test.ts index f72810ad59f693..de7eaa75fe9f9c 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, )); } @@ -56,6 +66,37 @@ suite('WorkspacePluginSettingsService', () => { // --- 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: { From b52c1621941d6deaac3f842f96d41c1bc3599389 Mon Sep 17 00:00:00 2001 From: Paul Wang <8560030+pwang347@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:55:30 -0700 Subject: [PATCH 2/9] chat: forward repository plugin enablement to agent hosts Publish repository-scoped plugin decisions through activeClientSet so Copilot, Claude, and Codex agent-host sessions resolve the same workspace overlay as local chat. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostLocalCustomizations.ts | 18 +++++++-- .../chat/common/plugins/agentPluginService.ts | 2 + .../common/plugins/agentPluginServiceImpl.ts | 25 +++++++++--- .../resolveCustomizationRefs.test.ts | 40 ++++++++++++++++++- 4 files changed, 74 insertions(+), 11 deletions(-) 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 4b73cba9bdca6d..492d98d6e2e567 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -242,15 +242,25 @@ 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 workspaceConfiguredEnablement = agentPluginService.getWorkspaceConfiguredEnablement(plugin); + if (workspaceConfiguredEnablement !== undefined && workingDirectories[0]) { + enablement = withCustomizationEnablement(enablement, CustomizationEnablementKind.Workspace, { + kind: CustomizationEnablementKind.Workspace, + uri: workingDirectories[0].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/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 7f318ebd913ff2..3fa7bd4e54d965 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -64,6 +64,8 @@ export interface IAgentPluginService { readonly _serviceBrand: undefined; readonly plugins: IObservable; readonly enablementModel: IEnablementModel; + /** Returns the repository's workspace decision after enterprise-policy precedence. */ + getWorkspaceConfiguredEnablement(plugin: IAgentPlugin): boolean | undefined; } export interface IAgentPluginDiscovery extends IDisposable { diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index f7d1be728a5455..6d531075868480 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -97,16 +97,16 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic constructor( @IInstantiationService instantiationService: IInstantiationService, - @IConfigurationService configurationService: IConfigurationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, @IStorageService storageService: IStorageService, @ILogService logService: ILogService, - @IWorkspacePluginSettingsService workspacePluginSettingsService: IWorkspacePluginSettingsService, + @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()) { @@ -119,14 +119,14 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic // 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 configuredEnablement = derived(reader => { const discoveredPlugins = readDiscoveredAgentPlugins(discoveries, reader); const policy = enabledPluginsPolicy.read(reader); - const workspaceEnabledPlugins = workspacePluginSettingsService.enabledPlugins.read(reader); + const workspaceEnabledPlugins = this._workspacePluginSettingsService.enabledPlugins.read(reader); const result = new Map(); if (discoveredPlugins) { for (const { plugins } of discoveredPlugins) { @@ -193,6 +193,19 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic }); })); } + + getWorkspaceConfiguredEnablement(plugin: IAgentPlugin): boolean | undefined { + const configuredState = getAgentPluginConfiguredEnablement( + plugin, + this._configurationService.inspect>(ChatConfiguration.EnabledPlugins).policyValue, + this._workspacePluginSettingsService.enabledPlugins.get(), + ); + return configuredState === ContributionEnablementState.EnabledWorkspace + ? true + : configuredState === ContributionEnablementState.DisabledWorkspace + ? false + : undefined; + } } interface IAgentPluginDiscoveryWithPriority { 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 cb8495494d11b3..f959fd2e77b134 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,16 @@ 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) => workspaceConfiguredEnablement.get(plugin.uri.toString()), } as unknown as IAgentPluginService; } @@ -692,6 +697,39 @@ 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('excludes workspace-discovered `.mcp.json` servers (the agent host discovers those itself)', async () => { const bundler = new FakeBundler(); const mcpService = makeMcpService([ From 8b584409c306f0736c03ddefe70d2ef3f4d503ad Mon Sep 17 00:00:00 2001 From: Paul Wang <8560030+pwang347@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:17:55 -0700 Subject: [PATCH 3/9] chat: auto-install repository plugins without confirmation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/pluginInstallService.ts | 6 +++--- .../browser/workspaceAgentPluginActivation.ts | 2 +- .../chat/common/plugins/AGENTS_PLUGINS.md | 4 ++-- .../common/plugins/pluginInstallService.ts | 7 ++++++- .../common/plugins/pluginMarketplaceService.ts | 2 +- .../plugins/pluginInstallService.test.ts | 18 ++++++++++++++++++ .../workspaceAgentPluginActivation.test.ts | 9 +++++++-- .../plugins/pluginMarketplaceService.test.ts | 7 +++++++ .../workspacePluginSettingsService.test.ts | 2 ++ .../mcp/test/common/pluginMcpDiscovery.test.ts | 1 + 10 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index b58291c6d97c05..1af43ce4860780 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 index 956621675a6849..5b89dfbda95a07 100644 --- a/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts +++ b/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts @@ -129,7 +129,7 @@ export class WorkspaceAgentPluginActivation extends Disposable implements IWorkb } try { - await this._pluginInstallService.installPlugin(plugin, this._cancellation.token); + 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); 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 682b09b1f9c6b8..bdbb13749c04d2 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 and trusted-repository `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`. - **GitHub caching** — caches raw GitHub API responses with an 8-hour TTL to avoid repeated fetches. ### Marketplace Definition Files @@ -182,7 +182,7 @@ Each `PluginSourceKind` has a strategy that knows how to compute cache paths, pr ### Repository-scoped activation -Trusted workspace settings in `.claude/settings(.local).json` and `.github/copilot/settings(.local).json` can define `extraKnownMarketplaces` and `enabledPlugins`. An `enabledPlugins` entry set to `true` installs a missing plugin and overlays workspace enablement; `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. Enterprise policy remains authoritative, and none of these workspace decisions are exposed while the workspace is untrusted. +Trusted workspace settings in `.claude/settings(.local).json` and `.github/copilot/settings(.local).json` can define `extraKnownMarketplaces` and `enabledPlugins`. An `enabledPlugins` entry set to `true` installs a missing plugin without another marketplace confirmation and overlays workspace enablement; `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. Repository marketplace `autoUpdate` values participate in the existing automatic update pipeline. Enterprise policy remains authoritative, and none of these workspace decisions are exposed while the workspace is untrusted. ### Enterprise customization lockdown diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts index 8612dc6bf186aa..0a0bd95baa90e2 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; /** * Installs a plugin directly from a source location string. Accepts diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index a2f11d89a475d0..54fdce9d8040ad 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -828,7 +828,7 @@ 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'; + return managedRef?.autoUpdate ?? ref.autoUpdate ?? this._extensionsWorkbenchService.getAutoUpdateValue() !== 'off'; } private _isMarketplaceAllowedByStrictPolicy(ref: IMarketplaceReference): boolean { 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 94097bd4519301..cabcbb28d5e464 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 @@ -999,6 +999,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 index a9fac60025d221..9636074462a905 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/workspaceAgentPluginActivation.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/workspaceAgentPluginActivation.test.ts @@ -16,7 +16,7 @@ import { IChatEntitlementService } from '../../../../../services/chat/common/cha import { WorkspaceAgentPluginActivation } from '../../../browser/workspaceAgentPluginActivation.js'; import { ContributionEnablementState, IEnablementModel } from '../../../common/enablement.js'; import { IAgentPlugin, IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; -import { IPluginInstallService } from '../../../common/plugins/pluginInstallService.js'; +import { IInstallMarketplacePluginOptions, IPluginInstallService } from '../../../common/plugins/pluginInstallService.js'; import { IMarketplaceInstalledPlugin, IMarketplacePlugin, IPluginMarketplaceService, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; suite('WorkspaceAgentPluginActivation', () => { @@ -63,6 +63,7 @@ suite('WorkspaceAgentPluginActivation', () => { const baselineSet = new DeferredPromise(); let installCount = 0; let fetchCount = 0; + let installOptions: IInstallMarketplacePluginOptions | undefined; const { model, states } = createEnablementModel(state => { if (state === ContributionEnablementState.DisabledProfile) { baselineSet.complete(); @@ -83,8 +84,9 @@ suite('WorkspaceAgentPluginActivation', () => { } }(); const installService = new class extends mock() { - override async installPlugin(_plugin: IMarketplacePlugin, _token?: CancellationToken): Promise { + override async installPlugin(_plugin: IMarketplacePlugin, _token?: CancellationToken, installOptionsArgument?: IInstallMarketplacePluginOptions): Promise { installCount++; + installOptions = installOptionsArgument; if (options?.installSucceeds !== false) { installedPlugins.set([{ pluginUri: installUri, plugin }], undefined); } @@ -116,6 +118,7 @@ suite('WorkspaceAgentPluginActivation', () => { installCalled, installedPlugins, states, + get installOptions() { return installOptions; }, get fetchCount() { return fetchCount; }, get installCount() { return installCount; }, }; @@ -129,10 +132,12 @@ suite('WorkspaceAgentPluginActivation', () => { assert.deepStrictEqual({ fetchCount: harness.fetchCount, installCount: harness.installCount, + skipTrust: harness.installOptions?.skipTrust, profileState: harness.states.get(installUri.toString()), }, { fetchCount: 1, installCount: 1, + skipTrust: true, profileState: ContributionEnablementState.DisabledProfile, }); }); 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 28b8b7a2fe752f..ec18704b1f6d52 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 @@ -724,6 +724,13 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { unmanaged: false, }); }); + + test('repository marketplace autoUpdate overrides the global setting', () => { + const service = createService('off'); + const repositoryMarketplace = { ...parseMarketplaceReference('microsoft/repository-plugins')!, autoUpdate: true }; + + assert.strictEqual(service.isMarketplaceAutoUpdateEnabled(repositoryMarketplace), true); + }); }); suite('PluginMarketplaceService - installed plugins lifecycle', () => { 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 de7eaa75fe9f9c..1578a90e0c1684 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 @@ -211,6 +211,7 @@ suite('WorkspacePluginSettingsService', () => { await writeClaudeSettings(JSON.stringify({ extraKnownMarketplaces: { 'nested-mp': { + autoUpdate: true, source: { source: 'github', repo: 'nested-owner/nested-repo', @@ -226,6 +227,7 @@ 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('deduplicates marketplaces across Claude and Copilot by canonical ID', () => runWithFakedTimers({ useFakeTimers: true }, async () => { 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..638e5292a49c34 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,7 @@ suite('PluginMcpDiscovery', () => { _serviceBrand: undefined, plugins, enablementModel: new class extends mock() { }, + getWorkspaceConfiguredEnablement: () => undefined, }, registry)); discovery.start(); From 05558eebae8a439a39355ed4b429f585cd5f65c5 Mon Sep 17 00:00:00 2001 From: Paul Wang <8560030+pwang347@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:33:52 -0700 Subject: [PATCH 4/9] test: simulate repository plugin auto-update Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plugins/pluginInstallService.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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 cabcbb28d5e464..5e04df36a10d02 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 @@ -71,6 +71,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; @@ -124,6 +125,7 @@ suite('PluginInstallService', () => { terminalExitCode: 0, terminalCompletes: true, pullRepositoryCalls: [], + pullRepositoryChanged: false, updatePluginSourceCalls: [], marketplaceTrusted: true, strictMarketplacePolicyActive: false, @@ -294,6 +296,7 @@ suite('PluginInstallService', () => { }, pullRepository: async (marketplace: IMarketplaceReference, options?: IPullRepositoryOptions) => { state.pullRepositoryCalls.push({ marketplace, options }); + return state.pullRepositoryChanged; }, getPluginSourceInstallUri: (descriptor: IPluginSourceDescriptor) => { const key = descriptor.kind; @@ -909,6 +912,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({ From 8a441d2f1272a1f6fb2baa000f272a99df160938 Mon Sep 17 00:00:00 2001 From: Paul Wang <8560030+pwang347@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:27:16 -0700 Subject: [PATCH 5/9] chat: schedule repository plugin auto-updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plugins/pluginMarketplaceService.ts | 13 ++++- .../plugins/pluginMarketplaceService.test.ts | 48 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 54fdce9d8040ad..b77ded2d12aebe 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -434,6 +434,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, @@ -441,7 +442,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), + onDidChangeWorkspaceMarketplaces, ); this._register(this.onDidChangeMarketplaces(() => this._invalidateQueries())); this._register(Event.filter( @@ -461,6 +462,10 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke this._marketplacesWithUpdates.set(new Set(), undefined); this._scheduleUpdateCheck(0); })); + this._register(onDidChangeWorkspaceMarketplaces(() => { + this._marketplacesWithUpdates.set(new Set(), undefined); + this._scheduleUpdateCheck(0); + })); this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { if (isMetered) { this._updateCheckDelayer.cancel(); @@ -956,7 +961,11 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke return true; } const { extraValues } = readConfiguredMarketplaces(this._configurationService); - return parseMarketplaceReferences(extraValues).some(ref => ref.autoUpdate === true); + if (parseMarketplaceReferences(extraValues).some(ref => ref.autoUpdate === true)) { + return true; + } + return this._workspacePluginSettingsService.extraMarketplaces.get() + .some(entry => this.isMarketplaceAutoUpdateEnabled(entry.reference)); } /** 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 ec18704b1f6d52..c3648da9c1fb79 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 @@ -755,6 +755,8 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { configurationService?: TestConfigurationService; meteredConnectionService?: IMeteredConnectionService; pluginRepositoryService?: Partial; + workspaceExtraMarketplaces?: IWorkspacePluginSettingsService['extraMarketplaces']; + autoUpdate?: AutoUpdateConfigurationValue; }): PluginMarketplaceService { const instantiationService = store.add(new TestInstantiationService()); @@ -772,7 +774,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, { @@ -780,7 +782,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); @@ -920,6 +922,48 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { assert.strictEqual(fetchCount, 1); }); + test('repository marketplace changes 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 repositoryRef = { ...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', repositoryRef), + ); + + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + await timeout(0); + const beforeRepositoryConfiguration = fetchCount; + + workspaceExtraMarketplaces.set([{ name: 'repository', reference: repositoryRef }], undefined); + await timeout(0); + await timeout(0); + + assert.deepStrictEqual({ + beforeRepositoryConfiguration, + afterRepositoryConfiguration: fetchCount, + }, { + beforeRepositoryConfiguration: 0, + afterRepositoryConfiguration: 1, + }); + }); + test('defers an overdue check until queued updates are acknowledged', async () => { const updateCheckInterval = 24 * 60 * 60 * 1000; const clock = sinon.useFakeTimers({ now: updateCheckInterval + 1 }); From 0519bfa74332fa48b2dda1d5c8d0cc9459fceb50 Mon Sep 17 00:00:00 2001 From: Paul Wang <8560030+pwang347@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:51:45 -0700 Subject: [PATCH 6/9] chat: refresh repository plugin update eligibility Resolve automatic-update eligibility from the current trusted repository marketplace configuration rather than the value captured when the plugin was installed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plugins/pluginMarketplaceService.ts | 4 ++- .../plugins/pluginMarketplaceService.test.ts | 27 ++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index b77ded2d12aebe..d90737a8acf311 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -833,7 +833,9 @@ 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 ?? ref.autoUpdate ?? this._extensionsWorkbenchService.getAutoUpdateValue() !== 'off'; + const workspaceRef = this._workspacePluginSettingsService.extraMarketplaces.get() + .find(entry => entry.reference.canonicalId === ref.canonicalId)?.reference; + return managedRef?.autoUpdate ?? workspaceRef?.autoUpdate ?? this._extensionsWorkbenchService.getAutoUpdateValue() !== 'off'; } private _isMarketplaceAllowedByStrictPolicy(ref: IMarketplaceReference): boolean { 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 c3648da9c1fb79..fba429498ce936 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,11 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { const marketplaceRef = parseMarketplaceReference('microsoft/plugins')!; - function createService(autoUpdate: AutoUpdateConfigurationValue = 'on', extraMarketplaces: Record = {}): PluginMarketplaceService { + function createService( + autoUpdate: AutoUpdateConfigurationValue = 'on', + extraMarketplaces: Record = {}, + workspaceExtraMarketplaces: IWorkspacePluginSettingsService['extraMarketplaces'] = observableValue('test.extraMarketplaces', []), + ): PluginMarketplaceService { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IConfigurationService, new TestConfigurationService({ @@ -658,7 +662,7 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { instantiationService.stub(IRequestService, {} as unknown as IRequestService); instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); instantiationService.stub(IWorkspacePluginSettingsService, { - extraMarketplaces: observableValue('test.extraMarketplaces', []), + extraMarketplaces: workspaceExtraMarketplaces, enabledPlugins: observableValue('test.enabledPlugins', new Map()), } as Partial as IWorkspacePluginSettingsService); instantiationService.stub(IWorkspaceTrustManagementService, { @@ -725,11 +729,15 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { }); }); - test('repository marketplace autoUpdate overrides the global setting', () => { - const service = createService('off'); - const repositoryMarketplace = { ...parseMarketplaceReference('microsoft/repository-plugins')!, autoUpdate: true }; + test('current repository marketplace autoUpdate overrides the installed snapshot and global setting', () => { + const installedMarketplace = { ...parseMarketplaceReference('microsoft/repository-plugins')!, autoUpdate: false }; + const configuredMarketplace = { ...installedMarketplace, autoUpdate: true }; + const service = createService('off', {}, observableValue('test.extraMarketplaces', [{ + name: 'repository', + reference: configuredMarketplace, + }])); - assert.strictEqual(service.isMarketplaceAutoUpdateEnabled(repositoryMarketplace), true); + assert.strictEqual(service.isMarketplaceAutoUpdateEnabled(installedMarketplace), true); }); }); @@ -928,7 +936,8 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { runIdle = runner; return Disposable.None; })); - const repositoryRef = { ...marketplaceRef, autoUpdate: true }; + const installedRepositoryRef = { ...marketplaceRef, autoUpdate: false }; + const configuredRepositoryRef = { ...marketplaceRef, autoUpdate: true }; const workspaceExtraMarketplaces = observableValue('test.extraMarketplaces', []); let fetchCount = 0; const service = createService({ @@ -943,7 +952,7 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { }); service.addInstalledPlugin( URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin'), - makePlugin('my-plugin', 'my-plugin', repositoryRef), + makePlugin('my-plugin', 'my-plugin', installedRepositoryRef), ); assert.ok(runIdle); @@ -951,7 +960,7 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { await timeout(0); const beforeRepositoryConfiguration = fetchCount; - workspaceExtraMarketplaces.set([{ name: 'repository', reference: repositoryRef }], undefined); + workspaceExtraMarketplaces.set([{ name: 'repository', reference: configuredRepositoryRef }], undefined); await timeout(0); await timeout(0); From 38c4837e31eec5f52e48aec29f99578141bf7042 Mon Sep 17 00:00:00 2001 From: Paul Wang <8560030+pwang347@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:07:55 -0700 Subject: [PATCH 7/9] chat: align repository plugin source resolution Match Copilot CLI repository-settings precedence and apply workspace enablement only to the plugin from the current repository marketplace source. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/workspaceAgentPluginActivation.ts | 11 +-- .../chat/common/plugins/AGENTS_PLUGINS.md | 2 +- .../common/plugins/agentPluginEnablement.ts | 14 +++- .../common/plugins/agentPluginServiceImpl.ts | 4 +- .../plugins/workspacePluginSettingsService.ts | 31 ++++---- .../workspaceAgentPluginActivation.test.ts | 40 ++++++++++- .../plugins/agentPluginEnablement.test.ts | 25 +++++++ .../workspacePluginSettingsService.test.ts | 71 ++++++++++++++++++- 8 files changed, 164 insertions(+), 34 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts b/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts index 5b89dfbda95a07..9221bfe961e6db 100644 --- a/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts +++ b/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts @@ -11,7 +11,6 @@ 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 { getAgentPluginPolicyId } from '../common/plugins/agentPluginEnablement.js'; import { IAgentPluginService } from '../common/plugins/agentPluginService.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; import { IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; @@ -101,19 +100,11 @@ export class WorkspaceAgentPluginActivation extends Disposable implements IWorkb } } - const discoveredPluginIds = new Set(); - for (const plugin of this._agentPluginService.plugins.get()) { - const pluginId = getAgentPluginPolicyId(plugin); - if (pluginId !== undefined) { - discoveredPluginIds.add(pluginId); - } - } - for (const pluginId of configuredPluginIds) { if (this._cancellation.token.isCancellationRequested || this._chatEntitlementService.sentiment.hidden) { return; } - if (!this._pluginMarketplaceService.recommendedPlugins.get().has(pluginId) || discoveredPluginIds.has(pluginId)) { + if (!this._pluginMarketplaceService.recommendedPlugins.get().has(pluginId)) { continue; } 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 bdbb13749c04d2..f717cb75b764e4 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md +++ b/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md @@ -182,7 +182,7 @@ Each `PluginSourceKind` has a strategy that knows how to compute cache paths, pr ### Repository-scoped activation -Trusted workspace settings in `.claude/settings(.local).json` and `.github/copilot/settings(.local).json` can define `extraKnownMarketplaces` and `enabledPlugins`. An `enabledPlugins` entry set to `true` installs a missing plugin without another marketplace confirmation and overlays workspace enablement; `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. Repository marketplace `autoUpdate` values participate in the existing automatic update pipeline. Enterprise policy remains authoritative, and none of these workspace decisions are exposed while the workspace is untrusted. +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. 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. Repository marketplace `autoUpdate` values participate in the existing automatic update pipeline. Enterprise policy remains authoritative, and none of these workspace decisions are exposed while the workspace is untrusted. ### Enterprise customization lockdown diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts index 9074abf47a4e72..f93cf4fe674be8 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 { @@ -159,14 +160,23 @@ 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 pluginId = getAgentPluginPolicyId(plugin); - const workspaceEnablement = pluginId === undefined ? undefined : workspaceEnabledPlugins?.get(pluginId); + const identity = getPolicyIdentity(plugin); + const pluginId = identity ? `${identity.name}@${identity.marketplace}` : undefined; + const workspaceMarketplace = identity + ? workspaceMarketplaces?.find(entry => entry.name === identity.marketplace) + : undefined; + const matchesWorkspaceMarketplace = workspaceMarketplace === undefined + || identity?.marketplaceReference?.canonicalId === workspaceMarketplace.reference.canonicalId; + const workspaceEnablement = pluginId === undefined || !matchesWorkspaceMarketplace + ? undefined + : workspaceEnabledPlugins?.get(pluginId); if (workspaceEnablement !== undefined) { return workspaceEnablement ? ContributionEnablementState.EnabledWorkspace : ContributionEnablementState.DisabledWorkspace; } diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 6d531075868480..42eb5f21bd20b3 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -127,11 +127,12 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic const discoveredPlugins = readDiscoveredAgentPlugins(discoveries, reader); const policy = enabledPluginsPolicy.read(reader); const workspaceEnabledPlugins = this._workspacePluginSettingsService.enabledPlugins.read(reader); + const workspaceMarketplaces = this._workspacePluginSettingsService.extraMarketplaces.read(reader); const result = new Map(); if (discoveredPlugins) { for (const { plugins } of discoveredPlugins) { for (const plugin of plugins) { - const configuredState = getAgentPluginConfiguredEnablement(plugin, policy, workspaceEnabledPlugins); + const configuredState = getAgentPluginConfiguredEnablement(plugin, policy, workspaceEnabledPlugins, workspaceMarketplaces); if (configuredState !== undefined) { result.set(plugin.uri.toString(), configuredState); } @@ -199,6 +200,7 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic plugin, this._configurationService.inspect>(ChatConfiguration.EnabledPlugins).policyValue, this._workspacePluginSettingsService.enabledPlugins.get(), + this._workspacePluginSettingsService.extraMarketplaces.get(), ); return configuredState === ContributionEnablementState.EnabledWorkspace ? true diff --git a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts index d5211693707b69..29385c99e86ef0 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts @@ -163,7 +163,7 @@ class WorkspaceSettingsReader extends Disposable { private async _readSettings(dirs: readonly URI[], logPrefix: string, fileService: IFileService): Promise { const readVersion = ++this._readVersion; - const allMarketplaces: IWorkspaceMarketplaceEntry[] = []; + const mergedMarketplaces = new Map(); const mergedEnabled = new Map(); for (const dir of dirs) { @@ -183,9 +183,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); @@ -199,7 +197,7 @@ class WorkspaceSettingsReader extends Disposable { } if (readVersion === this._readVersion) { - this._data.set({ marketplaces: allMarketplaces, enabledPlugins: mergedEnabled }, undefined); + this._data.set({ marketplaces: [...mergedMarketplaces.values()], enabledPlugins: mergedEnabled }, undefined); } } } @@ -232,24 +230,27 @@ export class WorkspacePluginSettingsService extends Disposable implements IWorks const workspaceTrusted = observableFromEvent(this, workspaceTrustService.onDidChangeTrust, () => workspaceTrustService.isWorkspaceTrusted()); - // Merge marketplaces from all readers, deduplicating by canonical ID. + // Copilot settings override Claude settings; each reader already applies local over shared. this.extraMarketplaces = derived(reader => { if (!workspaceTrusted.read(reader)) { return []; } 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 seenNames = new Set(); + const seenCanonicalIds = new Set(); + for (const entry of [...copilot, ...claude]) { + if (!seenNames.has(entry.name) && !seenCanonicalIds.has(entry.reference.canonicalId)) { + result.push(entry); + seenNames.add(entry.name); + 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). + // Copilot entries take precedence over Claude entries for the same key. this.enabledPlugins = derived(reader => { if (!workspaceTrusted.read(reader)) { return new Map(); @@ -257,10 +258,10 @@ export class WorkspacePluginSettingsService extends Disposable implements IWorks const claude = claudeReader.data.read(reader).enabledPlugins; const copilot = copilotReader.data.read(reader).enabledPlugins; const merged = new Map(); - for (const [key, value] of claude) { + for (const [key, value] of copilot) { merged.set(key, value); } - for (const [key, value] of copilot) { + for (const [key, value] of claude) { if (!merged.has(key)) { merged.set(key, value); } 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 index 9636074462a905..43d7b0145143c7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/workspaceAgentPluginActivation.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/workspaceAgentPluginActivation.test.ts @@ -11,6 +11,7 @@ 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 { WorkspaceAgentPluginActivation } from '../../../browser/workspaceAgentPluginActivation.js'; @@ -54,7 +55,7 @@ suite('WorkspaceAgentPluginActivation', () => { }; } - function createContribution(options?: { readonly installed?: boolean; readonly hidden?: boolean; readonly installSucceeds?: boolean }) { + function createContribution(options?: { readonly installed?: boolean; readonly hidden?: boolean; readonly installSucceeds?: boolean; readonly discoveredPlugin?: IAgentPlugin }) { const plugin = createPlugin(); const pluginId = `${plugin.name}@${plugin.marketplace}`; const installedPlugins = observableValue('installedPlugins', options?.installed ? [{ pluginUri: installUri, plugin }] : []); @@ -97,7 +98,7 @@ suite('WorkspaceAgentPluginActivation', () => { } }(); const agentPluginService = new class extends mock() { - override readonly plugins = observableValue('plugins', []); + override readonly plugins = observableValue('plugins', options?.discoveredPlugin ? [options.discoveredPlugin] : []); override readonly enablementModel = model; }(); const entitlementService = new class extends mock() { @@ -174,6 +175,41 @@ suite('WorkspaceAgentPluginActivation', () => { }); }); + 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 }); 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 1aa4eb479a5788..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 @@ -329,6 +329,31 @@ suite('AgentPlugin enablement', () => { }); }); + 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/workspacePluginSettingsService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/workspacePluginSettingsService.test.ts index 1578a90e0c1684..09f443f18422e2 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 @@ -64,6 +64,11 @@ 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 () => { @@ -151,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 } })); @@ -163,7 +168,19 @@ 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); })); // --- extraKnownMarketplaces parsing --- @@ -230,6 +247,30 @@ suite('WorkspacePluginSettingsService', () => { 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 () => { await writeClaudeSettings(JSON.stringify({ extraKnownMarketplaces: { @@ -247,7 +288,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 --- From 49eadcfb2dd0c4df287807e0bdd4611b2c4dec01 Mon Sep 17 00:00:00 2001 From: Paul Wang <8560030+pwang347@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:29:58 -0700 Subject: [PATCH 8/9] chat: align repository plugin update trust Keep repository-scoped install and activation while requiring user or managed settings to authorize silent updates to the shared plugin cache. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/common/plugins/AGENTS_PLUGINS.md | 4 ++-- .../plugins/pluginMarketplaceService.ts | 14 ++--------- .../plugins/pluginMarketplaceService.test.ts | 24 +++++++++---------- 3 files changed, 16 insertions(+), 26 deletions(-) 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 f717cb75b764e4..6754dd5dbd2e61 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 and trusted-repository `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 @@ -182,7 +182,7 @@ Each `PluginSourceKind` has a strategy that knows how to compute cache paths, pr ### 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. 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. Repository marketplace `autoUpdate` values participate in the existing automatic update pipeline. Enterprise policy remains authoritative, and none of these workspace decisions are exposed while the workspace is untrusted. +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. 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 diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index d90737a8acf311..2d35109e8622db 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -462,10 +462,6 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke this._marketplacesWithUpdates.set(new Set(), undefined); this._scheduleUpdateCheck(0); })); - this._register(onDidChangeWorkspaceMarketplaces(() => { - this._marketplacesWithUpdates.set(new Set(), undefined); - this._scheduleUpdateCheck(0); - })); this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { if (isMetered) { this._updateCheckDelayer.cancel(); @@ -833,9 +829,7 @@ 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); - const workspaceRef = this._workspacePluginSettingsService.extraMarketplaces.get() - .find(entry => entry.reference.canonicalId === ref.canonicalId)?.reference; - return managedRef?.autoUpdate ?? workspaceRef?.autoUpdate ?? this._extensionsWorkbenchService.getAutoUpdateValue() !== 'off'; + return managedRef?.autoUpdate ?? this._extensionsWorkbenchService.getAutoUpdateValue() !== 'off'; } private _isMarketplaceAllowedByStrictPolicy(ref: IMarketplaceReference): boolean { @@ -963,11 +957,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke return true; } const { extraValues } = readConfiguredMarketplaces(this._configurationService); - if (parseMarketplaceReferences(extraValues).some(ref => ref.autoUpdate === true)) { - return true; - } - return this._workspacePluginSettingsService.extraMarketplaces.get() - .some(entry => this.isMarketplaceAutoUpdateEnabled(entry.reference)); + return parseMarketplaceReferences(extraValues).some(ref => ref.autoUpdate === true); } /** 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 fba429498ce936..f452ab3adedf28 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 @@ -646,7 +646,6 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { function createService( autoUpdate: AutoUpdateConfigurationValue = 'on', extraMarketplaces: Record = {}, - workspaceExtraMarketplaces: IWorkspacePluginSettingsService['extraMarketplaces'] = observableValue('test.extraMarketplaces', []), ): PluginMarketplaceService { const instantiationService = store.add(new TestInstantiationService()); @@ -662,7 +661,7 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { instantiationService.stub(IRequestService, {} as unknown as IRequestService); instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); instantiationService.stub(IWorkspacePluginSettingsService, { - extraMarketplaces: workspaceExtraMarketplaces, + extraMarketplaces: observableValue('test.extraMarketplaces', []), enabledPlugins: observableValue('test.enabledPlugins', new Map()), } as Partial as IWorkspacePluginSettingsService); instantiationService.stub(IWorkspaceTrustManagementService, { @@ -729,15 +728,16 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { }); }); - test('current repository marketplace autoUpdate overrides the installed snapshot and global setting', () => { - const installedMarketplace = { ...parseMarketplaceReference('microsoft/repository-plugins')!, autoUpdate: false }; - const configuredMarketplace = { ...installedMarketplace, autoUpdate: true }; - const service = createService('off', {}, observableValue('test.extraMarketplaces', [{ - name: 'repository', - reference: configuredMarketplace, - }])); + test('repository marketplace autoUpdate does not override the global setting', () => { + const repositoryMarketplace = { ...parseMarketplaceReference('microsoft/repository-plugins')!, autoUpdate: true }; - assert.strictEqual(service.isMarketplaceAutoUpdateEnabled(installedMarketplace), true); + assert.deepStrictEqual({ + globalOff: createService('off').isMarketplaceAutoUpdateEnabled(repositoryMarketplace), + globalOn: createService('on').isMarketplaceAutoUpdateEnabled(repositoryMarketplace), + }, { + globalOff: false, + globalOn: true, + }); }); }); @@ -930,7 +930,7 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { assert.strictEqual(fetchCount, 1); }); - test('repository marketplace changes schedule update checks when global auto-update is off', async () => { + 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; @@ -969,7 +969,7 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { afterRepositoryConfiguration: fetchCount, }, { beforeRepositoryConfiguration: 0, - afterRepositoryConfiguration: 1, + afterRepositoryConfiguration: 0, }); }); From 80209ee775205be016f4d85a014eb1204c37d9b3 Mon Sep 17 00:00:00 2001 From: Paul Wang <8560030+pwang347@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:58:06 -0700 Subject: [PATCH 9/9] chat: address repository plugin review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostActiveClientService.ts | 42 ++-- .../agentHost/agentHostLocalCustomizations.ts | 18 +- .../chat/browser/chat.shared.contribution.ts | 3 +- .../browser/workspaceAgentPluginActivation.ts | 216 ++++++++++++------ .../chat/common/plugins/AGENTS_PLUGINS.md | 2 +- .../common/plugins/agentPluginEnablement.ts | 46 +++- .../chat/common/plugins/agentPluginService.ts | 9 +- .../common/plugins/agentPluginServiceImpl.ts | 89 +++++--- .../plugins/pluginMarketplaceService.ts | 66 +++++- .../plugins/workspacePluginSettingsService.ts | 167 ++++++++++---- ...tUntitledProvisionalSessionService.test.ts | 8 + .../resolveCustomizationRefs.test.ts | 46 +++- .../workspaceAgentPluginActivation.test.ts | 83 ++++++- .../plugins/pluginMarketplaceService.test.ts | 12 +- .../workspacePluginSettingsService.test.ts | 44 ++++ .../test/common/pluginMcpDiscovery.test.ts | 2 + 16 files changed, 647 insertions(+), 206 deletions(-) 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 f7a1fcc82f25ce..160a09871befba 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'; @@ -119,6 +120,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, @@ -131,6 +133,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, @@ -145,7 +148,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; @@ -184,6 +187,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); @@ -192,6 +196,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(); })); @@ -297,9 +304,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. @@ -307,7 +316,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo const createdScope: AgentCustomizationScope = this._instantiationService.createInstance( AgentCustomizationScope, sessionType, - normalizedRoots, + orderedRoots, scopeKey, this.getSyncProvider(sessionType), options, @@ -448,33 +457,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 492d98d6e2e567..35dc4e20b3c2bd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -133,6 +133,7 @@ export async function resolveLocalCustomAgents( agentPluginService: IAgentPluginService, sessionType: string, options: ILocalCustomizationSyncOptions | undefined, + workingDirectories: readonly URI[] = [], ): Promise { const plugins = agentPluginService.plugins.get(); const result: AgentCustomization[] = []; @@ -141,7 +142,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 @@ -150,6 +151,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'); @@ -246,11 +253,14 @@ export async function resolveCustomizationRefs( kind: CustomizationEnablementKind.Global, enabled: agentPluginService.enablementModel.readProfileEnabled(key), }); - const workspaceConfiguredEnablement = agentPluginService.getWorkspaceConfiguredEnablement(plugin); - if (workspaceConfiguredEnablement !== undefined && workingDirectories[0]) { + 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: workingDirectories[0].toString() as ProtocolURI, + uri: workspaceFolder.toString() as ProtocolURI, enabled: workspaceConfiguredEnablement, }); } 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 47e33b4f2e01ba..060586295fc3ce 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -228,7 +228,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 { WorkspaceAgentPluginActivation } from './workspaceAgentPluginActivation.js'; +import { IWorkspaceAgentPluginActivationService, WorkspaceAgentPluginActivation, WorkspaceAgentPluginActivationService } from './workspaceAgentPluginActivation.js'; import { ChatCodeBlockContextProviderService } from './codeBlockContextProviderService.js'; import { ExploreAgentDefaultModel } from './exploreAgentDefaultModel.js'; import { HasByokModelsContribution } from './hasByokModelsContribution.js'; @@ -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/workspaceAgentPluginActivation.ts b/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts index 9221bfe961e6db..9a0f3735c109a0 100644 --- a/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts +++ b/src/vs/workbench/contrib/chat/browser/workspaceAgentPluginActivation.ts @@ -7,37 +7,55 @@ 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 { IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.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; -export class WorkspaceAgentPluginActivation extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.workspaceAgentPluginActivation'; private readonly _cancellation = this._register(new CancellationTokenSource()); - private _reconcileRequested = false; - private _reconcileRunning = false; + 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._pluginMarketplaceService.recommendedPlugins), + Event.fromObservableLight(this._workspacePluginSettingsService.workspaceSettings), Event.fromObservableLight(this._pluginMarketplaceService.installedPlugins), this._pluginMarketplaceService.onDidChangeMarketplaces, this._chatEntitlementService.onDidChangeSentiment, - )(() => this._requestReconcile())); - this._requestReconcile(); + )(() => { + void this.reconcile(); + })); + void this.reconcile(); } override dispose(): void { @@ -45,99 +63,155 @@ export class WorkspaceAgentPluginActivation extends Disposable implements IWorkb super.dispose(); } - private _requestReconcile(): void { - this._reconcileRequested = true; - if (!this._reconcileRunning) { - void this._reconcile(); + 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 _reconcile(): Promise { - this._reconcileRunning = true; - try { - while (this._reconcileRequested && !this._store.isDisposed && !this._cancellation.token.isCancellationRequested) { - this._reconcileRequested = false; - await this._installConfiguredPlugins(); - } - } catch (error) { - if (!isCancellationError(error)) { - this._logService.error('[WorkspaceAgentPluginActivation] Failed to reconcile workspace plugins', error); - } - } finally { - this._reconcileRunning = false; - if (this._reconcileRequested && !this._store.isDisposed && !this._cancellation.token.isCancellationRequested) { - void this._reconcile(); + 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(): Promise { - let configuredPluginIds = this._pluginMarketplaceService.recommendedPlugins.get(); - if (configuredPluginIds.size === 0 || this._chatEntitlementService.sentiment.hidden) { + private async _installConfiguredPlugins(workspaceFolders: readonly URI[] | undefined): Promise { + await this._workspacePluginSettingsService.whenSettled(); + if (this._chatEntitlementService.sentiment.hidden) { return; } - await this._pluginMarketplaceService.whenInstalledPluginsReady; - configuredPluginIds = this._pluginMarketplaceService.recommendedPlugins.get(); - if (configuredPluginIds.size === 0 || this._cancellation.token.isCancellationRequested || this._chatEntitlementService.sentiment.hidden) { + const workspaceSettings = this._getWorkspaceSettings(workspaceFolders); + const hasConfiguredEnablement = workspaceSettings.some(settings => settings.enabledPlugins.size > 0); + if (!hasConfiguredEnablement) { return; } - const marketplacePlugins = await this._pluginMarketplaceService.fetchMarketplacePlugins( - this._cancellation.token, - undefined, - { - 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); - } + await this._pluginMarketplaceService.whenInstalledPluginsReady; + if (this._cancellation.token.isCancellationRequested || this._chatEntitlementService.sentiment.hidden) { + return; } - for (const pluginId of configuredPluginIds) { - if (this._cancellation.token.isCancellationRequested || this._chatEntitlementService.sentiment.hidden) { - return; - } - if (!this._pluginMarketplaceService.recommendedPlugins.get().has(pluginId)) { + 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 plugin = pluginsById.get(pluginId); - if (!plugin) { - this._logService.warn(`[WorkspaceAgentPluginActivation] Workspace plugin '${pluginId}' was not found in the configured marketplaces`); - 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); + } } - const installUri = this._pluginInstallService.getPluginInstallUri(plugin); - if (this._pluginMarketplaceService.isPluginInstalled(installUri)) { - continue; - } + 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; + } - 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); + const plugin = pluginsById.get(pluginId); + if (!plugin) { + this._logService.warn(`[WorkspaceAgentPluginActivation] Workspace plugin '${pluginId}' was not found in the configured marketplaces`); + continue; } - continue; - } - if (!this._pluginMarketplaceService.isPluginInstalled(installUri)) { - this._logService.warn(`[WorkspaceAgentPluginActivation] Workspace plugin '${pluginId}' was not installed`); - 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(); + } + } - this._agentPluginService.enablementModel.setEnabled(installUri.toString(), ContributionEnablementState.DisabledProfile); + 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 6754dd5dbd2e61..f6e0687b73ef33 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md +++ b/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md @@ -182,7 +182,7 @@ Each `PluginSourceKind` has a strategy that knows how to compute cache paths, pr ### 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. 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. +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 diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts index f93cf4fe674be8..b6fa01f497aaaa 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts @@ -167,16 +167,7 @@ export function getAgentPluginConfiguredEnablement( return policyEnablement ? ContributionEnablementState.EnabledProfile : ContributionEnablementState.DisabledProfile; } - const identity = getPolicyIdentity(plugin); - const pluginId = identity ? `${identity.name}@${identity.marketplace}` : undefined; - const workspaceMarketplace = identity - ? workspaceMarketplaces?.find(entry => entry.name === identity.marketplace) - : undefined; - const matchesWorkspaceMarketplace = workspaceMarketplace === undefined - || identity?.marketplaceReference?.canonicalId === workspaceMarketplace.reference.canonicalId; - const workspaceEnablement = pluginId === undefined || !matchesWorkspaceMarketplace - ? undefined - : workspaceEnabledPlugins?.get(pluginId); + const workspaceEnablement = getAgentPluginWorkspaceEnablement(plugin, workspaceEnabledPlugins, workspaceMarketplaces); if (workspaceEnablement !== undefined) { return workspaceEnablement ? ContributionEnablementState.EnabledWorkspace : ContributionEnablementState.DisabledWorkspace; } @@ -184,6 +175,41 @@ export function getAgentPluginConfiguredEnablement( 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 3fa7bd4e54d965..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,13 +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): boolean | undefined; + 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 42eb5f21bd20b3..cb9c80fca20c10 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 { Event } from '../../../../../base/common/event.js'; import { Iterable } from '../../../../../base/common/iterator.js'; import { ParseError, parse as parseJSONC } from '../../../../../base/common/json.js'; @@ -51,7 +51,7 @@ import { ChatConfiguration } from '../constants.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, getAgentPluginConfiguredEnablement, getAgentPluginPolicyEnablement, getAgentPluginPolicyId, getCanonicalAgentPluginCollisionGroups, getSortedAgentPlugins, IDiscoveredAgentPlugins } 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 { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js'; @@ -94,6 +94,9 @@ 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, @@ -108,11 +111,10 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic 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 @@ -124,15 +126,13 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic ); const configuredEnablement = derived(reader => { - const discoveredPlugins = readDiscoveredAgentPlugins(discoveries, reader); + const discoveredPlugins = readDiscoveredAgentPlugins(this._discoveries, reader); const policy = enabledPluginsPolicy.read(reader); - const workspaceEnabledPlugins = this._workspacePluginSettingsService.enabledPlugins.read(reader); - const workspaceMarketplaces = this._workspacePluginSettingsService.extraMarketplaces.read(reader); const result = new Map(); if (discoveredPlugins) { for (const { plugins } of discoveredPlugins) { for (const plugin of plugins) { - const configuredState = getAgentPluginConfiguredEnablement(plugin, policy, workspaceEnabledPlugins, workspaceMarketplaces); + const configuredState = getAgentPluginConfiguredEnablement(plugin, policy, undefined); if (configuredState !== undefined) { result.set(plugin.uri.toString(), configuredState); } @@ -142,11 +142,11 @@ 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(); } @@ -164,9 +164,9 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic ); }); - this.enablementModel = new AgentPluginCollisionEnablementModel(baseEnablementModel, collisionGroups, configuredEnablement); + this.enablementModel = new AgentPluginCollisionEnablementModel(baseEnablementModel, this._collisionGroups, configuredEnablement); - for (const { discovery } of discoveries) { + for (const { discovery } of this._discoveries) { discovery.start(this.enablementModel); } @@ -174,12 +174,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); @@ -195,18 +196,41 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic })); } - getWorkspaceConfiguredEnablement(plugin: IAgentPlugin): boolean | undefined { - const configuredState = getAgentPluginConfiguredEnablement( - plugin, - this._configurationService.inspect>(ChatConfiguration.EnabledPlugins).policyValue, - this._workspacePluginSettingsService.enabledPlugins.get(), - this._workspacePluginSettingsService.extraMarketplaces.get(), - ); - return configuredState === ContributionEnablementState.EnabledWorkspace - ? true - : configuredState === ContributionEnablementState.DisabledWorkspace - ? false - : undefined; + 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; } } @@ -293,6 +317,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; @@ -307,6 +332,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); @@ -474,7 +503,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements try { 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); @@ -681,7 +710,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(); @@ -811,7 +840,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 => { this._pluginMarketplaceService.installedPlugins.read(reader); scheduler.schedule(); @@ -1103,7 +1132,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); } @@ -1185,7 +1214,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/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 2d35109e8622db..af89d87bd4cc77 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -14,6 +14,7 @@ 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, 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'; @@ -153,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 @@ -203,7 +206,7 @@ export interface IPluginMarketplaceService { /** 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; @@ -498,8 +501,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 { @@ -578,7 +581,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) { @@ -595,17 +598,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); - 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); } @@ -787,7 +793,9 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } 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, @@ -796,8 +804,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); } @@ -829,7 +838,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 { @@ -1182,6 +1194,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 29385c99e86ef0..15a5cda35626b0 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts @@ -6,8 +6,8 @@ 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'; @@ -33,9 +33,18 @@ 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 trusted * workspace settings files (`.claude/settings.json`, `.github/copilot/settings.json`). @@ -47,6 +56,12 @@ export interface IWorkspacePluginSettingsService { * 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,9 +136,10 @@ const EMPTY_DATA: IWorkspaceSettingsData = { marketplaces: [], enabledPlugins: n */ class WorkspaceSettingsReader extends Disposable { - private readonly _data = observableValue('data', EMPTY_DATA); + private readonly _data = observableValue('data', []); private _readVersion = 0; - readonly data: IObservable = this._data; + private _pendingRead: Promise = Promise.resolve(); + readonly data: IObservable = this._data; constructor( /** Workspace-relative config folder (e.g. `.claude`). */ @@ -134,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()); @@ -142,33 +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(); } })); } - this._data.set(EMPTY_DATA, undefined); - void 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 { + 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 mergedMarketplaces = new Map(); - const mergedEnabled = new Map(); + 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 { @@ -194,10 +229,16 @@ class WorkspaceSettingsReader extends Disposable { this._logService.debug(`${logPrefix} Could not read ${uri.toString()}`); } } + + result.push({ + workspaceFolder, + marketplaces: [...mergedMarketplaces.values()], + enabledPlugins: mergedEnabled, + }); } if (readVersion === this._readVersion) { - this._data.set({ marketplaces: [...mergedMarketplaces.values()], enabledPlugins: mergedEnabled }, undefined); + this._data.set(result, undefined); } } } @@ -207,12 +248,13 @@ 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, ) { @@ -220,53 +262,88 @@ export class WorkspacePluginSettingsService extends Disposable implements IWorks 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, )); const workspaceTrusted = observableFromEvent(this, workspaceTrustService.onDidChangeTrust, () => workspaceTrustService.isWorkspaceTrusted()); - // Copilot settings override Claude settings; each reader already applies local over shared. - this.extraMarketplaces = derived(reader => { + this.workspaceSettings = derived(reader => { if (!workspaceTrusted.read(reader)) { return []; } - const claude = claudeReader.data.read(reader).marketplaces; - const copilot = copilotReader.data.read(reader).marketplaces; + + 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 result: IWorkspaceMarketplaceEntry[] = []; - const seenNames = new Set(); const seenCanonicalIds = new Set(); - for (const entry of [...copilot, ...claude]) { - if (!seenNames.has(entry.name) && !seenCanonicalIds.has(entry.reference.canonicalId)) { - result.push(entry); - seenNames.add(entry.name); - seenCanonicalIds.add(entry.reference.canonicalId); + 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 result; }); - // Copilot entries take precedence over Claude entries for the same key. this.enabledPlugins = derived(reader => { - if (!workspaceTrusted.read(reader)) { - return new Map(); - } - const claude = claudeReader.data.read(reader).enabledPlugins; - const copilot = copilotReader.data.read(reader).enabledPlugins; const merged = new Map(); - for (const [key, value] of copilot) { - merged.set(key, value); - } - for (const [key, value] of claude) { - 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 d59a2e5b6245a4..00aee14dc767f9 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 f959fd2e77b134..ac3d41b3d055af 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 @@ -94,7 +94,9 @@ function makeAgentPluginService( _serviceBrand: undefined, plugins: observableValue('plugins', plugins), enablementModel: { readProfileEnabled: (key: string) => profileEnablement.get(key) ?? true }, - getWorkspaceConfiguredEnablement: (plugin: IAgentPlugin) => workspaceConfiguredEnablement.get(plugin.uri.toString()), + getWorkspaceConfiguredEnablement: (plugin: IAgentPlugin, workspaceFolder: URI) => + workspaceConfiguredEnablement.get(`${workspaceFolder.toString()}\0${plugin.uri.toString()}`) + ?? workspaceConfiguredEnablement.get(plugin.uri.toString()), } as unknown as IAgentPluginService; } @@ -730,6 +732,48 @@ suite('resolveCustomizationRefs - built-in skills', () => { ]); }); + 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/workspaceAgentPluginActivation.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/workspaceAgentPluginActivation.test.ts index 43d7b0145143c7..0f29622d630f27 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/workspaceAgentPluginActivation.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/workspaceAgentPluginActivation.test.ts @@ -14,15 +14,17 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ 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 { WorkspaceAgentPluginActivation } from '../../../browser/workspaceAgentPluginActivation.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'); @@ -55,15 +57,23 @@ suite('WorkspaceAgentPluginActivation', () => { }; } - function createContribution(options?: { readonly installed?: boolean; readonly hidden?: boolean; readonly installSucceeds?: boolean; readonly discoveredPlugin?: IAgentPlugin }) { + 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}`; - const installedPlugins = observableValue('installedPlugins', options?.installed ? [{ pluginUri: installUri, plugin }] : []); + 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) { @@ -76,9 +86,17 @@ suite('WorkspaceAgentPluginActivation', () => { 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]; @@ -89,7 +107,7 @@ suite('WorkspaceAgentPluginActivation', () => { installCount++; installOptions = installOptionsArgument; if (options?.installSucceeds !== false) { - installedPlugins.set([{ pluginUri: installUri, plugin }], undefined); + marketplaceService.addInstalledPlugin(installUri, plugin); } installCalled.complete(); } @@ -100,16 +118,30 @@ suite('WorkspaceAgentPluginActivation', () => { 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 }; }(); - store.add(new WorkspaceAgentPluginActivation( + const service = store.add(new WorkspaceAgentPluginActivationService( marketplaceService, installService, agentPluginService, + workspacePluginSettingsService, entitlementService, new NullLogService(), )); @@ -119,7 +151,10 @@ suite('WorkspaceAgentPluginActivation', () => { 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; }, }; @@ -128,16 +163,16 @@ suite('WorkspaceAgentPluginActivation', () => { test('installs a configured plugin with a disabled profile baseline', async () => { const harness = createContribution(); - await harness.baselineSet.p; + await harness.reconcile(); assert.deepStrictEqual({ - fetchCount: harness.fetchCount, installCount: harness.installCount, + discoveryRefreshCount: harness.discoveryRefreshCount, skipTrust: harness.installOptions?.skipTrust, profileState: harness.states.get(installUri.toString()), }, { - fetchCount: 1, installCount: 1, + discoveryRefreshCount: 1, skipTrust: true, profileState: ContributionEnablementState.DisabledProfile, }); @@ -146,19 +181,45 @@ suite('WorkspaceAgentPluginActivation', () => { test('preserves enablement when the configured plugin is already installed', async () => { const harness = createContribution({ installed: true }); - await timeout(0); + await harness.reconcile(); assert.deepStrictEqual({ - fetchCount: harness.fetchCount, installCount: harness.installCount, + metadataRefreshCount: harness.metadataRefreshCount, states: [...harness.states], }, { - fetchCount: 1, 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); 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 f452ab3adedf28..b5a0ddfd16c825 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 @@ -729,14 +729,16 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { }); test('repository marketplace autoUpdate does not override the global setting', () => { - const repositoryMarketplace = { ...parseMarketplaceReference('microsoft/repository-plugins')!, autoUpdate: true }; + const repositoryMarketplace = parseMarketplaceReference('microsoft/repository-plugins')!; assert.deepStrictEqual({ - globalOff: createService('off').isMarketplaceAutoUpdateEnabled(repositoryMarketplace), - globalOn: createService('on').isMarketplaceAutoUpdateEnabled(repositoryMarketplace), + enabledWithGlobalOff: createService('off').isMarketplaceAutoUpdateEnabled({ ...repositoryMarketplace, autoUpdate: true }), + enabledWithGlobalOn: createService('on').isMarketplaceAutoUpdateEnabled({ ...repositoryMarketplace, autoUpdate: true }), + disabledWithGlobalOn: createService('on').isMarketplaceAutoUpdateEnabled({ ...repositoryMarketplace, autoUpdate: false }), }, { - globalOff: false, - globalOn: true, + enabledWithGlobalOff: false, + enabledWithGlobalOn: true, + disabledWithGlobalOn: false, }); }); }); 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 09f443f18422e2..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 @@ -183,6 +183,50 @@ suite('WorkspacePluginSettingsService', () => { 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 --- test('parses GitHub shorthand from extraKnownMarketplaces', () => runWithFakedTimers({ useFakeTimers: true }, async () => { 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 638e5292a49c34..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,8 @@ suite('PluginMcpDiscovery', () => { _serviceBrand: undefined, plugins, enablementModel: new class extends mock() { }, + whenReady: Promise.resolve(), + refresh: async () => { }, getWorkspaceConfiguredEnablement: () => undefined, }, registry)); discovery.start();