From 0c2892bbc458482c602142594b1fa7c9aab90533 Mon Sep 17 00:00:00 2001 From: Paul Wang <8560030+pwang347@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:31:23 -0700 Subject: [PATCH 1/3] chat: enforce managed plugin availability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHostProtocolClient.ts | 14 +- .../common/agentHostManagedSettings.ts | 20 ++ .../common/meta/clientPluginIdentityMeta.ts | 25 ++ .../node/agentHostManagedSettingsService.ts | 53 +++-- .../builtInChatContributions.ts | 2 + .../managedPluginAdmissionContribution.ts | 68 ++++++ .../agentHost/node/protocolServerHandler.ts | 17 +- .../common/agentHostManagedSettings.test.ts | 29 ++- .../agentHostProtocolClient.test.ts | 50 +++- .../agentHostManagedSettingsService.test.ts | 36 ++- .../node/agentHostToolCallTelemetry.test.ts | 2 + .../node/agentHostTurnHangTelemetry.test.ts | 2 + .../test/node/agentHostTurnTelemetry.test.ts | 2 + .../test/node/agentSideEffects.test.ts | 2 + .../test/node/chatContributions.test.ts | 43 +++- .../agentHost/test/node/copilotAgent.test.ts | 6 +- .../test/node/copilotSessionLauncher.test.ts | 2 +- .../test/node/protocolServerHandler.test.ts | 11 + .../agentHost/agentHostLocalCustomizations.ts | 4 + .../chat/browser/chat.shared.contribution.ts | 2 + .../browser/managedAgentPluginActivation.ts | 148 ++++++++++++ .../chat/common/plugins/AGENTS_PLUGINS.md | 1 + .../chat/common/plugins/agentPluginService.ts | 2 + .../common/plugins/agentPluginServiceImpl.ts | 9 +- .../managedAgentPluginActivation.test.ts | 224 ++++++++++++++++++ .../test/common/pluginMcpDiscovery.test.ts | 2 + 26 files changed, 721 insertions(+), 55 deletions(-) create mode 100644 src/vs/platform/agentHost/common/meta/clientPluginIdentityMeta.ts create mode 100644 src/vs/platform/agentHost/node/chatContributions/managedPluginAdmission/managedPluginAdmissionContribution.ts create mode 100644 src/vs/workbench/contrib/chat/browser/managedAgentPluginActivation.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/plugins/managedAgentPluginActivation.test.ts diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 13c43541aad3e..dd6fde2caa25a 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -47,7 +47,7 @@ import { ITelemetryService, TelemetryLevel, TELEMETRY_CRASH_REPORTER_SETTING_ID, import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostWorkspaceTrustConfigKey, getAgentHostTerminalAutoApproveRulesConfig, GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { formatAgentHostConfigurationSyncValueForLog, getAgentHostConfigurationSyncEntries, getAgentHostConfigurationSyncTarget, resolveAgentHostConfigurationSyncPatch, resolveAgentHostConfigurationSyncValue } from '../common/agentHostConfigurationSync.js'; -import { managedPermissionsConfigurationIds, resolveManagedSettingsPermissions, type IAgentHostManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; +import { AgentHostManagedPluginsSettingId, managedPermissionsConfigurationIds, resolveManagedPluginEnablement, resolveManagedSettingsPermissions, type IAgentHostManagedPluginEnablement, type IAgentHostManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { AgentHostClientConnectionKind, toAgentHostClientMeta } from '../common/agentHostTelemetry.js'; import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js'; import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js'; @@ -117,7 +117,7 @@ function isConnectionClosedError(error: unknown): boolean { } interface IRemoteAgentHostExtensionNotificationMap { - 'setClientManagedSettingsPermissions': { params: { permissions: IAgentHostManagedSettingsPermissions } }; + 'setClientManagedSettingsPermissions': { params: { permissions: IAgentHostManagedSettingsPermissions; enabledPlugins: IAgentHostManagedPluginEnablement } }; } interface IPendingRequest { @@ -522,7 +522,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect if (e.affectsConfiguration(DISABLE_REPO_INFO_TELEMETRY_SETTING_ID)) { this._updateDisableRepoInfoTelemetry(); } - if (managedPermissionsConfigurationIds.some(settingId => e.affectsConfiguration(settingId))) { + if (managedPermissionsConfigurationIds.some(settingId => e.affectsConfiguration(settingId)) || e.affectsConfiguration(AgentHostManagedPluginsSettingId)) { void this._updateManagedSettingsPermissions(); } })); @@ -2347,10 +2347,10 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } private _updateManagedSettingsPermissions(sendDuringReconnect = false): void { - const permissions = this._resourceIdentity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY - ? resolveManagedSettingsPermissions(this._configurationService) - : {}; - this._sendExtensionNotification('setClientManagedSettingsPermissions', { permissions }, sendDuringReconnect); + const local = this._resourceIdentity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY; + const permissions = local ? resolveManagedSettingsPermissions(this._configurationService) : {}; + const enabledPlugins = local ? resolveManagedPluginEnablement(this._configurationService) : {}; + this._sendExtensionNotification('setClientManagedSettingsPermissions', { permissions, enabledPlugins }, sendDuringReconnect); } /** diff --git a/src/vs/platform/agentHost/common/agentHostManagedSettings.ts b/src/vs/platform/agentHost/common/agentHostManagedSettings.ts index 5358dd3a25b26..1ca0b5a6c32a8 100644 --- a/src/vs/platform/agentHost/common/agentHostManagedSettings.ts +++ b/src/vs/platform/agentHost/common/agentHostManagedSettings.ts @@ -65,7 +65,15 @@ export interface IAgentHostManagedSettingsPermissions { ask?: string[]; } +export type IAgentHostManagedPluginEnablement = Readonly>; + +export interface IAgentHostManagedSettingsContribution { + readonly permissions: IAgentHostManagedSettingsPermissions; + readonly enabledPlugins: IAgentHostManagedPluginEnablement; +} + export const AgentHostMapLegacySettingsToManagedSettingsSettingId = 'chat.agentHost.copilot.mapLegacySettingsToManagedSettings'; +export const AgentHostManagedPluginsSettingId = 'chat.plugins.enabledPlugins'; /** * Which configuration layers may drive a mapping. @@ -293,6 +301,13 @@ export function isManagedSettingsPermissions(value: unknown): value is IAgentHos && isStringArrayOrUndefined(permissions.ask); } +export function isManagedPluginEnablement(value: unknown): value is IAgentHostManagedPluginEnablement { + return typeof value === 'object' + && value !== null + && !Array.isArray(value) + && Object.values(value).every(enabled => typeof enabled === 'boolean'); +} + function isStringArrayOrUndefined(value: unknown): boolean { return value === undefined || (Array.isArray(value) && value.every(item => typeof item === 'string')); } @@ -339,3 +354,8 @@ export function resolveManagedSettingsPermissions(configurationService: IConfigu } return permissions; } + +export function resolveManagedPluginEnablement(configurationService: IConfigurationService): IAgentHostManagedPluginEnablement { + const value = configurationService.inspect>>(AgentHostManagedPluginsSettingId)?.policyValue; + return isManagedPluginEnablement(value) ? value : {}; +} diff --git a/src/vs/platform/agentHost/common/meta/clientPluginIdentityMeta.ts b/src/vs/platform/agentHost/common/meta/clientPluginIdentityMeta.ts new file mode 100644 index 0000000000000..672a43bd23c4b --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/clientPluginIdentityMeta.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ClientPluginCustomization } from '../state/sessionState.js'; + +const clientPluginIdentityKey = 'vscode.clientPluginIdentity'; + +interface IClientPluginIdentity { + readonly pluginId: string; +} + +export function toClientPluginIdentityMeta(pluginId: string): Record { + return { [clientPluginIdentityKey]: { pluginId } satisfies IClientPluginIdentity }; +} + +export function readClientPluginIdentity(customization: Pick): string | undefined { + const value = customization._meta?.[clientPluginIdentityKey]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const pluginId = (value as Record).pluginId; + return typeof pluginId === 'string' && pluginId.length > 0 ? pluginId : undefined; +} diff --git a/src/vs/platform/agentHost/node/agentHostManagedSettingsService.ts b/src/vs/platform/agentHost/node/agentHostManagedSettingsService.ts index 3a413fc0b501c..516d31265dde5 100644 --- a/src/vs/platform/agentHost/node/agentHostManagedSettingsService.ts +++ b/src/vs/platform/agentHost/node/agentHostManagedSettingsService.ts @@ -7,7 +7,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { equals } from '../../../base/common/objects.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -import type { IAgentHostManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; +import type { IAgentHostManagedPluginEnablement, IAgentHostManagedSettingsContribution, IAgentHostManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; export const IAgentHostManagedSettingsService = createDecorator('agentHostManagedSettingsService'); @@ -15,8 +15,9 @@ export interface IAgentHostManagedSettingsService { readonly _serviceBrand: undefined; readonly onDidChange: Event; readonly permissions: IAgentHostManagedSettingsPermissions; - setClientPermissions(clientId: string, permissions: IAgentHostManagedSettingsPermissions): void; - removeClientPermissions(clientId: string): void; + readonly enabledPlugins: IAgentHostManagedPluginEnablement; + setClientContribution(clientId: string, contribution: IAgentHostManagedSettingsContribution): void; + removeClientContribution(clientId: string): void; } export class AgentHostManagedSettingsService extends Disposable implements IAgentHostManagedSettingsService { @@ -25,41 +26,51 @@ export class AgentHostManagedSettingsService extends Disposable implements IAgen private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; - private readonly _permissionsByClient = new Map(); + private readonly _contributionsByClient = new Map(); private _permissions: IAgentHostManagedSettingsPermissions = {}; + private _enabledPlugins: IAgentHostManagedPluginEnablement = {}; get permissions(): IAgentHostManagedSettingsPermissions { return this._permissions; } - setClientPermissions(clientId: string, permissions: IAgentHostManagedSettingsPermissions): void { - if (Object.keys(permissions).length === 0) { - this._permissionsByClient.delete(clientId); + get enabledPlugins(): IAgentHostManagedPluginEnablement { + return this._enabledPlugins; + } + + setClientContribution(clientId: string, contribution: IAgentHostManagedSettingsContribution): void { + if (Object.keys(contribution.permissions).length === 0 && Object.keys(contribution.enabledPlugins).length === 0) { + this._contributionsByClient.delete(clientId); } else { - this._permissionsByClient.set(clientId, permissions); + this._contributionsByClient.set(clientId, contribution); } - this._updatePermissions(); + this._update(); } - removeClientPermissions(clientId: string): void { - if (this._permissionsByClient.delete(clientId)) { - this._updatePermissions(); + removeClientContribution(clientId: string): void { + if (this._contributionsByClient.delete(clientId)) { + this._update(); } } - private _updatePermissions(): void { + private _update(): void { const permissions: IAgentHostManagedSettingsPermissions = {}; const deny = new Set(); const ask = new Set(); - for (const contribution of this._permissionsByClient.values()) { - if (contribution.disableBypassPermissionsMode === 'disable') { + const pluginValues = new Map(); + for (const contribution of this._contributionsByClient.values()) { + if (contribution.permissions.disableBypassPermissionsMode === 'disable') { permissions.disableBypassPermissionsMode = 'disable'; } - if (contribution.deny) { - contribution.deny.forEach(rule => deny.add(rule)); + if (contribution.permissions.deny) { + contribution.permissions.deny.forEach(rule => deny.add(rule)); + } + if (contribution.permissions.ask) { + contribution.permissions.ask.forEach(rule => ask.add(rule)); } - if (contribution.ask) { - contribution.ask.forEach(rule => ask.add(rule)); + for (const [pluginId, enabled] of Object.entries(contribution.enabledPlugins)) { + const previous = pluginValues.get(pluginId); + pluginValues.set(pluginId, previous === false ? false : enabled); } } if (deny.size > 0) { @@ -68,8 +79,10 @@ export class AgentHostManagedSettingsService extends Disposable implements IAgen if (ask.size > 0) { permissions.ask = [...ask]; } - if (!equals(this._permissions, permissions)) { + const enabledPlugins = Object.fromEntries(pluginValues); + if (!equals(this._permissions, permissions) || !equals(this._enabledPlugins, enabledPlugins)) { this._permissions = permissions; + this._enabledPlugins = enabledPlugins; this._onDidChange.fire(); } } diff --git a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts index 9a74160b52b81..b80159ee72bcd 100644 --- a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts +++ b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts @@ -14,6 +14,7 @@ import { CheckpointAndChangesetContribution } from './checkpointAndChangeset/che import { ExternalSessionAdoptionContribution } from './externalSessionAdoption/externalSessionAdoptionContribution.js'; import { GitHubReferencesContribution } from './githubReferences/githubReferencesContribution.js'; import { LocalCommandContribution } from './localCommand/localCommandContribution.js'; +import { ManagedPluginAdmissionContribution } from './managedPluginAdmission/managedPluginAdmissionContribution.js'; import { MarkdownPlanRichLinksContribution } from './markdownPlanRichLinks/markdownPlanRichLinksContribution.js'; import { MarkUnreadContribution } from './markUnread/markUnreadContribution.js'; import { PersistedTurnUsageContribution } from './persistedTurnUsage/persistedTurnUsageContribution.js'; @@ -36,6 +37,7 @@ export function registerBuiltInChatContributions( ): IDisposable { const registrations = new DisposableStore(); registrations.add(contributions.registerContribution(LocalCommandContribution)); + registrations.add(contributions.registerContribution(ManagedPluginAdmissionContribution)); registrations.add(contributions.registerContribution(ExternalSessionAdoptionContribution)); registrations.add(contributions.registerContribution(TurnAdmissionContribution)); registrations.add(contributions.registerContribution(ChatInputContribution)); diff --git a/src/vs/platform/agentHost/node/chatContributions/managedPluginAdmission/managedPluginAdmissionContribution.ts b/src/vs/platform/agentHost/node/chatContributions/managedPluginAdmission/managedPluginAdmissionContribution.ts new file mode 100644 index 0000000000000..f863b0f74ccb9 --- /dev/null +++ b/src/vs/platform/agentHost/node/chatContributions/managedPluginAdmission/managedPluginAdmissionContribution.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { ILogService } from '../../../../log/common/log.js'; +import { type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IIncomingRequest, type IncomingRequestDisposition } from '../../../common/agentHostChatContributionsService.js'; +import { isCustomizationEnabled } from '../../../common/customizationEnablement.js'; +import { readClientPluginIdentity } from '../../../common/meta/clientPluginIdentityMeta.js'; +import { CustomizationLoadStatus, CustomizationType } from '../../../common/state/protocol/state.js'; +import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } from '../../agentHostManagedSettingsService.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; + +export class ManagedPluginAdmissionContribution extends Disposable implements IAgentHostChatContribution { + + static readonly id = 'managedPluginAdmission'; + readonly order = 75; + + constructor( + protected readonly _context: IAgentHostChatContributionContext, + @IAgentHostManagedSettingsService private readonly _managedSettingsService: AgentHostManagedSettingsService, + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + onIncomingRequest(request: IIncomingRequest): IncomingRequestDisposition | undefined { + const required = Object.entries(this._managedSettingsService.enabledPlugins) + .filter(([, enabled]) => enabled) + .map(([pluginId]) => pluginId); + if (required.length === 0) { + return undefined; + } + + const loaded = new Set(); + for (const customization of this._stateManager.getSessionState(request.session)?.customizations ?? []) { + if (customization.type !== CustomizationType.Plugin + || customization.load?.kind !== CustomizationLoadStatus.Loaded + || !isCustomizationEnabled(customization)) { + continue; + } + const pluginId = readClientPluginIdentity(customization); + if (pluginId) { + loaded.add(pluginId); + } + } + + const unavailable = required.filter(pluginId => !loaded.has(pluginId)); + if (unavailable.length === 0) { + return undefined; + } + + this._logService.warn(`[ManagedPluginAdmissionContribution] Rejecting turn because required plugins are unavailable: ${unavailable.join(', ')}`); + return { + kind: 'reject', + error: { + errorType: 'managedPluginUnavailable', + message: unavailable.length === 1 + ? localize('managedPluginUnavailable', "The plugin \"{0}\" is required by your organization but is not available. Install or repair the plugin before continuing.", unavailable[0]) + : localize('managedPluginsUnavailable', "The following plugins are required by your organization but are not available: {0}. Install or repair the plugins before continuing.", unavailable.join(', ')), + }, + stage: 'validation', + }; + } +} diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 5e999ddcaa1bf..5196a8cb63eb7 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -18,7 +18,7 @@ import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js' import { getAgentHostClientType } from '../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, readClientConnectionKind, readClientDevDeviceId, readClientMachineId, readClientTelemetryLevel, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { AgentSession, type IAgentCreateChatRequestOptions, type IMcpNotification } from '../common/agent.js'; -import { isManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; +import { isManagedPluginEnablement, isManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { isAnnotationsUri } from '../common/annotationsUri.js'; import { parseChangesetUri } from '../common/changesetUri.js'; import { type IAgentService } from '../common/agentService.js'; @@ -514,11 +514,14 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien this._logService.trace(`[ProtocolServer] notification: method=${msg.method}`); if ((msg as { method: string }).method === 'setClientManagedSettingsPermissions') { if (client) { - const permissions = ((msg as { params?: { permissions?: unknown } }).params)?.permissions; - if (isManagedSettingsPermissions(permissions)) { - this._managedSettingsService.setClientPermissions(this._managedSettingsContributionId(client.clientId), permissions); + const params = (msg as { params?: { permissions?: unknown; enabledPlugins?: unknown } }).params; + if (isManagedSettingsPermissions(params?.permissions) && isManagedPluginEnablement(params.enabledPlugins)) { + this._managedSettingsService.setClientContribution(this._managedSettingsContributionId(client.clientId), { + permissions: params.permissions, + enabledPlugins: params.enabledPlugins, + }); } else { - this._logService.warn('[ProtocolServer] Ignoring invalid managed settings permissions contribution.'); + this._logService.warn('[ProtocolServer] Ignoring invalid managed settings contribution.'); } } return; @@ -1108,7 +1111,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien if (record?.state === 'grace') { record.disconnectTimeouts.set('managed-settings', disposableTimeout(() => { record.disconnectTimeouts.deleteAndDispose('managed-settings'); - this._managedSettingsService.removeClientPermissions(this._managedSettingsContributionId(clientId)); + this._managedSettingsService.removeClientContribution(this._managedSettingsContributionId(clientId)); }, CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT)); } for (const session of this._stateManager.getSessionUris()) { @@ -2366,7 +2369,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien override dispose(): void { for (const [clientId, record] of this._clients) { - this._managedSettingsService.removeClientPermissions(this._managedSettingsContributionId(clientId)); + this._managedSettingsService.removeClientContribution(this._managedSettingsContributionId(clientId)); if (record.state === 'active') { for (const connection of [...record.connections]) { const subscriptionCount = connection.subscriptions.size; diff --git a/src/vs/platform/agentHost/test/common/agentHostManagedSettings.test.ts b/src/vs/platform/agentHost/test/common/agentHostManagedSettings.test.ts index 5686e779edc50..997e66f1135f7 100644 --- a/src/vs/platform/agentHost/test/common/agentHostManagedSettings.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostManagedSettings.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import type { IConfigurationService, IConfigurationValue } from '../../../configuration/common/configuration.js'; -import { AgentHostMapLegacySettingsToManagedSettingsSettingId, resolveManagedSettingsPermissions } from '../../common/agentHostManagedSettings.js'; +import { AgentHostManagedPluginsSettingId, AgentHostMapLegacySettingsToManagedSettingsSettingId, resolveManagedPluginEnablement, resolveManagedSettingsPermissions } from '../../common/agentHostManagedSettings.js'; import { AgentNetworkDomainSettingId } from '../../../networkFilter/common/settings.js'; import { ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID } from '../../common/agentHostSchema.js'; @@ -77,6 +77,33 @@ suite('AgentHostManagedSettings', () => { assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), {}); }); + test('reads only boolean managed plugin policy entries', () => { + const valid = createConfigurationService({ + [AgentHostManagedPluginsSettingId]: { + policyValue: { + 'required@marketplace': true, + 'blocked@marketplace': false, + }, + }, + }); + const invalid = createConfigurationService({ + [AgentHostManagedPluginsSettingId]: { + policyValue: { + 'required@marketplace': true, + invalid: 'yes', + }, + }, + }); + + assert.deepStrictEqual([ + resolveManagedPluginEnablement(valid), + resolveManagedPluginEnablement(invalid), + ], [{ + 'required@marketplace': true, + 'blocked@marketplace': false, + }, {}]); + }); + test('deduplicates a rule that more than one entry produces', () => { const configurationService = createConfigurationService({ [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 05fb9ba5bf262..e75d4ee051b60 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -40,7 +40,7 @@ import { TestConfigurationService } from '../../../configuration/test/common/tes import { ITelemetryService, TelemetryConfiguration, TelemetryLevel, TELEMETRY_SETTING_ID } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostWorkspaceTrustConfigKey, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, telemetryLevelToAgentHostConfigValue, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, type AgentHostTerminalAutoApproveRules } from '../../common/agentHostSchema.js'; -import { AgentHostMapLegacySettingsToManagedSettingsSettingId } from '../../common/agentHostManagedSettings.js'; +import { AgentHostManagedPluginsSettingId, AgentHostMapLegacySettingsToManagedSettingsSettingId } from '../../common/agentHostManagedSettings.js'; import { AgentHostConfigurationSyncScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../configuration/common/configurationRegistry.js'; import { Registry } from '../../../registry/common/platform.js'; import type { IConnectionDiagnosticEvent } from '../../common/connectionDiagnostics.js'; @@ -262,6 +262,7 @@ class TerminalAutoApproveConfigurationService extends TestConfigurationService { class ManagedPermissionsConfigurationService extends TestConfigurationService { private globalAutoApprovePolicyValue: boolean | undefined = false; private eligibleForAutoApprovalPolicyValue: Record | undefined; + private managedPluginPolicyValue: Record | undefined; override inspect(key: string): IConfigurationValue { if (key === GLOBAL_AUTO_APPROVE_SETTING_ID) { @@ -276,6 +277,12 @@ class ManagedPermissionsConfigurationService extends TestConfigurationService { policyValue: this.eligibleForAutoApprovalPolicyValue as T | undefined, }; } + if (key === AgentHostManagedPluginsSettingId) { + return { + ...super.inspect(key), + policyValue: this.managedPluginPolicyValue as T | undefined, + }; + } return super.inspect(key); } @@ -286,6 +293,10 @@ class ManagedPermissionsConfigurationService extends TestConfigurationService { setEligibleForAutoApprovalPolicy(value: Record | undefined): void { this.eligibleForAutoApprovalPolicyValue = value; } + + setManagedPluginPolicy(value: Record | undefined): void { + this.managedPluginPolicyValue = value; + } } suite('AgentHostProtocolClient', () => { @@ -1609,6 +1620,36 @@ suite('AgentHostProtocolClient', () => { [AgentHostMapLegacySettingsToManagedSettingsSettingId]: true, [TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID]: false, }); + + test('forwards managed plugin policy for the local host', async () => { + const configurationService = new ManagedPermissionsConfigurationService({}); + configurationService.setManagedPluginPolicy({ + 'required@marketplace': true, + 'blocked@marketplace': false, + }); + const { client, transport } = createClientForIdentity( + LOCAL_AGENT_HOST_RESOURCE_IDENTITY, + disposables.add(new TestProtocolTransport()), + createPermissionService(), + undefined, + new NullLogService(), + configurationService, + ); + + await connectClient(client, transport); + + assert.deepStrictEqual(findLastManagedSettingsNotification(transport.sentMessages), { + jsonrpc: '2.0', + method: 'setClientManagedSettingsPermissions', + params: { + permissions: {}, + enabledPlugins: { + 'required@marketplace': true, + 'blocked@marketplace': false, + }, + }, + }); + }); const { client, transport } = createClientForIdentity( LOCAL_AGENT_HOST_RESOURCE_IDENTITY, disposables.add(new TestProtocolTransport()), @@ -1628,6 +1669,7 @@ suite('AgentHostProtocolClient', () => { disableBypassPermissionsMode: 'disable', ask: ['Shell'], }, + enabledPlugins: {}, }, }); @@ -1641,7 +1683,7 @@ suite('AgentHostProtocolClient', () => { assert.deepStrictEqual(findLastManagedSettingsNotification(transport.sentMessages), { jsonrpc: '2.0', method: 'setClientManagedSettingsPermissions', - params: { permissions: {} }, + params: { permissions: {}, enabledPlugins: {} }, }); }); @@ -1666,7 +1708,7 @@ suite('AgentHostProtocolClient', () => { assert.deepStrictEqual(findLastManagedSettingsNotification(transport.sentMessages), { jsonrpc: '2.0', method: 'setClientManagedSettingsPermissions', - params: { permissions: { disableBypassPermissionsMode: 'disable' } }, + params: { permissions: { disableBypassPermissionsMode: 'disable' }, enabledPlugins: {} }, }); transport.sentMessages.length = 0; @@ -1676,7 +1718,7 @@ suite('AgentHostProtocolClient', () => { assert.deepStrictEqual(findLastManagedSettingsNotification(transport.sentMessages), { jsonrpc: '2.0', method: 'setClientManagedSettingsPermissions', - params: { permissions: {} }, + params: { permissions: {}, enabledPlugins: {} }, }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostManagedSettingsService.test.ts b/src/vs/platform/agentHost/test/node/agentHostManagedSettingsService.test.ts index 09f3ee7c254f4..c29eef15f8c6a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostManagedSettingsService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostManagedSettingsService.test.ts @@ -14,28 +14,48 @@ suite('AgentHostManagedSettingsService', () => { test('aggregates restrictive client contributions and removes them by owner', () => { const service = store.add(new AgentHostManagedSettingsService()); - service.setClientPermissions('client-1', { disableBypassPermissionsMode: 'disable' }); - service.setClientPermissions('client-2', { ask: ['Shell'], deny: ['Write(**)'] }); + service.setClientContribution('client-1', { + permissions: { disableBypassPermissionsMode: 'disable' }, + enabledPlugins: { 'required@marketplace': true }, + }); + service.setClientContribution('client-2', { + permissions: { ask: ['Shell'], deny: ['Write(**)'] }, + enabledPlugins: { 'required@marketplace': false, 'other@marketplace': true }, + }); const combined = service.permissions; - service.removeClientPermissions('client-1'); + const combinedPlugins = service.enabledPlugins; + service.removeClientContribution('client-1'); const afterFirstRemoval = service.permissions; - service.removeClientPermissions('client-2'); + const pluginsAfterFirstRemoval = service.enabledPlugins; + service.removeClientContribution('client-2'); assert.deepStrictEqual({ combined, + combinedPlugins, afterFirstRemoval, + pluginsAfterFirstRemoval, afterAllRemoved: service.permissions, + pluginsAfterAllRemoved: service.enabledPlugins, }, { combined: { disableBypassPermissionsMode: 'disable', ask: ['Shell'], deny: ['Write(**)'], }, + combinedPlugins: { + 'required@marketplace': false, + 'other@marketplace': true, + }, afterFirstRemoval: { ask: ['Shell'], deny: ['Write(**)'], }, + pluginsAfterFirstRemoval: { + 'required@marketplace': false, + 'other@marketplace': true, + }, afterAllRemoved: {}, + pluginsAfterAllRemoved: {}, }); }); @@ -44,10 +64,10 @@ suite('AgentHostManagedSettingsService', () => { let changes = 0; store.add(service.onDidChange(() => changes++)); - service.setClientPermissions('client-1', { ask: ['Shell'] }); - service.setClientPermissions('client-2', { ask: ['Shell'] }); - service.removeClientPermissions('client-1'); - service.removeClientPermissions('client-2'); + service.setClientContribution('client-1', { permissions: { ask: ['Shell'] }, enabledPlugins: {} }); + service.setClientContribution('client-2', { permissions: { ask: ['Shell'] }, enabledPlugins: {} }); + service.removeClientContribution('client-1'); + service.removeClientContribution('client-2'); assert.strictEqual(changes, 2); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index 1dc66d35ed541..fbf82fa34c6b9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -31,6 +31,7 @@ import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.j import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; +import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } from '../../node/agentHostManagedSettingsService.js'; import { IAgentHostPeerChatPersistenceService } from '../../node/agentHostPeerChatStore.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; import { AgentHostChatInputService, IAgentHostChatInputService } from '../../node/agentHostChatInputService.js'; @@ -269,6 +270,7 @@ suite('AgentSideEffects — tool call telemetry', () => { clientConnectionService = disposables.add(new AgentHostClientConnectionService()); const worktreeIsolation = createNoopWorktreeIsolation(); const services = new ServiceCollection( + [IAgentHostManagedSettingsService, disposables.add(new AgentHostManagedSettingsService())], [IAgentHostLocalTurns, sharedLocalTurns], [ILogService, logService], [IAgentConfigurationService, configService], diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index 574f906fd0fe2..73d7151160938 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -32,6 +32,7 @@ import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.j import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; +import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } from '../../node/agentHostManagedSettingsService.js'; import { IAgentHostPeerChatPersistenceService } from '../../node/agentHostPeerChatStore.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; import { AgentHostChatInputService, IAgentHostChatInputService } from '../../node/agentHostChatInputService.js'; @@ -227,6 +228,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { const sharedLocalTurns = new AgentHostLocalTurns(sessionDataService, logService); const worktreeIsolation = createNoopWorktreeIsolation(); const services = new ServiceCollection( + [IAgentHostManagedSettingsService, disposables.add(new AgentHostManagedSettingsService())], [IAgentHostLocalTurns, sharedLocalTurns], [ILogService, logService], [IAgentConfigurationService, configService], diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index c2f40b2b0f069..7c854493effaf 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -38,6 +38,7 @@ import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.j import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; +import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } from '../../node/agentHostManagedSettingsService.js'; import { IAgentHostPeerChatPersistenceService } from '../../node/agentHostPeerChatStore.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; import { AgentHostChatInputService, IAgentHostChatInputService } from '../../node/agentHostChatInputService.js'; @@ -267,6 +268,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { }; const worktreeIsolation = createNoopWorktreeIsolation(); const services = new ServiceCollection( + [IAgentHostManagedSettingsService, disposables.add(new AgentHostManagedSettingsService())], [ILogService, logService], [IAgentConfigurationService, configService], [IAgentHostChangesetService, new FakeChangesetService()], diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index f79f92170219c..84a06a284f01a 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -52,6 +52,7 @@ import { IAgentHostGitStateService } from '../../common/agentHostGitStateService import { AgentSideEffects, IAgentSideEffectsOptions } from '../../node/agentSideEffects.js'; import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; +import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } from '../../node/agentHostManagedSettingsService.js'; import { IAgentHostPeerChatPersistenceService } from '../../node/agentHostPeerChatStore.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; @@ -183,6 +184,7 @@ function createTestSideEffects( const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); const worktreeIsolation = new NoopWorktreeIsolation(); const services = new ServiceCollection( + [IAgentHostManagedSettingsService, disposables.add(new AgentHostManagedSettingsService())], [ILogService, logService], [IAgentConfigurationService, configService], [IAgentHostChangesetService, changesets], diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 21920b1b6eba4..a86d6b54d7c77 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -28,16 +28,18 @@ import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext } fro import { createChatMementoKey, createSessionMementoKey, IAgentHostChatContributions, type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IAgentHostChatContributionHost, type IHydrationContext, type IIncomingRequest, type IAppliedClientAction, type IDispatchedAction, type IOutgoingTurn, type IOutgoingTurnContributionResult, type IRestoredChat, type ITurnEnd, type IncomingRequestDisposition } from '../../common/agentHostChatContributionsService.js'; import { AgentHostArtifactToolsConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, type ISchema, type SchemaDefinition, type SchemaValue } from '../../common/agentHostSchema.js'; import { createEditorInlineChatInstruction, type IChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; +import { toClientPluginIdentityMeta } from '../../common/meta/clientPluginIdentityMeta.js'; import { readAgentMessageDelegationMeta, toAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { SendRemoteMessageToolReferenceName, withRemoteSessionOrigin } from '../../common/meta/agentRemoteSessionMeta.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { ChatOriginKind, MessageAttachmentKind } from '../../common/state/protocol/state.js'; +import { ChatOriginKind, CustomizationLoadStatus, CustomizationType, MessageAttachmentKind } from '../../common/state/protocol/state.js'; import { AH_META_AUTO_ARCHIVED_AT_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatInteractivity, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, TurnState, withSessionExternal, type ISessionGitHubState, type Message, type PendingMessage, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; +import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } from '../../node/agentHostManagedSettingsService.js'; import { IAgentHostPeerChatPersistenceService } from '../../node/agentHostPeerChatStore.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; @@ -884,6 +886,7 @@ function createBuiltInContributions(disposables: ReturnType { }, @@ -932,7 +936,7 @@ function createBuiltInContributions(disposables: ReturnType; readonly enabled?: boolean }): Record { @@ -2546,6 +2550,41 @@ suite('AgentHostChatContributions', () => { stage: 'validation', }, }); + + test('rejects incoming requests until required managed plugins are loaded', () => { + const contributions = createBuiltInContributions(disposables); + contributions.managedSettingsService.setClientContribution('client', { + permissions: {}, + enabledPlugins: { 'required@marketplace': true }, + }); + const sessionState = contributions.stateManager.getSessionState(contributions.session)!; + const missing = contributions.service.incomingRequest(incomingRequest(contributions.session)); + sessionState.customizations = [{ + type: CustomizationType.Plugin, + id: 'required-plugin', + uri: 'file:///required-plugin', + name: 'Required Plugin', + clientId: 'client', + _meta: toClientPluginIdentityMeta('required@marketplace'), + load: { kind: CustomizationLoadStatus.Loaded }, + }]; + const loaded = contributions.service.incomingRequest(incomingRequest(contributions.session)); + + assert.deepStrictEqual({ + missing, + loaded, + }, { + missing: { + kind: 'reject', + error: { + errorType: 'managedPluginUnavailable', + message: 'The plugin "required@marketplace" is required by your organization but is not available. Install or repair the plugin before continuing.', + }, + stage: 'validation', + }, + loaded: { kind: 'accept' }, + }); + }); }); test('admits an unarchived session default chat with a stale archive flag', () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 1a8682c273260..02d8aec40023e 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -7492,12 +7492,12 @@ suite('CopilotAgent', () => { await agent.listChatsToMigrate(); const restricted = { disableBypassPermissionsMode: 'disable' as const }; - managedSettingsService.setClientPermissions('client', restricted); + managedSettingsService.setClientContribution('client', { permissions: restricted, enabledPlugins: {} }); await Promise.resolve(); await agent.listChatsToMigrate(); - managedSettingsService.setClientPermissions('client', restricted); + managedSettingsService.setClientContribution('client', { permissions: restricted, enabledPlugins: {} }); await Promise.resolve(); - managedSettingsService.removeClientPermissions('client'); + managedSettingsService.removeClientContribution('client'); await Promise.resolve(); assert.strictEqual(client.stopCount, 2); diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 730fc18ab32b7..ad8b4b14de47f 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -128,7 +128,7 @@ function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettin } as Partial as IAgentConfigurationService; return new CopilotSessionLauncher( configurationService, - { permissions: managedSettingsPermissions ?? {} } as IAgentHostManagedSettingsService, + { permissions: managedSettingsPermissions ?? {}, enabledPlugins: {} } as IAgentHostManagedSettingsService, {} as IAgentHostTerminalManager, logService, {} as IFileService, diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 65210592afb36..482f3bbf1c0b6 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -1507,6 +1507,7 @@ suite('ProtocolServerHandler', () => { })); transport.simulateMessage(notification('setClientManagedSettingsPermissions', { permissions: { disableBypassPermissionsMode: 'disable', ask: ['Shell'] }, + enabledPlugins: {}, })); assert.deepStrictEqual({ @@ -4485,9 +4486,11 @@ suite('ProtocolServerHandler', () => { transport.simulateMessage(notification('setClientManagedSettingsPermissions', { permissions: { disableBypassPermissionsMode: 'disable', ask: ['Shell'] }, + enabledPlugins: { 'required@marketplace': true }, })); transport.simulateMessage(notification('setClientManagedSettingsPermissions', { permissions: { allow: ['Shell'] }, + enabledPlugins: {}, })); await Promise.resolve(); @@ -4495,12 +4498,16 @@ suite('ProtocolServerHandler', () => { disableBypassPermissionsMode: 'disable', ask: ['Shell'], }); + assert.deepStrictEqual(managedSettingsService.enabledPlugins, { + 'required@marketplace': true, + }); }); test('scopes managed settings contributions to each protocol handler', () => { const firstTransport = connectClient('shared-client-id'); firstTransport.simulateMessage(notification('setClientManagedSettingsPermissions', { permissions: { ask: ['Shell'] }, + enabledPlugins: {}, })); const localDisposables = disposables.add(new DisposableStore()); @@ -4526,6 +4533,7 @@ suite('ProtocolServerHandler', () => { })); secondTransport.simulateMessage(notification('setClientManagedSettingsPermissions', { permissions: { disableBypassPermissionsMode: 'disable' }, + enabledPlugins: {}, })); assert.deepStrictEqual(managedSettingsService.permissions, { @@ -4543,10 +4551,12 @@ suite('ProtocolServerHandler', () => { const activeTransport = connectClient('client-managed-settings-active'); activeTransport.simulateMessage(notification('setClientManagedSettingsPermissions', { permissions: { ask: ['Shell'] }, + enabledPlugins: {}, })); const graceTransport = connectClient('client-managed-settings-grace'); graceTransport.simulateMessage(notification('setClientManagedSettingsPermissions', { permissions: { disableBypassPermissionsMode: 'disable' }, + enabledPlugins: {}, })); graceTransport.simulateClose(); @@ -4565,6 +4575,7 @@ suite('ProtocolServerHandler', () => { const transport = connectClient('client-managed-settings-disconnect'); transport.simulateMessage(notification('setClientManagedSettingsPermissions', { permissions: { ask: ['Shell'] }, + enabledPlugins: {}, })); transport.simulateClose(); 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 4b73cba9bdca6..508d59d2057dc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -10,11 +10,13 @@ import { URI } from '../../../../../../base/common/uri.js'; import { CustomizationEnablementKind, type AgentCustomization, CustomizationType, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { customizationId, type ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { withCustomizationEnablement } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; +import { toClientPluginIdentityMeta } from '../../../../../../platform/agentHost/common/meta/clientPluginIdentityMeta.js'; import { AICustomizationSource, AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { IPromptsService, isUserToggleableCustomization, matchesSessionType, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { type ICustomizationSyncProvider } from '../../../common/customizationHarnessService.js'; import { IAgentPlugin, IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; +import { getAgentPluginPolicyId } from '../../../common/plugins/agentPluginEnablement.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; import type { ISyncableFile, ISyncableMcpServer, SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; @@ -242,11 +244,13 @@ export async function resolveCustomizationRefs( // ignored, sync will probably fail later though... } + const pluginId = getAgentPluginPolicyId(plugin); const ref: ClientPluginCustomization = { type: CustomizationType.Plugin, id: customizationId(key), uri: key as ProtocolURI, name: plugin.label, + ...(pluginId ? { _meta: toClientPluginIdentityMeta(pluginId) } : {}), enablement: withCustomizationEnablement(undefined, CustomizationEnablementKind.Global, { kind: CustomizationEnablementKind.Global, enabled: agentPluginService.enablementModel.readProfileEnabled(key), 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 dc94284383a6c..9dd468d531fd3 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -236,6 +236,7 @@ import { PlanAgentDefaultModel } from './planAgentDefaultModel.js'; import './planReviewFeedback/planReviewFeedbackEditorOverlay.js'; import { IPlanReviewFeedbackService, PlanReviewFeedbackService } from './planReviewFeedback/planReviewFeedbackService.js'; import { PluginAutoUpdate } from './pluginAutoUpdate.js'; +import { ManagedAgentPluginActivation } from './managedAgentPluginActivation.js'; import { BrowserPluginGitCommandService } from './pluginGitCommandService.js'; import { PluginInstallService } from './pluginInstallService.js'; import { PluginUrlHandler } from './pluginUrlHandler.js'; @@ -3368,6 +3369,7 @@ registerWorkbenchContribution2(ChatRepoInfoContribution.ID, ChatRepoInfoContribu registerWorkbenchContribution2(AgentPluginRecommendations.ID, AgentPluginRecommendations, WorkbenchPhase.Eventually); registerWorkbenchContribution2(AgentPluginCommandsContribution.ID, AgentPluginCommandsContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(PluginAutoUpdate.ID, PluginAutoUpdate, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(ManagedAgentPluginActivation.ID, ManagedAgentPluginActivation, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatReferenceAttachmentWidgetContribution.ID, ChatReferenceAttachmentWidgetContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(TranscriptContextAttachmentWidgetContribution.ID, TranscriptContextAttachmentWidgetContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatPetContextContribution.ID, ChatPetContextContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/chat/browser/managedAgentPluginActivation.ts b/src/vs/workbench/contrib/chat/browser/managedAgentPluginActivation.ts new file mode 100644 index 0000000000000..4fafb6c0ed2cd --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/managedAgentPluginActivation.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; +import { ChatConfiguration } from '../common/constants.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 ManagedAgentPluginActivation extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.managedAgentPluginActivation'; + + 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, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._register(Event.any( + Event.fromObservableLight(this._pluginMarketplaceService.installedPlugins), + this._pluginMarketplaceService.onDidChangeMarketplaces, + this._chatEntitlementService.onDidChangeSentiment, + Event.filter(this._configurationService.onDidChangeConfiguration, event => event.affectsConfiguration(ChatConfiguration.EnabledPlugins)), + )(() => 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._installRequiredPlugins(); + } + } catch (error) { + if (!isCancellationError(error)) { + this._logService.error('[ManagedAgentPluginActivation] Failed to reconcile required plugins', error); + } + } finally { + this._reconcileRunning = false; + if (this._reconcileRequested && !this._store.isDisposed && !this._cancellation.token.isCancellationRequested) { + void this._reconcile(); + } + } + } + + private async _installRequiredPlugins(): Promise { + const requiredPluginIds = this._requiredPluginIds(); + if (requiredPluginIds.size === 0 || this._chatEntitlementService.sentiment.hidden) { + return; + } + + const marketplacePlugins = await this._pluginMarketplaceService.fetchMarketplacePlugins( + this._cancellation.token, + undefined, + { + onMarketplaceError: (marketplace, error) => { + this._logService.warn(`[ManagedAgentPluginActivation] Could not read marketplace '${marketplace.displayLabel}': ${getErrorMessage(error)}`); + }, + }, + ); + const pluginsById = new Map(); + for (const plugin of marketplacePlugins) { + const pluginId = `${plugin.name}@${plugin.marketplace}`; + if (!pluginsById.has(pluginId)) { + pluginsById.set(pluginId, plugin); + } + } + + const discoveredPluginIds = new Set(this._agentPluginService.plugins.get().map(plugin => getAgentPluginPolicyId(plugin)).filter(pluginId => pluginId !== undefined)); + for (const pluginId of requiredPluginIds) { + if (this._cancellation.token.isCancellationRequested || this._chatEntitlementService.sentiment.hidden) { + return; + } + if (!this._requiredPluginIds().has(pluginId) || discoveredPluginIds.has(pluginId)) { + continue; + } + + const plugin = pluginsById.get(pluginId); + if (!plugin) { + this._logService.warn(`[ManagedAgentPluginActivation] Required 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(`[ManagedAgentPluginActivation] Failed to install required plugin '${pluginId}'`, error); + } + continue; + } + + if (!this._pluginMarketplaceService.isPluginInstalled(installUri)) { + this._logService.warn(`[ManagedAgentPluginActivation] Required plugin '${pluginId}' was not installed`); + continue; + } + + this._agentPluginService.setInstalledPluginProfileBaseline(installUri.toString(), false); + } + } + + private _requiredPluginIds(): ReadonlySet { + const required = new Set(); + const managed = this._configurationService.inspect>(ChatConfiguration.EnabledPlugins).policyValue; + for (const [pluginId, enabled] of Object.entries(managed ?? {})) { + if (enabled) { + required.add(pluginId); + } + } + return required; + } +} 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 7faa5bfbbe2ff..404e036dba80f 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md +++ b/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md @@ -185,6 +185,7 @@ Each `PluginSourceKind` has a strategy that knows how to compute cache paths, pr The managed customization controls are complementary: - `strictKnownMarketplaces` restricts which marketplace sources may provide plugins. +- Managed `enabledPlugins` entries are authoritative per plugin: `true` installs and restores the exact `plugin@marketplace`, while the Agent Host rejects turns until the required plugin has loaded; `false` keeps it disabled. - `strictPluginOnlyCustomization` blocks standalone user and workspace skills, agents, hooks, instructions, and MCP servers. Eligible plugin contributions remain available. - `allowManagedMcpServersOnly` makes the managed MCP allowlist authoritative; lower-layer allow entries cannot broaden it and deny entries remain restrictive. - `allowManagedHooksOnly` permits plugin hooks only when managed `enabledPlugins` force-enables the plugin. User/workspace hooks and hooks from otherwise user-enabled plugins do not load. diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 7f318ebd913ff..9b511d3c9bde8 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; + /** Sets the stored profile baseline without persisting active policy. */ + setInstalledPluginProfileBaseline(key: string, enabled: boolean): void; } 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 1ffd39d0c32f7..03c73d62289be 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -93,6 +93,7 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic public readonly plugins: IObservable; public readonly enablementModel: IEnablementModel; + private readonly _baseEnablementModel: EnablementModel; constructor( @IInstantiationService instantiationService: IInstantiationService, @@ -102,7 +103,7 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic ) { super(); - const baseEnablementModel = this._register(new EnablementModel('agentPlugins.enablement', storageService)); + this._baseEnablementModel = this._register(new EnablementModel('agentPlugins.enablement', storageService)); const pluginsEnabled = observableConfigValue(ChatConfiguration.PluginsEnabled, true, configurationService); @@ -154,7 +155,7 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic ); }); - this.enablementModel = new AgentPluginCollisionEnablementModel(baseEnablementModel, collisionGroups, policyEnablement); + this.enablementModel = new AgentPluginCollisionEnablementModel(this._baseEnablementModel, collisionGroups, policyEnablement); for (const { discovery } of discoveries) { discovery.start(this.enablementModel); @@ -184,6 +185,10 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic }); })); } + + setInstalledPluginProfileBaseline(key: string, enabled: boolean): void { + this._baseEnablementModel.setEnabled(key, enabled ? ContributionEnablementState.EnabledProfile : ContributionEnablementState.DisabledProfile); + } } interface IAgentPluginDiscoveryWithPriority { diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/managedAgentPluginActivation.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/managedAgentPluginActivation.test.ts new file mode 100644 index 0000000000000..80d1e91f36ee6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/managedAgentPluginActivation.test.ts @@ -0,0 +1,224 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { ManagedAgentPluginActivation } from '../../../browser/managedAgentPluginActivation.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('ManagedAgentPluginActivation', () => { + 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; readonly managed?: boolean }) { + const plugin = createPlugin(); + const pluginId = `${plugin.name}@${plugin.marketplace}`; + const installedPlugins = observableValue('installedPlugins', options?.installed ? [{ pluginUri: installUri, plugin }] : []); + 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 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; + override setInstalledPluginProfileBaseline(key: string, enabled: boolean): void { + model.setEnabled(key, enabled ? ContributionEnablementState.EnabledProfile : ContributionEnablementState.DisabledProfile); + } + }(); + const entitlementService = new class extends mock() { + override readonly onDidChangeSentiment = Event.None; + override readonly sentiment = { hidden: options?.hidden }; + }(); + const configurationService = new class extends mock() { + override readonly onDidChangeConfiguration = Event.None; + override inspect(): { policyValue?: T } { + return { + policyValue: (options?.managed === false ? undefined : { [pluginId]: true }) as T | undefined, + }; + } + }(); + + store.add(new ManagedAgentPluginActivation( + marketplaceService, + installService, + agentPluginService, + entitlementService, + configurationService, + new NullLogService(), + )); + + return { + baselineSet, + installCalled, + installedPlugins, + states, + get fetchCount() { return fetchCount; }, + get installCount() { return installCount; }, + }; + } + + test('installs a required 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 required 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 required 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 without a required policy entry', async () => { + const harness = createContribution({ managed: false }); + + await timeout(0); + + assert.deepStrictEqual({ + fetchCount: harness.fetchCount, + installCount: harness.installCount, + }, { + fetchCount: 0, + installCount: 0, + }); + }); + + 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/mcp/test/common/pluginMcpDiscovery.test.ts b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts index 8602fefd54cbc..a80bb17654edc 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() { }, + getWorkspaceConfiguredEnablement: () => undefined, + setInstalledPluginProfileBaseline: () => { }, }, registry)); discovery.start(); From f8701d61eb4365ea9cefb64d460963aee746ab53 Mon Sep 17 00:00:00 2001 From: Paul Wang <8560030+pwang347@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:50:02 -0700 Subject: [PATCH 2/3] chat: preserve managed marketplace identity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/managedAgentPluginActivation.ts | 10 +++++-- .../common/plugins/agentPluginServiceImpl.ts | 2 +- .../plugins/pluginMarketplaceService.ts | 26 +++++++++++++------ .../managedAgentPluginActivation.test.ts | 3 --- .../plugins/pluginMarketplaceService.test.ts | 4 ++- .../test/common/pluginMcpDiscovery.test.ts | 1 - 6 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/managedAgentPluginActivation.ts b/src/vs/workbench/contrib/chat/browser/managedAgentPluginActivation.ts index 4fafb6c0ed2cd..722aba3bda8bb 100644 --- a/src/vs/workbench/contrib/chat/browser/managedAgentPluginActivation.ts +++ b/src/vs/workbench/contrib/chat/browser/managedAgentPluginActivation.ts @@ -7,6 +7,8 @@ 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 type { URI } from '../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; @@ -113,7 +115,7 @@ export class ManagedAgentPluginActivation extends Disposable implements IWorkben } const installUri = this._pluginInstallService.getPluginInstallUri(plugin); - if (this._pluginMarketplaceService.isPluginInstalled(installUri)) { + if (this._isPluginInstalled(installUri)) { continue; } @@ -126,7 +128,7 @@ export class ManagedAgentPluginActivation extends Disposable implements IWorkben continue; } - if (!this._pluginMarketplaceService.isPluginInstalled(installUri)) { + if (!this._isPluginInstalled(installUri)) { this._logService.warn(`[ManagedAgentPluginActivation] Required plugin '${pluginId}' was not installed`); continue; } @@ -145,4 +147,8 @@ export class ManagedAgentPluginActivation extends Disposable implements IWorkben } return required; } + + private _isPluginInstalled(pluginUri: URI): boolean { + return this._pluginMarketplaceService.installedPlugins.get().some(entry => isEqual(entry.pluginUri, pluginUri)); + } } diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 03c73d62289be..cc5d693c4e591 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -48,7 +48,7 @@ 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 } 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'; diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index aba7cd34be674..0a9e30e5d447e 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, observableFromEvent, observableSignalFromEvent, 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'; @@ -480,9 +480,17 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke // the in-memory cache (e.g. after restart when installed.json is read // but the metadata map is empty). Modern entries match by plugin name; // older entries without names fall back to matching by install URI. + const marketplacesChanged = observableSignalFromEvent(this, this.onDidChangeMarketplaces); this._register(autorun(reader => { + marketplacesChanged.read(reader); const entries = this._installedPluginsStore.value.read(reader); - const unhydrated = entries.filter(e => !this._pluginMetadata.has(e.pluginUri.toString())); + const configuredReferences = new Map(this._getConfiguredMarketplaceReferences().map(reference => [reference.canonicalId, reference])); + const unhydrated = entries.filter(entry => { + const metadata = this._pluginMetadata.get(entry.pluginUri.toString()); + const reference = parseMarketplaceReference(entry.marketplace); + const configuredReference = reference ? configuredReferences.get(reference.canonicalId) : undefined; + return !metadata || (configuredReference !== undefined && metadata.marketplace !== configuredReference.displayLabel); + }); if (unhydrated.length > 0) { this._hydratePluginMetadata(unhydrated); } @@ -860,18 +868,16 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke for (const entry of entries) { const key = entry.pluginUri.toString(); - if (this._pluginMetadata.has(key)) { - continue; - } const reference = parseMarketplaceReference(entry.marketplace); if (!reference) { this._logService.debug(`[PluginMarketplaceService] Cannot parse marketplace reference '${entry.marketplace}' for ${key}`); continue; } + const configuredReference = this._getConfiguredMarketplaceReferences().find(candidate => candidate.canonicalId === reference.canonicalId) ?? reference; try { - const plugins = await this._readPluginsForInstalledEntry(reference, CancellationToken.None); + const plugins = await this._readPluginsForInstalledEntry(configuredReference, CancellationToken.None); // A marketplace that resolves but no longer lists the entry (a renamed or // removed plugin) must not fall back: marketplace plugin directories // usually hold a manifest too, and reading it would reclassify the entry @@ -881,10 +887,14 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke // `installPluginFromSource`, whose descriptor lives in the recorded // install directory. const match = plugins.length === 0 - ? await this.readSinglePluginManifest(entry.pluginUri, reference) + ? await this.readSinglePluginManifest(entry.pluginUri, configuredReference) : plugins.find(p => entry.name ? p.name === entry.name : isEqual(this._pluginRepositoryService.getPluginInstallUri(p), entry.pluginUri)); if (match) { - this._pluginMetadata.set(key, match); + this._pluginMetadata.set(key, { + ...match, + marketplace: configuredReference.displayLabel, + marketplaceReference: configuredReference, + }); hydrated++; } } catch (err) { diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/managedAgentPluginActivation.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/managedAgentPluginActivation.test.ts index 80d1e91f36ee6..cd989530d0a3d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/managedAgentPluginActivation.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/managedAgentPluginActivation.test.ts @@ -72,9 +72,6 @@ suite('ManagedAgentPluginActivation', () => { const marketplaceService = new class extends mock() { override readonly onDidChangeMarketplaces = Event.None; override readonly installedPlugins = installedPlugins; - override isPluginInstalled(pluginUri: URI): boolean { - return installedPlugins.get().some(entry => entry.pluginUri.toString() === pluginUri.toString()); - } override async fetchMarketplacePlugins(): Promise { fetchCount++; return [plugin]; 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 28b8b7a2fe752..e993f5f608ce6 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 @@ -1310,6 +1310,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { const fileService = new TestFileService(); const awesomeCopilot = parseMarketplaceReference('github/awesome-copilot#marketplace')!; + const managedAwesomeCopilot = { ...awesomeCopilot, displayLabel: 'managed-marketplace' }; const azurePlugin = makeAzurePlugin(awesomeCopilot); storeMarketplaceCache(storageService, awesomeCopilot, azurePlugin); const azurePluginUri = URI.joinPath(CACHE_ROOT, 'github.com', 'microsoft', 'azure-skills', '.github', 'plugins', 'azure-skills'); @@ -1336,7 +1337,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { instantiationService.stub(IRequestService, {} as unknown as IRequestService); instantiationService.stub(IStorageService, storageService); instantiationService.stub(IWorkspacePluginSettingsService, { - extraMarketplaces: observableValue('test.extraMarketplaces', []), + extraMarketplaces: observableValue('test.extraMarketplaces', [{ name: managedAwesomeCopilot.displayLabel, reference: managedAwesomeCopilot }]), enabledPlugins: observableValue('test.enabledPlugins', new Map()), } as Partial as IWorkspacePluginSettingsService); instantiationService.stub(IWorkspaceTrustManagementService, { @@ -1363,6 +1364,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { assert.strictEqual(installed[0].plugin.name, 'azure'); assert.strictEqual(installed[0].plugin.sourceDescriptor.kind, PluginSourceKind.GitHub); assert.strictEqual(installed[0].plugin.marketplaceReference.canonicalId, awesomeCopilot.canonicalId); + assert.strictEqual(installed[0].plugin.marketplace, managedAwesomeCopilot.displayLabel); }); test('hydrates a single-plugin GitHub repo installed from source after restart', 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 a80bb17654edc..50f82720d2342 100644 --- a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts @@ -55,7 +55,6 @@ suite('PluginMcpDiscovery', () => { _serviceBrand: undefined, plugins, enablementModel: new class extends mock() { }, - getWorkspaceConfiguredEnablement: () => undefined, setInstalledPluginProfileBaseline: () => { }, }, registry)); discovery.start(); From edb264ca9cd5ff377114379c81e1002dea8de216 Mon Sep 17 00:00:00 2001 From: Paul Wang <8560030+pwang347@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:24:35 -0700 Subject: [PATCH 3/3] chat: reconcile managed plugins before agent host turns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ion.ts => agentPluginActivationService.ts} | 93 ++++++++++++------- .../agentHost/agentHostActiveClientService.ts | 12 ++- .../agentHost/agentHostSessionHandler.ts | 2 +- .../chat/browser/chat.shared.contribution.ts | 6 +- .../chat/browser/pluginInstallService.ts | 6 +- .../plugins/agentPluginActivationService.ts | 13 +++ .../common/plugins/pluginInstallService.ts | 10 +- .../plugins/pluginMarketplaceService.ts | 19 +++- .../agentHostClientTools.test.ts | 30 +++++- ...s => agentPluginActivationService.test.ts} | 56 +++++++---- .../plugins/pluginInstallService.test.ts | 18 ++++ 11 files changed, 200 insertions(+), 65 deletions(-) rename src/vs/workbench/contrib/chat/browser/{managedAgentPluginActivation.ts => agentPluginActivationService.ts} (62%) create mode 100644 src/vs/workbench/contrib/chat/common/plugins/agentPluginActivationService.ts rename src/vs/workbench/contrib/chat/test/browser/plugins/{managedAgentPluginActivation.test.ts => agentPluginActivationService.test.ts} (76%) diff --git a/src/vs/workbench/contrib/chat/browser/managedAgentPluginActivation.ts b/src/vs/workbench/contrib/chat/browser/agentPluginActivationService.ts similarity index 62% rename from src/vs/workbench/contrib/chat/browser/managedAgentPluginActivation.ts rename to src/vs/workbench/contrib/chat/browser/agentPluginActivationService.ts index 722aba3bda8bb..f596905468767 100644 --- a/src/vs/workbench/contrib/chat/browser/managedAgentPluginActivation.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginActivationService.ts @@ -3,28 +3,31 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceTimeout } from '../../../../base/common/async.js'; 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 type { URI } from '../../../../base/common/uri.js'; +import { waitForState } from '../../../../base/common/observable.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { ChatConfiguration } from '../common/constants.js'; +import { IAgentPluginActivationService } from '../common/plugins/agentPluginActivationService.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 ManagedAgentPluginActivation extends Disposable implements IWorkbenchContribution { - static readonly ID = 'workbench.contrib.managedAgentPluginActivation'; +export class AgentPluginActivationService extends Disposable implements IAgentPluginActivationService { + declare readonly _serviceBrand: undefined; private readonly _cancellation = this._register(new CancellationTokenSource()); private _reconcileRequested = false; - private _reconcileRunning = false; + private _reconcilePromise: Promise | undefined; constructor( @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, @@ -41,8 +44,18 @@ export class ManagedAgentPluginActivation extends Disposable implements IWorkben this._pluginMarketplaceService.onDidChangeMarketplaces, this._chatEntitlementService.onDidChangeSentiment, Event.filter(this._configurationService.onDidChangeConfiguration, event => event.affectsConfiguration(ChatConfiguration.EnabledPlugins)), - )(() => this._requestReconcile())); - this._requestReconcile(); + )(() => void this.reconcile())); + void this.reconcile(); + } + + reconcile(): Promise { + this._reconcileRequested = true; + if (!this._reconcilePromise) { + this._reconcilePromise = this._runReconcile().finally(() => { + this._reconcilePromise = undefined; + }); + } + return this._reconcilePromise; } override dispose(): void { @@ -50,35 +63,22 @@ export class ManagedAgentPluginActivation extends Disposable implements IWorkben super.dispose(); } - private _requestReconcile(): void { - this._reconcileRequested = true; - if (!this._reconcileRunning) { - void this._reconcile(); - } - } - - private async _reconcile(): Promise { - this._reconcileRunning = true; + private async _runReconcile(): Promise { try { while (this._reconcileRequested && !this._store.isDisposed && !this._cancellation.token.isCancellationRequested) { this._reconcileRequested = false; - await this._installRequiredPlugins(); + await this._installManagedPlugins(); } } catch (error) { if (!isCancellationError(error)) { - this._logService.error('[ManagedAgentPluginActivation] Failed to reconcile required plugins', error); - } - } finally { - this._reconcileRunning = false; - if (this._reconcileRequested && !this._store.isDisposed && !this._cancellation.token.isCancellationRequested) { - void this._reconcile(); + this._logService.error('[AgentPluginActivationService] Failed to reconcile managed plugins', error); } } } - private async _installRequiredPlugins(): Promise { - const requiredPluginIds = this._requiredPluginIds(); - if (requiredPluginIds.size === 0 || this._chatEntitlementService.sentiment.hidden) { + private async _installManagedPlugins(): Promise { + const managedPluginIds = this._managedPluginIds(); + if (managedPluginIds.size === 0 || this._chatEntitlementService.sentiment.hidden) { return; } @@ -87,30 +87,35 @@ export class ManagedAgentPluginActivation extends Disposable implements IWorkben undefined, { onMarketplaceError: (marketplace, error) => { - this._logService.warn(`[ManagedAgentPluginActivation] Could not read marketplace '${marketplace.displayLabel}': ${getErrorMessage(error)}`); + this._logService.warn(`[AgentPluginActivationService] Could not read marketplace '${marketplace.displayLabel}': ${getErrorMessage(error)}`); }, }, ); const pluginsById = new Map(); for (const plugin of marketplacePlugins) { const pluginId = `${plugin.name}@${plugin.marketplace}`; + if (!managedPluginIds.has(pluginId) || !this._pluginMarketplaceService.isMarketplaceTrustedByPolicy(plugin.marketplaceReference)) { + continue; + } if (!pluginsById.has(pluginId)) { pluginsById.set(pluginId, plugin); } } const discoveredPluginIds = new Set(this._agentPluginService.plugins.get().map(plugin => getAgentPluginPolicyId(plugin)).filter(pluginId => pluginId !== undefined)); - for (const pluginId of requiredPluginIds) { + const installedPluginIds: string[] = []; + for (const pluginId of managedPluginIds) { if (this._cancellation.token.isCancellationRequested || this._chatEntitlementService.sentiment.hidden) { return; } - if (!this._requiredPluginIds().has(pluginId) || discoveredPluginIds.has(pluginId)) { + const currentManagedIds = this._managedPluginIds(); + if (!currentManagedIds.has(pluginId) || discoveredPluginIds.has(pluginId)) { continue; } const plugin = pluginsById.get(pluginId); if (!plugin) { - this._logService.warn(`[ManagedAgentPluginActivation] Required plugin '${pluginId}' was not found in the configured marketplaces`); + this._logService.warn(`[AgentPluginActivationService] Managed plugin '${pluginId}' was not found in a policy-trusted marketplace`); continue; } @@ -120,24 +125,42 @@ export class ManagedAgentPluginActivation extends Disposable implements IWorkben } 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(`[ManagedAgentPluginActivation] Failed to install required plugin '${pluginId}'`, error); + this._logService.error(`[AgentPluginActivationService] Failed to install managed plugin '${pluginId}'`, error); } continue; } if (!this._isPluginInstalled(installUri)) { - this._logService.warn(`[ManagedAgentPluginActivation] Required plugin '${pluginId}' was not installed`); + this._logService.warn(`[AgentPluginActivationService] Managed plugin '${pluginId}' was not installed`); continue; } this._agentPluginService.setInstalledPluginProfileBaseline(installUri.toString(), false); + installedPluginIds.push(pluginId); + } + + if (installedPluginIds.length > 0) { + const waitCancellation = new CancellationTokenSource(this._cancellation.token); + try { + const discoveryReady = waitForState(this._agentPluginService.plugins, plugins => { + const discovered = new Set(plugins.map(plugin => getAgentPluginPolicyId(plugin))); + return installedPluginIds.every(pluginId => discovered.has(pluginId)); + }, undefined, waitCancellation.token).catch(error => { + if (!isCancellationError(error)) { + throw error; + } + }); + await raceTimeout(discoveryReady, 10_000); + } finally { + waitCancellation.dispose(true); + } } } - private _requiredPluginIds(): ReadonlySet { + private _managedPluginIds(): ReadonlySet { const required = new Set(); const managed = this._configurationService.inspect>(ChatConfiguration.EnabledPlugins).policyValue; for (const [pluginId, enabled] of Object.entries(managed ?? {})) { @@ -152,3 +175,9 @@ export class ManagedAgentPluginActivation extends Disposable implements IWorkben return this._pluginMarketplaceService.installedPlugins.get().some(entry => isEqual(entry.pluginUri, pluginUri)); } } + +export class AgentPluginActivationContribution implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.agentPluginActivation'; + + constructor(@IAgentPluginActivationService _service: IAgentPluginActivationService) { } +} 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 f7a1fcc82f25c..1d2302659d1bb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts @@ -26,6 +26,7 @@ import { observableConfigValue } from '../../../../../../platform/observable/com import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; import type { ICustomizationSyncProvider } from '../../../common/customizationHarnessService.js'; +import { IAgentPluginActivationService } from '../../../common/plugins/agentPluginActivationService.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { ILanguageModelToolsService, IToolData, IToolSet } from '../../../common/tools/languageModelToolsService.js'; @@ -58,6 +59,8 @@ export interface IAgentCustomizationScope extends IDisposable { readonly isResolved: IObservable; /** Resolves once the scope's initial customization resolution has completed. */ whenResolved(): Promise; + /** Reconciles configured plugins and refreshes the customization snapshot. */ + refresh?(): Promise; } export interface IAgentHostActiveClientService { @@ -90,6 +93,7 @@ class AgentCustomizationScope extends Disposable { private _refCount = 0; private _updateSeq = 0; private _isDisposed = false; + private readonly _refreshCustomizations: () => Promise; get customizations(): IObservable { return this._customizations; @@ -119,6 +123,7 @@ class AgentCustomizationScope extends Disposable { @IFileService private readonly _fileService: IFileService, @IPromptsService private readonly _promptsService: IPromptsService, @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, + @IAgentPluginActivationService private readonly _agentPluginActivationService: IAgentPluginActivationService, @IInstantiationService instantiationService: IInstantiationService, @IMcpService private readonly _mcpService: IMcpService, @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService, @@ -172,8 +177,12 @@ class AgentCustomizationScope extends Disposable { } } }; + this._refreshCustomizations = async () => { + await this._agentPluginActivationService.reconcile(); + await updateCustomizations(); + }; const scheduleUpdate = () => { - this._updateDelayer.trigger(() => updateCustomizations()).catch(() => { /* delayer disposed */ }); + this._updateDelayer.trigger(() => this._refreshCustomizations()).catch(() => { /* delayer disposed */ }); }; this._register(this._syncProvider.onDidChange(() => scheduleUpdate())); @@ -213,6 +222,7 @@ class AgentCustomizationScope extends Disposable { tools: this.tools, isResolved: this.isResolved, whenResolved: () => this._initialResolution.p, + refresh: () => this._refreshCustomizations(), activeClient: clientId => this.activeClient(clientId), dispose: () => { if (!released) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 5dbe2d03482f1..8dc69a22ea133 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -1011,7 +1011,7 @@ class ActiveClientEntry extends Disposable { /** Binds the backend session and reconciles this client's latest snapshot before returning. */ async claim(backendSession: URI, cancellationToken: CancellationToken): Promise { - await raceCancellation(this._scope.whenResolved(), cancellationToken); + await raceCancellation(this._scope.refresh?.() ?? this._scope.whenResolved(), cancellationToken); if (cancellationToken.isCancellationRequested) { return; } 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 9dd468d531fd3..341bfc4006ba0 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -79,6 +79,7 @@ import { ChatAgentNameService, ChatAgentService, IChatAgentNameService, IChatAge import { ChatSlashCommandService, IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; import { ISessionChatPillVisibilityService, SessionChatPillVisibility } from '../common/sessionChatPills.js'; import { AgentPluginDiscoveryPriority, IAgentPluginService, agentPluginDiscoveryRegistry } from '../common/plugins/agentPluginService.js'; +import { IAgentPluginActivationService } from '../common/plugins/agentPluginActivationService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig, isTildePath } from '../common/promptSyntax/config/config.js'; import { AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, CLAUDE_AGENTS_SOURCE_FOLDER, COPILOT_USER_AGENTS_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS, DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS, DEFAULT_SKILL_SOURCE_FOLDERS, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, SKILL_FILENAME } from '../common/promptSyntax/config/promptFileLocations.js'; @@ -236,7 +237,7 @@ import { PlanAgentDefaultModel } from './planAgentDefaultModel.js'; import './planReviewFeedback/planReviewFeedbackEditorOverlay.js'; import { IPlanReviewFeedbackService, PlanReviewFeedbackService } from './planReviewFeedback/planReviewFeedbackService.js'; import { PluginAutoUpdate } from './pluginAutoUpdate.js'; -import { ManagedAgentPluginActivation } from './managedAgentPluginActivation.js'; +import { AgentPluginActivationContribution, AgentPluginActivationService } from './agentPluginActivationService.js'; import { BrowserPluginGitCommandService } from './pluginGitCommandService.js'; import { PluginInstallService } from './pluginInstallService.js'; import { PluginUrlHandler } from './pluginUrlHandler.js'; @@ -3369,7 +3370,7 @@ registerWorkbenchContribution2(ChatRepoInfoContribution.ID, ChatRepoInfoContribu registerWorkbenchContribution2(AgentPluginRecommendations.ID, AgentPluginRecommendations, WorkbenchPhase.Eventually); registerWorkbenchContribution2(AgentPluginCommandsContribution.ID, AgentPluginCommandsContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(PluginAutoUpdate.ID, PluginAutoUpdate, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(ManagedAgentPluginActivation.ID, ManagedAgentPluginActivation, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(AgentPluginActivationContribution.ID, AgentPluginActivationContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatReferenceAttachmentWidgetContribution.ID, ChatReferenceAttachmentWidgetContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(TranscriptContextAttachmentWidgetContribution.ID, TranscriptContextAttachmentWidgetContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatPetContextContribution.ID, ChatPetContextContribution, WorkbenchPhase.BlockRestore); @@ -3439,6 +3440,7 @@ registerSingleton(IChatAgentService, ChatAgentService, InstantiationType.Delayed registerSingleton(IChatAgentNameService, ChatAgentNameService, InstantiationType.Delayed); registerSingleton(IChatVariablesService, ChatVariablesService, InstantiationType.Delayed); registerSingleton(IAgentPluginService, AgentPluginService, InstantiationType.Delayed); +registerSingleton(IAgentPluginActivationService, AgentPluginActivationService, InstantiationType.Delayed); registerSingleton(IPluginMarketplaceService, PluginMarketplaceService, InstantiationType.Delayed); registerSingleton(IWorkspacePluginSettingsService, WorkspacePluginSettingsService, InstantiationType.Delayed); registerSingleton(IAgentPluginRepositoryService, AgentPluginRepositoryService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index b58291c6d97c0..1af43ce486078 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/common/plugins/agentPluginActivationService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginActivationService.ts new file mode 100644 index 0000000000000..3311fb9f44fb7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginActivationService.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; + +export const IAgentPluginActivationService = createDecorator('agentPluginActivationService'); + +export interface IAgentPluginActivationService { + readonly _serviceBrand: undefined; + reconcile(): Promise; +} diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts index 8612dc6bf186a..4ecc6ee1f247a 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts @@ -57,6 +57,14 @@ export interface IInstallPluginFromSourceResult { readonly matchedPlugin?: IMarketplacePlugin; } +export interface IInstallMarketplacePluginOptions { + /** + * Skips the interactive marketplace trust prompt after the caller has + * established trust through workspace trust or enterprise policy. + */ + readonly skipTrust?: boolean; +} + export interface IPluginInstallService { readonly _serviceBrand: undefined; @@ -64,7 +72,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 0a9e30e5d447e..6afe4f3d90b1e 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -211,6 +211,8 @@ export interface IPluginMarketplaceService { 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. */ isMarketplaceTrusted(ref: IMarketplaceReference): boolean; + /** Returns whether enterprise policy establishes trust in the marketplace source. */ + isMarketplaceTrustedByPolicy(ref: IMarketplaceReference): boolean; /** * Returns whether the strict-marketplace enterprise policy * (`chat.plugins.strictMarketplaces`) is active — i.e. an allowlist is @@ -822,18 +824,25 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } isMarketplaceTrusted(ref: IMarketplaceReference): boolean { - // In strict mode (`chat.plugins.strictMarketplaces`, typically delivered via the - // `ChatStrictMarketplaces` enterprise policy), trust is governed entirely by the - // allowlist: a marketplace is trusted only if it matches one of the configured - // source entries. The user-trusted store is bypassed — that's the whole point of - // "strict": the enterprise fully controls the allowed marketplaces. const allowlist = getStrictKnownMarketplaces(this._configurationService.getValue(ChatConfiguration.StrictMarketplaces)); if (allowlist !== undefined) { return isMarketplaceReferenceAllowed(allowlist, ref); } + if (this.isMarketplaceTrustedByPolicy(ref)) { + return true; + } return this._trustedMarketplacesStore.get().includes(ref.canonicalId); } + isMarketplaceTrustedByPolicy(ref: IMarketplaceReference): boolean { + const allowlist = getStrictKnownMarketplaces(this._configurationService.getValue(ChatConfiguration.StrictMarketplaces)); + if (allowlist !== undefined && isMarketplaceReferenceAllowed(allowlist, ref)) { + return true; + } + const { extraValues } = readConfiguredMarketplaces(this._configurationService); + return parseMarketplaceReferences(extraValues).some(managed => managed.canonicalId === ref.canonicalId); + } + isStrictMarketplacePolicyActive(): boolean { return getStrictKnownMarketplaces(this._configurationService.getValue(ChatConfiguration.StrictMarketplaces)) !== undefined; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 6c65df17cf5e5..f74d0521cd9c0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -64,11 +64,12 @@ import { IAgentHostSessionWorkingDirectorySynchronizer } from '../../../browser/ import { IAgentHostShellInitSynchronizer } from '../../../browser/agentSessions/agentHost/agentHostShellInitSynchronizer.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, IToolSet, ToolAndToolSetEnablementMap, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; -import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { IChatSessionsService, SessionType } from '../../../common/chatSessionsService.js'; import { IChatWidgetService } from '../../../browser/chat.js'; import { IChatInputNotification, IChatInputNotificationService } from '../../../browser/widget/input/chatInputNotificationService.js'; import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; +import { IAgentPluginActivationService } from '../../../common/plugins/agentPluginActivationService.js'; import { IOutputService } from '../../../../../services/output/common/output.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; @@ -98,6 +99,7 @@ suite('AgentHostClientTools', () => { tools: IObservable = constObservable([]), toolSets: IObservable> = constObservable([]), mcpOptions?: { remoteAuthority?: string; servers: readonly IMcpServer[] }, + reconcile: () => Promise = async () => { }, ) { const instantiationService = disposables.add(new TestInstantiationService()); let semanticSearchEnabled = false; @@ -133,6 +135,9 @@ suite('AgentHostClientTools', () => { instantiationService.stub(IAgentPluginService, { plugins: observableValue('plugins', []), }); + instantiationService.stub(IAgentPluginActivationService, { + reconcile, + }); instantiationService.stub(IMcpService, { servers: observableValue('mcpServers', mcpOptions?.servers ?? []), lazyCollectionState: observableValue('mcpLazyCollectionState', { state: LazyCollectionState.AllKnown, collections: [] }), @@ -170,6 +175,26 @@ suite('AgentHostClientTools', () => { }; } + test('waits for plugin reconciliation before resolving a customization scope', async () => { + const reconciliation = new DeferredPromise(); + let reconciliationCount = 0; + const { service } = createActiveClientService(undefined, undefined, undefined, () => { + reconciliationCount++; + return reconciliation.p; + }); + const scope = disposables.add(service.acquireScope(SessionType.CopilotCLI, [])); + let resolved = false; + void scope.whenResolved().then(() => resolved = true); + + await timeout(0); + assert.strictEqual(resolved, false); + + reconciliation.complete(); + await scope.whenResolved(); + await scope.refresh?.(); + assert.deepStrictEqual({ resolved, reconciliationCount }, { resolved: true, reconciliationCount: 2 }); + }); + test('lazily creates scopes and shares them for equivalent root sets', async () => { const { service } = createActiveClientService(); const rootA = URI.file('/Workspace-A'); @@ -904,6 +929,9 @@ suite('AgentHostClientTools', () => { instantiationService.stub(IAgentPluginService, { plugins: observableValue('plugins', []), }); + instantiationService.stub(IAgentPluginActivationService, { + reconcile: async () => { }, + }); // Acquiring a customization scope is now infallible, so the handler // constructs a real one — which reads these on its first autorun. instantiationService.stub(IMcpService, { diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/managedAgentPluginActivation.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginActivationService.test.ts similarity index 76% rename from src/vs/workbench/contrib/chat/test/browser/plugins/managedAgentPluginActivation.test.ts rename to src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginActivationService.test.ts index cd989530d0a3d..5f40edca7cafa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/managedAgentPluginActivation.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginActivationService.test.ts @@ -14,13 +14,13 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; -import { ManagedAgentPluginActivation } from '../../../browser/managedAgentPluginActivation.js'; +import { AgentPluginActivationService } from '../../../browser/agentPluginActivationService.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('ManagedAgentPluginActivation', () => { +suite('AgentPluginActivationService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); const installUri = URI.file('/agent-plugins/owner/marketplace/example-plugin'); @@ -55,14 +55,20 @@ suite('ManagedAgentPluginActivation', () => { }; } - function createContribution(options?: { readonly installed?: boolean; readonly hidden?: boolean; readonly installSucceeds?: boolean; readonly managed?: boolean }) { + function createService(options?: { readonly installed?: boolean; readonly hidden?: boolean; readonly installSucceeds?: boolean; readonly managed?: boolean; readonly policyTrusted?: boolean }) { const plugin = createPlugin(); const pluginId = `${plugin.name}@${plugin.marketplace}`; const installedPlugins = observableValue('installedPlugins', options?.installed ? [{ pluginUri: installUri, plugin }] : []); + const discoveredPlugins = observableValue('plugins', []); + const discoveredPlugin = new class extends mock() { + override readonly uri = installUri; + override readonly fromMarketplace = plugin; + }(); const installCalled = new DeferredPromise(); 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(); @@ -72,16 +78,21 @@ suite('ManagedAgentPluginActivation', () => { const marketplaceService = new class extends mock() { override readonly onDidChangeMarketplaces = Event.None; override readonly installedPlugins = installedPlugins; + override isMarketplaceTrustedByPolicy(): boolean { + return options?.policyTrusted !== false; + } override async fetchMarketplacePlugins(): Promise { fetchCount++; return [plugin]; } }(); 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); + discoveredPlugins.set([discoveredPlugin], undefined); } installCalled.complete(); } @@ -90,7 +101,7 @@ suite('ManagedAgentPluginActivation', () => { } }(); const agentPluginService = new class extends mock() { - override readonly plugins = observableValue('plugins', []); + override readonly plugins = discoveredPlugins; override readonly enablementModel = model; override setInstalledPluginProfileBaseline(key: string, enabled: boolean): void { model.setEnabled(key, enabled ? ContributionEnablementState.EnabledProfile : ContributionEnablementState.DisabledProfile); @@ -104,12 +115,12 @@ suite('ManagedAgentPluginActivation', () => { override readonly onDidChangeConfiguration = Event.None; override inspect(): { policyValue?: T } { return { - policyValue: (options?.managed === false ? undefined : { [pluginId]: true }) as T | undefined, + policyValue: (options?.managed ? { [pluginId]: true } : undefined) as T | undefined, }; } }(); - store.add(new ManagedAgentPluginActivation( + store.add(new AgentPluginActivationService( marketplaceService, installService, agentPluginService, @@ -123,29 +134,36 @@ suite('ManagedAgentPluginActivation', () => { installCalled, installedPlugins, states, + get installOptions() { return installOptions; }, get fetchCount() { return fetchCount; }, get installCount() { return installCount; }, }; } - test('installs a required plugin with a disabled profile baseline', async () => { - const harness = createContribution(); + test('installs a managed plugin from a policy-trusted marketplace', async () => { + const harness = createService({ managed: true }); await harness.baselineSet.p; assert.deepStrictEqual({ - fetchCount: harness.fetchCount, installCount: harness.installCount, - profileState: harness.states.get(installUri.toString()), + skipTrust: harness.installOptions?.skipTrust, }, { - fetchCount: 1, installCount: 1, - profileState: ContributionEnablementState.DisabledProfile, + skipTrust: true, }); }); + test('does not silently trust a workspace marketplace for a managed-only plugin', async () => { + const harness = createService({ managed: true, policyTrusted: false }); + + await timeout(0); + + assert.strictEqual(harness.installCount, 0); + }); + test('preserves enablement when the required plugin is already installed', async () => { - const harness = createContribution({ installed: true }); + const harness = createService({ installed: true, managed: true }); await timeout(0); @@ -161,7 +179,7 @@ suite('ManagedAgentPluginActivation', () => { }); test('reinstalls a required plugin after it is removed', async () => { - const harness = createContribution({ installed: true }); + const harness = createService({ installed: true, managed: true }); await timeout(0); harness.installedPlugins.set([], undefined); @@ -177,7 +195,7 @@ suite('ManagedAgentPluginActivation', () => { }); test('does not change profile enablement when installation fails', async () => { - const harness = createContribution({ installSucceeds: false }); + const harness = createService({ installSucceeds: false, managed: true }); await harness.installCalled.p; await timeout(0); @@ -192,7 +210,7 @@ suite('ManagedAgentPluginActivation', () => { }); test('does not fetch or install without a required policy entry', async () => { - const harness = createContribution({ managed: false }); + const harness = createService(); await timeout(0); @@ -206,7 +224,7 @@ suite('ManagedAgentPluginActivation', () => { }); test('does not fetch or install plugins when AI features are hidden', async () => { - const harness = createContribution({ hidden: true }); + const harness = createService({ hidden: true, managed: true }); await timeout(0); 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 94097bd451930..bd579aac8d4b7 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 enterprise policy established trust', async () => { + const { service, state } = createService({ marketplaceTrusted: false, dialogConfirmResult: false }); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + + await service.installPlugin(plugin, 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({