Path: blob/main/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Action } from '../../../../base/common/actions.js';6import { SequencerByKey } from '../../../../base/common/async.js';7import { CancellationTokenSource } from '../../../../base/common/cancellation.js';8import { Lazy } from '../../../../base/common/lazy.js';9import { revive } from '../../../../base/common/marshalling.js';10import { dirname, isEqual, isEqualOrParent, joinPath } from '../../../../base/common/resources.js';11import { URI } from '../../../../base/common/uri.js';12import { localize } from '../../../../nls.js';13import { ICommandService } from '../../../../platform/commands/common/commands.js';14import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';15import { IFileService } from '../../../../platform/files/common/files.js';16import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';17import { ILogService } from '../../../../platform/log/common/log.js';18import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';19import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js';20import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';21import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js';22import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js';23import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js';24import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js';25import { IPluginSource } from '../common/plugins/pluginSource.js';26import { IPluginGitService } from '../common/plugins/pluginGitService.js';27import { GitHubPluginSource, GitUrlPluginSource, NpmPluginSource, PipPluginSource, RelativePathPluginSource } from './pluginSources.js';2829const MARKETPLACE_INDEX_STORAGE_KEY = 'chat.plugins.marketplaces.index.v1';3031interface IMarketplaceIndexEntry {32repositoryUri: URI;33marketplaceType?: MarketplaceType;34}3536type IStoredMarketplaceIndex = Dto<Record<string, IMarketplaceIndexEntry>>;3738export class AgentPluginRepositoryService implements IAgentPluginRepositoryService {39declare readonly _serviceBrand: undefined;4041readonly agentPluginsHome: URI;42private readonly _cacheRoot: URI;43private readonly _marketplaceIndex = new Lazy<Map<string, IMarketplaceIndexEntry>>(() => this._loadMarketplaceIndex());44private readonly _pluginSources: ReadonlyMap<PluginSourceKind, IPluginSource>;45private readonly _cloneSequencer = new SequencerByKey<string>();46private readonly _migrationDone: Promise<void>;4748constructor(49@ICommandService private readonly _commandService: ICommandService,50@IEnvironmentService environmentService: IEnvironmentService,51@IFileService private readonly _fileService: IFileService,52@IInstantiationService instantiationService: IInstantiationService,53@ILogService private readonly _logService: ILogService,54@INotificationService private readonly _notificationService: INotificationService,55@IPluginGitService private readonly _pluginGit: IPluginGitService,56@IProgressService private readonly _progressService: IProgressService,57@IStorageService private readonly _storageService: IStorageService,58@IUserDataProfileService userDataProfileService: IUserDataProfileService,59) {60// On native, use the well-known ~/{dataFolderName}/agent-plugins/ path61// so that external tools can discover it. On web, fall back to the62// internal cache location.63this.agentPluginsHome = userDataProfileService.currentProfile.agentPluginsHome;64const legacyCacheRoot = joinPath(environmentService.cacheHome, 'agentPlugins');65const oldCacheRoot = environmentService.cacheHome.scheme === 'file'66? legacyCacheRoot67: this.agentPluginsHome;68this._cacheRoot = this.agentPluginsHome;6970// Migrate plugin files from the old internal cache directory to the71// new well-known location. This is a one-time operation.72if (!isEqual(oldCacheRoot, this.agentPluginsHome)) {73this._migrationDone = this._migrateDirectory(oldCacheRoot);74} else {75this._migrationDone = Promise.resolve();76}7778// Build per-kind source repository map via instantiation service so79// each repository can inject its own dependencies.80this._pluginSources = new Map<PluginSourceKind, IPluginSource>([81[PluginSourceKind.RelativePath, new RelativePathPluginSource()],82[PluginSourceKind.GitHub, instantiationService.createInstance(GitHubPluginSource)],83[PluginSourceKind.GitUrl, instantiationService.createInstance(GitUrlPluginSource)],84[PluginSourceKind.Npm, instantiationService.createInstance(NpmPluginSource)],85[PluginSourceKind.Pip, instantiationService.createInstance(PipPluginSource)],86]);87}8889getPluginSource(kind: PluginSourceKind): IPluginSource {90const repo = this._pluginSources.get(kind);91if (!repo) {92throw new Error(`No source repository registered for kind '${kind}'`);93}94return repo;95}9697getRepositoryUri(marketplace: IMarketplaceReference, marketplaceType?: MarketplaceType): URI {98if (marketplace.kind === MarketplaceReferenceKind.LocalFileUri && marketplace.localRepositoryUri) {99return marketplace.localRepositoryUri;100}101102const indexed = this._marketplaceIndex.value.get(marketplace.canonicalId);103if (indexed?.repositoryUri) {104return indexed.repositoryUri;105}106107return this._getRepoCacheDirForReference(marketplace);108}109110getPluginInstallUri(plugin: IMarketplacePlugin): URI {111const repoDir = this.getRepositoryUri(plugin.marketplaceReference, plugin.marketplaceType);112return this._getPluginDir(repoDir, plugin.source);113}114115async ensureRepository(marketplace: IMarketplaceReference, options?: IEnsureRepositoryOptions): Promise<URI> {116await this._migrationDone;117const repoDir = this.getRepositoryUri(marketplace, options?.marketplaceType);118return this._cloneSequencer.queue(repoDir.fsPath, async () => {119const repoExists = await this._fileService.exists(repoDir);120if (repoExists) {121this._updateMarketplaceIndex(marketplace, repoDir, options?.marketplaceType);122return repoDir;123}124125if (marketplace.kind === MarketplaceReferenceKind.LocalFileUri) {126throw new Error(`Local marketplace repository does not exist: ${repoDir.fsPath}`);127}128129const progressTitle = options?.progressTitle ?? localize('preparingMarketplace', "Preparing plugin marketplace '{0}'...", marketplace.displayLabel);130const failureLabel = options?.failureLabel ?? marketplace.displayLabel;131await this._cloneRepository(repoDir, marketplace.cloneUrl, progressTitle, failureLabel);132this._updateMarketplaceIndex(marketplace, repoDir, options?.marketplaceType);133return repoDir;134});135}136137async pullRepository(marketplace: IMarketplaceReference, options?: IPullRepositoryOptions): Promise<boolean> {138const repoDir = this.getRepositoryUri(marketplace, options?.marketplaceType);139const repoExists = await this._fileService.exists(repoDir);140if (!repoExists) {141this._logService.warn(`[AgentPluginRepositoryService] Cannot update plugin '${options?.pluginName ?? marketplace.displayLabel}': repository not cloned`);142return false;143}144145const updateLabel = options?.pluginName ?? marketplace.displayLabel;146147try {148if (options?.silent) {149return await this._pluginGit.pull(repoDir);150}151152const cts = new CancellationTokenSource();153try {154return await this._progressService.withProgress(155{156location: ProgressLocation.Notification,157title: localize('updatingPlugin', "Updating plugin '{0}'...", updateLabel),158cancellable: true,159},160() => this._pluginGit.pull(repoDir, cts.token),161() => cts.dispose(true),162);163} finally {164cts.dispose();165}166} catch (err) {167this._logService.error(`[AgentPluginRepositoryService] Failed to update ${marketplace.displayLabel}:`, err);168if (!options?.silent) {169this._notificationService.notify({170severity: Severity.Error,171message: localize('pullFailed', "Failed to update plugin '{0}': {1}", options?.failureLabel ?? updateLabel, err?.message ?? String(err)),172actions: {173primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => {174this._commandService.executeCommand('git.showOutput');175})],176},177});178}179throw err;180}181}182183private _getRepoCacheDirForReference(reference: IMarketplaceReference): URI {184return joinPath(this._cacheRoot, ...reference.cacheSegments);185}186187private _loadMarketplaceIndex(): Map<string, IMarketplaceIndexEntry> {188const result = new Map<string, IMarketplaceIndexEntry>();189const stored = this._storageService.getObject<IStoredMarketplaceIndex>(MARKETPLACE_INDEX_STORAGE_KEY, StorageScope.APPLICATION);190if (!stored) {191return result;192}193194const revived = revive<IStoredMarketplaceIndex>(stored);195for (const [canonicalId, entry] of Object.entries(revived)) {196if (!entry || !entry.repositoryUri) {197continue;198}199200result.set(canonicalId, {201repositoryUri: entry.repositoryUri,202marketplaceType: entry.marketplaceType,203});204}205206return result;207}208209private _updateMarketplaceIndex(marketplace: IMarketplaceReference, repositoryUri: URI, marketplaceType?: MarketplaceType): void {210if (marketplace.kind === MarketplaceReferenceKind.LocalFileUri) {211return;212}213214const previous = this._marketplaceIndex.value.get(marketplace.canonicalId);215if (previous && previous.repositoryUri.toString() === repositoryUri.toString() && previous.marketplaceType === marketplaceType) {216return;217}218219this._marketplaceIndex.value.set(marketplace.canonicalId, { repositoryUri, marketplaceType });220this._saveMarketplaceIndex();221}222223private _saveMarketplaceIndex(): void {224const serialized: IStoredMarketplaceIndex = {};225for (const [canonicalId, entry] of this._marketplaceIndex.value) {226serialized[canonicalId] = JSON.parse(JSON.stringify({227repositoryUri: entry.repositoryUri,228marketplaceType: entry.marketplaceType,229}));230}231232if (Object.keys(serialized).length === 0) {233this._storageService.remove(MARKETPLACE_INDEX_STORAGE_KEY, StorageScope.APPLICATION);234return;235}236237this._storageService.store(MARKETPLACE_INDEX_STORAGE_KEY, JSON.stringify(serialized), StorageScope.APPLICATION, StorageTarget.MACHINE);238}239240private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string, ref?: string): Promise<void> {241const cts = new CancellationTokenSource();242try {243await this._progressService.withProgress(244{245location: ProgressLocation.Notification,246title: progressTitle,247cancellable: true,248},249async () => {250await this._fileService.createFolder(dirname(repoDir));251await this._pluginGit.cloneRepository(cloneUrl, repoDir, ref, cts.token);252},253() => cts.dispose(true),254);255} catch (err) {256this._logService.error(`[AgentPluginRepositoryService] Failed to clone ${cloneUrl}:`, err);257this._notificationService.notify({258severity: Severity.Error,259message: localize('cloneFailed', "Failed to install plugin '{0}': {1}", failureLabel, err?.message ?? String(err)),260actions: {261primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => {262this._commandService.executeCommand('git.showOutput');263})],264},265});266throw err;267} finally {268cts.dispose();269}270}271272private _getPluginDir(repoDir: URI, source: string): URI {273const normalizedSource = source.trim().replace(/^\.?\/+|\/+$/g, '');274const pluginDir = normalizedSource ? joinPath(repoDir, normalizedSource) : repoDir;275if (!isEqualOrParent(pluginDir, repoDir)) {276throw new Error(`Invalid plugin source path '${source}'`);277}278return pluginDir;279}280281getPluginSourceInstallUri(sourceDescriptor: IPluginSourceDescriptor): URI {282return this.getPluginSource(sourceDescriptor.kind).getInstallUri(this._cacheRoot, sourceDescriptor);283}284285async ensurePluginSource(plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise<URI> {286await this._migrationDone;287const repo = this.getPluginSource(plugin.sourceDescriptor.kind);288if (plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) {289return this.ensureRepository(plugin.marketplaceReference, options);290}291return repo.ensure(this._cacheRoot, plugin, options);292}293294async updatePluginSource(plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise<boolean> {295const repo = this.getPluginSource(plugin.sourceDescriptor.kind);296if (plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) {297return this.pullRepository(plugin.marketplaceReference, options);298}299return repo.update(this._cacheRoot, plugin, options);300}301302async fetchRepository(marketplace: IMarketplaceReference): Promise<boolean> {303const repoDir = this.getRepositoryUri(marketplace);304const repoExists = await this._fileService.exists(repoDir);305if (!repoExists) {306return false;307}308309try {310await this._pluginGit.fetchRepository(repoDir);311const behindCount = await this._pluginGit.revListCount(repoDir, 'HEAD', '@{u}');312return behindCount > 0;313} catch (err) {314this._logService.debug(`[AgentPluginRepositoryService] Silent fetch failed for ${marketplace.displayLabel}:`, err);315return false;316}317}318319async cleanupPluginSource(plugin: IMarketplacePlugin, otherInstalledDescriptors?: readonly IPluginSourceDescriptor[]): Promise<void> {320const repo = this.getPluginSource(plugin.sourceDescriptor.kind);321const cleanupDir = repo.getCleanupTarget(this._cacheRoot, plugin.sourceDescriptor);322if (!cleanupDir) {323return;324}325326// Skip deletion when another installed plugin shares the same327// cleanup target (e.g. same cloned repository with different sub-paths).328if (otherInstalledDescriptors) {329const shared = otherInstalledDescriptors.some(other => {330const otherRepo = this.getPluginSource(other.kind);331const otherTarget = otherRepo.getCleanupTarget(this._cacheRoot, other);332return otherTarget && isEqual(otherTarget, cleanupDir);333});334if (shared) {335this._logService.info(`[${plugin.sourceDescriptor.kind}] Skipping cleanup of shared cache: ${cleanupDir.toString()}`);336return;337}338}339340try {341const exists = await this._fileService.exists(cleanupDir);342if (exists) {343await this._fileService.del(cleanupDir, { recursive: true });344this._logService.info(`[${plugin.sourceDescriptor.kind}] Removed plugin cache: ${cleanupDir.toString()}`);345}346} catch (err) {347this._logService.warn(`[${plugin.sourceDescriptor.kind}] Failed to remove plugin cache '${cleanupDir.toString()}':`, err);348}349350try {351// Prune empty parent directories up to (but not including) the cache root352// so we don't leave dangling owner/authority folders behind.353await this._pruneEmptyParents(cleanupDir);354} catch (err) {355this._logService.warn(`[${plugin.sourceDescriptor.kind}] Failed to cleanup plugin source:`, err);356}357}358359/**360* Walk from {@link child}'s parent toward {@link _cacheRoot}, removing361* each directory that is empty. Stops as soon as a non-empty directory362* is found or the cache root is reached. Only operates on descendants363* of the cache root — returns immediately for paths outside it.364*/365private async _pruneEmptyParents(child: URI): Promise<void> {366if (!isEqualOrParent(child, this._cacheRoot)) {367return;368}369let current = dirname(child);370while (isEqualOrParent(current, this._cacheRoot) && !isEqual(current, this._cacheRoot)) {371try {372const stat = await this._fileService.resolve(current);373if (stat.children && stat.children.length > 0) {374break;375}376await this._fileService.del(current);377} catch {378break;379}380current = dirname(current);381}382}383384/**385* One-time migration of plugin files from the old internal cache386* directory (`{cacheHome}/agentPlugins/`) to the new well-known387* location (`~/{dataFolderName}/agent-plugins/`).388*/389private async _migrateDirectory(oldCacheRoot: URI): Promise<void> {390try {391const oldExists = await this._fileService.exists(oldCacheRoot);392if (!oldExists) {393return;394}395396const newExists = await this._fileService.exists(this.agentPluginsHome);397if (newExists) {398this._logService.info('[AgentPluginRepositoryService] Both old and new agent-plugins directories exist; skipping directory migration');399return;400}401402this._logService.info(`[AgentPluginRepositoryService] Migrating agent plugins from ${oldCacheRoot.toString()} to ${this.agentPluginsHome.toString()}`);403await this._fileService.move(oldCacheRoot, this.agentPluginsHome, false);404405// Clear the marketplace index — it caches repository URIs that406// pointed to the old location and would cause path mismatches.407this._storageService.remove(MARKETPLACE_INDEX_STORAGE_KEY, StorageScope.APPLICATION);408this._marketplaceIndex.value.clear();409} catch (error) {410this._logService.error('[AgentPluginRepositoryService] Directory migration failed', error);411}412}413414}415416417