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