Path: blob/main/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Emitter, Event } from '../../../base/common/event.js';6import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';7import { URI } from '../../../base/common/uri.js';8import { ILogService } from '../../log/common/log.js';9import { IConfigurationService } from '../../configuration/common/configuration.js';10import { ISharedProcessService } from '../../ipc/electron-browser/services.js';11import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js';12import { IRemoteAgentHostService, RemoteAgentHostEntryType } from '../common/remoteAgentHostService.js';13import { IInstantiationService } from '../../instantiation/common/instantiation.js';14import { SSHRelayTransport } from './sshRelayTransport.js';15import { RemoteAgentHostProtocolClient } from '../browser/remoteAgentHostProtocolClient.js';16import {17ISSHRemoteAgentHostService,18SSH_REMOTE_AGENT_HOST_CHANNEL,19type ISSHAgentHostConfig,20type ISSHAgentHostConnection,21type ISSHConnectResult,22type ISSHRemoteAgentHostMainService,23type ISSHResolvedConfig,24type ISSHConnectProgress,25} from '../common/sshRemoteAgentHost.js';2627/**28* Renderer-side implementation of {@link ISSHRemoteAgentHostService} that29* delegates the actual SSH work to the main process via IPC, then registers30* the resulting connection with the renderer-local {@link IRemoteAgentHostService}.31*/32export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteAgentHostService {33declare readonly _serviceBrand: undefined;3435private readonly _mainService: ISSHRemoteAgentHostMainService;3637private readonly _onDidChangeConnections = this._register(new Emitter<void>());38readonly onDidChangeConnections: Event<void> = this._onDidChangeConnections.event;3940readonly onDidReportConnectProgress: Event<ISSHConnectProgress>;4142private readonly _connections = new Map<string, SSHAgentHostConnectionHandle>();4344constructor(45@ISharedProcessService sharedProcessService: ISharedProcessService,46@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,47@ILogService private readonly _logService: ILogService,48@IInstantiationService private readonly _instantiationService: IInstantiationService,49@IConfigurationService private readonly _configurationService: IConfigurationService,50) {51super();5253this._mainService = ProxyChannel.toService<ISSHRemoteAgentHostMainService>(54sharedProcessService.getChannel(SSH_REMOTE_AGENT_HOST_CHANNEL),55);5657this.onDidReportConnectProgress = this._mainService.onDidReportConnectProgress;5859// When shared process fires onDidCloseConnection, clean up the renderer-side handle.60// Do NOT remove the configured entry — it stays in settings so startup reconnect61// can re-establish the SSH tunnel on next launch.62this._register(this._mainService.onDidCloseConnection(connectionId => {63const handle = this._connections.get(connectionId);64if (handle) {65this._connections.delete(connectionId);66handle.fireClose();67handle.dispose();68this._onDidChangeConnections.fire();69}70}));7172}7374get connections(): readonly ISSHAgentHostConnection[] {75return [...this._connections.values()];76}7778async connect(config: ISSHAgentHostConfig): Promise<ISSHAgentHostConnection> {79const augmentedConfig = this._augmentConfig(config);80this._logService.info(`[SSHRemoteAgentHost] Connecting to ${config.host}`);81const result = await this._mainService.connect(augmentedConfig);82this._logService.trace('[SSHRemoteAgentHost] SSH tunnel established, connectionId=' + result.connectionId);83return this._setupConnection(result);84}8586async disconnect(host: string): Promise<void> {87await this._mainService.disconnect(host);88}8990async listSSHConfigHosts(): Promise<string[]> {91return this._mainService.listSSHConfigHosts();92}9394async ensureUserSSHConfig(): Promise<URI> {95return this._mainService.ensureUserSSHConfig();96}9798async listSSHConfigFiles(): Promise<URI[]> {99return this._mainService.listSSHConfigFiles();100}101102async resolveSSHConfig(host: string): Promise<ISSHResolvedConfig> {103return this._mainService.resolveSSHConfig(host);104}105106async reconnect(sshConfigHost: string, name: string): Promise<ISSHAgentHostConnection> {107const commandOverride = this._getRemoteAgentHostCommand();108const agentForward = this._isSSHAgentForwardingEnabled();109this._logService.info(`[SSHRemoteAgentHost] Reconnecting to ${sshConfigHost}`);110const result = await this._mainService.reconnect(sshConfigHost, name, commandOverride, agentForward);111return this._setupConnection(result);112}113114/**115* Build the renderer-side handle, do the protocol handshake, and register116* with IRemoteAgentHostService. Any failure after the shared-process tunnel117* was established tears it back down so we don't leak it.118*/119private async _setupConnection(result: ISSHConnectResult): Promise<ISSHAgentHostConnection> {120const existing = this._connections.get(result.connectionId);121if (existing) {122this._logService.trace('[SSHRemoteAgentHost] Returning existing connection handle');123return existing;124}125126let protocolClient: RemoteAgentHostProtocolClient | undefined;127let handle: SSHAgentHostConnectionHandle | undefined;128let registeredHandle = false;129try {130protocolClient = this._createRelayClient(result);131await protocolClient.connect();132this._logService.trace('[SSHRemoteAgentHost] Protocol handshake completed');133134handle = new SSHAgentHostConnectionHandle(135result.config,136result.address,137result.name,138() => this._mainService.disconnect(result.connectionId),139);140141this._connections.set(result.connectionId, handle);142registeredHandle = true;143this._onDidChangeConnections.fire();144145await this._remoteAgentHostService.addManagedConnection({146name: result.name,147connectionToken: result.connectionToken,148connection: {149type: RemoteAgentHostEntryType.SSH,150address: result.address,151sshConfigHost: result.sshConfigHost,152hostName: result.config.host,153user: result.config.username || undefined,154port: result.config.port,155},156}, protocolClient, this._createTransportDisposable(result.connectionId, handle));157158return handle;159} catch (err) {160this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err);161if (registeredHandle && this._connections.get(result.connectionId) === handle) {162this._connections.delete(result.connectionId);163this._onDidChangeConnections.fire();164}165handle?.dispose();166protocolClient?.dispose();167this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ });168throw err;169}170}171172/**173* Build a disposable that the {@link IRemoteAgentHostService} will own174* for the lifetime of this entry. When the entry is removed (either by175* the user via "Remove Remote" or by config reconciliation), this runs176* and tears down the renderer-side handle and the shared-process SSH177* tunnel together. Without this hookup, the SSH tunnel would leak and178* the next `connect()` would silently reuse it.179*/180private _createTransportDisposable(connectionId: string, handle: SSHAgentHostConnectionHandle): IDisposable {181return toDisposable(() => {182// Drop the renderer-side handle map entry first so a concurrent183// `connect()` for the same key doesn't latch onto a being-torn-down184// connection.185if (this._connections.get(connectionId) === handle) {186this._connections.delete(connectionId);187this._onDidChangeConnections.fire();188}189// Mark the handle as already closed-from-main so disposing it190// doesn't kick off a redundant second disconnect IPC. The actual191// disconnect is initiated below.192handle.fireClose();193handle.dispose();194this._mainService.disconnect(connectionId).catch(() => { /* best effort */ });195});196}197198private _createRelayClient(result: { connectionId: string; address: string }): RemoteAgentHostProtocolClient {199const transport = new SSHRelayTransport(result.connectionId, this._mainService);200return this._instantiationService.createInstance(201RemoteAgentHostProtocolClient, result.address, transport,202);203}204205private _augmentConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfig {206const result = { ...config };207const commandOverride = this._getRemoteAgentHostCommand();208if (commandOverride) {209result.remoteAgentHostCommand = commandOverride;210}211// Agent forwarding requires both the global setting (security opt-in)212// and the per-host SSH config `ForwardAgent yes` to be enabled.213if (this._isSSHAgentForwardingEnabled() && config.agentForward) {214result.agentForward = true;215}216return result;217}218219private _getRemoteAgentHostCommand(): string | undefined {220return this._configurationService.getValue<string>('chat.sshRemoteAgentHostCommand') || undefined;221}222223private _isSSHAgentForwardingEnabled(): boolean | undefined {224return this._configurationService.getValue<boolean>('chat.agentHost.forwardSSHAgent') || undefined;225}226}227228/**229* Lightweight renderer-side handle that represents a connection230* managed by the main process.231*/232class SSHAgentHostConnectionHandle extends Disposable implements ISSHAgentHostConnection {233private readonly _onDidClose = this._register(new Emitter<void>());234readonly onDidClose = this._onDidClose.event;235236private _closedByMain = false;237238constructor(239readonly config: ISSHAgentHostConnection['config'],240readonly localAddress: string,241readonly name: string,242disconnectFn: () => Promise<void>,243) {244super();245246// When this handle is disposed, tear down the main-process tunnel247// (skip if already closed from the main process side)248this._register(toDisposable(() => {249if (!this._closedByMain) {250disconnectFn().catch(() => { /* best effort */ });251}252}));253}254255/** Called by the service when the main process signals connection closure. */256fireClose(): void {257this._closedByMain = true;258this._onDidClose.fire();259}260}261262263