agentPlugins: normalize to user data dir storage (#304977)

* agentPlugins: normalize to user data dir storage

Previously we stored plugins in a very internal way that was inaccessible
by other tooling. This sets up a `agent-plugins` folder beside `extensions`
and creates an `installed.json` which is easy to integrate with.

* comments

* fix compile

---------

Co-authored-by: Raymond Zhao <7199958+rzhao271@users.noreply.github.com>
This commit is contained in:
Connor Peet
2026-03-26 22:46:59 -07:00
committed by GitHub
parent 228f1b6987
commit 1d62cc626a
14 changed files with 876 additions and 138 deletions

View File

@@ -75,6 +75,7 @@ export interface NativeParsedArgs {
'extensions-dir'?: string;
'extensions-download-dir'?: string;
'builtin-extensions-dir'?: string;
'agent-plugins-dir'?: string;
extensionDevelopmentPath?: string[]; // undefined or array of 1 or more local paths or URIs
extensionTestsPath?: string; // either a local path or a URI
extensionDevelopmentKind?: string[];

View File

@@ -147,6 +147,26 @@ export abstract class AbstractNativeEnvironmentService implements INativeEnviron
return joinPath(this.userHome, this.productService.dataFolderName, 'extensions').fsPath;
}
@memoize
get agentPluginsPath(): string {
const cliAgentPluginsDir = this.args['agent-plugins-dir'];
if (cliAgentPluginsDir) {
return resolve(cliAgentPluginsDir);
}
const vscodeAgentPlugins = env['VSCODE_AGENT_PLUGINS'];
if (vscodeAgentPlugins) {
return vscodeAgentPlugins;
}
const vscodePortable = env['VSCODE_PORTABLE'];
if (vscodePortable) {
return join(vscodePortable, 'agent-plugins');
}
return joinPath(this.userHome, this.productService.dataFolderName, 'agent-plugins').fsPath;
}
@memoize
get extensionDevelopmentLocationURI(): URI[] | undefined {
const extensionDevelopmentPaths = this.args.extensionDevelopmentPath;

View File

@@ -120,6 +120,7 @@ export const OPTIONS: OptionDescriptions<Required<NativeParsedArgs>> = {
'extensions-download-dir': { type: 'string' },
'builtin-extensions-dir': { type: 'string' },
'list-extensions': { type: 'boolean', cat: 'e', description: localize('listExtensions', "List the installed extensions.") },
'agent-plugins-dir': { type: 'string' },
'show-versions': { type: 'boolean', cat: 'e', description: localize('showVersions', "Show versions of installed extensions, when using --list-extensions.") },
'category': { type: 'string', allowEmptyValue: true, cat: 'e', description: localize('category', "Filters installed extensions by provided category, when using --list-extensions."), args: 'category' },
'install-extension': { type: 'string[]', cat: 'e', args: 'ext-id | path', description: localize('installExtension', "Installs or updates an extension. The argument is either an extension id or a path to a VSIX. The identifier of an extension is '${publisher}.${name}'. Use '--force' argument to update to latest version. To install a specific version provide '@${version}'. For example: 'vscode.csharp@1.2.3'.") },

View File

@@ -11,7 +11,7 @@ import { dirname, isEqual, isEqualOrParent, joinPath } from '../../../../base/co
import { URI } from '../../../../base/common/uri.js';
import { localize } from '../../../../nls.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';
import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../../platform/log/common/log.js';
@@ -36,14 +36,16 @@ type IStoredMarketplaceIndex = Dto<Record<string, IMarketplaceIndexEntry>>;
export class AgentPluginRepositoryService implements IAgentPluginRepositoryService {
declare readonly _serviceBrand: undefined;
readonly agentPluginsHome: URI;
private readonly _cacheRoot: URI;
private readonly _marketplaceIndex = new Lazy<Map<string, IMarketplaceIndexEntry>>(() => this._loadMarketplaceIndex());
private readonly _pluginSources: ReadonlyMap<PluginSourceKind, IPluginSource>;
private readonly _cloneSequencer = new SequencerByKey<string>();
private readonly _migrationDone: Promise<void>;
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IEnvironmentService environmentService: IEnvironmentService,
@IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService,
@IFileService private readonly _fileService: IFileService,
@IInstantiationService instantiationService: IInstantiationService,
@ILogService private readonly _logService: ILogService,
@@ -51,7 +53,23 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
@IProgressService private readonly _progressService: IProgressService,
@IStorageService private readonly _storageService: IStorageService,
) {
this._cacheRoot = joinPath(environmentService.cacheHome, 'agentPlugins');
// On native, use the well-known ~/{dataFolderName}/agent-plugins/ path
// so that external tools can discover it. On web, fall back to the
// internal cache location.
this.agentPluginsHome = environmentService.agentPluginsHome;
const legacyCacheRoot = joinPath(environmentService.cacheHome, 'agentPlugins');
const oldCacheRoot = environmentService.cacheHome.scheme === 'file'
? legacyCacheRoot
: this.agentPluginsHome;
this._cacheRoot = this.agentPluginsHome;
// Migrate plugin files from the old internal cache directory to the
// new well-known location. This is a one-time operation.
if (!isEqual(oldCacheRoot, this.agentPluginsHome)) {
this._migrationDone = this._migrateDirectory(oldCacheRoot);
} else {
this._migrationDone = Promise.resolve();
}
// Build per-kind source repository map via instantiation service so
// each repository can inject its own dependencies.
@@ -91,6 +109,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
}
async ensureRepository(marketplace: IMarketplaceReference, options?: IEnsureRepositoryOptions): Promise<URI> {
await this._migrationDone;
const repoDir = this.getRepositoryUri(marketplace, options?.marketplaceType);
return this._cloneSequencer.queue(repoDir.fsPath, async () => {
const repoExists = await this._fileService.exists(repoDir);
@@ -254,6 +273,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
}
async ensurePluginSource(plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise<URI> {
await this._migrationDone;
const repo = this.getPluginSource(plugin.sourceDescriptor.kind);
if (plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) {
return this.ensureRepository(plugin.marketplaceReference, options);
@@ -351,4 +371,34 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
}
}
/**
* One-time migration of plugin files from the old internal cache
* directory (`{cacheHome}/agentPlugins/`) to the new well-known
* location (`~/{dataFolderName}/agent-plugins/`).
*/
private async _migrateDirectory(oldCacheRoot: URI): Promise<void> {
try {
const oldExists = await this._fileService.exists(oldCacheRoot);
if (!oldExists) {
return;
}
const newExists = await this._fileService.exists(this.agentPluginsHome);
if (newExists) {
this._logService.info('[AgentPluginRepositoryService] Both old and new agent-plugins directories exist; skipping directory migration');
return;
}
this._logService.info(`[AgentPluginRepositoryService] Migrating agent plugins from ${oldCacheRoot.toString()} to ${this.agentPluginsHome.toString()}`);
await this._fileService.move(oldCacheRoot, this.agentPluginsHome, false);
// Clear the marketplace index — it caches repository URIs that
// pointed to the old location and would cause path mismatches.
this._storageService.remove(MARKETPLACE_INDEX_STORAGE_KEY, StorageScope.APPLICATION);
this._marketplaceIndex.value.clear();
} catch (error) {
this._logService.error('[AgentPluginRepositoryService] Directory migration failed', error);
}
}
}

View File

@@ -238,7 +238,7 @@ export class PluginInstallService implements IPluginInstallService {
}
async updateAllPlugins(options: IUpdateAllPluginsOptions, token: CancellationToken): Promise<IUpdateAllPluginsResult> {
const installed = this._pluginMarketplaceService.installedPlugins.get().filter(e => e.enabled);
const installed = this._pluginMarketplaceService.installedPlugins.get();
if (installed.length === 0) {
return { updatedNames: [], failedNames: [] };
}

View File

@@ -43,6 +43,13 @@ export interface IPullRepositoryOptions {
export interface IAgentPluginRepositoryService {
readonly _serviceBrand: undefined;
/**
* Root directory where agent plugins are stored on disk.
* On native this is `~/{dataFolderName}/agent-plugins/`; on web it
* falls back to `{cacheHome}/agentPlugins/`.
*/
readonly agentPluginsHome: URI;
/**
* Returns the local cache URI for a marketplace repository reference.
* Uses a storage-backed marketplace index when available.

View File

@@ -0,0 +1,276 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { RunOnceScheduler, ThrottledDelayer } from '../../../../../base/common/async.js';
import { VSBuffer } from '../../../../../base/common/buffer.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { revive } from '../../../../../base/common/marshalling.js';
import { IObservable, ITransaction, observableValue } from '../../../../../base/common/observable.js';
import { isEqual, joinPath } from '../../../../../base/common/resources.js';
import { URI, UriComponents } from '../../../../../base/common/uri.js';
import { IFileService } from '../../../../../platform/files/common/files.js';
import { ILogService } from '../../../../../platform/log/common/log.js';
import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js';
const INSTALLED_JSON_FILENAME = 'installed.json';
const INSTALLED_JSON_VERSION = 1;
/** Legacy storage key used before migration to file-backed store. */
const LEGACY_INSTALLED_PLUGINS_STORAGE_KEY = 'chat.plugins.installed.v1';
/** Legacy storage key for the marketplace index that cached old URI paths. */
const LEGACY_MARKETPLACE_INDEX_STORAGE_KEY = 'chat.plugins.marketplaces.index.v1';
/**
* Minimal entry stored in `installed.json`. URIs are serialised as strings
* so that external tools can read and write the file without depending on
* VS Code internal URI representations.
*/
interface IInstalledJsonEntry {
readonly pluginUri: string;
readonly marketplace: string;
}
/**
* On-disk schema for `installed.json`.
*/
interface IInstalledJson {
readonly version: number;
readonly installed: readonly IInstalledJsonEntry[];
}
/**
* In-memory representation of an installed plugin entry.
*/
export interface IStoredInstalledPlugin {
readonly pluginUri: URI;
readonly marketplace: string;
}
/**
* An observable store for installed agent plugins that is backed by a
* `installed.json` file within the agent-plugins directory. This makes
* the installed-plugin manifest discoverable by external tools (CLIs,
* other editors, etc.) without depending on VS Code internals.
*
* The on-disk format stores only the plugin URI (as a string) and the
* marketplace identifier. Plugin metadata (name, description, etc.) is
* read from the plugin manifest on disk by the discovery layer -
* keeping a single source of truth.
*
* On construction the store:
* 1. Attempts to read `installed.json` from the agent-plugins directory.
* 2. If no file exists, migrates data from the legacy {@link StorageService}
* key (`chat.plugins.installed.v1`), rebasing plugin URIs from the old
* cache directory to the new agent-plugins directory.
* 3. Sets up a correlated file watcher so that external edits to
* `installed.json` are picked up automatically.
*
* Write operations update the in-memory observable synchronously and
* schedule a debounced file write so that rapid successive mutations
* (e.g. batch enables) are coalesced into a single I/O operation.
*/
export class FileBackedInstalledPluginsStore extends Disposable {
private readonly _installed = observableValue<readonly IStoredInstalledPlugin[]>('file/installed.json', []);
private readonly _fileUri: URI;
private readonly _writeDelayer: ThrottledDelayer<void>;
private _suppressFileWatch = false;
private _initialized = false;
readonly value: IObservable<readonly IStoredInstalledPlugin[]> = this._installed;
constructor(
private readonly _agentPluginsHome: URI,
private readonly _oldCacheRoot: URI | undefined,
private readonly _fileService: IFileService,
private readonly _logService: ILogService,
private readonly _storageService: IStorageService,
) {
super();
this._fileUri = joinPath(_agentPluginsHome, INSTALLED_JSON_FILENAME);
this._writeDelayer = this._register(new ThrottledDelayer<void>(100));
void this._initialize();
}
get(): readonly IStoredInstalledPlugin[] {
return this._installed.get();
}
set(newValue: readonly IStoredInstalledPlugin[], tx: ITransaction | undefined): void {
this._setValue(newValue, tx, true);
}
private async _initialize(): Promise<void> {
try {
const read = await this._readFromFile();
if (read !== undefined) {
this._setValue(read, undefined, false);
} else {
// No installed.json yet — attempt migration from legacy storage.
await this._migrateFromStorage();
}
} catch (error) {
this._logService.error('[FileBackedInstalledPluginsStore] Initialization failed', error);
}
this._initialized = true;
this._setupFileWatcher();
}
// --- File I/O ----------------------------------------------------------------
private async _readFromFile(): Promise<readonly IStoredInstalledPlugin[] | undefined> {
try {
const exists = await this._fileService.exists(this._fileUri);
if (!exists) {
return undefined;
}
const content = await this._fileService.readFile(this._fileUri);
const json: IInstalledJson = JSON.parse(content.value.toString());
if (!json || !Array.isArray(json.installed)) {
this._logService.warn('[FileBackedInstalledPluginsStore] installed.json has unexpected format, ignoring');
return undefined;
}
// Each entry is { pluginUri: string, enabled: boolean }.
return json.installed
.filter((entry): entry is IInstalledJsonEntry => typeof entry.pluginUri === 'string' && typeof entry.marketplace === 'string')
.map(entry => ({ pluginUri: URI.parse(entry.pluginUri), marketplace: entry.marketplace }));
} catch {
return undefined;
}
}
private _scheduleWrite(): void {
void this._writeDelayer.trigger(async () => {
await this._writeToFile();
});
}
private async _writeToFile(): Promise<boolean> {
const entries: IInstalledJsonEntry[] = this.get().map(e => ({
pluginUri: e.pluginUri.toString(),
marketplace: e.marketplace,
}));
const data: IInstalledJson = {
version: INSTALLED_JSON_VERSION,
installed: entries,
};
try {
this._suppressFileWatch = true;
const content = JSON.stringify(data, undefined, '\t');
await this._fileService.createFolder(this._agentPluginsHome);
await this._fileService.writeFile(this._fileUri, VSBuffer.fromString(content));
return true;
} catch (error) {
this._logService.error('[FileBackedInstalledPluginsStore] Failed to write installed.json', error);
return false;
} finally {
this._suppressFileWatch = false;
}
}
// --- File watching ------------------------------------------------------------
private _setupFileWatcher(): void {
if (typeof this._fileService.createWatcher !== 'function') {
return;
}
const dir = this._agentPluginsHome;
const watcher = this._fileService.createWatcher(dir, { recursive: false, excludes: [] });
this._register(watcher);
const scheduler = this._register(new RunOnceScheduler(() => this._onFileChanged(), 100));
this._register(watcher.onDidChange(e => {
if (!this._suppressFileWatch && e.affects(this._fileUri)) {
scheduler.schedule();
}
}));
}
private async _onFileChanged(): Promise<void> {
const read = await this._readFromFile();
if (read !== undefined) {
// Suppress file write for externally triggered updates.
this._suppressFileWatch = true;
try {
this._setValue(read, undefined, false);
} finally {
this._suppressFileWatch = false;
}
}
}
// --- Write-through to file ----------------------------------------------------
private _setValue(newValue: readonly IStoredInstalledPlugin[], tx: ITransaction | undefined, scheduleWrite: boolean): void {
this._installed.set(newValue, tx);
// Only schedule writes after initialization and when not processing
// an external file change.
if (scheduleWrite && this._initialized && !this._suppressFileWatch) {
this._scheduleWrite();
}
}
// --- Migration from legacy storage -------------------------------------------
private async _migrateFromStorage(): Promise<void> {
const raw = this._storageService.get(LEGACY_INSTALLED_PLUGINS_STORAGE_KEY, StorageScope.APPLICATION);
if (!raw) {
return;
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length === 0) {
return;
}
const migrated: IStoredInstalledPlugin[] = (revive(parsed) as { pluginUri: UriComponents; plugin?: { marketplaceReference?: { rawValue?: string } } }[]).map(entry => {
const uri = URI.revive(entry.pluginUri);
const rebased = this._rebasePluginUri(uri);
return {
pluginUri: rebased ?? uri,
marketplace: entry.plugin?.marketplaceReference?.rawValue ?? '',
};
}).filter(e => !!e.marketplace);
this._logService.info(`[FileBackedInstalledPluginsStore] Migrating ${migrated.length} plugin(s) from storage to installed.json`);
// Set in memory and persist to file before removing legacy keys.
this._setValue(migrated, undefined, false);
const didPersist = await this._writeToFile();
if (!didPersist) {
return;
}
// Clean up legacy keys.
this._storageService.remove(LEGACY_INSTALLED_PLUGINS_STORAGE_KEY, StorageScope.APPLICATION);
this._storageService.remove(LEGACY_MARKETPLACE_INDEX_STORAGE_KEY, StorageScope.APPLICATION);
} catch (error) {
this._logService.error('[FileBackedInstalledPluginsStore] Migration from storage failed', error);
}
}
/**
* If the plugin URI was under the old cache root, rebase it to the
* new agent-plugins directory. Otherwise, return `undefined` to keep
* the original.
*/
private _rebasePluginUri(uri: URI): URI | undefined {
if (!this._oldCacheRoot) {
return undefined;
}
const oldRoot = this._oldCacheRoot;
if (!isEqual(uri, oldRoot) && uri.scheme === oldRoot.scheme && uri.path.startsWith(oldRoot.path + '/')) {
const relativePart = uri.path.substring(oldRoot.path.length);
return uri.with({ path: this._agentPluginsHome.path + relativePart });
}
return undefined;
}
}

View File

@@ -10,10 +10,11 @@ import { parse as parseJSONC } from '../../../../../base/common/json.js';
import { Lazy } from '../../../../../base/common/lazy.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { revive } from '../../../../../base/common/marshalling.js';
import { derived, IObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js';
import { autorun, derived, IObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js';
import { isEqual, isEqualOrParent, joinPath, normalizePath, relativePath } from '../../../../../base/common/resources.js';
import { URI, UriComponents } from '../../../../../base/common/uri.js';
import { URI } from '../../../../../base/common/uri.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js';
import { IFileService } from '../../../../../platform/files/common/files.js';
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../../../platform/log/common/log.js';
@@ -24,6 +25,7 @@ import type { Dto } from '../../../../services/extensions/common/proxyIdentifier
import { AutoUpdateConfigurationKey, AutoUpdateConfigurationValue } from '../../../extensions/common/extensions.js';
import { ChatConfiguration } from '../constants.js';
import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js';
import { FileBackedInstalledPluginsStore, IStoredInstalledPlugin } from './fileBackedInstalledPluginsStore.js';
import { IWorkspacePluginSettingsService } from './workspacePluginSettingsService.js';
import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js';
import { type IMarketplaceReference, deduplicateMarketplaceReferences, MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences } from './marketplaceReference.js';
@@ -134,7 +136,6 @@ interface IMarketplaceJson {
export interface IMarketplaceInstalledPlugin {
readonly pluginUri: URI;
readonly plugin: IMarketplacePlugin;
readonly enabled: boolean;
}
export const IPluginMarketplaceService = createDecorator<IPluginMarketplaceService>('pluginMarketplaceService');
@@ -168,7 +169,6 @@ export interface IPluginMarketplaceService {
getMarketplacePluginMetadata(pluginUri: URI): IMarketplacePlugin | undefined;
addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void;
removeInstalledPlugin(pluginUri: URI): void;
setInstalledPluginEnabled(pluginUri: URI, enabled: boolean): void;
/** Returns whether the given marketplace has been explicitly trusted by the user. */
isMarketplaceTrusted(ref: IMarketplaceReference): boolean;
/** Records that the user trusts the given marketplace, persisted permanently. */
@@ -207,12 +207,6 @@ interface IGitHubMarketplaceCacheEntry {
type IStoredGitHubMarketplaceCache = Dto<Record<string, IGitHubMarketplaceCacheEntry>>;
interface IStoredInstalledPlugin {
readonly pluginUri: UriComponents;
readonly plugin: IMarketplacePlugin;
readonly enabled: boolean;
}
/**
* Ensures that an {@link IMarketplacePlugin} loaded from storage has a
* {@link IMarketplacePlugin.sourceDescriptor sourceDescriptor}. Plugins
@@ -230,16 +224,6 @@ function ensureSourceDescriptor(plugin: IMarketplacePlugin): IMarketplacePlugin
};
}
const installedPluginsMemento = observableMemento<readonly IStoredInstalledPlugin[]>({
defaultValue: [],
key: 'chat.plugins.installed.v1',
toStorage: value => JSON.stringify(value),
fromStorage: value => {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
},
});
const trustedMarketplacesMemento = observableMemento<readonly string[]>({
defaultValue: [],
key: 'chat.plugins.trustedMarketplaces.v1',
@@ -271,7 +255,8 @@ const lastFetchedPluginsMemento = observableMemento<IStoredLastFetchedPlugins>({
export class PluginMarketplaceService extends Disposable implements IPluginMarketplaceService {
declare readonly _serviceBrand: undefined;
private readonly _gitHubMarketplaceCache = new Lazy<Map<string, IGitHubMarketplaceCacheEntry>>(() => this._loadPersistedGitHubMarketplaceCache());
private readonly _installedPluginsStore: ObservableMemento<readonly IStoredInstalledPlugin[]>;
private readonly _installedPluginsStore: FileBackedInstalledPluginsStore;
private readonly _pluginMetadata = new Map<string, IMarketplacePlugin>();
private readonly _trustedMarketplacesStore: ObservableMemento<readonly string[]>;
private readonly _lastFetchedPluginsStore: ObservableMemento<IStoredLastFetchedPlugins>;
private readonly _hasUpdatesAvailable = observableValue<boolean>('hasUpdatesAvailable', false);
@@ -287,6 +272,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
constructor(
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IRequestService private readonly _requestService: IRequestService,
@IEnvironmentService environmentService: IEnvironmentService,
@IFileService private readonly _fileService: IFileService,
@IAgentPluginRepositoryService private readonly _pluginRepositoryService: IAgentPluginRepositoryService,
@ILogService private readonly _logService: ILogService,
@@ -296,8 +282,17 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
) {
super();
// File-backed store for installed plugins. The old cache location
// is passed so the store can rebase URIs during migration.
const oldCacheRoot = joinPath(environmentService.cacheHome, 'agentPlugins');
this._installedPluginsStore = this._register(
installedPluginsMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, _storageService)
new FileBackedInstalledPluginsStore(
_pluginRepositoryService.agentPluginsHome,
oldCacheRoot,
_fileService,
_logService,
_storageService,
)
);
this._trustedMarketplacesStore = this._register(
@@ -313,12 +308,16 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
return revived.plugins.map(ensureSourceDescriptor);
});
this.installedPlugins = this._installedPluginsStore.map(s =>
(revive(s) as readonly IMarketplaceInstalledPlugin[]).map(e => ({
...e,
plugin: ensureSourceDescriptor(e.plugin),
}))
);
this.installedPlugins = this._installedPluginsStore.value.map(entries => {
const result: IMarketplaceInstalledPlugin[] = [];
for (const e of entries) {
const plugin = this._pluginMetadata.get(e.pluginUri.toString());
if (plugin) {
result.push({ pluginUri: e.pluginUri, plugin });
}
}
return result;
});
// Aggregate recommended plugin keys from all providers.
// Currently sourced from Claude workspace settings; more providers can be
@@ -356,6 +355,18 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
e => e.affectsConfiguration(AutoUpdateConfigurationKey),
)(() => this._scheduleUpdateCheck()));
}));
// Hydrate plugin metadata for installed entries that are not yet in
// the in-memory cache (e.g. after restart when installed.json is read
// but the metadata map is empty). Walks up from each plugin URI to
// find the marketplace.json in the enclosing repository directory.
this._register(autorun(reader => {
const entries = this._installedPluginsStore.value.read(reader);
const unhydrated = entries.filter(e => !this._pluginMetadata.has(e.pluginUri.toString()));
if (unhydrated.length > 0) {
this._hydratePluginMetadata(unhydrated);
}
}));
}
override dispose(): void {
@@ -423,65 +434,34 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
let repoMayBePrivate = true;
for (const def of MARKETPLACE_DEFINITIONS) {
const plugins = await this._readPluginsFromDefinitions(reference, async (defPath) => {
if (token.isCancellationRequested) {
return [];
return undefined;
}
const url = `https://raw.githubusercontent.com/${repo}/main/${def.path}`;
const url = `https://raw.githubusercontent.com/${repo}/main/${defPath}`;
try {
const context = await this._requestService.request({ type: 'GET', url, callSite: 'pluginMarketplaceService.fetchPluginList' }, token);
const statusCode = context.res.statusCode;
if (statusCode !== 200) {
repoMayBePrivate &&= statusCode !== undefined && statusCode >= 400 && statusCode < 500;
this._logService.debug(`[PluginMarketplaceService] ${url} returned status ${statusCode}, skipping`);
continue;
return undefined;
}
const json = await asJson<IMarketplaceJson>(context);
if (!json?.plugins || !Array.isArray(json.plugins)) {
this._logService.debug(`[PluginMarketplaceService] ${url} did not contain a valid plugins array, skipping`);
continue;
}
const plugins = json.plugins
.filter((p): p is { name: string; description?: string; version?: string; source?: string | IJsonPluginSource } =>
typeof p.name === 'string' && !!p.name
)
.flatMap(p => {
const sourceDescriptor = parsePluginSource(p.source, json.metadata?.pluginRoot, {
pluginName: p.name,
logService: this._logService,
logPrefix: `[PluginMarketplaceService]`,
});
if (!sourceDescriptor) {
return [];
}
const source = sourceDescriptor.kind === PluginSourceKind.RelativePath ? sourceDescriptor.path : '';
return [{
name: p.name,
description: p.description ?? '',
version: p.version ?? '',
source,
sourceDescriptor,
marketplace: reference.displayLabel,
marketplaceReference: reference,
marketplaceType: def.type,
readmeUri: getMarketplaceReadmeUri(repo, source),
}];
});
cache.set(reference.canonicalId, {
plugins,
expiresAt: Date.now() + GITHUB_MARKETPLACE_CACHE_TTL_MS,
referenceRawValue: reference.rawValue,
});
this._savePersistedGitHubMarketplaceCache(cache);
return plugins;
return await asJson<IMarketplaceJson>(context) ?? undefined;
} catch (err) {
this._logService.debug(`[PluginMarketplaceService] Failed to fetch marketplace.json from ${url}:`, err);
continue;
return undefined;
}
});
if (plugins.length > 0) {
cache.set(reference.canonicalId, {
plugins,
expiresAt: Date.now() + GITHUB_MARKETPLACE_CACHE_TTL_MS,
referenceRawValue: reference.rawValue,
});
this._savePersistedGitHubMarketplaceCache(cache);
return plugins;
}
if (repoMayBePrivate) {
@@ -572,38 +552,122 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
}
getMarketplacePluginMetadata(pluginUri: URI): IMarketplacePlugin | undefined {
const installed = this.installedPlugins.get();
return installed.find(e => isEqualOrParent(pluginUri, e.pluginUri))?.plugin;
return this._pluginMetadata.get(pluginUri.toString())
?? [...this._pluginMetadata.entries()].find(([key]) => isEqualOrParent(pluginUri, URI.parse(key)))?.[1];
}
addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void {
const current = this.installedPlugins.get();
this._pluginMetadata.set(pluginUri.toString(), plugin);
const current = this._installedPluginsStore.get();
const existing = current.find(e => isEqual(e.pluginUri, pluginUri));
if (existing) {
// Still update to trigger watchers to re-check, something might have happened that we want to know about
this._installedPluginsStore.set(current.map(c => c === existing ? { pluginUri, plugin, enabled: existing.enabled } : c), undefined);
this._installedPluginsStore.set(current.map(c => c === existing ? { pluginUri, marketplace: plugin.marketplaceReference.rawValue } : c), undefined);
} else {
this._installedPluginsStore.set([...current, { pluginUri, plugin, enabled: true }], undefined);
this._installedPluginsStore.set([...current, { pluginUri, marketplace: plugin.marketplaceReference.rawValue }], undefined);
}
}
removeInstalledPlugin(pluginUri: URI): void {
const current = this.installedPlugins.get();
this._pluginMetadata.delete(pluginUri.toString());
const current = this._installedPluginsStore.get();
this._installedPluginsStore.set(current.filter(e => !isEqual(e.pluginUri, pluginUri)), undefined);
}
setInstalledPluginEnabled(pluginUri: URI, enabled: boolean): void {
const current = this.installedPlugins.get();
this._installedPluginsStore.set(
current.map(e => isEqual(e.pluginUri, pluginUri) ? { ...e, enabled } : e),
undefined,
);
}
isMarketplaceTrusted(ref: IMarketplaceReference): boolean {
return this._trustedMarketplacesStore.get().includes(ref.canonicalId);
}
// --- Plugin metadata hydration -----------------------------------------------
/**
* For each plugin URI that has no cached metadata, walk up the directory
* tree from the plugin towards the agent-plugins root looking for a
* marketplace definition file. When found, read the marketplace plugins
* and match by source path to populate {@link _pluginMetadata}.
*
* After hydration completes the installed-plugins store is "touched" so
* that the derived {@link installedPlugins} observable re-evaluates with
* the newly available metadata.
*/
private async _hydratePluginMetadata(entries: readonly IStoredInstalledPlugin[]): Promise<void> {
let hydrated = 0;
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;
}
try {
const repoDir = this._pluginRepositoryService.getRepositoryUri(reference);
const plugins = await this._readPluginsFromDirectory(repoDir, reference);
const match = plugins.find(p => {
const installUri = this._pluginRepositoryService.getPluginInstallUri(p);
return isEqual(installUri, entry.pluginUri);
});
if (match) {
this._pluginMetadata.set(key, match);
hydrated++;
}
} catch (err) {
this._logService.debug(`[PluginMarketplaceService] Failed to hydrate metadata for ${key}:`, err);
}
}
if (hydrated > 0) {
// Touch the store to trigger the derived observable to re-evaluate
// now that _pluginMetadata has new entries.
const current = this._installedPluginsStore.get();
this._installedPluginsStore.set([...current], undefined);
}
}
/**
* Shared logic to parse a marketplace.json into {@link IMarketplacePlugin}
* objects. Used by both fetch and hydration paths.
*/
private _parseMarketplacePlugins(json: IMarketplaceJson, reference: IMarketplaceReference, marketplaceType: MarketplaceType, repoDir?: URI): IMarketplacePlugin[] {
if (!json.plugins || !Array.isArray(json.plugins)) {
return [];
}
return json.plugins
.filter((p): p is { name: string; description?: string; version?: string; source?: string | IJsonPluginSource } =>
typeof p.name === 'string' && !!p.name
)
.flatMap(p => {
const sourceDescriptor = parsePluginSource(p.source, json.metadata?.pluginRoot, {
pluginName: p.name,
logService: this._logService,
logPrefix: '[PluginMarketplaceService]',
});
if (!sourceDescriptor) {
return [];
}
const source = sourceDescriptor.kind === PluginSourceKind.RelativePath ? sourceDescriptor.path : '';
return [{
name: p.name,
description: p.description ?? '',
version: p.version ?? '',
source,
sourceDescriptor,
marketplace: reference.displayLabel,
marketplaceReference: reference,
marketplaceType,
readmeUri: repoDir ? getMarketplaceReadmeFileUri(repoDir, source) : getMarketplaceReadmeUri(reference.githubRepo ?? '', source),
}];
});
}
trustMarketplace(ref: IMarketplaceReference): void {
const current = this._trustedMarketplacesStore.get();
if (!current.includes(ref.canonicalId)) {
@@ -646,7 +710,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
this._updateCheckTimer = undefined;
try {
const installed = this.installedPlugins.get().filter(e => e.enabled);
const installed = this.installedPlugins.get();
if (installed.length === 0) {
return;
}
@@ -706,53 +770,36 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
}
private async _readPluginsFromDirectory(repoDir: URI, reference: IMarketplaceReference, token?: CancellationToken): Promise<IMarketplacePlugin[]> {
for (const def of MARKETPLACE_DEFINITIONS) {
return this._readPluginsFromDefinitions(reference, async (defPath) => {
if (token?.isCancellationRequested) {
return [];
return undefined;
}
const definitionUri = joinPath(repoDir, def.path);
let json: IMarketplaceJson | undefined;
const definitionUri = joinPath(repoDir, defPath);
try {
const contents = await this._fileService.readFile(definitionUri);
json = parseJSONC(contents.value.toString()) as IMarketplaceJson | undefined;
return parseJSONC(contents.value.toString()) as IMarketplaceJson | undefined;
} catch {
continue;
return undefined;
}
}, repoDir);
}
/**
* Iterates over {@link MARKETPLACE_DEFINITIONS} paths, calling
* {@link readJson} for each to obtain the parsed JSON. Returns the
* plugins from the first definition that yields a valid result.
*/
private async _readPluginsFromDefinitions(
reference: IMarketplaceReference,
readJson: (defPath: string) => Promise<IMarketplaceJson | undefined>,
repoDir?: URI,
): Promise<IMarketplacePlugin[]> {
for (const def of MARKETPLACE_DEFINITIONS) {
const json = await readJson(def.path);
if (!json?.plugins || !Array.isArray(json.plugins)) {
this._logService.debug(`[PluginMarketplaceService] ${definitionUri.toString()} did not contain a valid plugins array, skipping`);
continue;
}
return json.plugins
.filter((p): p is { name: string; description?: string; version?: string; source?: string | IJsonPluginSource } =>
typeof p.name === 'string' && !!p.name
)
.flatMap(p => {
const sourceDescriptor = parsePluginSource(p.source, json.metadata?.pluginRoot, {
pluginName: p.name,
logService: this._logService,
logPrefix: `[PluginMarketplaceService]`,
});
if (!sourceDescriptor) {
return [];
}
const source = sourceDescriptor.kind === PluginSourceKind.RelativePath ? sourceDescriptor.path : '';
return [{
name: p.name,
description: p.description ?? '',
version: p.version ?? '',
source,
sourceDescriptor,
marketplace: reference.displayLabel,
marketplaceReference: reference,
marketplaceType: def.type,
readmeUri: getMarketplaceReadmeFileUri(repoDir, source),
}];
});
return this._parseMarketplacePlugins(json, reference, def.type, repoDir);
}
this._logService.debug(`[PluginMarketplaceService] No marketplace.json found in ${reference.rawValue}`);

View File

@@ -59,7 +59,7 @@ suite('AgentPluginRepositoryService', () => {
return undefined;
},
} as unknown as ICommandService);
instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService);
instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService);
instantiationService.stub(IFileService, fileService);
instantiationService.stub(ILogService, new NullLogService());
instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService);
@@ -136,7 +136,7 @@ suite('AgentPluginRepositoryService', () => {
return undefined;
},
} as unknown as ICommandService);
instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService);
instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService);
instantiationService.stub(IFileService, fileService);
instantiationService.stub(ILogService, new NullLogService());
instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService);
@@ -176,7 +176,7 @@ suite('AgentPluginRepositoryService', () => {
const instantiationService = store.add(new TestInstantiationService());
instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService);
instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService);
instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService);
instantiationService.stub(IFileService, { exists: async () => true } as unknown as IFileService);
instantiationService.stub(ILogService, new NullLogService());
instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService);
@@ -270,7 +270,7 @@ suite('AgentPluginRepositoryService', () => {
) {
const instantiationService = store.add(new TestInstantiationService());
instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService);
instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService);
instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService);
instantiationService.stub(IFileService, {
exists: async () => true,
del: async (resource: URI) => { onDel(resource); },
@@ -363,7 +363,7 @@ suite('AgentPluginRepositoryService', () => {
test('does not throw when delete fails', async () => {
const instantiationService = store.add(new TestInstantiationService());
instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService);
instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService);
instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService);
instantiationService.stub(IFileService, {
exists: async () => true,
del: async () => { throw new Error('permission denied'); },

View File

@@ -0,0 +1,208 @@
/*---------------------------------------------------------------------------------------------
* 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 { timeout } from '../../../../../../base/common/async.js';
import { VSBuffer } from '../../../../../../base/common/buffer.js';
import { Event } from '../../../../../../base/common/event.js';
import { URI } from '../../../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { IFileService, IFileSystemWatcher } from '../../../../../../platform/files/common/files.js';
import { NullLogService } from '../../../../../../platform/log/common/log.js';
import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js';
import { FileBackedInstalledPluginsStore } from '../../../common/plugins/fileBackedInstalledPluginsStore.js';
import { MarketplaceType, PluginSourceKind, parseMarketplaceReference } from '../../../common/plugins/pluginMarketplaceService.js';
const LEGACY_INSTALLED_PLUGINS_STORAGE_KEY = 'chat.plugins.installed.v1';
const LEGACY_MARKETPLACE_INDEX_STORAGE_KEY = 'chat.plugins.marketplaces.index.v1';
class TestFileService {
private readonly _files = new Map<string, string>();
private readonly _folders = new Set<string>();
constructor(private readonly _failWrites = false) { }
async exists(resource: URI): Promise<boolean> {
const key = resource.toString();
return this._files.has(key) || this._folders.has(key);
}
async readFile(resource: URI): Promise<{ value: VSBuffer }> {
const key = resource.toString();
const value = this._files.get(key);
if (value === undefined) {
throw new Error(`Missing file: ${key}`);
}
return { value: VSBuffer.fromString(value) };
}
async writeFile(resource: URI, content: VSBuffer): Promise<unknown> {
if (this._failWrites) {
throw new Error('write failed');
}
this._files.set(resource.toString(), content.toString());
return {};
}
async createFolder(resource: URI): Promise<unknown> {
this._folders.add(resource.toString());
return {};
}
createWatcher(): IFileSystemWatcher {
return {
onDidChange: Event.None,
dispose: () => { },
};
}
setFile(resource: URI, content: string): void {
this._files.set(resource.toString(), content);
}
getFile(resource: URI): string | undefined {
return this._files.get(resource.toString());
}
}
suite('FileBackedInstalledPluginsStore', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
function createLegacyEntry(pluginPath: string) {
const reference = parseMarketplaceReference('microsoft/plugins');
assert.ok(reference);
return {
pluginUri: URI.file(pluginPath),
plugin: {
name: 'my-plugin',
description: 'A plugin',
version: '1.0.0',
source: 'plugins/my-plugin',
sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/my-plugin' } as const,
marketplace: reference.displayLabel,
marketplaceReference: reference,
marketplaceType: MarketplaceType.Copilot,
},
enabled: true,
};
}
async function waitFor(predicate: () => boolean): Promise<void> {
for (let i = 0; i < 40; i++) {
if (predicate()) {
return;
}
await timeout(10);
}
assert.fail('Condition not met in time');
}
test('migrates legacy storage to installed.json and removes legacy keys', async () => {
const storageService = store.add(new InMemoryStorageService());
const fileService = new TestFileService();
const legacyEntry = createLegacyEntry('/cache/agentPlugins/github.com/microsoft/plugins/plugins/my-plugin');
storageService.store(
LEGACY_INSTALLED_PLUGINS_STORAGE_KEY,
JSON.stringify([legacyEntry]),
StorageScope.APPLICATION,
StorageTarget.MACHINE,
);
const agentPluginsHome = URI.file('/home/user/.vscode/agent-plugins');
const installedJson = URI.joinPath(agentPluginsHome, 'installed.json');
const pluginsStore = store.add(new FileBackedInstalledPluginsStore(
agentPluginsHome,
URI.file('/cache/agentPlugins'),
fileService as unknown as IFileService,
new NullLogService(),
storageService as unknown as IStorageService,
));
await waitFor(() => !!fileService.getFile(installedJson));
const serialized = fileService.getFile(installedJson);
assert.ok(serialized);
const parsed = JSON.parse(serialized!);
assert.strictEqual(parsed.version, 1);
assert.strictEqual(parsed.installed.length, 1);
assert.ok(parsed.installed[0].pluginUri.includes('/home/user/.vscode/agent-plugins/github.com/microsoft/plugins/plugins/my-plugin'));
// plugin metadata is NOT stored in the file
assert.strictEqual(parsed.installed[0].plugin, undefined);
assert.strictEqual(pluginsStore.get().length, 1);
assert.strictEqual(storageService.get(LEGACY_INSTALLED_PLUGINS_STORAGE_KEY, StorageScope.APPLICATION), undefined);
assert.strictEqual(storageService.get(LEGACY_MARKETPLACE_INDEX_STORAGE_KEY, StorageScope.APPLICATION), undefined);
});
test('keeps legacy keys when migration write fails', async () => {
const storageService = store.add(new InMemoryStorageService());
const fileService = new TestFileService(true);
const legacyEntry = createLegacyEntry('/cache/agentPlugins/github.com/microsoft/plugins/plugins/my-plugin');
storageService.store(
LEGACY_INSTALLED_PLUGINS_STORAGE_KEY,
JSON.stringify([legacyEntry]),
StorageScope.APPLICATION,
StorageTarget.MACHINE,
);
storageService.store(
LEGACY_MARKETPLACE_INDEX_STORAGE_KEY,
JSON.stringify({ any: 'value' }),
StorageScope.APPLICATION,
StorageTarget.MACHINE,
);
const agentPluginsHome = URI.file('/home/user/.vscode/agent-plugins');
const installedJson = URI.joinPath(agentPluginsHome, 'installed.json');
store.add(new FileBackedInstalledPluginsStore(
agentPluginsHome,
URI.file('/cache/agentPlugins'),
fileService as unknown as IFileService,
new NullLogService(),
storageService as unknown as IStorageService,
));
await timeout(30);
assert.strictEqual(fileService.getFile(installedJson), undefined);
assert.ok(storageService.get(LEGACY_INSTALLED_PLUGINS_STORAGE_KEY, StorageScope.APPLICATION));
assert.ok(storageService.get(LEGACY_MARKETPLACE_INDEX_STORAGE_KEY, StorageScope.APPLICATION));
});
test('loads existing installed.json on startup', async () => {
const storageService = store.add(new InMemoryStorageService());
const fileService = new TestFileService();
const agentPluginsHome = URI.file('/home/user/.vscode/agent-plugins');
const installedJson = URI.joinPath(agentPluginsHome, 'installed.json');
const existingData = {
version: 1,
installed: [{
pluginUri: URI.file('/home/user/.vscode/agent-plugins/github.com/microsoft/plugins/plugins/my-plugin').toString(),
marketplace: 'microsoft/plugins',
}],
};
fileService.setFile(installedJson, JSON.stringify(existingData));
const pluginsStore = store.add(new FileBackedInstalledPluginsStore(
agentPluginsHome,
URI.file('/cache/agentPlugins'),
fileService as unknown as IFileService,
new NullLogService(),
storageService as unknown as IStorageService,
));
await waitFor(() => pluginsStore.get().length === 1);
assert.strictEqual(pluginsStore.get()[0].pluginUri.path, '/home/user/.vscode/agent-plugins/github.com/microsoft/plugins/plugins/my-plugin');
});
});

View File

@@ -16,9 +16,10 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm
import { IRequestService } from '../../../../../../platform/request/common/request.js';
import { IStorageService, InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js';
import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js';
import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js';
import { ChatConfiguration } from '../../../common/constants.js';
import { IAgentPluginRepositoryService } from '../../../common/plugins/agentPluginRepositoryService.js';
import { MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource } from '../../../common/plugins/pluginMarketplaceService.js';
import { IMarketplacePlugin, MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource } from '../../../common/plugins/pluginMarketplaceService.js';
import { IWorkspacePluginSettingsService } from '../../../common/plugins/workspacePluginSettingsService.js';
suite('PluginMarketplaceService', () => {
@@ -186,8 +187,9 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => {
[ChatConfiguration.PluginMarketplaces]: ['microsoft/plugins'],
[ChatConfiguration.PluginsEnabled]: true,
}));
instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as Partial<IEnvironmentService> as IEnvironmentService);
instantiationService.stub(IFileService, {} as unknown as IFileService);
instantiationService.stub(IAgentPluginRepositoryService, {} as unknown as IAgentPluginRepositoryService);
instantiationService.stub(IAgentPluginRepositoryService, { agentPluginsHome: URI.file('/agent-plugins') } as unknown as IAgentPluginRepositoryService);
instantiationService.stub(ILogService, new NullLogService());
instantiationService.stub(IRequestService, {} as unknown as IRequestService);
instantiationService.stub(IStorageService, store.add(new InMemoryStorageService()));
@@ -236,6 +238,125 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => {
});
});
suite('PluginMarketplaceService - installed plugins lifecycle', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
const marketplaceRef = parseMarketplaceReference('microsoft/plugins')!;
function makePlugin(name: string, source: string): IMarketplacePlugin {
return {
name,
description: `${name} description`,
version: '1.0.0',
source,
sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: source } as const,
marketplace: marketplaceRef.displayLabel,
marketplaceReference: marketplaceRef,
marketplaceType: MarketplaceType.Copilot,
};
}
function createService(): PluginMarketplaceService {
const instantiationService = store.add(new TestInstantiationService());
instantiationService.stub(IConfigurationService, new TestConfigurationService({
[ChatConfiguration.PluginMarketplaces]: ['microsoft/plugins'],
[ChatConfiguration.PluginsEnabled]: true,
}));
instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as Partial<IEnvironmentService> as IEnvironmentService);
instantiationService.stub(IFileService, {} as unknown as IFileService);
instantiationService.stub(IAgentPluginRepositoryService, { agentPluginsHome: URI.file('/agent-plugins') } as unknown as IAgentPluginRepositoryService);
instantiationService.stub(ILogService, new NullLogService());
instantiationService.stub(IRequestService, {} as unknown as IRequestService);
instantiationService.stub(IStorageService, store.add(new InMemoryStorageService()));
instantiationService.stub(IWorkspacePluginSettingsService, {
extraMarketplaces: observableValue('test.extraMarketplaces', []),
enabledPlugins: observableValue('test.enabledPlugins', new Map()),
} as Partial<IWorkspacePluginSettingsService> as IWorkspacePluginSettingsService);
instantiationService.stub(IWorkspaceTrustManagementService, {
isWorkspaceTrusted: () => true,
onDidChangeTrust: Event.None,
} as Partial<IWorkspaceTrustManagementService> as IWorkspaceTrustManagementService);
return store.add(instantiationService.createInstance(PluginMarketplaceService));
}
test('installedPlugins observable is empty with no plugins', () => {
const service = createService();
assert.deepStrictEqual(service.installedPlugins.get(), []);
});
test('addInstalledPlugin makes plugin appear in installedPlugins', () => {
const service = createService();
const uri = URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin');
const plugin = makePlugin('my-plugin', 'my-plugin');
service.addInstalledPlugin(uri, plugin);
const installed = service.installedPlugins.get();
assert.strictEqual(installed.length, 1);
assert.strictEqual(installed[0].plugin.name, 'my-plugin');
});
test('removeInstalledPlugin removes plugin from installedPlugins and metadata', () => {
const service = createService();
const uri = URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin');
const plugin = makePlugin('my-plugin', 'my-plugin');
service.addInstalledPlugin(uri, plugin);
assert.strictEqual(service.installedPlugins.get().length, 1);
service.removeInstalledPlugin(uri);
assert.strictEqual(service.installedPlugins.get().length, 0);
assert.strictEqual(service.getMarketplacePluginMetadata(uri), undefined);
});
test('addInstalledPlugin updates metadata for existing entry', () => {
const service = createService();
const uri = URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin');
const v1 = makePlugin('my-plugin', 'my-plugin');
const v2 = { ...v1, version: '2.0.0', description: 'updated' };
service.addInstalledPlugin(uri, v1);
service.addInstalledPlugin(uri, v2);
const installed = service.installedPlugins.get();
assert.strictEqual(installed.length, 1);
assert.strictEqual(installed[0].plugin.version, '2.0.0');
assert.strictEqual(installed[0].plugin.description, 'updated');
});
test('getMarketplacePluginMetadata finds metadata for child URI', () => {
const service = createService();
const uri = URI.file('/agent-plugins/github.com/microsoft/plugins');
const plugin = makePlugin('my-plugin', 'my-plugin');
service.addInstalledPlugin(uri, plugin);
const childUri = URI.file('/agent-plugins/github.com/microsoft/plugins/subdir/file.ts');
const result = service.getMarketplacePluginMetadata(childUri);
assert.strictEqual(result?.name, 'my-plugin');
});
test('multiple plugins can be installed independently', () => {
const service = createService();
const uri1 = URI.file('/agent-plugins/github.com/microsoft/plugins/plugin-a');
const uri2 = URI.file('/agent-plugins/github.com/microsoft/plugins/plugin-b');
const pluginA = makePlugin('plugin-a', 'plugin-a');
const pluginB = makePlugin('plugin-b', 'plugin-b');
service.addInstalledPlugin(uri1, pluginA);
service.addInstalledPlugin(uri2, pluginB);
assert.strictEqual(service.installedPlugins.get().length, 2);
service.removeInstalledPlugin(uri1);
const remaining = service.installedPlugins.get();
assert.strictEqual(remaining.length, 1);
assert.strictEqual(remaining[0].plugin.name, 'plugin-b');
});
});
suite('parsePluginSource', () => {
ensureNoDisposablesAreLeakedInTestSuite();

View File

@@ -144,6 +144,9 @@ export class BrowserWorkbenchEnvironmentService implements IBrowserWorkbenchEnvi
@memoize
get extHostLogsPath(): URI { return joinPath(this.logsHome, 'exthost'); }
@memoize
get agentPluginsHome(): URI { return joinPath(this.userRoamingDataHome, 'agent-plugins'); }
private extensionHostDebugEnvironment: IExtensionHostDebugEnvironment | undefined = undefined;
@memoize

View File

@@ -26,6 +26,7 @@ export interface IWorkbenchEnvironmentService extends IEnvironmentService {
readonly logFile: URI;
readonly windowLogsPath: URI;
readonly extHostLogsPath: URI;
readonly agentPluginsHome: URI;
// --- Extensions
readonly extensionEnabledProposedApi?: string[];

View File

@@ -154,6 +154,9 @@ export class NativeWorkbenchEnvironmentService extends AbstractNativeEnvironment
@memoize
get isSessionsWindow(): boolean { return !!this.configuration.isSessionsWindow; }
@memoize
get agentPluginsHome(): URI { return URI.file(this.agentPluginsPath); }
constructor(
private readonly configuration: INativeWindowConfiguration,
productService: IProductService