Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/vs/platform/agentHost/common/agentPluginManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ import type { ClientPluginCustomization, PluginCustomization } from './state/ses

export const IAgentPluginManager = createDecorator<IAgentPluginManager>('agentPluginManager');

/** Static active-client identity used for host-resolved automation plugins. */
export const AUTOMATION_ACTIVE_CLIENT_ID = 'vscode.automation';

/**
* Scheme for plugin customization URIs that name a directory on the agent
* host's own disk. Every other URI, including `file:`, names a resource on the
* client that published the customization.
*/
export const AGENT_HOST_FILE_SCHEME = 'vscode-agent-host-file';

/** Marks a `file:` URI as a path on the agent host's own disk. */
export function toAgentHostFileUri(uri: URI): URI {
return uri.with({ scheme: AGENT_HOST_FILE_SCHEME });
}

/**
* A synced customization with its local plugin directory (when available).
*/
Expand Down Expand Up @@ -37,11 +52,13 @@ export interface IAgentPluginManager {
*/
readonly basePath: URI;

/** Immutable host-owned plugin directories. File URIs equal to or under this path are synced in place, without cache entries, regardless of clientId. */
/** Directory for immutable host-owned plugin copies, such as automation captures. */
readonly hostPluginsPath: URI;

/**
* Syncs a set of client-provided plugin customizations to local storage.
* Customizations with an {@link AGENT_HOST_FILE_SCHEME} URI are already on
* the host's disk and are used in place without a copy or cache entry.
*
* Each plugin is copied to a local directory, respecting nonce-based
* caching. The optional {@link progress} callback fires with the single
Expand Down
30 changes: 18 additions & 12 deletions src/vs/platform/agentHost/node/agentHostAutomationCustomizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { createHash } from 'crypto';
import { Schemas } from '../../../base/common/network.js';
import { extUriBiasedIgnorePathCase, joinPath } from '../../../base/common/resources.js';
import { URI } from '../../../base/common/uri.js';
import { generateUuid } from '../../../base/common/uuid.js';
Expand All @@ -12,14 +13,13 @@ import { parsePlugin } from '../../agentPlugins/common/pluginParsers.js';
import { IFileService } from '../../files/common/files.js';
import { ILogService } from '../../log/common/log.js';
import { toAgentClientUri } from '../common/agentClientUri.js';
import { AUTOMATION_ACTIVE_CLIENT_ID, toAgentHostFileUri } from '../common/agentPluginManager.js';
import type { AutomationEntry, AutomationSessionTemplate } from '../common/state/protocol/channels-automation/state.js';
import { CustomizationLoadStatus, CustomizationType, type AgentSelection, type ClientPluginCustomization, type PluginCustomization, type SessionActiveClient } from '../common/state/sessionState.js';
import { toChildCustomizations } from './copilot/copilotPluginConverters.js';

/** Static active-client identity used for captured automation plugins. */
export const AUTOMATION_ACTIVE_CLIENT_ID = 'vscode.automation';

/** Owns immutable automation plugin copies, independently of connected clients and the plugin cache. */
/** Captures automation plugins as host paths, independently of connected clients and the plugin cache. */
export class AgentHostAutomationCustomizations {
private readonly _path: URI;
/** Copies handed to run sessions in this process; those sessions keep using them in place for follow-up turns. */
Expand All @@ -35,7 +35,7 @@ export class AgentHostAutomationCustomizations {
}

/** Captures changed references atomically for the caller, reusing unchanged entries without contacting their client. */
async capture(clientId: string | undefined, next: readonly ClientPluginCustomization[] | undefined, previous: AutomationEntry | undefined): Promise<PluginCustomization[] | undefined> {
async capture(clientId: string | undefined, next: readonly ClientPluginCustomization[] | undefined, previous: AutomationEntry | undefined, isLocalClient = false): Promise<PluginCustomization[] | undefined> {
if (!next?.length) {
return undefined;
}
Expand All @@ -56,18 +56,23 @@ export class AgentHostAutomationCustomizations {
if (!clientId) {
throw new Error('Capturing automation customizations requires a dispatching client.');
}
const key = ref.nonce === undefined ? generateUuid() : createHash('sha256').update(`${ref.uri}\n${ref.nonce}`).digest('hex');
const destination = joinPath(this._path, key);
if (!await this._fileService.exists(destination)) {
const staging = joinPath(this._path, `.staging-${generateUuid()}`);
await this._fileService.copy(toAgentClientUri(URI.parse(ref.uri), clientId), staging);
await this._fileService.move(staging, destination);
const uri = URI.parse(ref.uri);
const inPlace = isLocalClient && uri.scheme === Schemas.file;
let destination = uri;
if (!inPlace) {
const key = ref.nonce === undefined ? generateUuid() : createHash('sha256').update(`${ref.uri}\n${ref.nonce}`).digest('hex');
destination = joinPath(this._path, key);
if (!await this._fileService.exists(destination)) {
const staging = joinPath(this._path, `.staging-${generateUuid()}`);
await this._fileService.copy(toAgentClientUri(uri, clientId), staging);
await this._fileService.move(staging, destination);
}
}
const parsed = await parsePlugin(destination, this._fileService, undefined, this._userHome, destination);
copy = {
type: CustomizationType.Plugin,
id: ref.id,
uri: destination.toString(),
uri: inPlace ? ref.uri : destination.toString(),
name: ref.name,
children: toChildCustomizations([parsed]),
load: { kind: CustomizationLoadStatus.Loaded },
Expand All @@ -92,7 +97,8 @@ export class AgentHostAutomationCustomizations {
throw new Error(`Missing captured automation customization: ${ref.id}`);
}
this._usedByRuns.add(copy.uri);
return { ...ref, uri: copy.uri, clientId: AUTOMATION_ACTIVE_CLIENT_ID };
// Captured copies and local in-place plugins are host paths, not client resources.
return { ...ref, uri: toAgentHostFileUri(URI.parse(copy.uri)).toString(), clientId: AUTOMATION_ACTIVE_CLIENT_ID };
});
return customizations?.length ? {
clientId: AUTOMATION_ACTIVE_CLIENT_ID,
Expand Down
4 changes: 2 additions & 2 deletions src/vs/platform/agentHost/node/agentHostAutomationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost
}

const timestamp = new Date().toISOString();
const customizations = await this._customizations.capture(clientId, definition.session.customizations, undefined);
const customizations = await this._customizations.capture(clientId, definition.session.customizations, undefined, clientId !== undefined && this._clientConnections.isLocalClient(clientId));
const automation = this._withInitialScheduleState({
resource: action.resource,
definition,
Expand Down Expand Up @@ -274,7 +274,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost
};
this._validateDefinition(automation.definition);
if (action.changes.session !== undefined) {
automation.customizations = await this._customizations.capture(clientId, action.changes.session.customizations, existing);
automation.customizations = await this._customizations.capture(clientId, action.changes.session.customizations, existing, clientId !== undefined && this._clientConnections.isLocalClient(clientId));
}
if (action.changes.triggers !== undefined || action.changes.enabled !== undefined) {
automation = this._withInitialScheduleState(automation, new Date());
Expand Down
13 changes: 13 additions & 0 deletions src/vs/platform/agentHost/node/agentHostClientConnectionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface IAgentHostClientConnectionCounts {
export interface IAgentHostClientConnectionSource {
hasSeenClient(clientId: string): boolean;
isClientConnected(clientId: string): boolean;
/** Whether an active connection is local and uses the server's MessagePort transport. */
isLocalClient(clientId: string): boolean;
getConnectedClientTransportCounts(): ReadonlyMap<string, number>;
requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise<boolean>;
/** Requests silent MCP authentication from connected clients. */
Expand All @@ -31,6 +33,8 @@ export interface IAgentHostClientConnectionService {
registerSource(source: IAgentHostClientConnectionSource): IDisposable;
hasSeenClient(clientId: string): boolean;
isClientConnected(clientId: string): boolean;
/** Whether any source has an active local MessagePort connection for this client. */
isLocalClient(clientId: string): boolean;
getConnectionCounts(clientId: string): IAgentHostClientConnectionCounts;
requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise<boolean>;
/** Requests silent MCP authentication, stopping at the first successful source. */
Expand Down Expand Up @@ -72,6 +76,15 @@ export class AgentHostClientConnectionService extends Disposable implements IAge
return false;
}

isLocalClient(clientId: string): boolean {
for (const source of this._sources) {
if (source.isLocalClient(clientId)) {
return true;
}
}
return false;
}

getConnectionCounts(clientId: string): IAgentHostClientConnectionCounts {
const connectedClients = new Set<string>();
let connectedTransportCount = 0;
Expand Down
8 changes: 3 additions & 5 deletions src/vs/platform/agentHost/node/agentPluginManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ import { VSBuffer } from '../../../base/common/buffer.js';
import { SequencerByKey } from '../../../base/common/async.js';
import { URI } from '../../../base/common/uri.js';
import { Schemas } from '../../../base/common/network.js';
import { extUriBiasedIgnorePathCase } from '../../../base/common/resources.js';
import { FileOperationResult, IFileService, toFileOperationResult } from '../../files/common/files.js';
import { ILogService } from '../../log/common/log.js';
import { IAgentPluginManager, type ISyncedCustomization } from '../common/agentPluginManager.js';
import { AGENT_HOST_FILE_SCHEME, IAgentPluginManager, type ISyncedCustomization } from '../common/agentPluginManager.js';
import { CustomizationLoadStatus, type ClientPluginCustomization, type PluginCustomization } from '../common/state/sessionState.js';
import { toAgentClientUri } from '../common/agentClientUri.js';

Expand Down Expand Up @@ -141,9 +140,8 @@ export class AgentPluginManager implements IAgentPluginManager {
*/
private async _syncPlugin(clientId: string, ref: ClientPluginCustomization): Promise<URI> {
const uri = URI.parse(ref.uri);
// Normalize so `..` segments cannot escape the host-owned directory.
if (uri.scheme === Schemas.file && extUriBiasedIgnorePathCase.isEqualOrParent(extUriBiasedIgnorePathCase.normalizePath(uri), this.hostPluginsPath)) {
return uri;
if (uri.scheme === AGENT_HOST_FILE_SCHEME) {
return uri.with({ scheme: Schemas.file });
}
const pluginUri = toAgentClientUri(uri, clientId);
const destDir = this._dirFor(ref.uri, ref.nonce);
Expand Down
6 changes: 6 additions & 0 deletions src/vs/platform/agentHost/node/protocolServerHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,12 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien
&& record.connections.some(connection => connection.telemetryConnectionActive);
}

isLocalClient(clientId: string): boolean {
const record = this._clients.get(clientId);
return record?.state === 'active'
&& record.connections.some(connection => connection.telemetryConnectionActive && this._supportsCanvases(connection));
}

getConnectedClientTransportCounts(): ReadonlyMap<string, number> {
const result = new Map<string, number>();
for (const [clientId, record] of this._clients) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { AGENT_CLIENT_SCHEME, toAgentClientUri } from '../../common/agentClientU
import { CustomizationType, MessageKind, type ClientPluginCustomization, type PluginCustomization } from '../../common/state/sessionState.js';
import { CustomizationEnablementKind } from '../../common/state/protocol/channels-session/state.js';
import type { AutomationEntry } from '../../common/state/protocol/channels-automation/state.js';
import { AgentHostAutomationCustomizations, AUTOMATION_ACTIVE_CLIENT_ID } from '../../node/agentHostAutomationCustomizations.js';
import { AgentHostAutomationCustomizations } from '../../node/agentHostAutomationCustomizations.js';
import { AUTOMATION_ACTIVE_CLIENT_ID, toAgentHostFileUri } from '../../common/agentPluginManager.js';

suite('AgentHostAutomationCustomizations', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
Expand Down Expand Up @@ -88,6 +89,63 @@ suite('AgentHostAutomationCustomizations', () => {
});
});

test('captures local file plugins in place, reuses them, and never garbage collects their paths', async () => {
const directory = URI.file('/local/bundle');
const localRef = { ...ref, uri: directory.toString() };
await fileService.writeFile(URI.joinPath(directory, '.plugin/plugin.json'), VSBuffer.fromString('{"name":"bundle"}'));
await fileService.writeFile(URI.joinPath(directory, 'agents/reviewer.md'), VSBuffer.fromString('---\nname: Reviewer\n---\nReview.'));
const copySpy = sinon.spy(fileService, 'copy');
const copies = (await customizations.capture('author', [localRef], undefined, true))!;
const reused = await customizations.capture(undefined, [{ ...localRef, name: 'Renamed' }], entry([localRef], copies));
await fileService.createFolder(URI.joinPath(root, 'unused'));
await customizations.collectGarbage([]);
assert.deepStrictEqual({
uri: copies[0].uri,
children: copies[0].children?.map(child => ({ type: child.type, name: child.name, uri: child.uri })),
load: copies[0].load,
reused,
copyCount: copySpy.callCount,
collected: (await fileService.resolve(root)).children,
sourceExists: await fileService.exists(directory),
}, {
uri: localRef.uri,
children: [{ type: CustomizationType.Agent, name: 'Reviewer', uri: URI.joinPath(directory, 'agents/reviewer.md').toString() }],
load: { kind: 'loaded' },
reused: [{ ...copies[0], name: 'Renamed' }],
copyCount: 0,
collected: [],
sourceExists: true,
});
});

test('still copies virtual plugins for local clients', async () => {
await seed();
const [copy] = (await customizations.capture('author', [ref], undefined, true))!;
const destination = URI.joinPath(root, createHash('sha256').update(`${ref.uri}\n${ref.nonce}`).digest('hex'));
assert.deepStrictEqual({
uri: copy.uri,
children: copy.children?.map(child => child.uri),
}, {
uri: destination.toString(),
children: [URI.joinPath(destination, 'agents/reviewer.md').toString()],
});
});

test('still copies file plugins for remote clients', async () => {
const remoteRef = { ...ref, uri: URI.file('/remote/bundle').toString() };
const source = toAgentClientUri(URI.parse(remoteRef.uri), 'author');
await fileService.writeFile(URI.joinPath(source, '.plugin/plugin.json'), VSBuffer.fromString('{"name":"bundle"}'));
const [copy] = (await customizations.capture('author', [remoteRef], undefined, false))!;
const destination = URI.joinPath(root, createHash('sha256').update(`${remoteRef.uri}\n${remoteRef.nonce}`).digest('hex'));
assert.deepStrictEqual({
uri: copy.uri,
manifest: (await fileService.readFile(URI.joinPath(destination, '.plugin/plugin.json'))).value.toString(),
sourceExistsOnHost: await fileService.exists(URI.parse(remoteRef.uri)),
}, {
uri: destination.toString(), manifest: '{"name":"bundle"}', sourceExistsOnHost: false,
});
});

test('shares nonce revisions across automations but not captures without a nonce', async () => {
await seed();
const first = (await customizations.capture('author', [ref], undefined))!;
Expand Down Expand Up @@ -152,7 +210,7 @@ suite('AgentHostAutomationCustomizations', () => {
}, {
activeClient: {
clientId: AUTOMATION_ACTIVE_CLIENT_ID, displayName: 'Automation', tools: [],
customizations: [{ ...input, uri: copies[0].uri, clientId: AUTOMATION_ACTIVE_CLIENT_ID }],
customizations: [{ ...input, uri: toAgentHostFileUri(URI.parse(copies[0].uri)).toString(), clientId: AUTOMATION_ACTIVE_CLIENT_ID }],
},
empty: undefined,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { FileService } from '../../../files/common/fileService.js';
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
import { AGENT_CLIENT_SCHEME, toAgentClientUri } from '../../common/agentClientUri.js';
import { AgentPluginManager } from '../../node/agentPluginManager.js';
import { AUTOMATION_ACTIVE_CLIENT_ID } from '../../node/agentHostAutomationCustomizations.js';
import { AUTOMATION_ACTIVE_CLIENT_ID, toAgentHostFileUri } from '../../common/agentPluginManager.js';
import { AgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js';
import type { IAgentHostMcpAuthenticationRequest } from '../../common/agentHostExtensionProtocol.js';
import { McpAuthRequiredReason, McpServerStatus, type Customization, type McpAuthRequirement } from '../../common/state/protocol/channels-session/state.js';
Expand Down Expand Up @@ -74,6 +74,7 @@ suite('AgentHostAutomationService', () => {
disposables.add(clientConnections.registerSource({
hasSeenClient: () => false,
isClientConnected: () => false,
isLocalClient: () => false,
getConnectedClientTransportCounts: () => new Map(),
requestWorkspaceTrust: async () => false,
requestMcpAuthentication: request => {
Expand Down Expand Up @@ -253,12 +254,46 @@ suite('AgentHostAutomationService', () => {
copy: { type: CustomizationType.Plugin, id: 'bundle', uri: copy.uri, name: 'Bundle', children: [], load: { kind: 'loaded' }, icons: undefined, range: undefined, version: undefined },
stored: [copy],
run: {
activeClient: { clientId: AUTOMATION_ACTIVE_CLIENT_ID, displayName: 'Automation', tools: [], customizations: [{ ...ref, uri: copy.uri, clientId: AUTOMATION_ACTIVE_CLIENT_ID }] },
activeClient: { clientId: AUTOMATION_ACTIVE_CLIENT_ID, displayName: 'Automation', tools: [], customizations: [{ ...ref, uri: toAgentHostFileUri(URI.parse(copy.uri)).toString(), clientId: AUTOMATION_ACTIVE_CLIENT_ID }] },
agent: URI.joinPath(URI.parse(copy.uri), 'agents/reviewer.md').toString(),
},
});
});

test('creates and updates local file customizations in place using connection service locality', async () => {
disposables.add(clientConnections.registerSource({
hasSeenClient: clientId => clientId === 'local',
isClientConnected: clientId => clientId === 'local',
isLocalClient: clientId => clientId === 'local',
getConnectedClientTransportCounts: () => new Map([['local', 1]]),
requestWorkspaceTrust: async () => true,
requestMcpAuthentication: async () => false,
}));
const ref: ClientPluginCustomization = { type: CustomizationType.Plugin, id: 'bundle', uri: URI.file('/local/bundle').toString(), name: 'Bundle', nonce: 'one' };
await fileService.writeFile(URI.joinPath(URI.parse(ref.uri), '.plugin/plugin.json'), VSBuffer.fromString('{"name":"bundle"}'));
const service = createService();
const action = createAction();
action.definition.session.customizations = [ref];
await service.handleCreate(action, 'local');
const created = stateManager.getAutomationCatalogState()!.entries[0].customizations;
const updatedRef = { ...ref, nonce: 'two' };
await service.handleUpdate({
type: ActionType.AutomationUpdateRequested, resource: action.resource,
changes: { session: { ...action.definition.session, customizations: [updatedRef] } },
}, 'local');
const updated = stateManager.getAutomationCatalogState()!.entries[0];
const [runSync] = await pluginManager.syncCustomizations(AUTOMATION_ACTIVE_CLIENT_ID, [{ ...updatedRef, uri: toAgentHostFileUri(URI.parse(updatedRef.uri)).toString(), clientId: AUTOMATION_ACTIVE_CLIENT_ID }]);
assert.deepStrictEqual({
createdUris: created?.map(copy => copy.uri),
updatedUris: updated.customizations?.map(copy => copy.uri),
refs: updated.definition.session.customizations,
copiesExist: await fileService.exists(URI.joinPath(pluginManager.hostPluginsPath, 'automations')),
runPluginDir: runSync.pluginDir?.toString(),
}, {
createdUris: [ref.uri], updatedUris: [ref.uri], refs: [updatedRef], copiesExist: false, runPluginDir: ref.uri,
});
});

test('keeps copies on unrelated updates and rejects failed captures atomically', async () => {
const service = createService();
const action = createAction();
Expand Down
Loading
Loading