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..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 @@ -26,6 +26,12 @@ 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. 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`: @@ -106,6 +112,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 +127,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 +136,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/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index ca876e4ceba677..a0faf142eae5e5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -35,6 +35,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.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'; @@ -1414,6 +1415,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, @@ -1430,6 +1432,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement @IDialogService protected readonly _dialogService: IDialogService, ) { super(); + this._workspaceTrust = this._instantiationService.createInstance(AgentHostWorkspaceTrust); this._register(toDisposable(() => { for (const cached of this._sessionCache.values()) { cached.dispose(); @@ -1717,8 +1720,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}`)); } /** @@ -2400,6 +2411,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/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index b3eac872025e9a..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 @@ -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 ------------------------------------------------- @@ -283,7 +285,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; confirmDelete?: 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; confirmDelete?: boolean }): LocalAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IAgentHostService, agentHostService); @@ -311,6 +313,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; }()); @@ -340,6 +344,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); @@ -456,6 +467,29 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual(provider.sessionTypes, []); }); + test('advertises session types while workspace is untrusted', () => { + const provider = createProvider(disposables, agentHost, undefined, { workspaceTrusted: false }); + + assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [ + { id: 'copilotcli', label: 'Copilot' }, + ]); + }); + + 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.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([ { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo, @@ -1326,6 +1360,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, @@ -1342,9 +1377,11 @@ 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.mode === 'autopilot'); + const seededImmediately = provider.getSessionConfig(session.sessionId)?.values.mode; + await waitForResolveSessionConfigRequests(agentHost, 1); assert.deepStrictEqual({ - seededImmediately: provider.getSessionConfig(session.sessionId)?.values.mode, + seededImmediately, forwardedToAgentHost: agentHost.resolveSessionConfigRequests.at(-1)?.config?.mode, }, { seededImmediately: 'autopilot', @@ -1380,9 +1417,11 @@ suite('LocalAgentHostSessionsProvider', () => { await config.setUserConfiguration('chat.agentSessions.defaultConfiguration', { approvals: 'autoApprove' }); 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', @@ -1422,14 +1461,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' }, @@ -1448,12 +1489,14 @@ suite('LocalAgentHostSessionsProvider', () => { await policyRestrictedConfig.setUserConfiguration('chat.agentSessions.defaultConfiguration', { approvals: '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 'autoApprove' const configuredDefaultConfig = new TestConfigurationService(); await configuredDefaultConfig.setUserConfiguration('chat.agentSessions.defaultConfiguration', { approvals: '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. @@ -2123,6 +2166,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 only commits a session of the same type, ignoring a foreign-type session that appears mid-send', async () => { // Regression test: the local agent host runs a single provider whose // session cache holds every agent-host session type (codex, claude, @@ -2153,7 +2214,6 @@ suite('LocalAgentHostSessionsProvider', () => { const session = provider.createNewSession(URI.parse('file:///home/user/project'), 'codex'); 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()}`); }); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index cde3c5041aa241..c308b09e58eac8 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -529,7 +529,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 3b6dc03eb23e2f..f13cd2c1569ccb 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, {}); @@ -211,6 +213,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, }); @@ -242,6 +246,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; @@ -472,10 +486,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, }); })); @@ -903,6 +919,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/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index c45e402d223133..751e8ed57c2a03 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, usageInfoToQuotas, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; @@ -435,6 +436,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; /** * Backend session URIs with an in-flight {@link provideChatSessionContent} @@ -467,6 +469,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); @@ -1282,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; @@ -1358,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 @@ -2788,6 +2794,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. @@ -3033,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/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 93aa3218ac4539..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 @@ -40,12 +40,13 @@ 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, AgentHostSessionHandler } from '../../../browser/agentSessions/agentHost/agentHostChatContribution.js'; import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; import { AgentHostSessionListContribution, CoalescingAgentHostSessionListConnection } from '../../../browser/agentSessions/agentHost/agentHostSessionListContribution.js'; import { AgentHostSessionListController } from '../../../browser/agentSessions/agentHost/agentHostSessionListController.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'; @@ -458,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(); @@ -525,6 +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, + trustOptions?.workspaceTrustManagementService ?? disposables.add(new TestWorkspaceTrustManagementService(true)) + ); + instantiationService.stub( + IWorkspaceTrustRequestService, + disposables.add(new TestWorkspaceTrustRequestService(trustOptions?.resourceTrustResponse ?? true)) + ); instantiationService.stub(IChatEditingService, { registerEditingSessionProvider: () => toDisposable(() => { }), }); @@ -636,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, { @@ -1812,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');