Skip to content
Draft

WIP #308904

Show file tree
Hide file tree
Changes from 1 commit
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
36 changes: 36 additions & 0 deletions src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Event } from '../../../base/common/event.js';
import type { ISSHRemoteAgentHostService, ISSHAgentHostConnection, ISSHAgentHostConfig, ISSHConnectProgress, ISSHResolvedConfig } from '../common/sshRemoteAgentHost.js';

/**
* Null implementation of {@link ISSHRemoteAgentHostService} for browser contexts
* where SSH is not available.
*/
export class NullSSHRemoteAgentHostService implements ISSHRemoteAgentHostService {
declare readonly _serviceBrand: undefined;
readonly onDidChangeConnections = Event.None;
readonly onDidReportConnectProgress: Event<ISSHConnectProgress> = Event.None;
readonly connections: readonly ISSHAgentHostConnection[] = [];

async connect(_config: ISSHAgentHostConfig): Promise<ISSHAgentHostConnection> {
throw new Error('SSH connections are not supported in the browser.');
}

async disconnect(_host: string): Promise<void> { }

async listSSHConfigHosts(): Promise<string[]> {
return [];
}

async resolveSSHConfig(_host: string): Promise<ISSHResolvedConfig> {
throw new Error('SSH is not supported in the browser.');
}

async reconnect(_sshConfigHost: string, _name: string): Promise<ISSHAgentHostConnection> {
throw new Error('SSH connections are not supported in the browser.');
}
}
286 changes: 286 additions & 0 deletions src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

// Protocol client for communicating with a remote agent host process.
// Wraps WebSocketClientTransport and SessionClientState to provide a
// higher-level API matching IAgentService.

import { DeferredPromise } from '../../../base/common/async.js';
import { Emitter } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { hasKey } from '../../../base/common/types.js';
import { URI } from '../../../base/common/uri.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { ILogService } from '../../log/common/log.js';
import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js';
import type { IClientNotificationMap, ICommandMap } from '../common/state/protocol/messages.js';
import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js';
import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js';
import { isJsonRpcNotification, isJsonRpcResponse, type IJsonRpcResponse, type IProtocolMessage, type IResourceCopyParams, type IResourceCopyResult, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js';
import type { ISessionSummary } from '../common/state/sessionState.js';
import { WebSocketClientTransport } from './webSocketClientTransport.js';

/**
* A protocol-level client for a single remote agent host connection.
* Manages the WebSocket transport, handshake, subscriptions, action dispatch,
* and command/response correlation.
*
* Implements {@link IAgentConnection} so consumers can program against
* a single interface regardless of whether the agent host is local or remote.
*/
export class RemoteAgentHostProtocolClient extends Disposable implements IAgentConnection {

Check failure on line 33 in src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts

View workflow job for this annotation

GitHub Actions / Monaco Editor checks

Class 'RemoteAgentHostProtocolClient' incorrectly implements interface 'IAgentConnection'.

declare readonly _serviceBrand: undefined;

private readonly _clientId = generateUuid();
private readonly _transport: WebSocketClientTransport;
private _serverSeq = 0;
private _nextClientSeq = 1;
private _defaultDirectory: string | undefined;

private readonly _onDidAction = this._register(new Emitter<IActionEnvelope>());
readonly onDidAction = this._onDidAction.event;

private readonly _onDidNotification = this._register(new Emitter<INotification>());
readonly onDidNotification = this._onDidNotification.event;

private readonly _onDidClose = this._register(new Emitter<void>());
readonly onDidClose = this._onDidClose.event;

/** Pending JSON-RPC requests keyed by request id. */
private readonly _pendingRequests = new Map<number, DeferredPromise<unknown>>();
private _nextRequestId = 1;

get clientId(): string {
return this._clientId;
}

get address(): string {
return this._transport['_address'];
}
Comment on lines +60 to +62
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

address is implemented by reaching into a private field (this._transport['_address']). This is brittle and bypasses type-safety. Please add a real get address() accessor on WebSocketClientTransport (or store the address on the protocol client) instead of relying on private property indexing.

Copilot uses AI. Check for mistakes.

get defaultDirectory(): string | undefined {
return this._defaultDirectory;
}

constructor(
address: string,
connectionToken: string | undefined,
@ILogService private readonly _logService: ILogService,
) {
super();
this._transport = this._register(new WebSocketClientTransport(address, connectionToken));
this._register(this._transport.onMessage(msg => this._handleMessage(msg)));
this._register(this._transport.onClose(() => this._onDidClose.fire()));
}

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pending JSON-RPC requests in _pendingRequests are never rejected when the WebSocket closes. If the connection drops while requests are in flight, callers will await forever and the map will leak entries. Please reject/clear all pending requests on transport close/error (and consider also clearing them during dispose()).

Suggested change
this._register(this._transport.onClose(() => this._onDidClose.fire()));
}
this._register(this._transport.onClose(() => {
this._rejectAllPendingRequests('Remote agent host connection closed.');
this._onDidClose.fire();
}));
}
override dispose(): void {
this._rejectAllPendingRequests('Remote agent host connection disposed.');
super.dispose();
}
private _rejectAllPendingRequests(reason: string): void {
if (this._pendingRequests.size === 0) {
return;
}
const error = new Error(reason);
for (const pendingRequest of this._pendingRequests.values()) {
pendingRequest.error(error);
}
this._pendingRequests.clear();
}

