From 89c4027af1cd9d0c1c3357fa985f889e365e5bf0 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 19 Jun 2026 20:18:12 -0700 Subject: [PATCH 1/5] agentHost: respect workspace trust Prevent untrusted windows from communicating with the local Agent Host while still allowing the process to start. Require folder trust before creating or sending Agent Host sessions, including remote Agent Host workspaces, and prompt instead of silently trusting folders. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../electron-browser/localAgentHostService.ts | 253 ++++++++++++++---- .../contrib/chat/browser/newChatWidget.ts | 3 +- .../browser/baseAgentHostSessionsProvider.ts | 33 ++- .../browser/localAgentHostSessionsProvider.ts | 49 +++- .../localAgentHostSessionsProvider.test.ts | 58 +++- .../remoteAgentHostSessionsProvider.ts | 2 +- .../remoteAgentHostSessionsProvider.test.ts | 46 +++- .../browser/workspaceFolderManagement.ts | 26 +- .../agentHost/agentHostChatContribution.ts | 43 +++ .../agentHost/agentHostSessionHandler.ts | 7 + .../agentHostTerminalContribution.ts | 14 +- .../agentHost/agentHostWorkspaceTrust.ts | 47 ++++ .../agentHostChatContribution.test.ts | 5 +- .../agentHostTerminalContribution.test.ts | 4 +- 14 files changed, 503 insertions(+), 87 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorkspaceTrust.ts diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 45e744031786f6..75f2a4f7cfce9f 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -3,14 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DeferredPromise } from '../../../base/common/async.js'; -import { Emitter, Relay } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, IReference } from '../../../base/common/lifecycle.js'; +import { Emitter, Event, Relay } from '../../../base/common/event.js'; +import { ErrorNoTelemetry } from '../../../base/common/errors.js'; +import { Disposable, DisposableStore, IReference, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import { IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { getDelayedChannel, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Client as MessagePortClient } from '../../../base/parts/ipc/common/ipc.mp.js'; import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js'; +import { localize } from '../../../nls.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; @@ -31,12 +32,18 @@ import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AgentHostClientResourceChannel } fr import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryLevelConfigKey, AgentHostSessionSyncEnabledConfigKey, SESSION_SYNC_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; +import { IWorkspaceTrustManagementService } from '../../workspace/common/workspaceTrust.js'; + +interface ILocalAgentHostConnection extends IDisposable { + readonly proxy: IAgentService; + readonly connectionTracker: IConnectionTrackerService; +} /** * Renderer-side implementation of {@link IAgentHostService} that connects * directly to the agent host utility process via MessagePort, bypassing - * the main process relay. Uses the same `getDelayedChannel` pattern as - * the pty host so the proxy is usable immediately while the port is acquired. + * the main process relay. The local agent host is process-wide, but each + * renderer window only connects while its workspace is trusted. */ export class LocalAgentHostServiceClient extends Disposable implements IAgentHostService { declare readonly _serviceBrand: undefined; @@ -44,11 +51,13 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos /** Unique identifier for this window, used in action envelope origin tracking. */ readonly clientId = generateUuid(); - private readonly _clientEventually = new DeferredPromise(); - private readonly _proxy: IAgentService; private readonly _ahpLogger: AhpJsonlLogger | undefined; - private readonly _connectionTracker: IConnectionTrackerService; private readonly _subscriptionManager: AgentSubscriptionManager; + private readonly _connection = this._register(new MutableDisposable()); + private _connecting: Promise | undefined; + private _connectionGeneration = 0; + private _workspaceTrusted = false; + private _isDisposed = false; private readonly _onAgentHostExit = this._register(new Emitter()); readonly onAgentHostExit = this._onAgentHostExit.event; @@ -86,15 +95,10 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvironmentService environmentService: IEnvironmentService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { super(); - // Create a proxy backed by a delayed channel - usable immediately, - // calls queue until the MessagePort connection is established. - const rawProxy = ProxyChannel.toService( - getDelayedChannel(this._clientEventually.p.then(client => client.getChannel(AgentHostIpcChannels.AgentHost))) - ); - // Optionally wrap the proxy with a logging layer that synthesizes JSON-RPC // frames for every request/response/notification on the in-process MessagePort // channel, mirroring the AHP transport JSONL logs produced by remote agent hosts. @@ -105,11 +109,6 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos transport: 'local', })) : undefined; - this._proxy = this._ahpLogger ? wrapAgentServiceWithAhpLogging(rawProxy, this._ahpLogger) : rawProxy; - - this._connectionTracker = ProxyChannel.toService( - getDelayedChannel(this._clientEventually.p.then(client => client.getChannel(AgentHostIpcChannels.ConnectionTracker))) - ); this._subscriptionManager = this._register(new AgentSubscriptionManager( this.clientId, @@ -128,17 +127,113 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } })); + this._register(this._workspaceTrustManagementService.onDidChangeTrust(trusted => this._setWorkspaceTrusted(trusted))); + void this._initializeWorkspaceTrust(); + } + + override dispose(): void { + this._isDisposed = true; + super.dispose(); + } + + private async _initializeWorkspaceTrust(): Promise { + await this._workspaceTrustManagementService.workspaceTrustInitialized; + this._setWorkspaceTrusted(this._workspaceTrustManagementService.isWorkspaceTrusted()); + } + + private _setWorkspaceTrusted(trusted: boolean): void { + if (this._workspaceTrusted === trusted) { + return; + } + + this._workspaceTrusted = trusted; + if (!trusted) { + this._disconnect(); + return; + } + if (isAgentHostEnabled(this._configurationService)) { - this._connect(); + void this._connect().catch(err => this._logService.error('[AgentHost:renderer] Failed to connect to agent host', err)); } } - private async _connect(): Promise { + private _disconnect(): void { + this._connectionGeneration++; + this._connecting = undefined; + this._connection.clear(); + this._onMcpNotification.input = Event.None; + this._completionTriggerCharactersOnce = undefined; + } + + private _canCommunicate(): boolean { + return !this._isDisposed && this._workspaceTrusted && isAgentHostEnabled(this._configurationService); + } + + private _createWorkspaceTrustError(): Error { + return new ErrorNoTelemetry(localize('agentHost.workspaceTrustRequired', "The local agent host is unavailable because this workspace is not trusted.")); + } + + private async _getConnection(): Promise { + if (!this._canCommunicate()) { + throw this._createWorkspaceTrustError(); + } + + const existing = this._connection.value; + if (existing) { + return existing; + } + + const connection = await this._connect(); + if (!connection || !this._canCommunicate()) { + throw this._createWorkspaceTrustError(); + } + + return connection; + } + + private async _withProxy(callback: (proxy: IAgentService) => Promise): Promise { + const connection = await this._getConnection(); + if (!this._canCommunicate() || this._connection.value !== connection) { + throw this._createWorkspaceTrustError(); + } + return callback(connection.proxy); + } + + private async _connect(): Promise { + if (!this._canCommunicate()) { + return undefined; + } + + const existing = this._connection.value; + if (existing) { + return existing; + } + + if (this._connecting) { + return this._connecting; + } + + const generation = this._connectionGeneration; + const connecting = this._doConnect(generation); + this._connecting = connecting; + void connecting.finally(() => { + if (this._connecting === connecting) { + this._connecting = undefined; + } + }); + return connecting; + } + + private async _doConnect(generation: number): Promise { this._logService.info('[AgentHost:renderer] Acquiring MessagePort to agent host...'); const port = await acquirePort('vscode:createAgentHostMessageChannel', 'vscode:createAgentHostMessageChannelResult'); + if (!this._canCommunicate() || generation !== this._connectionGeneration) { + port.close(); + return undefined; + } this._logService.info('[AgentHost:renderer] MessagePort acquired, creating client...'); - const store = this._register(new DisposableStore()); + const store = new DisposableStore(); // Use clientId as the IPC ctx so the agent host can route reverse-RPC // calls (vscode-agent-client filesystem reads) back to this renderer // via `IPCServer.getChannel(name, c => c.ctx === clientId)`. @@ -147,11 +242,21 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos // agent host registers an authority on its // AgentHostClientFileSystemProvider that calls back through this channel. client.registerChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, this._instantiationService.createInstance(AgentHostClientResourceChannel, this._ahpLogger)); - this._clientEventually.complete(client); + + const rawProxy = ProxyChannel.toService(client.getChannel(AgentHostIpcChannels.AgentHost)); + const proxy = this._ahpLogger ? wrapAgentServiceWithAhpLogging(rawProxy, this._ahpLogger) : rawProxy; + const connectionTracker = ProxyChannel.toService(client.getChannel(AgentHostIpcChannels.ConnectionTracker)); + const connection: ILocalAgentHostConnection = { + proxy, + connectionTracker, + dispose: () => store.dispose(), + }; + this._connection.value = connection; + const subscriptionsToRestore = this._subscriptionManager.getActiveSubscriptions().map(subscription => subscription.resource); this._updateTelemetryLevel(); this._updateSessionSyncEnabled(); - store.add(this._proxy.onDidAction(e => { + store.add(proxy.onDidAction(e => { const revived = revive(e) as ActionEnvelope; if (this._ahpLogger) { const frame = { jsonrpc: '2.0' as const, method: 'action', params: e }; @@ -160,23 +265,38 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos this._subscriptionManager.receiveEnvelope(revived); this._onDidAction.fire(revived); })); - store.add(this._proxy.onDidNotification(e => { + store.add(proxy.onDidNotification(e => { if (this._ahpLogger) { const frame = { jsonrpc: '2.0' as const, method: 'notification', params: { notification: e } }; this._ahpLogger.log(frame, 's2c'); } this._onDidNotification.fire(revive(e)); })); - this._onMcpNotification.input = this._proxy.onMcpNotification; + this._onMcpNotification.input = proxy.onMcpNotification; this._logService.info('[AgentHost:renderer] Direct MessagePort connection established'); this._onAgentHostStart.fire(); // Subscribe to root state - this.subscribe(URI.parse(ROOT_STATE_URI)).then(snapshot => { + proxy.subscribe(URI.parse(ROOT_STATE_URI), this.clientId).then(snapshot => { + if (this._connection.value !== connection || !this._canCommunicate()) { + return; + } this._subscriptionManager.handleRootSnapshot(snapshot.state as RootState, snapshot.fromSeq); }).catch(err => { this._logService.error('[AgentHost:renderer] Failed to subscribe to root state', err); }); + for (const resource of subscriptionsToRestore) { + proxy.subscribe(resource, this.clientId).then(snapshot => { + if (this._connection.value !== connection || !this._canCommunicate()) { + return; + } + this._subscriptionManager.applyReconnectSnapshot(resource.toString(), snapshot.state, snapshot.fromSeq); + }).catch(err => { + this._logService.error(`[AgentHost:renderer] Failed to restore subscription ${resource.toString()}`, err); + }); + } + + return connection; } private _updateTelemetryLevel(): void { @@ -194,16 +314,16 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos }, this.clientId, 0); } - // ---- IAgentService forwarding (no await needed, delayed channel handles queuing) ---- + // ---- IAgentService forwarding ---- authenticate(params: AuthenticateParams): Promise { - return this._proxy.authenticate(params); + return this._withProxy(proxy => proxy.authenticate(params)); } listSessions(): Promise { - return this._proxy.listSessions(); + return this._withProxy(proxy => proxy.listSessions()); } createSession(config?: IAgentCreateSessionConfig): Promise { - const promise = this._proxy.createSession(config); + const promise = this._withProxy(proxy => proxy.createSession(config)); // When the caller pre-specifies the session URI, a subscribe for // that URI can race the in-flight create. Register the promise so // `AgentSubscriptionManager.getSubscription` gates the wire-level @@ -216,54 +336,69 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return promise; } resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { - return this._proxy.resolveSessionConfig(params); + return this._withProxy(proxy => proxy.resolveSessionConfig(params)); } sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { - return this._proxy.sessionConfigCompletions(params); + return this._withProxy(proxy => proxy.sessionConfigCompletions(params)); } completions(params: CompletionsParams): Promise { - return this._proxy.completions(params); + return this._withProxy(proxy => proxy.completions(params)); } getCompletionTriggerCharacters(): Promise { - return this._completionTriggerCharactersOnce ??= this._proxy.getCompletionTriggerCharacters(); + if (!this._completionTriggerCharactersOnce) { + const promise = this._withProxy(proxy => proxy.getCompletionTriggerCharacters()); + this._completionTriggerCharactersOnce = promise; + void promise.catch(() => { + if (this._completionTriggerCharactersOnce === promise) { + this._completionTriggerCharactersOnce = undefined; + } + }); + } + return this._completionTriggerCharactersOnce; } private _completionTriggerCharactersOnce: Promise | undefined; disposeSession(session: URI): Promise { - return this._proxy.disposeSession(session); + return this._withProxy(proxy => proxy.disposeSession(session)); } createChat(session: URI, chat: URI, options?: IAgentCreateChatOptions): Promise { - return this._proxy.createChat(session, chat, options); + return this._withProxy(proxy => proxy.createChat(session, chat, options)); } disposeChat(chat: URI): Promise { const session = parseChatUri(chat)?.session; if (!session) { return Promise.resolve(); } - return this._proxy.disposeChat(URI.parse(session), chat); + return this._withProxy(proxy => proxy.disposeChat(URI.parse(session), chat)); } createTerminal(params: CreateTerminalParams): Promise { - return this._proxy.createTerminal(params); + return this._withProxy(proxy => proxy.createTerminal(params)); } disposeTerminal(terminal: URI): Promise { - return this._proxy.disposeTerminal(terminal); + return this._withProxy(proxy => proxy.disposeTerminal(terminal)); } invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise { - return this._proxy.invokeChangesetOperation(params); + return this._withProxy(proxy => proxy.invokeChangesetOperation(params)); } handleMcpRequest(channel: string, method: string, params: Record | undefined): Promise { - return this._proxy.handleMcpRequest(channel, method, params); + return this._withProxy(proxy => proxy.handleMcpRequest(channel, method, params)); } shutdown(): Promise { - return this._proxy.shutdown(); + return this._withProxy(proxy => proxy.shutdown()); } private subscribe(resource: URI): Promise { - return this._proxy.subscribe(resource, this.clientId); + return this._withProxy(proxy => proxy.subscribe(resource, this.clientId)); } private unsubscribe(resource: URI): void { - this._proxy.unsubscribe(resource, this.clientId); + const proxy = this._connection.value?.proxy; + if (this._canCommunicate() && proxy) { + proxy.unsubscribe(resource, this.clientId); + } } dispatchAction(channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { - this._proxy.dispatchAction(channel, action, clientId, clientSeq); + const proxy = this._connection.value?.proxy; + if (this._canCommunicate() && proxy) { + proxy.dispatchAction(channel, action, clientId, clientSeq); + } } private _nextSeq = 1; nextClientSeq(): number { @@ -296,35 +431,35 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } resourceList(uri: URI): Promise { - return this._proxy.resourceList(uri); + return this._withProxy(proxy => proxy.resourceList(uri)); } resourceRead(uri: URI): Promise { - return this._proxy.resourceRead(uri); + return this._withProxy(proxy => proxy.resourceRead(uri)); } resourceWrite(params: ResourceWriteParams): Promise { - return this._proxy.resourceWrite(params); + return this._withProxy(proxy => proxy.resourceWrite(params)); } resourceCopy(params: ResourceCopyParams): Promise { - return this._proxy.resourceCopy(params); + return this._withProxy(proxy => proxy.resourceCopy(params)); } resourceDelete(params: ResourceDeleteParams): Promise { - return this._proxy.resourceDelete(params); + return this._withProxy(proxy => proxy.resourceDelete(params)); } resourceMove(params: ResourceMoveParams): Promise { - return this._proxy.resourceMove(params); + return this._withProxy(proxy => proxy.resourceMove(params)); } resourceResolve(params: ResourceResolveParams): Promise { - return this._proxy.resourceResolve(params); + return this._withProxy(proxy => proxy.resourceResolve(params)); } resourceMkdir(params: ResourceMkdirParams): Promise { - return this._proxy.resourceMkdir(params); + return this._withProxy(proxy => proxy.resourceMkdir(params)); } createResourceWatch(params: CreateResourceWatchParams): Promise { - return this._proxy.createResourceWatch(params); + return this._withProxy(proxy => proxy.createResourceWatch(params)); } watchResource(params: CreateResourceWatchParams): Promise { return createRemoteWatchHandle({ - createResourceWatch: p => this._proxy.createResourceWatch(p), + createResourceWatch: p => this.createResourceWatch(p), subscribe: uri => this.subscribe(uri), unsubscribe: uri => this.unsubscribe(uri), onDidAction: this.onDidAction, @@ -335,10 +470,10 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } startWebSocketServer(): Promise { - return this._connectionTracker.startWebSocketServer(); + return this._getConnection().then(connection => connection.connectionTracker.startWebSocketServer()); } getInspectInfo(tryEnable: boolean): Promise { - return this._connectionTracker.getInspectInfo(tryEnable); + return this._getConnection().then(connection => connection.connectionTracker.getInspectInfo(tryEnable)); } } diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 3aad8b445598dc..60ff53ecd623dd 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -25,6 +25,7 @@ import { sessionHasNoSelectableModel } from './modelPicker.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { NoAgentHostEmptyState } from './noAgentHostEmptyState.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { defaultAgentHostWorkspaceTrustRequestMessage } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorkspaceTrust.js'; import { IAgentHostFilterService } from '../../../services/agentHostFilter/common/agentHostFilter.js'; import { IChatViewOptions } from '../../../browser/parts/chatView.js'; @@ -414,7 +415,7 @@ export class NewChatWidget extends Disposable { private async _requestFolderTrust(folderUri: URI): Promise { const trusted = await this.workspaceTrustRequestService.requestResourcesTrust({ uri: folderUri, - message: localize('trustFolderMessage', "An agent session will be able to read files, run commands, and make changes in this folder."), + message: defaultAgentHostWorkspaceTrustRequestMessage, }); if (!trusted) { this._workspacePicker.removeFromRecents(folderUri); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 12f69c32033663..343e45fc7added 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -33,6 +33,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; +import { AgentHostWorkspaceTrust } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorkspaceTrust.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatSendRequestOptions, IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionFileChange, IChatSessionFileChange2, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -1396,6 +1397,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** True while a {@link _refreshSessions} call is awaiting `listSessions()`. */ private _sessionRefreshInFlight = false; + private readonly _workspaceTrust: AgentHostWorkspaceTrust; constructor( @IChatSessionsService protected readonly _chatSessionsService: IChatSessionsService, @@ -1411,6 +1413,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement @IStorageService protected readonly _storageService: IStorageService, ) { super(); + this._workspaceTrust = this._instantiationService.createInstance(AgentHostWorkspaceTrust); this._register(toDisposable(() => { for (const cached of this._sessionCache.values()) { cached.dispose(); @@ -1554,6 +1557,22 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeRootConfig.fire(); } + protected _clearRootState(): void { + if (this._lastAgents !== undefined) { + this._lastAgents = undefined; + this._onDidChangeCustomAgents.fire(); + this._onDidChangeCustomizations.fire(); + } + if (this._sessionTypes.length) { + this._sessionTypes = []; + this._onDidChangeSessionTypes.fire(); + } + if (this._rootConfig) { + this._rootConfig = undefined; + this._onDidChangeRootConfig.fire(); + } + } + abstract resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined; /** Optional event fired when the underlying connection is lost; used to short-circuit `_waitForNewSession`. */ @@ -1698,8 +1717,16 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private _startNewSessionBackend(newSession: NewSession, connection: IAgentConnection): void { - void this._refreshNewSessionConfig(newSession); - newSession.eagerCreate(connection); + void (async () => { + if (!await this._workspaceTrust.isTrusted(newSession.workspaceUri)) { + newSession.endResolveConfigSync(); + newSession.setLoading(false); + this._onDidChangeSessionConfig.fire(newSession.sessionId); + return; + } + void this._refreshNewSessionConfig(newSession); + newSession.eagerCreate(connection); + })().catch(err => this._logService.warn(`[${this.id}] Failed to start new session backend: ${err}`)); } /** @@ -2352,6 +2379,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement throw new Error(`Session '${chatId}' not found or not a new session`); } + await this._workspaceTrust.requireTrusted(newSession.workspaceUri); + const connection = this.connection; if (!connection) { throw new Error(this._notConnectedSendErrorMessage()); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 5e30ececa0d572..d849d68fb8340b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -18,6 +18,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -52,6 +53,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide /** `true` when running in the dedicated Agents window vs. a regular editor window. */ private readonly _isSessionsWindow: boolean; + private _workspaceTrusted = false; protected override getLogOutputChannelId(): string | undefined { return AGENT_HOST_LOG_OUTPUT_CHANNEL_ID; @@ -83,6 +85,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide @IAgentHostActiveClientService activeClientService: IAgentHostActiveClientService, @IStorageService storageService: IStorageService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService); @@ -93,13 +96,48 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide this.browseActions = []; this._attachConnectionListeners(this._agentHostService, this._store); + this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); + void this._workspaceTrustManagementService.workspaceTrustInitialized.then(() => { + this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); + if (this._workspaceTrusted) { + const current = this._agentHostService.rootState.value; + if (current && !(current instanceof Error)) { + this._syncSessionTypesFromRootState(current); + this._syncRootConfigFromRootState(current); + } + } else { + this._clearRootState(); + } + }); + this._register(this._workspaceTrustManagementService.onDidChangeTrust(trusted => { + this._workspaceTrusted = trusted; + if (trusted) { + const current = this._agentHostService.rootState.value; + if (current && !(current instanceof Error)) { + this._syncSessionTypesFromRootState(current); + this._syncRootConfigFromRootState(current); + } + this._cancelSessionRefreshRetry(); + if (!this._agentHostService.authenticationPending.get()) { + this._refreshSessions(); + this._resumeNewSessionAfterAuthenticationSettles(); + } + } else { + this._cancelSessionRefreshRetry(); + this._clearRootState(); + } + })); const rootStateValue = this._agentHostService.rootState.value; - if (rootStateValue && !(rootStateValue instanceof Error)) { + if (this._workspaceTrusted && rootStateValue && !(rootStateValue instanceof Error)) { this._syncSessionTypesFromRootState(rootStateValue); this._syncRootConfigFromRootState(rootStateValue); } this._register(this._agentHostService.rootState.onDidChange(rootState => { + if (!this._workspaceTrusted) { + this._clearRootState(); + return; + } this._syncSessionTypesFromRootState(rootState); this._syncRootConfigFromRootState(rootState); })); @@ -117,6 +155,9 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide if (this._agentHostService.authenticationPending.read(reader)) { return; } + if (!this._workspaceTrusted) { + return; + } this._refreshSessions(); this._resumeNewSessionAfterAuthenticationSettles(); })); @@ -134,6 +175,10 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide this._onDidChangeSessionTypes.fire(); } if (e.affectsConfiguration(preferAgentHostClaudeSettingId)) { + if (!this._workspaceTrusted) { + this._clearRootState(); + return; + } const current = this._agentHostService.rootState.value; if (current && !(current instanceof Error)) { this._syncSessionTypesFromRootState(current); @@ -151,7 +196,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide // -- BaseAgentHostSessionsProvider hooks --------------------------------- - protected get connection(): IAgentConnection { return this._agentHostService; } + protected get connection(): IAgentConnection | undefined { return this._workspaceTrusted ? this._agentHostService : undefined; } protected get authenticationPending(): IObservable { return this._agentHostService.authenticationPending; } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 3fff3586f736c5..a7df5c88ade281 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -39,6 +39,8 @@ import { ILabelService } from '../../../../../../platform/label/common/label.js' import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IGitHubService } from '../../../../github/browser/githubService.js'; import { IWorkbenchEnvironmentService } from '../../../../../../workbench/services/environment/common/environmentService.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; +import { TestWorkspaceTrustManagementService, TestWorkspaceTrustRequestService } from '../../../../../../workbench/test/common/workbenchTestServices.js'; // ---- Mock IAgentHostService ------------------------------------------------- @@ -278,7 +280,7 @@ function createPolicyRestrictedConfigurationService(): TestConfigurationService function createProvider(disposables: DisposableStore, agentHostService: MockAgentHostService, contributions = [ { type: 'agent-host-copilotcli', name: 'copilot', displayName: 'Copilot', description: 'test', icon: undefined }, -], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable; storageService?: IStorageService; isSessionsWindow?: boolean }): LocalAgentHostSessionsProvider { +], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable; storageService?: IStorageService; isSessionsWindow?: boolean; workspaceTrusted?: boolean; resourceTrustResponse?: boolean; workspaceTrustManagementService?: TestWorkspaceTrustManagementService }): LocalAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IAgentHostService, agentHostService); @@ -305,6 +307,8 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen }); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IStorageService, options?.storageService ?? disposables.add(new InMemoryStorageService())); + instantiationService.stub(IWorkspaceTrustManagementService, options?.workspaceTrustManagementService ?? disposables.add(new TestWorkspaceTrustManagementService(options?.workspaceTrusted ?? true))); + instantiationService.stub(IWorkspaceTrustRequestService, disposables.add(new TestWorkspaceTrustRequestService(options?.resourceTrustResponse ?? true))); instantiationService.stub(IGitHubService, new class extends mock() { override findPullRequestNumberByHeadBranch = async () => undefined; }()); @@ -334,6 +338,13 @@ async function waitForSessionConfig(provider: LocalAgentHostSessionsProvider, se }); } +async function waitForResolveSessionConfigRequests(agentHost: MockAgentHostService, count: number): Promise { + for (let i = 0; i < 10 && agentHost.resolveSessionConfigRequests.length < count; i++) { + await timeout(0); + } + assert.ok(agentHost.resolveSessionConfigRequests.length >= count, `Expected at least ${count} resolveSessionConfig requests`); +} + function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; model?: string; modelConfig?: Record; project?: { uri: string; displayName: string }; workingDirectory?: string; changes?: ChangesSummary }): void { const provider = opts?.provider ?? 'copilotcli'; const sessionUri = AgentSession.uri(provider, rawId); @@ -450,6 +461,20 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual(provider.sessionTypes, []); }); + test('does not advertise session types while workspace is untrusted', () => { + const provider = createProvider(disposables, agentHost, undefined, { workspaceTrusted: false }); + + assert.deepStrictEqual(provider.sessionTypes, []); + }); + + test('does not list sessions while workspace is untrusted', async () => { + const provider = createProvider(disposables, agentHost, undefined, { workspaceTrusted: false }); + provider.getSessions(); + await timeout(0); + + assert.strictEqual(agentHost.listSessionsCallCount, 0); + }); + test('session type icons use per-agent codicons', () => { agentHost.setAgents([ { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo, @@ -1320,6 +1345,7 @@ suite('LocalAgentHostSessionsProvider', () => { const provider = createProvider(disposables, agentHost, undefined, { configurationService: config }); const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); await waitForSessionConfig(provider, session.sessionId, c => c?.values.autoApprove === 'autoApprove'); + await waitForResolveSessionConfigRequests(agentHost, 1); assert.deepStrictEqual({ seededImmediately: provider.getSessionConfig(session.sessionId)?.values.autoApprove, @@ -1358,9 +1384,11 @@ suite('LocalAgentHostSessionsProvider', () => { await config.setUserConfiguration('chat.permissions.default', 'autopilot'); const provider = createProvider(disposables, agentHost, undefined, { configurationService: config }); const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); + const seededImmediately = provider.getSessionConfig(session.sessionId)?.values.autoApprove; + await waitForResolveSessionConfigRequests(agentHost, 1); assert.deepStrictEqual({ - seededImmediately: provider.getSessionConfig(session.sessionId)?.values.autoApprove, + seededImmediately, forwardedToAgentHost: agentHost.resolveSessionConfigRequests.at(-1)?.config?.autoApprove, }, { seededImmediately: 'default', @@ -1400,14 +1428,16 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); - test('createNewSession seeds remembered values and skips unsafe remembered keys', () => { + test('createNewSession seeds remembered values and skips unsafe remembered keys', async () => { const storageService = disposables.add(new InMemoryStorageService()); storageService.store(STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES, `{"${SessionConfigKey.Isolation}":"folder","${SessionConfigKey.Branch}":"main","__proto__":"polluted"}`, StorageScope.PROFILE, StorageTarget.MACHINE); const provider = createProvider(disposables, agentHost, undefined, { storageService }); const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); + const seededImmediately = provider.getSessionConfig(session.sessionId)?.values; + await waitForResolveSessionConfigRequests(agentHost, 1); assert.deepStrictEqual({ - seededImmediately: provider.getSessionConfig(session.sessionId)?.values, + seededImmediately, forwardedToAgentHost: agentHost.resolveSessionConfigRequests.at(-1)?.config, }, { seededImmediately: { isolation: 'folder', branch: 'main' }, @@ -1426,12 +1456,14 @@ suite('LocalAgentHostSessionsProvider', () => { await policyRestrictedConfig.setUserConfiguration('chat.permissions.default', 'autoApprove'); const policyRestrictedProvider = createProvider(disposables, agentHost, undefined, { configurationService: policyRestrictedConfig, storageService }); policyRestrictedProvider.createNewSession(URI.parse('file:///home/user/project'), policyRestrictedProvider.sessionTypes[0].id); + await waitForResolveSessionConfigRequests(agentHost, 1); // Case 2: configured 'default' wins over remembered 'autopilot' const configuredDefaultConfig = new TestConfigurationService(); await configuredDefaultConfig.setUserConfiguration('chat.permissions.default', 'default'); const configuredDefaultProvider = createProvider(disposables, agentHost, undefined, { configurationService: configuredDefaultConfig, storageService }); configuredDefaultProvider.createNewSession(URI.parse('file:///home/user/project'), configuredDefaultProvider.sessionTypes[0].id); + await waitForResolveSessionConfigRequests(agentHost, 2); // The forwarded config proves the setting took precedence over the // remembered value and was properly normalized. @@ -2048,6 +2080,24 @@ suite('LocalAgentHostSessionsProvider', () => { ); }); + test('sendRequest requires workspace trust before sending a new session', async () => { + const workspaceTrustManagementService = disposables.add(new TestWorkspaceTrustManagementService(true)); + const provider = createProvider(disposables, agentHost, undefined, { + openSession: true, + resourceTrustResponse: false, + workspaceTrustManagementService, + }); + const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); + const chat = await provider.createNewChat(session.sessionId); + + await workspaceTrustManagementService.setWorkspaceTrust(false); + + await assert.rejects( + () => provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }), + /Workspace trust is required/, + ); + }); + test('sendRequest forwards resolved session config to chat service', async () => { const sendOptions: IChatSendRequestOptions[] = []; const provider = createProvider(disposables, agentHost, undefined, { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 9d9fde8d569e6c..db2eb1eb17d759 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -528,7 +528,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid // -- Workspaces ---------------------------------------------------------- static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, providerLabel: string | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined, description?: string, branchProtectionPatterns?: readonly string[]): ISessionWorkspace | undefined { - return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel, fallbackIcon: Codicon.remote, requiresWorkspaceTrust: false, description, branchProtectionPatterns, group: SESSION_WORKSPACE_GROUP_REMOTE }, gitHubInfo, gitState); + return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel, fallbackIcon: Codicon.remote, requiresWorkspaceTrust: true, description, branchProtectionPatterns, group: SESSION_WORKSPACE_GROUP_REMOTE }, gitHubInfo, gitState); } private _buildWorkspaceFromUri(uri: URI): ISessionWorkspace { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 47f5633d4c8705..c0a9f921596f79 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -38,6 +38,8 @@ import { CopilotCLISessionType } from '../../../agentHost/browser/baseAgentHostS import { IObservable, constObservable } from '../../../../../../base/common/observable.js'; import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../../services/sessions/browser/sessionsService.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, type ResourceTrustRequestOptions } from '../../../../../../platform/workspace/common/workspaceTrust.js'; +import { TestWorkspaceTrustManagementService, TestWorkspaceTrustRequestService } from '../../../../../../workbench/test/common/workbenchTestServices.js'; // ---- Mock connection -------------------------------------------------------- @@ -189,7 +191,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; }; } -function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; noConnection?: boolean; isWebPlatform?: boolean }): RemoteAgentHostSessionsProvider { +function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; noConnection?: boolean; isWebPlatform?: boolean; workspaceTrustManagementService?: TestWorkspaceTrustManagementService; workspaceTrustRequestService?: TestWorkspaceTrustRequestService }): RemoteAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IFileDialogService, {}); @@ -210,6 +212,8 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne lookupLanguageModel: () => undefined, }); instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService())); + instantiationService.stub(IWorkspaceTrustManagementService, overrides?.workspaceTrustManagementService ?? disposables.add(new TestWorkspaceTrustManagementService(true))); + instantiationService.stub(IWorkspaceTrustRequestService, overrides?.workspaceTrustRequestService ?? disposables.add(new TestWorkspaceTrustRequestService(true))); instantiationService.stub(ILabelService, { getUriLabel: (uri: URI) => uri.path, }); @@ -241,6 +245,16 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne return provider; } +class RecordingWorkspaceTrustRequestService extends TestWorkspaceTrustRequestService { + + readonly resourceTrustRequests: URI[] = []; + + override async requestResourcesTrust(options: ResourceTrustRequestOptions): Promise { + this.resourceTrustRequests.push(options.uri); + return super.requestResourcesTrust(options); + } +} + async function waitForSessionConfig(provider: RemoteAgentHostSessionsProvider, sessionId: string, predicate: (config: ResolveSessionConfigResult | undefined) => boolean): Promise { if (predicate(provider.getSessionConfig(sessionId))) { return; @@ -471,10 +485,12 @@ suite('RemoteAgentHostSessionsProvider', () => { label: workspace?.label, repository: workspace?.folders[0]?.root.toString(), workingDirectory: workspace?.folders[0]?.workingDirectory?.toString(), + requiresWorkspaceTrust: workspace?.requiresWorkspaceTrust, }, { label: 'vscode', repository: projectUri.toString(), workingDirectory: workingDirectory.toString(), + requiresWorkspaceTrust: true, }); })); @@ -902,6 +918,34 @@ suite('RemoteAgentHostSessionsProvider', () => { ); }); + test('sendRequest requires workspace trust for the underlying remote folder', async () => { + const workspaceTrustManagementService = disposables.add(new TestWorkspaceTrustManagementService(false)); + const workspaceTrustRequestService = disposables.add(new RecordingWorkspaceTrustRequestService(false)); + const provider = createProvider(disposables, connection, { + openSession: true, + workspaceTrustManagementService, + workspaceTrustRequestService, + }); + const session = provider.createNewSession(URI.parse('vscode-agent-host://localhost__4321/home/user/project'), provider.sessionTypes[0].id); + provider.setAuthenticationPending(false); + await timeout(0); + + assert.deepStrictEqual({ + loading: session.loading.get(), + createdSessions: connection.createdSessionUris.length, + }, { + loading: false, + createdSessions: 0, + }); + + const chat = await provider.createNewChat(session.sessionId); + await assert.rejects( + () => provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }), + /Workspace trust is required/, + ); + assert.deepStrictEqual(workspaceTrustRequestService.resourceTrustRequests.map(uri => uri.toString()), ['file:///home/user/project']); + }); + test('sendRequest forwards resolved session config to chat service', async () => { const sendOptions: IChatSendRequestOptions[] = []; const provider = createProvider(disposables, connection, { diff --git a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts index 8eb1c274000f9a..a8f0832bbff3c4 100644 --- a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -8,27 +8,29 @@ import { IWorkbenchContribution } from '../../../../workbench/common/contributio import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IWorkspaceEditingService } from '../../../../workbench/services/workspaces/common/workspaceEditing.js'; -import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { URI } from '../../../../base/common/uri.js'; import { autorun } from '../../../../base/common/observable.js'; import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/common/workspaces.js'; import { Queue } from '../../../../base/common/async.js'; import { ISession } from '../../../services/sessions/common/session.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { AgentHostWorkspaceTrust } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorkspaceTrust.js'; export class WorkspaceFolderManagementContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.workspaceFolderManagement'; private queue = this._register(new Queue()); + private readonly workspaceTrust: AgentHostWorkspaceTrust; constructor( @ISessionsService private readonly sessionsService: ISessionsService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService, - @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IInstantiationService instantiationService: IInstantiationService, ) { super(); + this.workspaceTrust = instantiationService.createInstance(AgentHostWorkspaceTrust); this._register(autorun(reader => { const activeSession = this.sessionsService.activeSession.read(reader); activeSession?.workspace.read(reader); @@ -37,7 +39,9 @@ export class WorkspaceFolderManagementContribution extends Disposable implements } private async updateWorkspaceFoldersForSession(session: ISession | undefined): Promise { - await this.manageTrustWorkspaceForSession(session); + if (!await this.manageTrustWorkspaceForSession(session)) { + return; + } const activeSessionFolderData = this.getActiveSessionFolderData(session); const currentRepo = this.workspaceContextService.getWorkspace().folders[0]?.uri; @@ -78,23 +82,17 @@ export class WorkspaceFolderManagementContribution extends Disposable implements }; } - private async manageTrustWorkspaceForSession(session: ISession | undefined): Promise { + private async manageTrustWorkspaceForSession(session: ISession | undefined): Promise { const workspace = session?.workspace.get(); if (!workspace?.requiresWorkspaceTrust) { - return; + return true; } const folder = workspace?.folders[0]; if (!folder) { - return; + return true; } - if (!this.isUriTrusted(folder.workingDirectory)) { - await this.workspaceTrustManagementService.setUrisTrust([folder.workingDirectory], true); - } - } - - private isUriTrusted(uri: URI): boolean { - return this.workspaceTrustManagementService.getTrustedUris().some(trustedUri => this.uriIdentityService.extUri.isEqual(trustedUri, uri)); + return this.workspaceTrust.ensureTrusted(folder.workingDirectory); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index c5607b0544cda2..6dc237ef793527 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -16,6 +16,7 @@ import { IDefaultAccountService } from '../../../../../../platform/defaultAccoun import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { Registry } from '../../../../../../platform/registry/common/platform.js'; +import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IWorkbenchContribution } from '../../../../../common/contributions.js'; import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; @@ -51,8 +52,13 @@ async function waitForLocalAgentHostActivation(accessor: ServicesAccessor, sessi } const agentHostService = accessor.get(IAgentHostService); + const workspaceTrustManagementService = accessor.get(IWorkspaceTrustManagementService); const environmentService = accessor.get(IWorkbenchEnvironmentService); + await workspaceTrustManagementService.workspaceTrustInitialized; while (true) { + if (!workspaceTrustManagementService.isWorkspaceTrusted()) { + return false; + } const rootState = agentHostService.rootState.value; if (rootState instanceof Error) { return false; @@ -97,6 +103,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr private readonly _modelProviders = new Map(); /** List controllers keyed by agent provider, for cache resets on reconnect. */ private readonly _listControllers = new Map(); + private _workspaceTrusted = false; /** Dedupes redundant `authenticate` RPCs when the resolved token hasn't changed. */ private readonly _authTokenCache = new AgentHostAuthTokenCache(); @@ -117,6 +124,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr @ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService, + @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { super(); @@ -128,6 +136,29 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr } this._register(_agentHostFileSystemService.registerAuthority('local', this._agentHostService)); + this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); + void this._workspaceTrustManagementService.workspaceTrustInitialized.then(() => { + this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); + if (!this._workspaceTrusted) { + this._clearAgentRegistrations(); + } else { + const current = this._agentHostService.rootState.value; + if (current && !(current instanceof Error)) { + this._handleRootStateChange(current); + } + } + }); + this._register(this._workspaceTrustManagementService.onDidChangeTrust(trusted => { + this._workspaceTrusted = trusted; + if (!trusted) { + this._clearAgentRegistrations(); + } else { + const current = this._agentHostService.rootState.value; + if (current && !(current instanceof Error)) { + this._handleRootStateChange(current); + } + } + })); // React to root state changes (agent discovery / removal) this._register(this._agentHostService.rootState.onDidChange(rootState => { @@ -189,6 +220,11 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr } private _handleRootStateChange(rootState: RootState): void { + if (!this._workspaceTrusted) { + this._clearAgentRegistrations(); + return; + } + const allowed = rootState.agents.filter(a => this._shouldRegisterAgent(a.provider)); const incoming = new Set(allowed.map(a => a.provider)); @@ -219,6 +255,13 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr } } + private _clearAgentRegistrations(): void { + this._agentRegistrations.clearAndDisposeAll(); + this._modelProviders.clear(); + this._listControllers.clear(); + this._authTokenCache.clear(); + } + private _registerAgent(agent: AgentInfo): void { const store = new DisposableStore(); this._agentRegistrations.set(agent.provider, store); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 582edc23673dc7..3986be051bdde7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -65,6 +65,7 @@ import { IAgentHostActiveClientService } from './agentHostActiveClientService.js import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; import { AgentHostSnapshotController } from './agentHostSnapshotController.js'; +import { AgentHostWorkspaceTrust } from './agentHostWorkspaceTrust.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; @@ -453,6 +454,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * a resource that carries a chatId fragment (see {@link _resolveChatUri}). */ private readonly _additionalChatSubscriptions = new Map>>(); + private readonly _workspaceTrust: AgentHostWorkspaceTrust; constructor( config: IAgentHostSessionHandlerConfig, @@ -476,6 +478,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ) { super(); this._config = config; + this._workspaceTrust = this._instantiationService.createInstance(AgentHostWorkspaceTrust); this._register(autorun(reader => { const defs = this._activeClientService.clientTools.read(reader); @@ -2813,6 +2816,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._logService.trace(`[AgentHost] Creating new session, model=${model?.id ?? '(default)'}, provider=${this._config.provider}${fork ? `, fork from ${fork.session.toString()} at index ${fork.turnIndex}` : ''}`); + if (workingDirectory) { + await this._workspaceTrust.requireTrusted(workingDirectory); + } + // Eagerly authenticate before creating the session if the agent // declares required protected resources. This avoids a wasted // round-trip where createSession fails with AuthRequired. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts index 19da2678097c6b..056275485c391c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts @@ -12,6 +12,7 @@ import { ActionType } from '../../../../../../platform/agentHost/common/state/pr import { ROOT_STATE_URI } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TerminalSettingId } from '../../../../../../platform/terminal/common/terminal.js'; +import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IWorkbenchContribution } from '../../../../../../workbench/common/contributions.js'; import { ITerminalProfileResolverService, ITerminalProfileService } from '../../../../../../workbench/contrib/terminal/common/terminal.js'; import { IAgentHostTerminalService } from '../../../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; @@ -65,6 +66,7 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe private readonly _localEntry = this._register(new MutableDisposable()); private readonly _conditionalListeners = this._register(new MutableDisposable()); + private _workspaceTrusted = false; /** Declarative table of the root-config keys we manage. */ private readonly _managedKeys: readonly IManagedRootConfigKey[]; @@ -82,6 +84,7 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe @IConfigurationService private readonly _configurationService: IConfigurationService, @ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService, @ITerminalProfileResolverService private readonly _terminalProfileResolverService: ITerminalProfileResolverService, + @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { super(); @@ -116,12 +119,21 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe this._updateEnabled(); } })); + this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); + void this._workspaceTrustManagementService.workspaceTrustInitialized.then(() => { + this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); + this._updateEnabled(); + }); + this._register(this._workspaceTrustManagementService.onDidChangeTrust(trusted => { + this._workspaceTrusted = trusted; + this._updateEnabled(); + })); this._updateEnabled(); } private _updateEnabled(): void { - if (this._configurationService.getValue(AgentHostEnabledSettingId)) { + if (this._configurationService.getValue(AgentHostEnabledSettingId) && this._workspaceTrusted) { if (!this._conditionalListeners.value) { const store = new DisposableStore(); store.add(this._agentHostService.onAgentHostStart(() => this._reconcile())); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorkspaceTrust.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorkspaceTrust.ts new file mode 100644 index 00000000000000..fe95d0ef62995d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorkspaceTrust.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ErrorNoTelemetry } from '../../../../../../base/common/errors.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { fromAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; + +export const defaultAgentHostWorkspaceTrustRequestMessage = localize('agentHost.workspaceTrust.request', "An agent session will be able to read files, run commands, and make changes in this folder."); + +export class AgentHostWorkspaceTrust { + + constructor( + @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, + ) { } + + async isTrusted(uri: URI): Promise { + const trustUri = this._toWorkspaceTrustUri(uri); + await this._workspaceTrustManagementService.workspaceTrustInitialized; + if (this._workspaceTrustManagementService.isWorkspaceTrusted()) { + return true; + } + return (await this._workspaceTrustManagementService.getUriTrustInfo(trustUri)).trusted; + } + + async ensureTrusted(uri: URI, message = defaultAgentHostWorkspaceTrustRequestMessage): Promise { + if (await this.isTrusted(uri)) { + return true; + } + + return !!(await this._workspaceTrustRequestService.requestResourcesTrust({ uri: this._toWorkspaceTrustUri(uri), message })); + } + + async requireTrusted(uri: URI, message?: string): Promise { + if (!await this.ensureTrusted(uri, message)) { + throw new ErrorNoTelemetry(localize('agentHost.workspaceTrust.required', "Workspace trust is required to start an agent session in this folder.")); + } + } + + private _toWorkspaceTrustUri(uri: URI): URI { + return fromAgentHostUri(uri); + } +} diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index a55c243897896a..f27ee5529eb07c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -40,10 +40,11 @@ import { IOpenerService } from '../../../../../../platform/opener/common/opener. import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IOutputService } from '../../../../../services/output/common/output.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { AgentHostContribution, AgentHostSessionListController, AgentHostSessionHandler } from '../../../browser/agentSessions/agentHost/agentHostChatContribution.js'; import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { TestFileService } from '../../../../../test/common/workbenchTestServices.js'; +import { TestFileService, TestWorkspaceTrustManagementService, TestWorkspaceTrustRequestService } from '../../../../../test/common/workbenchTestServices.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { MockLabelService } from '../../../../../services/label/test/common/mockLabelService.js'; import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; @@ -513,6 +514,8 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv }); instantiationService.stub(IOutputService, { getChannel: () => undefined }); instantiationService.stub(IWorkspaceContextService, { getWorkspace: () => ({ id: '', folders: [] }), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None }); + instantiationService.stub(IWorkspaceTrustManagementService, disposables.add(new TestWorkspaceTrustManagementService(true))); + instantiationService.stub(IWorkspaceTrustRequestService, disposables.add(new TestWorkspaceTrustRequestService(true))); instantiationService.stub(IChatEditingService, { registerEditingSessionProvider: () => toDisposable(() => { }), }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts index e3d669eb2c5db7..50e322c8360017 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts @@ -23,6 +23,8 @@ import { TerminalSettingId, type ITerminalProfile } from '../../../../../../plat import { ITerminalProfileResolverService, ITerminalProfileService, type IShellLaunchConfigResolveOptions } from '../../../../terminal/common/terminal.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { AgentHostTerminalContribution } from '../../../browser/agentSessions/agentHost/agentHostTerminalContribution.js'; +import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; +import { TestWorkspaceTrustManagementService } from '../../../../../test/common/workbenchTestServices.js'; // ---- Mock agent host service (minimal — only what the contribution touches) ---- @@ -177,6 +179,7 @@ function setup(disposables: DisposableStore, agentHostEnabled: boolean = true): instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(ITerminalProfileResolverService, resolver); instantiationService.stub(ITerminalProfileService, profileService); + instantiationService.stub(IWorkspaceTrustManagementService, disposables.add(new TestWorkspaceTrustManagementService(true))); instantiationService.stub(IAgentHostTerminalService, { registerEntry: (): IDisposable => ({ dispose() { } }), profiles: observableValue('test', []), @@ -441,4 +444,3 @@ suite('AgentHostTerminalContribution', () => { assert.deepStrictEqual(agentHostService.dispatchedActions as readonly unknown[], []); }); }); - From 367015d323030a30e7d1d48625c47f2119443616 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sat, 20 Jun 2026 11:59:32 -0700 Subject: [PATCH 2/5] agentHost: address workspace trust review feedback Respond to Copilot review feedback by resyncing the local renderer connection when the agent host setting changes, reporting disabled and untrusted states distinctly, and dropping in-flight local connections if trust changes before connection publication. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../electron-browser/localAgentHostService.ts | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 75f2a4f7cfce9f..85a44d1c1135d2 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -16,7 +16,7 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentHostAhpJsonlLoggingSettingId, AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService, isAgentHostEnabled, IMcpNotification } from '../common/agentService.js'; +import { AgentHostAhpJsonlLoggingSettingId, AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService, isAgentHostEnabled, IMcpNotification } from '../common/agentService.js'; import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { wrapAgentServiceWithAhpLogging } from './localAhpJsonlLogging.js'; import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; @@ -125,6 +125,9 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos if (e.affectsConfiguration(SESSION_SYNC_ENABLED_SETTING_ID)) { this._updateSessionSyncEnabled(); } + if (e.affectsConfiguration(AgentHostEnabledSettingId)) { + this._syncConnectionState(); + } })); this._register(this._workspaceTrustManagementService.onDidChangeTrust(trusted => this._setWorkspaceTrusted(trusted))); @@ -147,14 +150,16 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } this._workspaceTrusted = trusted; - if (!trusted) { + this._syncConnectionState(); + } + + private _syncConnectionState(): void { + if (!this._canCommunicate()) { this._disconnect(); return; } - if (isAgentHostEnabled(this._configurationService)) { - void this._connect().catch(err => this._logService.error('[AgentHost:renderer] Failed to connect to agent host', err)); - } + void this._connect().catch(err => this._logService.error('[AgentHost:renderer] Failed to connect to agent host', err)); } private _disconnect(): void { @@ -169,13 +174,19 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return !this._isDisposed && this._workspaceTrusted && isAgentHostEnabled(this._configurationService); } - private _createWorkspaceTrustError(): Error { - return new ErrorNoTelemetry(localize('agentHost.workspaceTrustRequired', "The local agent host is unavailable because this workspace is not trusted.")); + private _createUnavailableError(): Error { + if (!this._workspaceTrusted) { + return new ErrorNoTelemetry(localize('agentHost.workspaceTrustRequired', "The local agent host is unavailable because this workspace is not trusted.")); + } + if (!isAgentHostEnabled(this._configurationService)) { + return new ErrorNoTelemetry(localize('agentHost.disabled', "The local agent host is disabled.")); + } + return new ErrorNoTelemetry(localize('agentHost.unavailable', "The local agent host is unavailable.")); } private async _getConnection(): Promise { if (!this._canCommunicate()) { - throw this._createWorkspaceTrustError(); + throw this._createUnavailableError(); } const existing = this._connection.value; @@ -185,7 +196,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos const connection = await this._connect(); if (!connection || !this._canCommunicate()) { - throw this._createWorkspaceTrustError(); + throw this._createUnavailableError(); } return connection; @@ -194,7 +205,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos private async _withProxy(callback: (proxy: IAgentService) => Promise): Promise { const connection = await this._getConnection(); if (!this._canCommunicate() || this._connection.value !== connection) { - throw this._createWorkspaceTrustError(); + throw this._createUnavailableError(); } return callback(connection.proxy); } @@ -251,6 +262,10 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos connectionTracker, dispose: () => store.dispose(), }; + if (!this._canCommunicate() || generation !== this._connectionGeneration) { + store.dispose(); + return undefined; + } this._connection.value = connection; const subscriptionsToRestore = this._subscriptionManager.getActiveSubscriptions().map(subscription => subscription.resource); this._updateTelemetryLevel(); From e9fb59f30f5188ce4bd49a11f53a70e1b51a00c7 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 21 Jun 2026 14:06:01 -0700 Subject: [PATCH 3/5] Align agent host trust with local visibility Keep local agent host communication and registration visible in untrusted windows, and enforce workspace trust at concrete send/create boundaries instead.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../electron-browser/localAgentHostService.ts | 28 +------ .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 21 +++-- .../browser/localAgentHostSessionsProvider.ts | 50 +---------- .../localAgentHostSessionsProvider.test.ts | 19 +++-- .../agentHost/agentHostChatContribution.ts | 42 ---------- .../agentHost/agentHostSessionHandler.ts | 13 ++- .../agentHostSessionListContribution.ts | 41 +-------- .../agentHostChatContribution.test.ts | 83 +++++++++++++++++-- 8 files changed, 125 insertions(+), 172 deletions(-) diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 85a44d1c1135d2..5a036e326cad21 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -32,7 +32,6 @@ import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AgentHostClientResourceChannel } fr import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryLevelConfigKey, AgentHostSessionSyncEnabledConfigKey, SESSION_SYNC_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; -import { IWorkspaceTrustManagementService } from '../../workspace/common/workspaceTrust.js'; interface ILocalAgentHostConnection extends IDisposable { readonly proxy: IAgentService; @@ -42,8 +41,7 @@ interface ILocalAgentHostConnection extends IDisposable { /** * Renderer-side implementation of {@link IAgentHostService} that connects * directly to the agent host utility process via MessagePort, bypassing - * the main process relay. The local agent host is process-wide, but each - * renderer window only connects while its workspace is trusted. + * the main process relay. */ export class LocalAgentHostServiceClient extends Disposable implements IAgentHostService { declare readonly _serviceBrand: undefined; @@ -56,7 +54,6 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos private readonly _connection = this._register(new MutableDisposable()); private _connecting: Promise | undefined; private _connectionGeneration = 0; - private _workspaceTrusted = false; private _isDisposed = false; private readonly _onAgentHostExit = this._register(new Emitter()); @@ -95,7 +92,6 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvironmentService environmentService: IEnvironmentService, @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { super(); @@ -130,8 +126,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } })); - this._register(this._workspaceTrustManagementService.onDidChangeTrust(trusted => this._setWorkspaceTrusted(trusted))); - void this._initializeWorkspaceTrust(); + this._syncConnectionState(); } override dispose(): void { @@ -139,20 +134,6 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos super.dispose(); } - private async _initializeWorkspaceTrust(): Promise { - await this._workspaceTrustManagementService.workspaceTrustInitialized; - this._setWorkspaceTrusted(this._workspaceTrustManagementService.isWorkspaceTrusted()); - } - - private _setWorkspaceTrusted(trusted: boolean): void { - if (this._workspaceTrusted === trusted) { - return; - } - - this._workspaceTrusted = trusted; - this._syncConnectionState(); - } - private _syncConnectionState(): void { if (!this._canCommunicate()) { this._disconnect(); @@ -171,13 +152,10 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } private _canCommunicate(): boolean { - return !this._isDisposed && this._workspaceTrusted && isAgentHostEnabled(this._configurationService); + return !this._isDisposed && isAgentHostEnabled(this._configurationService); } private _createUnavailableError(): Error { - if (!this._workspaceTrusted) { - return new ErrorNoTelemetry(localize('agentHost.workspaceTrustRequired', "The local agent host is unavailable because this workspace is not trusted.")); - } if (!isAgentHostEnabled(this._configurationService)) { return new ErrorNoTelemetry(localize('agentHost.disabled', "The local agent host is disabled.")); } diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 5d4cc783353f18..a9cb08120b5c9c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -26,6 +26,10 @@ Agent host providers implement `IAgentHostSessionsProvider` (defined in sessions Registered by `LocalAgentHostContribution` in `browser/localAgentHost.contribution.ts`: - **Gated on `chat.agentHost.enabled`** (`AgentHostEnabledSettingId`). If the setting is off the contribution returns early and registers nothing. +- **Not hidden by workspace trust.** The local agent host is host-level, not + workspace-specific, so the provider and its session types remain visible in + untrusted windows. Trust is enforced only when a concrete workspace folder + would be used to create or send an agent session. - Creates `LocalAgentHostSessionsProvider` via `IInstantiationService` and registers it through `ISessionsProvidersService.registerProvider`. - Registers a per-session-type **working-directory resolver** (`IAgentHostSessionWorkingDirectoryResolver`) for each `agent-host-${sessionType.id}` scheme, refreshed on `onDidChangeSessionTypes`. - The same module also wires the heavy lifting from the workbench chat layer at `WorkbenchPhase.AfterRestored`: @@ -106,6 +110,10 @@ End-to-end in the Agents window: - **Open / load content** — `ChatView.setChat(chat)` → `IChatService.acquireOrLoadSession(chat.resource, …)` → `ChatWidget.setModel(ref.object)`. `IChatService` routes the resource scheme to `AgentHostSessionHandler.provideChatSessionContent()`. `ChatView` first **locks** the widget to the contributed chat session type so follow-up turns keep routing to the same handler. - **Send** — `ISessionsManagementService.sendNewChatRequest` → `provider.createNewChat()` → `provider.sendRequest()` → `IChatService.sendRequest(chatResource, …)`, which the bound `AgentHostSessionHandler` forwards to the backend over the agent host protocol. +Workspace trust is checked at send time, not registration time: new-session +drafts prompt in `sendRequest`, while existing/restored sessions prompt in +`AgentHostSessionHandler` before dispatching the turn to the backend. + The Agents window thus depends on the classic `ChatWidget` for rendering and on the `IChatSessionContentProvider` for content/send, but **not** on `IChatSessionItemController` — that API exists only to feed the classic chat @@ -117,7 +125,7 @@ sidebar list. 1. Resolves the `ISessionType` and validates the workspace (`resolveWorkspace`). 2. Constructs a `NewSession` draft, stores it in `_newSessions`, and fires `onDidChangeSessionConfig`. -3. If a connection exists and authentication is **not** pending, eagerly starts the backend session and resolves its dynamic config in parallel. While auth is pending the draft waits; `_resumeNewSessionAfterAuthenticationSettles` (driven by the `authenticationPending` observable going false) starts the backend for all pending drafts. +3. If a connection exists, the workspace folder is trusted, and authentication is **not** pending, eagerly starts the backend session and resolves its dynamic config in parallel. While auth is pending the draft waits; `_resumeNewSessionAfterAuthenticationSettles` (driven by the `authenticationPending` observable going false) starts the backend for all pending drafts. If the folder is untrusted, the draft stays local until `sendRequest` prompts for trust. `createNewChat(chatId)` creates the chat session model (`IChatSessionsService.getOrCreateChatSession`) so the management service can open the widget, and returns the draft's main chat. @@ -126,11 +134,12 @@ sidebar list. `sendRequest(chatId, chatResource, options)`: 1. Requires the draft and an active connection. -2. Builds `IChatSendRequestOptions` (agent mode from the selected custom agent or the built-in agent, selected model, attached context, and `agentHostSessionConfig` from `getCreateSessionConfig`). -3. Loads the chat model and seeds the selected model / custom agent into the input state so the pickers reflect the choice immediately. -4. Snapshots existing cache keys, then `IChatService.sendRequest` (which the registered `AgentHostSessionHandler` routes to the backend). -5. Publishes a skeleton session (title seeded from the first line of the query) via `onDidChangeSessions` as `_pendingSession`. -6. Waits for the committed backend session (`_waitForNewSession`); on arrival the draft **graduates** (releases its eager subscription without firing `disposeSession`), config is preserved, `_pendingSession` is cleared, and `onDidReplaceSession` fires from skeleton → committed session. +2. Requires workspace trust for the draft's workspace URI, prompting the user if the window or folder is untrusted. +3. Builds `IChatSendRequestOptions` (agent mode from the selected custom agent or the built-in agent, selected model, attached context, and `agentHostSessionConfig` from `getCreateSessionConfig`). +4. Loads the chat model and seeds the selected model / custom agent into the input state so the pickers reflect the choice immediately. +5. Snapshots existing cache keys, then `IChatService.sendRequest` (which the registered `AgentHostSessionHandler` routes to the backend). +6. Publishes a skeleton session (title seeded from the first line of the query) via `onDidChangeSessions` as `_pendingSession`. +7. Waits for the committed backend session (`_waitForNewSession`); on arrival the draft **graduates** (releases its eager subscription without firing `disposeSession`), config is preserved, `_pendingSession` is cleared, and `onDidReplaceSession` fires from skeleton → committed session. ## CRUD & Stubbed Operations diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 31f197fa2a6ce9..41952424b25ce2 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -19,7 +19,6 @@ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -51,10 +50,8 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide readonly icon: ThemeIcon = Codicon.vm; readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly supportsLocalWorkspaces = true; - /** `true` when running in the dedicated Agents window vs. a regular editor window. */ private readonly _isSessionsWindow: boolean; - private _workspaceTrusted = false; protected override getLogOutputChannelId(): string | undefined { return AGENT_HOST_LOG_OUTPUT_CHANNEL_ID; @@ -87,7 +84,6 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide @IStorageService storageService: IStorageService, @IDialogService dialogService: IDialogService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, - @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService); @@ -98,48 +94,13 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide this.browseActions = []; this._attachConnectionListeners(this._agentHostService, this._store); - this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); - void this._workspaceTrustManagementService.workspaceTrustInitialized.then(() => { - this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); - if (this._workspaceTrusted) { - const current = this._agentHostService.rootState.value; - if (current && !(current instanceof Error)) { - this._syncSessionTypesFromRootState(current); - this._syncRootConfigFromRootState(current); - } - } else { - this._clearRootState(); - } - }); - this._register(this._workspaceTrustManagementService.onDidChangeTrust(trusted => { - this._workspaceTrusted = trusted; - if (trusted) { - const current = this._agentHostService.rootState.value; - if (current && !(current instanceof Error)) { - this._syncSessionTypesFromRootState(current); - this._syncRootConfigFromRootState(current); - } - this._cancelSessionRefreshRetry(); - if (!this._agentHostService.authenticationPending.get()) { - this._refreshSessions(); - this._resumeNewSessionAfterAuthenticationSettles(); - } - } else { - this._cancelSessionRefreshRetry(); - this._clearRootState(); - } - })); const rootStateValue = this._agentHostService.rootState.value; - if (this._workspaceTrusted && rootStateValue && !(rootStateValue instanceof Error)) { + if (rootStateValue && !(rootStateValue instanceof Error)) { this._syncSessionTypesFromRootState(rootStateValue); this._syncRootConfigFromRootState(rootStateValue); } this._register(this._agentHostService.rootState.onDidChange(rootState => { - if (!this._workspaceTrusted) { - this._clearRootState(); - return; - } this._syncSessionTypesFromRootState(rootState); this._syncRootConfigFromRootState(rootState); })); @@ -157,9 +118,6 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide if (this._agentHostService.authenticationPending.read(reader)) { return; } - if (!this._workspaceTrusted) { - return; - } this._refreshSessions(); this._resumeNewSessionAfterAuthenticationSettles(); })); @@ -177,10 +135,6 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide this._onDidChangeSessionTypes.fire(); } if (e.affectsConfiguration(preferAgentHostClaudeSettingId)) { - if (!this._workspaceTrusted) { - this._clearRootState(); - return; - } const current = this._agentHostService.rootState.value; if (current && !(current instanceof Error)) { this._syncSessionTypesFromRootState(current); @@ -198,7 +152,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide // -- BaseAgentHostSessionsProvider hooks --------------------------------- - protected get connection(): IAgentConnection | undefined { return this._workspaceTrusted ? this._agentHostService : undefined; } + protected get connection(): IAgentConnection | undefined { return this._agentHostService; } protected get authenticationPending(): IObservable { return this._agentHostService.authenticationPending; } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 4948cdda3cb9da..b31a369e909d77 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -467,19 +467,28 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual(provider.sessionTypes, []); }); - test('does not advertise session types while workspace is untrusted', () => { + test('advertises session types while workspace is untrusted', () => { const provider = createProvider(disposables, agentHost, undefined, { workspaceTrusted: false }); - assert.deepStrictEqual(provider.sessionTypes, []); + assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [ + { id: 'copilotcli', label: 'Copilot' }, + ]); }); - test('does not list sessions while workspace is untrusted', async () => { + test('lists sessions while workspace is untrusted', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('untrusted-visible', { summary: 'Untrusted visible' })); const provider = createProvider(disposables, agentHost, undefined, { workspaceTrusted: false }); provider.getSessions(); await timeout(0); - assert.strictEqual(agentHost.listSessionsCallCount, 0); - }); + assert.deepStrictEqual({ + listCalls: agentHost.listSessionsCallCount, + titles: provider.getSessions().map(session => session.title.get()), + }, { + listCalls: 1, + titles: ['Untrusted visible'], + }); + })); test('session type icons use per-agent codicons', () => { agentHost.setAgents([ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 6f04be0446fdf2..b6c080030a2b91 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -16,7 +16,6 @@ import { IDefaultAccountService } from '../../../../../../platform/defaultAccoun import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { Registry } from '../../../../../../platform/registry/common/platform.js'; -import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IWorkbenchContribution } from '../../../../../common/contributions.js'; import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; @@ -51,13 +50,8 @@ async function waitForLocalAgentHostActivation(accessor: ServicesAccessor, sessi } const agentHostService = accessor.get(IAgentHostService); - const workspaceTrustManagementService = accessor.get(IWorkspaceTrustManagementService); const environmentService = accessor.get(IWorkbenchEnvironmentService); - await workspaceTrustManagementService.workspaceTrustInitialized; while (true) { - if (!workspaceTrustManagementService.isWorkspaceTrusted()) { - return false; - } const rootState = agentHostService.rootState.value; if (rootState instanceof Error) { return false; @@ -99,7 +93,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr private readonly _agentRegistrations = this._register(new DisposableMap()); /** Model providers keyed by agent provider, for pushing model updates. */ private readonly _modelProviders = new Map(); - private _workspaceTrusted = false; /** Dedupes redundant `authenticate` RPCs when the resolved token hasn't changed. */ private readonly _authTokenCache = new AgentHostAuthTokenCache(); @@ -120,7 +113,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr @ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService, - @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { super(); this._isSessionsWindow = environmentService.isSessionsWindow; @@ -131,29 +123,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr } this._register(_agentHostFileSystemService.registerAuthority('local', this._agentHostService)); - this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); - void this._workspaceTrustManagementService.workspaceTrustInitialized.then(() => { - this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); - if (!this._workspaceTrusted) { - this._clearAgentRegistrations(); - } else { - const current = this._agentHostService.rootState.value; - if (current && !(current instanceof Error)) { - this._handleRootStateChange(current); - } - } - }); - this._register(this._workspaceTrustManagementService.onDidChangeTrust(trusted => { - this._workspaceTrusted = trusted; - if (!trusted) { - this._clearAgentRegistrations(); - } else { - const current = this._agentHostService.rootState.value; - if (current && !(current instanceof Error)) { - this._handleRootStateChange(current); - } - } - })); // React to root state changes (agent discovery / removal) this._register(this._agentHostService.rootState.onDidChange(rootState => { @@ -210,11 +179,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr } private _handleRootStateChange(rootState: RootState): void { - if (!this._workspaceTrusted) { - this._clearAgentRegistrations(); - return; - } - const allowed = rootState.agents.filter(a => this._shouldRegisterAgent(a.provider)); const incoming = new Set(allowed.map(a => a.provider)); @@ -245,12 +209,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr } } - private _clearAgentRegistrations(): void { - this._agentRegistrations.clearAndDisposeAll(); - this._modelProviders.clear(); - this._authTokenCache.clear(); - } - private _registerAgent(agent: AgentInfo): void { const store = new DisposableStore(); this._agentRegistrations.set(agent.provider, store); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 2daeff34acc6fa..751e8ed57c2a03 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -1285,11 +1285,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (cancellationToken.isCancellationRequested) { return; } - const turnId = request.requestId; - this._clientDispatchedTurnIds.add(turnId); const chatKey = this._resolveChatUri(request.sessionResource).toString(); const turnChannel = this._resolveTurnDispatchChannel(request.sessionResource); + const workingDirectory = this._resolveSessionWorkingDirectory(session, request.sessionResource); + if (workingDirectory) { + await this._workspaceTrust.requireTrusted(workingDirectory); + } const messageAttachments = await this._convertVariablesToAttachments(request); if (cancellationToken.isCancellationRequested) { return; @@ -1361,6 +1363,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC turnId, message: userOriginMessage(request.message, messageAttachments), }; + this._clientDispatchedTurnIds.add(turnId); this._config.connection.dispatch(turnChannel, turnAction); // Ensure the snapshot controller records a sentinel checkpoint for this @@ -3040,6 +3043,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ?? this._workspaceContextService.getWorkspace().folders[0]?.uri; } + private _resolveSessionWorkingDirectory(session: URI, sessionResource: URI): URI | undefined { + const rawWorkingDirectory = this._getSessionState(session.toString())?.summary.workingDirectory; + const workingDirectory = typeof rawWorkingDirectory === 'string' ? URI.parse(rawWorkingDirectory) : rawWorkingDirectory; + return workingDirectory ?? this._resolveRequestedWorkingDirectory(sessionResource); + } + private _convertVariablesToAttachments(request: IChatAgentRequest): MessageAttachment[] { return this._variableEntriesToAttachments(request.variables.variables, request.sessionResource, request.message); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts index 16f5250647768d..a78de16876b538 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts @@ -9,7 +9,6 @@ import { AgentHostEnabledSettingId, claudePreferAgentHostSettingId, IAgentHostSe import { type AgentInfo, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IWorkbenchContribution } from '../../../../../common/contributions.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; @@ -67,9 +66,7 @@ export class AgentHostSessionListContribution extends Disposable implements IWor private readonly _agentRegistrations = this._register(new DisposableMap()); private readonly _listControllers = new Map(); private readonly _sessionListConnection: CoalescingAgentHostSessionListConnection; - private readonly _isSessionsWindow: boolean; - private _workspaceTrusted = false; constructor( @IAgentHostService private readonly _agentHostService: IAgentHostService, @@ -78,7 +75,6 @@ export class AgentHostSessionListContribution extends Disposable implements IWor @IInstantiationService private readonly _instantiationService: IInstantiationService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IAgentHostSessionWorkingDirectoryResolver private readonly _workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, - @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { super(); @@ -89,30 +85,6 @@ export class AgentHostSessionListContribution extends Disposable implements IWor return; } - this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); - void this._workspaceTrustManagementService.workspaceTrustInitialized.then(() => { - this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); - if (!this._workspaceTrusted) { - this._clearAgentRegistrations(); - } else { - const current = this._agentHostService.rootState.value; - if (current && !(current instanceof Error)) { - this._handleRootStateChange(current); - } - } - }); - this._register(this._workspaceTrustManagementService.onDidChangeTrust(trusted => { - this._workspaceTrusted = trusted; - if (!trusted) { - this._clearAgentRegistrations(); - } else { - const current = this._agentHostService.rootState.value; - if (current && !(current instanceof Error)) { - this._handleRootStateChange(current); - } - } - })); - this._register(this._agentHostService.rootState.onDidChange(rootState => { this._handleRootStateChange(rootState); })); @@ -124,7 +96,7 @@ export class AgentHostSessionListContribution extends Disposable implements IWor })); const initialRootState = this._agentHostService.rootState.value; - if (this._workspaceTrusted && initialRootState && !(initialRootState instanceof Error)) { + if (initialRootState && !(initialRootState instanceof Error)) { this._handleRootStateChange(initialRootState); } @@ -134,7 +106,7 @@ export class AgentHostSessionListContribution extends Disposable implements IWor return; } const current = this._agentHostService.rootState.value; - if (this._workspaceTrusted && current && !(current instanceof Error)) { + if (current && !(current instanceof Error)) { this._handleRootStateChange(current); } })); @@ -145,11 +117,6 @@ export class AgentHostSessionListContribution extends Disposable implements IWor } private _handleRootStateChange(rootState: RootState): void { - if (!this._workspaceTrusted) { - this._clearAgentRegistrations(); - return; - } - const allowed = rootState.agents.filter(agent => this._shouldRegisterAgent(agent.provider)); const incoming = new Set(allowed.map(agent => agent.provider)); @@ -179,8 +146,4 @@ export class AgentHostSessionListContribution extends Disposable implements IWor store.add(this._workingDirectoryResolver.registerResolver(sessionType, _sessionResource => undefined, sessionResource => listController.isNewSession(sessionResource))); } - private _clearAgentRegistrations(): void { - this._agentRegistrations.clearAndDisposeAll(); - this._listControllers.clear(); - } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index c8ee9bd13f6e2e..e322f6fc714efd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -459,7 +459,32 @@ class MockChatWidgetService extends mock() { // ---- Helpers ---------------------------------------------------------------- -function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }, authServiceOverride?: Partial, languageModels?: ReadonlyMap, provisionalServiceOverride?: Partial, isSessionsWindow = false) { +type TestWorkingDirectoryResolver = { + resolve(sessionResource: URI): URI | undefined; + isNewSession?: (sessionResource: URI) => boolean; +}; + +type TestWorkspaceTrustOptions = { + workspaceTrustManagementService?: TestWorkspaceTrustManagementService; + resourceTrustResponse?: boolean; +}; + +type TestContributionOptions = { + authServiceOverride?: Partial; + workingDirectoryResolver?: TestWorkingDirectoryResolver; + languageModels?: ReadonlyMap; + provisionalServiceOverride?: Partial; +} & TestWorkspaceTrustOptions; + +function createTestServices( + disposables: DisposableStore, + workingDirectoryResolver?: TestWorkingDirectoryResolver, + authServiceOverride?: Partial, + languageModels?: ReadonlyMap, + provisionalServiceOverride?: Partial, + isSessionsWindow = false, + trustOptions?: TestWorkspaceTrustOptions +) { const instantiationService = disposables.add(new TestInstantiationService()); const agentHostService = new MockAgentHostService(); @@ -526,8 +551,14 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv }); instantiationService.stub(IOutputService, { getChannel: () => undefined }); instantiationService.stub(IWorkspaceContextService, { getWorkspace: () => ({ id: '', folders: [] }), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None }); - instantiationService.stub(IWorkspaceTrustManagementService, disposables.add(new TestWorkspaceTrustManagementService(true))); - instantiationService.stub(IWorkspaceTrustRequestService, disposables.add(new TestWorkspaceTrustRequestService(true))); + instantiationService.stub( + IWorkspaceTrustManagementService, + trustOptions?.workspaceTrustManagementService ?? disposables.add(new TestWorkspaceTrustManagementService(true)) + ); + instantiationService.stub( + IWorkspaceTrustRequestService, + disposables.add(new TestWorkspaceTrustRequestService(trustOptions?.resourceTrustResponse ?? true)) + ); instantiationService.stub(IChatEditingService, { registerEditingSessionProvider: () => toDisposable(() => { }), }); @@ -639,8 +670,16 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions, chatSessionItemControllers, newSessionFolderService }; } -function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap; provisionalServiceOverride?: Partial }) { - const { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride, opts?.languageModels, opts?.provisionalServiceOverride); +function createContribution(disposables: DisposableStore, opts?: TestContributionOptions) { + const { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService } = createTestServices( + disposables, + opts?.workingDirectoryResolver, + opts?.authServiceOverride, + opts?.languageModels, + opts?.provisionalServiceOverride, + false, + { workspaceTrustManagementService: opts?.workspaceTrustManagementService, resourceTrustResponse: opts?.resourceTrustResponse } + ); const listController = disposables.add(instantiationService.createInstance(AgentHostSessionListController, 'agent-host-copilot', 'copilot', agentHostService, undefined, 'local')); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { @@ -1815,6 +1854,40 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(AgentSession.id(URI.parse(session)), 'existing-session-42'); })); + test('requires workspace trust before sending to an existing session', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const workspaceTrustManagementService = disposables.add(new TestWorkspaceTrustManagementService(false)); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { + workspaceTrustManagementService, + resourceTrustResponse: false, + }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/existing-untrusted' }); + const backendSession = AgentSession.uri('copilot', 'existing-untrusted'); + const workingDirectory = URI.file('/home/user/untrusted'); + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState({ + resource: backendSession.toString(), + provider: 'copilot', + title: 'Existing untrusted', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + workingDirectory: workingDirectory.toString(), + }), + lifecycle: SessionLifecycle.Ready, + }); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + agentHostService.dispatchedActions.length = 0; + const registered = chatAgentService.registeredAgents.get('agent-host-copilot')!; + + await assert.rejects( + () => registered.impl.invoke(makeRequest({ message: 'Blocked', sessionResource }), () => { }, [], CancellationToken.None), + /Workspace trust is required/, + ); + + assert.deepStrictEqual(agentHostService.turnActions, []); + })); + test('recovers from stale failed subscription before first send', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/existing-subscribe-retry' }); const backendSession = AgentSession.uri('copilot', 'existing-subscribe-retry'); From 5ca60b250891e6ad221f0ba17c421f26c0c2c623 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 21 Jun 2026 14:28:32 -0700 Subject: [PATCH 4/5] Address agent host review feedback Remove a duplicate assertion and add workspace-trust coverage for the agent host terminal contribution.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../localAgentHostSessionsProvider.test.ts | 1 - .../agentHostTerminalContribution.test.ts | 68 ++++++++++++++++--- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index b31a369e909d77..7ab369f2511f78 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -2215,7 +2215,6 @@ suite('LocalAgentHostSessionsProvider', () => { const chat = await provider.createNewChat(session.sessionId); const committed = await provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }); assert.strictEqual(committed.resource.scheme, 'agent-host-codex', `expected the committed session to be the codex session, got ${committed.resource.toString()}`); - assert.strictEqual(committed.resource.scheme, 'agent-host-codex', `expected the committed session to be the codex session, got ${committed.resource.toString()}`); }); test('sendRequest forwards resolved session config to chat service', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts index 50e322c8360017..dc991af0627961 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts @@ -18,10 +18,10 @@ import { AgentHostConfigKey } from '../../../../../../platform/agentHost/common/ import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ActionEnvelope, IRootConfigChangedAction, INotification, SessionAction, TerminalAction, ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import type { RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ROOT_STATE_URI, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { TerminalSettingId, type ITerminalProfile } from '../../../../../../platform/terminal/common/terminal.js'; import { ITerminalProfileResolverService, ITerminalProfileService, type IShellLaunchConfigResolveOptions } from '../../../../terminal/common/terminal.js'; -import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; +import { IAgentHostTerminalService, type IAgentHostEntry } from '../../../../terminal/browser/agentHostTerminalService.js'; import { AgentHostTerminalContribution } from '../../../browser/agentSessions/agentHost/agentHostTerminalContribution.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { TestWorkspaceTrustManagementService } from '../../../../../test/common/workbenchTestServices.js'; @@ -123,6 +123,25 @@ class MockTerminalProfileService extends mock() { } } +class MockAgentHostTerminalService extends mock() { + declare readonly _serviceBrand: undefined; + + override readonly profiles = observableValue('test', []); + readonly entries: IAgentHostEntry[] = []; + + override registerEntry(entry: IAgentHostEntry): IDisposable { + this.entries.push(entry); + return { + dispose: () => { + const index = this.entries.indexOf(entry); + if (index >= 0) { + this.entries.splice(index, 1); + } + } + }; + } +} + // ---- Helpers ---- function makeRootStateWithSchema(properties: Record): RootState { @@ -158,18 +177,22 @@ function rootStateWithEnableCustomTerminalToolKey(): RootState { interface ITestSetup { contribution: AgentHostTerminalContribution; agentHostService: MockAgentHostService; + terminalService: MockAgentHostTerminalService; resolver: MockTerminalProfileResolverService; profileService: MockTerminalProfileService; configurationService: TestConfigurationService; + workspaceTrustManagementService: TestWorkspaceTrustManagementService; } -function setup(disposables: DisposableStore, agentHostEnabled: boolean = true): ITestSetup { +function setup(disposables: DisposableStore, agentHostEnabled: boolean = true, workspaceTrusted: boolean = true): ITestSetup { const instantiationService = disposables.add(new TestInstantiationService()); const agentHostService = new MockAgentHostService(); disposables.add({ dispose: () => agentHostService.dispose() }); const resolver = new MockTerminalProfileResolverService(); const profileService = new MockTerminalProfileService(); disposables.add({ dispose: () => profileService.dispose() }); + const terminalService = new MockAgentHostTerminalService(); + const workspaceTrustManagementService = disposables.add(new TestWorkspaceTrustManagementService(workspaceTrusted)); const configurationService = new TestConfigurationService({ [AgentHostEnabledSettingId]: agentHostEnabled, [AgentHostCustomTerminalToolEnabledSettingId]: true, @@ -179,14 +202,11 @@ function setup(disposables: DisposableStore, agentHostEnabled: boolean = true): instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(ITerminalProfileResolverService, resolver); instantiationService.stub(ITerminalProfileService, profileService); - instantiationService.stub(IWorkspaceTrustManagementService, disposables.add(new TestWorkspaceTrustManagementService(true))); - instantiationService.stub(IAgentHostTerminalService, { - registerEntry: (): IDisposable => ({ dispose() { } }), - profiles: observableValue('test', []), - }); + instantiationService.stub(IWorkspaceTrustManagementService, workspaceTrustManagementService); + instantiationService.stub(IAgentHostTerminalService, terminalService); const contribution = disposables.add(instantiationService.createInstance(AgentHostTerminalContribution)); - return { contribution, agentHostService, resolver, profileService, configurationService }; + return { contribution, agentHostService, terminalService, resolver, profileService, configurationService, workspaceTrustManagementService }; } /** Wait for any in-flight `_pushDefaultShell` promises to settle. */ @@ -218,6 +238,36 @@ suite('AgentHostTerminalContribution', () => { assert.deepStrictEqual(agentHostService.dispatchedActions, []); }); + test('does not register or dispatch until workspace is trusted', async () => { + const { agentHostService, terminalService, workspaceTrustManagementService } = setup(disposables, true, false); + + agentHostService.setRootState(rootStateWithDefaultShellKey()); + agentHostService.fireAgentHostStart(); + await flush(); + + assert.deepStrictEqual({ + entries: terminalService.entries.length, + actions: agentHostService.dispatchedActions, + }, { + entries: 0, + actions: [], + }); + + await workspaceTrustManagementService.setWorkspaceTrust(true); + await flush(); + + assert.deepStrictEqual({ + entries: terminalService.entries.map(entry => ({ name: entry.name, address: entry.address })), + actions: agentHostService.dispatchedActions.map(({ channel, action }) => ({ channel, action })), + }, { + entries: [{ name: 'Local', address: '__local__' }], + actions: [{ + channel: ROOT_STATE_URI.toString(), + action: { type: ActionType.RootConfigChanged, config: { [AgentHostConfigKey.DefaultShell]: '/bin/bash' } }, + }], + }); + }); + test('does not dispatch while rootState has not hydrated', async () => { const { agentHostService } = setup(disposables); From 450b36ab127e7affd4be7b46aabfd3038fd605c7 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 21 Jun 2026 14:48:23 -0700 Subject: [PATCH 5/5] agentHost: simplify workspace trust gating Keep workspace trust enforcement at concrete Agent Host create/send boundaries and remove the earlier host-level connection and terminal gating changes. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../electron-browser/localAgentHostService.ts | 250 +++++------------- .../contrib/chat/browser/newChatWidget.ts | 3 +- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 4 +- .../browser/baseAgentHostSessionsProvider.ts | 16 -- .../browser/localAgentHostSessionsProvider.ts | 3 +- .../agentHostSessionListContribution.ts | 2 +- .../agentHostTerminalContribution.ts | 14 +- .../agentHostTerminalContribution.test.ts | 70 +---- 8 files changed, 78 insertions(+), 284 deletions(-) diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 5a036e326cad21..45e744031786f6 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -3,20 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter, Event, Relay } from '../../../base/common/event.js'; -import { ErrorNoTelemetry } from '../../../base/common/errors.js'; -import { Disposable, DisposableStore, IReference, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { DeferredPromise } from '../../../base/common/async.js'; +import { Emitter, Relay } from '../../../base/common/event.js'; +import { Disposable, DisposableStore, IReference } from '../../../base/common/lifecycle.js'; import { IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { getDelayedChannel, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Client as MessagePortClient } from '../../../base/parts/ipc/common/ipc.mp.js'; import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js'; -import { localize } from '../../../nls.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentHostAhpJsonlLoggingSettingId, AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService, isAgentHostEnabled, IMcpNotification } from '../common/agentService.js'; +import { AgentHostAhpJsonlLoggingSettingId, AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService, isAgentHostEnabled, IMcpNotification } from '../common/agentService.js'; import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { wrapAgentServiceWithAhpLogging } from './localAhpJsonlLogging.js'; import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; @@ -33,15 +32,11 @@ import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETR import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryLevelConfigKey, AgentHostSessionSyncEnabledConfigKey, SESSION_SYNC_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; -interface ILocalAgentHostConnection extends IDisposable { - readonly proxy: IAgentService; - readonly connectionTracker: IConnectionTrackerService; -} - /** * Renderer-side implementation of {@link IAgentHostService} that connects * directly to the agent host utility process via MessagePort, bypassing - * the main process relay. + * the main process relay. Uses the same `getDelayedChannel` pattern as + * the pty host so the proxy is usable immediately while the port is acquired. */ export class LocalAgentHostServiceClient extends Disposable implements IAgentHostService { declare readonly _serviceBrand: undefined; @@ -49,12 +44,11 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos /** Unique identifier for this window, used in action envelope origin tracking. */ readonly clientId = generateUuid(); + private readonly _clientEventually = new DeferredPromise(); + private readonly _proxy: IAgentService; private readonly _ahpLogger: AhpJsonlLogger | undefined; + private readonly _connectionTracker: IConnectionTrackerService; private readonly _subscriptionManager: AgentSubscriptionManager; - private readonly _connection = this._register(new MutableDisposable()); - private _connecting: Promise | undefined; - private _connectionGeneration = 0; - private _isDisposed = false; private readonly _onAgentHostExit = this._register(new Emitter()); readonly onAgentHostExit = this._onAgentHostExit.event; @@ -95,6 +89,12 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos ) { super(); + // Create a proxy backed by a delayed channel - usable immediately, + // calls queue until the MessagePort connection is established. + const rawProxy = ProxyChannel.toService( + getDelayedChannel(this._clientEventually.p.then(client => client.getChannel(AgentHostIpcChannels.AgentHost))) + ); + // Optionally wrap the proxy with a logging layer that synthesizes JSON-RPC // frames for every request/response/notification on the in-process MessagePort // channel, mirroring the AHP transport JSONL logs produced by remote agent hosts. @@ -105,6 +105,11 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos transport: 'local', })) : undefined; + this._proxy = this._ahpLogger ? wrapAgentServiceWithAhpLogging(rawProxy, this._ahpLogger) : rawProxy; + + this._connectionTracker = ProxyChannel.toService( + getDelayedChannel(this._clientEventually.p.then(client => client.getChannel(AgentHostIpcChannels.ConnectionTracker))) + ); this._subscriptionManager = this._register(new AgentSubscriptionManager( this.clientId, @@ -121,108 +126,19 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos if (e.affectsConfiguration(SESSION_SYNC_ENABLED_SETTING_ID)) { this._updateSessionSyncEnabled(); } - if (e.affectsConfiguration(AgentHostEnabledSettingId)) { - this._syncConnectionState(); - } })); - this._syncConnectionState(); - } - - override dispose(): void { - this._isDisposed = true; - super.dispose(); - } - - private _syncConnectionState(): void { - if (!this._canCommunicate()) { - this._disconnect(); - return; - } - - void this._connect().catch(err => this._logService.error('[AgentHost:renderer] Failed to connect to agent host', err)); - } - - private _disconnect(): void { - this._connectionGeneration++; - this._connecting = undefined; - this._connection.clear(); - this._onMcpNotification.input = Event.None; - this._completionTriggerCharactersOnce = undefined; - } - - private _canCommunicate(): boolean { - return !this._isDisposed && isAgentHostEnabled(this._configurationService); - } - - private _createUnavailableError(): Error { - if (!isAgentHostEnabled(this._configurationService)) { - return new ErrorNoTelemetry(localize('agentHost.disabled', "The local agent host is disabled.")); - } - return new ErrorNoTelemetry(localize('agentHost.unavailable', "The local agent host is unavailable.")); - } - - private async _getConnection(): Promise { - if (!this._canCommunicate()) { - throw this._createUnavailableError(); - } - - const existing = this._connection.value; - if (existing) { - return existing; - } - - const connection = await this._connect(); - if (!connection || !this._canCommunicate()) { - throw this._createUnavailableError(); - } - - return connection; - } - - private async _withProxy(callback: (proxy: IAgentService) => Promise): Promise { - const connection = await this._getConnection(); - if (!this._canCommunicate() || this._connection.value !== connection) { - throw this._createUnavailableError(); - } - return callback(connection.proxy); - } - - private async _connect(): Promise { - if (!this._canCommunicate()) { - return undefined; - } - - const existing = this._connection.value; - if (existing) { - return existing; - } - - if (this._connecting) { - return this._connecting; + if (isAgentHostEnabled(this._configurationService)) { + this._connect(); } - - const generation = this._connectionGeneration; - const connecting = this._doConnect(generation); - this._connecting = connecting; - void connecting.finally(() => { - if (this._connecting === connecting) { - this._connecting = undefined; - } - }); - return connecting; } - private async _doConnect(generation: number): Promise { + private async _connect(): Promise { this._logService.info('[AgentHost:renderer] Acquiring MessagePort to agent host...'); const port = await acquirePort('vscode:createAgentHostMessageChannel', 'vscode:createAgentHostMessageChannelResult'); - if (!this._canCommunicate() || generation !== this._connectionGeneration) { - port.close(); - return undefined; - } this._logService.info('[AgentHost:renderer] MessagePort acquired, creating client...'); - const store = new DisposableStore(); + const store = this._register(new DisposableStore()); // Use clientId as the IPC ctx so the agent host can route reverse-RPC // calls (vscode-agent-client filesystem reads) back to this renderer // via `IPCServer.getChannel(name, c => c.ctx === clientId)`. @@ -231,25 +147,11 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos // agent host registers an authority on its // AgentHostClientFileSystemProvider that calls back through this channel. client.registerChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, this._instantiationService.createInstance(AgentHostClientResourceChannel, this._ahpLogger)); - - const rawProxy = ProxyChannel.toService(client.getChannel(AgentHostIpcChannels.AgentHost)); - const proxy = this._ahpLogger ? wrapAgentServiceWithAhpLogging(rawProxy, this._ahpLogger) : rawProxy; - const connectionTracker = ProxyChannel.toService(client.getChannel(AgentHostIpcChannels.ConnectionTracker)); - const connection: ILocalAgentHostConnection = { - proxy, - connectionTracker, - dispose: () => store.dispose(), - }; - if (!this._canCommunicate() || generation !== this._connectionGeneration) { - store.dispose(); - return undefined; - } - this._connection.value = connection; - const subscriptionsToRestore = this._subscriptionManager.getActiveSubscriptions().map(subscription => subscription.resource); + this._clientEventually.complete(client); this._updateTelemetryLevel(); this._updateSessionSyncEnabled(); - store.add(proxy.onDidAction(e => { + store.add(this._proxy.onDidAction(e => { const revived = revive(e) as ActionEnvelope; if (this._ahpLogger) { const frame = { jsonrpc: '2.0' as const, method: 'action', params: e }; @@ -258,38 +160,23 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos this._subscriptionManager.receiveEnvelope(revived); this._onDidAction.fire(revived); })); - store.add(proxy.onDidNotification(e => { + store.add(this._proxy.onDidNotification(e => { if (this._ahpLogger) { const frame = { jsonrpc: '2.0' as const, method: 'notification', params: { notification: e } }; this._ahpLogger.log(frame, 's2c'); } this._onDidNotification.fire(revive(e)); })); - this._onMcpNotification.input = proxy.onMcpNotification; + this._onMcpNotification.input = this._proxy.onMcpNotification; this._logService.info('[AgentHost:renderer] Direct MessagePort connection established'); this._onAgentHostStart.fire(); // Subscribe to root state - proxy.subscribe(URI.parse(ROOT_STATE_URI), this.clientId).then(snapshot => { - if (this._connection.value !== connection || !this._canCommunicate()) { - return; - } + this.subscribe(URI.parse(ROOT_STATE_URI)).then(snapshot => { this._subscriptionManager.handleRootSnapshot(snapshot.state as RootState, snapshot.fromSeq); }).catch(err => { this._logService.error('[AgentHost:renderer] Failed to subscribe to root state', err); }); - for (const resource of subscriptionsToRestore) { - proxy.subscribe(resource, this.clientId).then(snapshot => { - if (this._connection.value !== connection || !this._canCommunicate()) { - return; - } - this._subscriptionManager.applyReconnectSnapshot(resource.toString(), snapshot.state, snapshot.fromSeq); - }).catch(err => { - this._logService.error(`[AgentHost:renderer] Failed to restore subscription ${resource.toString()}`, err); - }); - } - - return connection; } private _updateTelemetryLevel(): void { @@ -307,16 +194,16 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos }, this.clientId, 0); } - // ---- IAgentService forwarding ---- + // ---- IAgentService forwarding (no await needed, delayed channel handles queuing) ---- authenticate(params: AuthenticateParams): Promise { - return this._withProxy(proxy => proxy.authenticate(params)); + return this._proxy.authenticate(params); } listSessions(): Promise { - return this._withProxy(proxy => proxy.listSessions()); + return this._proxy.listSessions(); } createSession(config?: IAgentCreateSessionConfig): Promise { - const promise = this._withProxy(proxy => proxy.createSession(config)); + const promise = this._proxy.createSession(config); // When the caller pre-specifies the session URI, a subscribe for // that URI can race the in-flight create. Register the promise so // `AgentSubscriptionManager.getSubscription` gates the wire-level @@ -329,69 +216,54 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return promise; } resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { - return this._withProxy(proxy => proxy.resolveSessionConfig(params)); + return this._proxy.resolveSessionConfig(params); } sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { - return this._withProxy(proxy => proxy.sessionConfigCompletions(params)); + return this._proxy.sessionConfigCompletions(params); } completions(params: CompletionsParams): Promise { - return this._withProxy(proxy => proxy.completions(params)); + return this._proxy.completions(params); } getCompletionTriggerCharacters(): Promise { - if (!this._completionTriggerCharactersOnce) { - const promise = this._withProxy(proxy => proxy.getCompletionTriggerCharacters()); - this._completionTriggerCharactersOnce = promise; - void promise.catch(() => { - if (this._completionTriggerCharactersOnce === promise) { - this._completionTriggerCharactersOnce = undefined; - } - }); - } - return this._completionTriggerCharactersOnce; + return this._completionTriggerCharactersOnce ??= this._proxy.getCompletionTriggerCharacters(); } private _completionTriggerCharactersOnce: Promise | undefined; disposeSession(session: URI): Promise { - return this._withProxy(proxy => proxy.disposeSession(session)); + return this._proxy.disposeSession(session); } createChat(session: URI, chat: URI, options?: IAgentCreateChatOptions): Promise { - return this._withProxy(proxy => proxy.createChat(session, chat, options)); + return this._proxy.createChat(session, chat, options); } disposeChat(chat: URI): Promise { const session = parseChatUri(chat)?.session; if (!session) { return Promise.resolve(); } - return this._withProxy(proxy => proxy.disposeChat(URI.parse(session), chat)); + return this._proxy.disposeChat(URI.parse(session), chat); } createTerminal(params: CreateTerminalParams): Promise { - return this._withProxy(proxy => proxy.createTerminal(params)); + return this._proxy.createTerminal(params); } disposeTerminal(terminal: URI): Promise { - return this._withProxy(proxy => proxy.disposeTerminal(terminal)); + return this._proxy.disposeTerminal(terminal); } invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise { - return this._withProxy(proxy => proxy.invokeChangesetOperation(params)); + return this._proxy.invokeChangesetOperation(params); } handleMcpRequest(channel: string, method: string, params: Record | undefined): Promise { - return this._withProxy(proxy => proxy.handleMcpRequest(channel, method, params)); + return this._proxy.handleMcpRequest(channel, method, params); } shutdown(): Promise { - return this._withProxy(proxy => proxy.shutdown()); + return this._proxy.shutdown(); } private subscribe(resource: URI): Promise { - return this._withProxy(proxy => proxy.subscribe(resource, this.clientId)); + return this._proxy.subscribe(resource, this.clientId); } private unsubscribe(resource: URI): void { - const proxy = this._connection.value?.proxy; - if (this._canCommunicate() && proxy) { - proxy.unsubscribe(resource, this.clientId); - } + this._proxy.unsubscribe(resource, this.clientId); } dispatchAction(channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { - const proxy = this._connection.value?.proxy; - if (this._canCommunicate() && proxy) { - proxy.dispatchAction(channel, action, clientId, clientSeq); - } + this._proxy.dispatchAction(channel, action, clientId, clientSeq); } private _nextSeq = 1; nextClientSeq(): number { @@ -424,35 +296,35 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } resourceList(uri: URI): Promise { - return this._withProxy(proxy => proxy.resourceList(uri)); + return this._proxy.resourceList(uri); } resourceRead(uri: URI): Promise { - return this._withProxy(proxy => proxy.resourceRead(uri)); + return this._proxy.resourceRead(uri); } resourceWrite(params: ResourceWriteParams): Promise { - return this._withProxy(proxy => proxy.resourceWrite(params)); + return this._proxy.resourceWrite(params); } resourceCopy(params: ResourceCopyParams): Promise { - return this._withProxy(proxy => proxy.resourceCopy(params)); + return this._proxy.resourceCopy(params); } resourceDelete(params: ResourceDeleteParams): Promise { - return this._withProxy(proxy => proxy.resourceDelete(params)); + return this._proxy.resourceDelete(params); } resourceMove(params: ResourceMoveParams): Promise { - return this._withProxy(proxy => proxy.resourceMove(params)); + return this._proxy.resourceMove(params); } resourceResolve(params: ResourceResolveParams): Promise { - return this._withProxy(proxy => proxy.resourceResolve(params)); + return this._proxy.resourceResolve(params); } resourceMkdir(params: ResourceMkdirParams): Promise { - return this._withProxy(proxy => proxy.resourceMkdir(params)); + return this._proxy.resourceMkdir(params); } createResourceWatch(params: CreateResourceWatchParams): Promise { - return this._withProxy(proxy => proxy.createResourceWatch(params)); + return this._proxy.createResourceWatch(params); } watchResource(params: CreateResourceWatchParams): Promise { return createRemoteWatchHandle({ - createResourceWatch: p => this.createResourceWatch(p), + createResourceWatch: p => this._proxy.createResourceWatch(p), subscribe: uri => this.subscribe(uri), unsubscribe: uri => this.unsubscribe(uri), onDidAction: this.onDidAction, @@ -463,10 +335,10 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } startWebSocketServer(): Promise { - return this._getConnection().then(connection => connection.connectionTracker.startWebSocketServer()); + return this._connectionTracker.startWebSocketServer(); } getInspectInfo(tryEnable: boolean): Promise { - return this._getConnection().then(connection => connection.connectionTracker.getInspectInfo(tryEnable)); + return this._connectionTracker.getInspectInfo(tryEnable); } } diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 60ff53ecd623dd..3aad8b445598dc 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -25,7 +25,6 @@ import { sessionHasNoSelectableModel } from './modelPicker.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { NoAgentHostEmptyState } from './noAgentHostEmptyState.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -import { defaultAgentHostWorkspaceTrustRequestMessage } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorkspaceTrust.js'; import { IAgentHostFilterService } from '../../../services/agentHostFilter/common/agentHostFilter.js'; import { IChatViewOptions } from '../../../browser/parts/chatView.js'; @@ -415,7 +414,7 @@ export class NewChatWidget extends Disposable { private async _requestFolderTrust(folderUri: URI): Promise { const trusted = await this.workspaceTrustRequestService.requestResourcesTrust({ uri: folderUri, - message: defaultAgentHostWorkspaceTrustRequestMessage, + message: localize('trustFolderMessage', "An agent session will be able to read files, run commands, and make changes in this folder."), }); if (!trusted) { this._workspacePicker.removeFromRecents(folderUri); diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index a9cb08120b5c9c..9317cdb7131da1 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -29,7 +29,9 @@ Registered by `LocalAgentHostContribution` in `browser/localAgentHost.contributi - **Not hidden by workspace trust.** The local agent host is host-level, not workspace-specific, so the provider and its session types remain visible in untrusted windows. Trust is enforced only when a concrete workspace folder - would be used to create or send an agent session. + would be used to create or send an agent session. Do not gate host-level + plumbing (local connection, provider listing, terminal entry registration, or + root config sync) on workspace trust. - Creates `LocalAgentHostSessionsProvider` via `IInstantiationService` and registers it through `ISessionsProvidersService.registerProvider`. - Registers a per-session-type **working-directory resolver** (`IAgentHostSessionWorkingDirectoryResolver`) for each `agent-host-${sessionType.id}` scheme, refreshed on `onDidChangeSessionTypes`. - The same module also wires the heavy lifting from the workbench chat layer at `WorkbenchPhase.AfterRestored`: diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index ef7ef3db53d8c5..a0faf142eae5e5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -1576,22 +1576,6 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeRootConfig.fire(); } - protected _clearRootState(): void { - if (this._lastAgents !== undefined) { - this._lastAgents = undefined; - this._onDidChangeCustomAgents.fire(); - this._onDidChangeCustomizations.fire(); - } - if (this._sessionTypes.length) { - this._sessionTypes = []; - this._onDidChangeSessionTypes.fire(); - } - if (this._rootConfig) { - this._rootConfig = undefined; - this._onDidChangeRootConfig.fire(); - } - } - abstract resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined; /** Optional event fired when the underlying connection is lost; used to short-circuit `_waitForNewSession`. */ diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 41952424b25ce2..a655d3f390ffbc 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -50,6 +50,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide readonly icon: ThemeIcon = Codicon.vm; readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly supportsLocalWorkspaces = true; + /** `true` when running in the dedicated Agents window vs. a regular editor window. */ private readonly _isSessionsWindow: boolean; @@ -152,7 +153,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide // -- BaseAgentHostSessionsProvider hooks --------------------------------- - protected get connection(): IAgentConnection | undefined { return this._agentHostService; } + protected get connection(): IAgentConnection { return this._agentHostService; } protected get authenticationPending(): IObservable { return this._agentHostService.authenticationPending; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts index a78de16876b538..2f3265079d30f8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts @@ -66,6 +66,7 @@ export class AgentHostSessionListContribution extends Disposable implements IWor private readonly _agentRegistrations = this._register(new DisposableMap()); private readonly _listControllers = new Map(); private readonly _sessionListConnection: CoalescingAgentHostSessionListConnection; + private readonly _isSessionsWindow: boolean; constructor( @@ -145,5 +146,4 @@ export class AgentHostSessionListContribution extends Disposable implements IWor store.add(this._chatSessionsService.registerChatSessionItemController(sessionType, listController)); store.add(this._workingDirectoryResolver.registerResolver(sessionType, _sessionResource => undefined, sessionResource => listController.isNewSession(sessionResource))); } - } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts index 056275485c391c..19da2678097c6b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts @@ -12,7 +12,6 @@ import { ActionType } from '../../../../../../platform/agentHost/common/state/pr import { ROOT_STATE_URI } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TerminalSettingId } from '../../../../../../platform/terminal/common/terminal.js'; -import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IWorkbenchContribution } from '../../../../../../workbench/common/contributions.js'; import { ITerminalProfileResolverService, ITerminalProfileService } from '../../../../../../workbench/contrib/terminal/common/terminal.js'; import { IAgentHostTerminalService } from '../../../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; @@ -66,7 +65,6 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe private readonly _localEntry = this._register(new MutableDisposable()); private readonly _conditionalListeners = this._register(new MutableDisposable()); - private _workspaceTrusted = false; /** Declarative table of the root-config keys we manage. */ private readonly _managedKeys: readonly IManagedRootConfigKey[]; @@ -84,7 +82,6 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe @IConfigurationService private readonly _configurationService: IConfigurationService, @ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService, @ITerminalProfileResolverService private readonly _terminalProfileResolverService: ITerminalProfileResolverService, - @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { super(); @@ -119,21 +116,12 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe this._updateEnabled(); } })); - this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); - void this._workspaceTrustManagementService.workspaceTrustInitialized.then(() => { - this._workspaceTrusted = this._workspaceTrustManagementService.isWorkspaceTrusted(); - this._updateEnabled(); - }); - this._register(this._workspaceTrustManagementService.onDidChangeTrust(trusted => { - this._workspaceTrusted = trusted; - this._updateEnabled(); - })); this._updateEnabled(); } private _updateEnabled(): void { - if (this._configurationService.getValue(AgentHostEnabledSettingId) && this._workspaceTrusted) { + if (this._configurationService.getValue(AgentHostEnabledSettingId)) { if (!this._conditionalListeners.value) { const store = new DisposableStore(); store.add(this._agentHostService.onAgentHostStart(() => this._reconcile())); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts index dc991af0627961..e3d669eb2c5db7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts @@ -18,13 +18,11 @@ import { AgentHostConfigKey } from '../../../../../../platform/agentHost/common/ import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ActionEnvelope, IRootConfigChangedAction, INotification, SessionAction, TerminalAction, ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { ROOT_STATE_URI, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import type { RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { TerminalSettingId, type ITerminalProfile } from '../../../../../../platform/terminal/common/terminal.js'; import { ITerminalProfileResolverService, ITerminalProfileService, type IShellLaunchConfigResolveOptions } from '../../../../terminal/common/terminal.js'; -import { IAgentHostTerminalService, type IAgentHostEntry } from '../../../../terminal/browser/agentHostTerminalService.js'; +import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { AgentHostTerminalContribution } from '../../../browser/agentSessions/agentHost/agentHostTerminalContribution.js'; -import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; -import { TestWorkspaceTrustManagementService } from '../../../../../test/common/workbenchTestServices.js'; // ---- Mock agent host service (minimal — only what the contribution touches) ---- @@ -123,25 +121,6 @@ class MockTerminalProfileService extends mock() { } } -class MockAgentHostTerminalService extends mock() { - declare readonly _serviceBrand: undefined; - - override readonly profiles = observableValue('test', []); - readonly entries: IAgentHostEntry[] = []; - - override registerEntry(entry: IAgentHostEntry): IDisposable { - this.entries.push(entry); - return { - dispose: () => { - const index = this.entries.indexOf(entry); - if (index >= 0) { - this.entries.splice(index, 1); - } - } - }; - } -} - // ---- Helpers ---- function makeRootStateWithSchema(properties: Record): RootState { @@ -177,22 +156,18 @@ function rootStateWithEnableCustomTerminalToolKey(): RootState { interface ITestSetup { contribution: AgentHostTerminalContribution; agentHostService: MockAgentHostService; - terminalService: MockAgentHostTerminalService; resolver: MockTerminalProfileResolverService; profileService: MockTerminalProfileService; configurationService: TestConfigurationService; - workspaceTrustManagementService: TestWorkspaceTrustManagementService; } -function setup(disposables: DisposableStore, agentHostEnabled: boolean = true, workspaceTrusted: boolean = true): ITestSetup { +function setup(disposables: DisposableStore, agentHostEnabled: boolean = true): ITestSetup { const instantiationService = disposables.add(new TestInstantiationService()); const agentHostService = new MockAgentHostService(); disposables.add({ dispose: () => agentHostService.dispose() }); const resolver = new MockTerminalProfileResolverService(); const profileService = new MockTerminalProfileService(); disposables.add({ dispose: () => profileService.dispose() }); - const terminalService = new MockAgentHostTerminalService(); - const workspaceTrustManagementService = disposables.add(new TestWorkspaceTrustManagementService(workspaceTrusted)); const configurationService = new TestConfigurationService({ [AgentHostEnabledSettingId]: agentHostEnabled, [AgentHostCustomTerminalToolEnabledSettingId]: true, @@ -202,11 +177,13 @@ function setup(disposables: DisposableStore, agentHostEnabled: boolean = true, w instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(ITerminalProfileResolverService, resolver); instantiationService.stub(ITerminalProfileService, profileService); - instantiationService.stub(IWorkspaceTrustManagementService, workspaceTrustManagementService); - instantiationService.stub(IAgentHostTerminalService, terminalService); + instantiationService.stub(IAgentHostTerminalService, { + registerEntry: (): IDisposable => ({ dispose() { } }), + profiles: observableValue('test', []), + }); const contribution = disposables.add(instantiationService.createInstance(AgentHostTerminalContribution)); - return { contribution, agentHostService, terminalService, resolver, profileService, configurationService, workspaceTrustManagementService }; + return { contribution, agentHostService, resolver, profileService, configurationService }; } /** Wait for any in-flight `_pushDefaultShell` promises to settle. */ @@ -238,36 +215,6 @@ suite('AgentHostTerminalContribution', () => { assert.deepStrictEqual(agentHostService.dispatchedActions, []); }); - test('does not register or dispatch until workspace is trusted', async () => { - const { agentHostService, terminalService, workspaceTrustManagementService } = setup(disposables, true, false); - - agentHostService.setRootState(rootStateWithDefaultShellKey()); - agentHostService.fireAgentHostStart(); - await flush(); - - assert.deepStrictEqual({ - entries: terminalService.entries.length, - actions: agentHostService.dispatchedActions, - }, { - entries: 0, - actions: [], - }); - - await workspaceTrustManagementService.setWorkspaceTrust(true); - await flush(); - - assert.deepStrictEqual({ - entries: terminalService.entries.map(entry => ({ name: entry.name, address: entry.address })), - actions: agentHostService.dispatchedActions.map(({ channel, action }) => ({ channel, action })), - }, { - entries: [{ name: 'Local', address: '__local__' }], - actions: [{ - channel: ROOT_STATE_URI.toString(), - action: { type: ActionType.RootConfigChanged, config: { [AgentHostConfigKey.DefaultShell]: '/bin/bash' } }, - }], - }); - }); - test('does not dispatch while rootState has not hydrated', async () => { const { agentHostService } = setup(disposables); @@ -494,3 +441,4 @@ suite('AgentHostTerminalContribution', () => { assert.deepStrictEqual(agentHostService.dispatchedActions as readonly unknown[], []); }); }); +