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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -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}`));
}

/**
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -------------------------------------------------

Expand Down Expand Up @@ -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<ChatSendResult>; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable<IActiveSession | undefined>; storageService?: IStorageService; isSessionsWindow?: boolean; confirmDelete?: boolean }): LocalAgentHostSessionsProvider {
], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise<ChatSendResult>; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable<IActiveSession | undefined>; storageService?: IStorageService; isSessionsWindow?: boolean; workspaceTrusted?: boolean; resourceTrustResponse?: boolean; workspaceTrustManagementService?: TestWorkspaceTrustManagementService; confirmDelete?: boolean }): LocalAgentHostSessionsProvider {
const instantiationService = disposables.add(new TestInstantiationService());

instantiationService.stub(IAgentHostService, agentHostService);
Expand Down Expand Up @@ -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<IGitHubService>() {
override findPullRequestNumberByHeadBranch = async () => undefined;
}());
Expand Down Expand Up @@ -340,6 +344,13 @@ async function waitForSessionConfig(provider: LocalAgentHostSessionsProvider, se
});
}

async function waitForResolveSessionConfigRequests(agentHost: MockAgentHostService, count: number): Promise<void> {
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<string, string>; project?: { uri: string; displayName: string }; workingDirectory?: string; changes?: ChangesSummary }): void {
const provider = opts?.provider ?? 'copilotcli';
const sessionUri = AgentSession.uri(provider, rawId);
Expand Down Expand Up @@ -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<void>({ 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,
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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' },
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()}`);
});

Expand Down
Loading
Loading