Copilot uses AI. Check for mistakes.
/**
* Connect to the remote agent host and perform the protocol handshake.
*/
async connect(): Promise<void> {
await this._transport.connect();

const result = await this._sendRequest('initialize', {
protocolVersion: PROTOCOL_VERSION,
clientId: this._clientId,
});
this._serverSeq = result.serverSeq;
// defaultDirectory arrives from the protocol as either a URI string
// (e.g. "file:///Users/roblou") or a serialized URI object
// ({ scheme, path, ... }). Extract just the filesystem path.
if (result.defaultDirectory) {
const dir = result.defaultDirectory;
if (typeof dir === 'string') {
this._defaultDirectory = URI.parse(dir).path;
} else {
this._defaultDirectory = URI.revive(dir).path;
}
}
}

/**
* Subscribe to state at a URI. Returns the current state snapshot.
*/
async subscribe(resource: URI): Promise<IStateSnapshot> {
const result = await this._sendRequest('subscribe', { resource: resource.toString() });
return result.snapshot;
}

/**
* Unsubscribe from state at a URI.
*/
unsubscribe(resource: URI): void {
this._sendNotification('unsubscribe', { resource: resource.toString() });
}

/**
* Dispatch a client action to the server. Returns the clientSeq used.
*/
dispatchAction(action: ISessionAction, _clientId: string, clientSeq: number): void {
this._sendNotification('dispatchAction', { clientSeq, action });
}

/**
* Create a new session on the remote agent host.
*/
async createSession(config?: IAgentCreateSessionConfig): Promise<URI> {
const provider = config?.provider ?? 'copilot';
const session = AgentSession.uri(provider, generateUuid());
await this._sendRequest('createSession', {
session: session.toString(),
provider,
model: config?.model,
workingDirectory: config?.workingDirectory?.toString(),
});
return session;
}

/**
* Authenticate with the remote agent host using a specific scheme.
*/
async authenticate(params: IAuthenticateParams): Promise<IAuthenticateResult> {
return await this._sendExtensionRequest('authenticate', params) as IAuthenticateResult;
}

/**
* Refresh the model list from all providers on the remote host.
*/
async refreshModels(): Promise<void> {
await this._sendExtensionRequest('refreshModels');
}

/**
* Discover available agent backends from the remote host.
*/
async listAgents(): Promise<IAgentDescriptor[]> {
return await this._sendExtensionRequest('listAgents') as IAgentDescriptor[];
}

/**
* Gracefully shut down all sessions on the remote host.
*/
async shutdown(): Promise<void> {
await this._sendExtensionRequest('shutdown');
}

/**
* Dispose a session on the remote agent host.
*/
async disposeSession(session: URI): Promise<void> {
await this._sendRequest('disposeSession', { session: session.toString() });
}

/**
* List all sessions from the remote agent host.
*/
async listSessions(): Promise<IAgentSessionMetadata[]> {
const result = await this._sendRequest('listSessions', {});
return result.items.map((s: ISessionSummary) => ({
session: URI.parse(s.resource),
startTime: s.createdAt,
modifiedTime: s.modifiedAt,
summary: s.title,
workingDirectory: typeof s.workingDirectory === 'string' ? URI.parse(s.workingDirectory) : undefined,
}));
}

async resourceList(uri: URI): Promise<IResourceListResult> {
return await this._sendRequest('resourceList', { uri: uri.toString() });
}

