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
5 changes: 5 additions & 0 deletions src/vs/platform/agentHost/browser/agentHostProtocolClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1800,6 +1800,11 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
return this._dispatchRequest<IVscodeUpgradeResult>(method, {}, { allowIncompatibleUpgrade: true });
}

/** Low-level transport for typed host-extension adapters, not part of the shared agent connection. */
requestHostExtension(method: string, params: Record<string, unknown>): Promise<unknown> {
return this._dispatchRequest<unknown>(method, params);
}

private _handleMessage(msg: ProtocolMessage): void {
if (this._state.kind === AgentHostClientState.Closed) {
// After close, the transport may still emit late messages (e.g.
Expand Down
5 changes: 5 additions & 0 deletions src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
// to reach an agent host over one transport; it does not define a new kind of agent host.

import { CancellationToken } from '../../../base/common/cancellation.js';
import { URI } from '../../../base/common/uri.js';
import { IConfigurationService } from '../../configuration/common/configuration.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { RemoteAgentHostsEnabledSettingId } from './remoteAgentHostService.js';
import { IReplayedTaskHistory } from './taskEventReplay.js';
import type { IAgentConnection } from './agentService.js';

/** Configuration key gating the cloud-sandbox connection path. Disabled by default. */
export const CloudSandboxEnabledSettingId = 'chat.agentHost.cloudSandbox.enabled';
Expand Down Expand Up @@ -360,4 +362,7 @@ export interface ICloudSandboxAgentHostService {
* `/connect` and refreshed by `/reconnect`, or `undefined` when there is no connection.
*/
getSealedGitHubToken(environmentId: string): string | undefined;

/** Prepare a repository through the typed project client owned by this sandbox connection. */
prepareWorkingDirectory(connection: IAgentConnection, workingDirectory: URI | undefined, token: CancellationToken): Promise<URI | undefined>;
}
13 changes: 13 additions & 0 deletions src/vs/platform/agentHost/common/meta/agentHostProjectMeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import type { RootState } from '../state/sessionState.js';

/** Whether the host exposes the Copilot project-management extension. */
export function supportsAgentHostProjectManagement(state: RootState): boolean {
const capability = state._meta?.['copilot.projectManagement'];
return typeof capability === 'object' && capability !== null
&& (capability as { available?: unknown }).available === true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,29 @@ suite('AgentHostProtocolClient', () => {
await connectPromise;
}

test('host extension requests preserve parameters and return the host response', async () => {
const { client, transport } = createClient();
await connectClient(client, transport);
const params = { channel: ROOT_STATE_URI, repository: 'https://example.com/owner/repo' };
const response = client.requestHostExtension('x-test/prepareRepository', params);
await timeout(0);
const request = transport.sentMessages.find((message): message is JsonRpcRequest => 'id' in message && 'method' in message && message.method === 'x-test/prepareRepository');
assert.ok(request);
transport.fireMessage({ jsonrpc: '2.0', id: request.id, result: { directory: '/checkout/repo' } });
assert.deepStrictEqual({ params: request.params, result: await response }, { params, result: { directory: '/checkout/repo' } });
});

test('host extension requests propagate protocol errors', async () => {
const { client, transport } = createClient();
await connectClient(client, transport);
const response = client.requestHostExtension('x-test/prepareRepository', {});
await timeout(0);
const request = transport.sentMessages.find((message): message is JsonRpcRequest => 'id' in message && 'method' in message && message.method === 'x-test/prepareRepository');
assert.ok(request);
transport.fireMessage({ jsonrpc: '2.0', id: request.id, error: { code: JsonRpcErrorCodes.MethodNotFound, message: 'Project management unavailable' } });
await assert.rejects(response, /Project management unavailable/);
});

for (const identity of [LOCAL_AGENT_HOST_RESOURCE_IDENTITY, 'test.example:1234', 'vscode-remote://ssh-remote+test'] as const) {
test(`workspace trust forwards only the target host's trusted roots (${String(identity)})`, async () => {
const transport = disposables.add(new TestProtocolTransport());
Expand Down
9 changes: 9 additions & 0 deletions src/vs/sessions/common/gitHubRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

export function getGitHubRepositoryId(repository: string): string | undefined {
const match = /^(?:(?:https?|ssh|git):\/\/(?:git@)?github\.com\/|git@github\.com:)?(?<owner>[^/:\s]+)\/(?<repo>[^/\s]+?)(?:\.git)?\/?$/i.exec(repository);
return match?.groups ? `${match.groups.owner}/${match.groups.repo}` : undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession
import { IFileService } from '../../../../../platform/files/common/files.js';
import { resolveGitRepositoryFromGitConfig } from '../../../../services/sessions/browser/gitHubRepositoryResolver.js';
import { IPathService } from '../../../../../workbench/services/path/common/pathService.js';
import { getGitHubRepositoryId } from '../../../../common/gitHubRepository.js';

/** Copilot Cloud session type - cloud-hosted agent. */
export const CopilotCloudSessionType: ISessionType = {
Expand All @@ -79,11 +80,6 @@ const STORAGE_KEY_ISOLATION_MODE = 'sessions.isolationPicker.selectedMode';
/** Remembers the cloud sandbox choice across new sessions, like the isolation picker above. */
const STORAGE_KEY_USE_SANDBOX = 'sessions.cloudSandboxPicker.useSandbox';

function getGitHubRepositoryId(repository: string): string | undefined {
const match = /^(?:(?:https?|ssh|git):\/\/(?:git@)?github\.com\/|git@github\.com:)?(?<owner>[^/:\s]+)\/(?<repo>[^/\s]+?)(?:\.git)?\/?$/i.exec(repository);
return match?.groups ? `${match.groups.owner}/${match.groups.repo}` : undefined;
}

export type IsolationMode = 'worktree' | 'workspace';

export interface ICopilotChatSession {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ Drafts expose the shared untitled `ISession` contract and use remote workspace m

Remote session and chat resources preserve connection-specific routing identity through creation, hydration, and replacement. Backend session identifiers are translated only inside the provider.

### Preparing a remote workspace

A connection customization may prepare a new session's working directory after authentication and before backend session creation. The shared session handler owns this ordering; the customization owns host-specific capability checks, requests, progress, and validation. The resolved directory is retained by the connection's working-directory resolver and passed through the normal resource URI mapping.

Cloud sandbox repository selections use this boundary to resolve a checkout on hosts that advertise project management. They reuse a ready checkout or await the host's clone result before creating the session. The client does not clone locally or acquire a second workload credential. Hosts without the capability retain the existing host-selected directory behavior.

The sandbox connection factory creates a typed project adapter for each connection. It owns capability checks, wire requests, and validation of both responses and catalogue entries; the resolver consumes only typed project operations. The sandbox service looks up the adapter by connection identity, not address, and connection teardown cancels outstanding preparation and removes the adapter without affecting a replacement connection. Arbitrary RPC dispatch is not exposed through `IAgentConnection`; only the concrete transport client supplies the low-level host-extension sender.

Preparation failures and cancellation stop session creation and the first turn; they do not fall back to an unrelated directory. Existing backend sessions keep their established directories and do not run preparation again. Host-specific requests stay outside the standard protocol command map and are used only after checking the advertised capability.

## Authentication and recovery

Authentication challenges, credential refresh, and transport retries remain connection policy. The request that encountered a challenge observes its actual success, cancellation, or failure; provider operations do not silently convert authentication failures into availability results.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,6 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo
) {
super();

// Supply the generic remote-agent-host contribution with the sandbox host's per-connection
// deviations (sealed-token auth + `ahp-session` backend scheme) without leaking sandbox
// specifics into that shared code path.
this._register(this._connectionCustomizations.register(
isCloudSandboxConnectionAddress,
address => createCloudSandboxConnectionCustomization(address, this._cloudSandboxService)!,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@

import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js';
import { CancellationToken } from '../../../../../base/common/cancellation.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js';
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
import { IObservable, observableValue } from '../../../../../base/common/observable.js';
import { URI } from '../../../../../base/common/uri.js';
import { raceCancellationError, timeout } from '../../../../../base/common/async.js';
import { localize } from '../../../../../nls.js';
import { IProtocolTransport } from '../../../../../platform/agentHost/common/state/sessionTransport.js';
import { AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js';
import { editorWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js';
import { WebPubSubRelayTransport } from '../../../../../platform/agentHost/browser/webPubSubRelayTransport.js';
import { AhpJsonlLogger } from '../../../../../platform/agentHost/common/ahpJsonlLogger.js';
import { GITHUB_COPILOT_PROTECTED_RESOURCE, AgentHostAhpJsonlLoggingSettingId } from '../../../../../platform/agentHost/common/agentService.js';
import { GITHUB_COPILOT_PROTECTED_RESOURCE, AgentHostAhpJsonlLoggingSettingId, IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js';
import {
buildWpsUrl,
cloudSandboxAddress,
Expand All @@ -31,6 +33,8 @@ import { IEnvironmentService } from '../../../../../platform/environment/common/
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../../../platform/log/common/log.js';
import { CloudSandboxCredentialRefresher, MAX_WAKING_DELAY_MS, type ICloudSandboxCreds } from './cloudSandboxCredentialRefresh.js';
import { CloudSandboxProjectResolver } from './cloudSandboxProjectResolver.js';
import { CloudSandboxProjectsClient, ICloudSandboxProjectsClient } from './cloudSandboxProjectsClient.js';

const LOG_PREFIX = '[CloudSandboxAgentHost]';

Expand Down Expand Up @@ -60,6 +64,8 @@ class CloudSandboxConnectionFactory extends Disposable implements IRemoteAgentHo

private readonly _stagedConnections = new Map<string, IStagedCloudSandboxConnection>();
private readonly _entries = observableValue<readonly IRemoteAgentHostEntry[]>(this, []);
private readonly _projectClients = new WeakMap<IAgentConnection, ICloudSandboxProjectsClient>();
private readonly _projectResolver: CloudSandboxProjectResolver;

constructor(
private readonly _instantiationService: IInstantiationService,
Expand All @@ -68,6 +74,7 @@ class CloudSandboxConnectionFactory extends Disposable implements IRemoteAgentHo
) {
super();
this.entries = this._entries;
this._projectResolver = this._instantiationService.createInstance(CloudSandboxProjectResolver);
// Staging is cleared only by an explicit `unstageConfiguration`, never by
// observing the connection disappear. The service withdraws an entry
// before arming a retry, so treating that as removal would delete the
Expand Down Expand Up @@ -107,6 +114,17 @@ class CloudSandboxConnectionFactory extends Disposable implements IRemoteAgentHo
return this._stagedConnections.get(cloudSandboxAddress(environmentId))?.creds.token.encrypted_github_token;
}

async prepareWorkingDirectory(connection: IAgentConnection, workingDirectory: URI | undefined, token: CancellationToken): Promise<URI | undefined> {
if (token.isCancellationRequested) {
throw new CancellationError();
}
const projects = this._projectClients.get(connection);
if (!projects) {
throw new Error(localize('cloudSandbox.projectConnectionUnavailable', "The sandbox connection is no longer available for repository preparation."));
}
return this._projectResolver.resolve(projects, workingDirectory, token);
}

async createConnection(entry: IRemoteAgentHostEntry, _options: IRemoteAgentHostConnectOptions): Promise<IRemoteAgentHostCreatedConnection> {
if (entry.connection.type !== RemoteAgentHostEntryType.CloudSandbox) {
throw new Error(`Cloud sandbox factory cannot create a ${entry.connection.type} connection.`);
Expand Down Expand Up @@ -142,6 +160,9 @@ class CloudSandboxConnectionFactory extends Disposable implements IRemoteAgentHo
},
);
const store = new DisposableStore();
const projects = store.add(new CloudSandboxProjectsClient(client));
this._projectClients.set(client, projects);
store.add(toDisposable(() => this._projectClients.delete(client)));
const refresher = store.add(new MutableDisposable<CloudSandboxCredentialRefresher>());
store.add(client.onDidChangeConnectionState(state => {
if (state === 'connected' && !refresher.value) {
Expand Down Expand Up @@ -207,6 +228,10 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa
return this._connectionFactory.getSealedGitHubToken(environmentId);
}

prepareWorkingDirectory(connection: IAgentConnection, workingDirectory: URI | undefined, token: CancellationToken): Promise<URI | undefined> {
return this._connectionFactory.prepareWorkingDirectory(connection, workingDirectory, token);
}

async connect(options: ICloudSandboxConnectOptions, token: CancellationToken): Promise<string> {
if (!this._configurationService.getValue<boolean>(CloudSandboxEnabledSettingId)) {
throw new Error('Copilot cloud sandbox connections are not enabled.');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,7 @@ function isGitHubResource(resource: string): boolean {
|| host.endsWith('.ghe.com');
}

/**
* The {@link IRemoteAgentHostConnectionCustomization} for a cloud sandbox address, supplying the two
* ways the sandbox host deviates from the generic path:
*
* - **Auth**: the host only accepts a sealed envelope, so the connection's `encrypted_github_token`
* is presented instead of the resolved bearer. Fails closed if no sealed token is available.
* - **Scheme**: the host advertises provider `copilot` but addresses sessions as `ahp-session`.
*
* Returns `undefined` for non-sandbox addresses.
*/
/** Adapts authentication, session identity and repository preparation for a cloud sandbox. */
export function createCloudSandboxConnectionCustomization(
address: string,
sandboxService: ICloudSandboxAgentHostService,
Expand Down Expand Up @@ -66,6 +57,7 @@ export function createCloudSandboxConnectionCustomization(
},
backendSessionScheme: (provider: string): string | undefined =>
provider === CLOUD_SANDBOX_AGENT_PROVIDER ? CLOUD_SANDBOX_SESSION_SCHEME : undefined,
prepareWorkingDirectory: (connection, workingDirectory, token) => sandboxService.prepareWorkingDirectory(connection, workingDirectory, token),
};
}

Expand Down
Loading
Loading