mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-17 23:35:54 +01:00
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:
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'.") },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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'); },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user