async resourceRead(uri: URI): Promise<IResourceReadResult> {
return await this._sendRequest('resourceRead', { uri: uri.toString() });
}

async resourceWrite(params: IResourceWriteParams): Promise<IResourceWriteResult> {
return await this._sendRequest('resourceWrite', params);
}

async resourceCopy(params: IResourceCopyParams): Promise<IResourceCopyResult> {
return await this._sendRequest('resourceCopy', params);
}

async resourceDelete(params: IResourceDeleteParams): Promise<IResourceDeleteResult> {
return await this._sendRequest('resourceDelete', params);
}

async resourceMove(params: IResourceMoveParams): Promise<IResourceMoveResult> {
return await this._sendRequest('resourceMove', params);
}

private _handleMessage(msg: IProtocolMessage): void {
if (isJsonRpcResponse(msg)) {
const pending = this._pendingRequests.get(msg.id);
if (pending) {
this._pendingRequests.delete(msg.id);
if (hasKey(msg, { error: true })) {
this._logService.warn(`[RemoteAgentHostProtocol] Request ${msg.id} failed:`, msg.error);
pending.error(new Error(msg.error.message));
} else {
pending.complete(msg.result);
}
} else {
this._logService.warn(`[RemoteAgentHostProtocol] Received response for unknown request id ${msg.id}`);
}
} else if (isJsonRpcNotification(msg)) {
switch (msg.method) {
case 'action': {
// Protocol envelope → VS Code envelope (superset of action types)
const envelope = msg.params as unknown as IActionEnvelope;
this._serverSeq = Math.max(this._serverSeq, envelope.serverSeq);
this._onDidAction.fire(envelope);
break;
}
case 'notification': {
const notification = msg.params.notification as unknown as INotification;
this._logService.trace(`[RemoteAgentHostProtocol] Notification: ${notification.type}`);
this._onDidNotification.fire(notification);
break;
}
default:
this._logService.trace(`[RemoteAgentHostProtocol] Unhandled method: ${msg.method}`);
break;
}
} else {
this._logService.warn(`[RemoteAgentHostProtocol] Unrecognized message:`, JSON.stringify(msg));
}
}

/** Send a typed JSON-RPC notification for a protocol-defined method. */
private _sendNotification<M extends keyof IClientNotificationMap>(method: M, params: IClientNotificationMap[M]['params']): void {
// Generic M can't satisfy the distributive IAhpNotification union directly
// eslint-disable-next-line local/code-no-dangerous-type-assertions
this._transport.send({ jsonrpc: '2.0' as const, method, params } as IProtocolMessage);
}

/** Send a typed JSON-RPC request for a protocol-defined method. */
private _sendRequest<M extends keyof ICommandMap>(method: M, params: ICommandMap[M]['params']): Promise<ICommandMap[M]['result']> {
const id = this._nextRequestId++;
const deferred = new DeferredPromise<unknown>();
this._pendingRequests.set(id, deferred);
// Generic M can't satisfy the distributive IAhpRequest union directly
// eslint-disable-next-line local/code-no-dangerous-type-assertions
this._transport.send({ jsonrpc: '2.0' as const, id, method, params } as IProtocolMessage);
return deferred.p as Promise<ICommandMap[M]['result']>;
}

/** Send a JSON-RPC request for a VS Code extension method (not in the protocol spec). */
private _sendExtensionRequest(method: string, params?: unknown): Promise<unknown> {
const id = this._nextRequestId++;
const deferred = new DeferredPromise<unknown>();
this._pendingRequests.set(id, deferred);
// Cast: extension methods aren't in the typed protocol maps yet
// eslint-disable-next-line local/code-no-dangerous-type-assertions
this._transport.send({ jsonrpc: '2.0', id, method, params } as unknown as IJsonRpcResponse);
return deferred.p;
Comment on lines +270 to +277
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_sendExtensionRequest builds a JSON-RPC request ({ jsonrpc, id, method, params }) but type-asserts it to IJsonRpcResponse, which is a response shape. This defeats compile-time checking and is likely to confuse future refactors. Please introduce/consume an explicit request type (or reuse IProtocolMessage) instead of asserting to a response type.

Copilot uses AI. Check for mistakes.
}

/**
* Get the next client sequence number for optimistic dispatch.
*/
nextClientSeq(): number {
return this._nextClientSeq++;
}
}
Loading
Loading