Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts
13394 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Emitter, Event } from '../../../base/common/event.js';
7
import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
8
import { URI } from '../../../base/common/uri.js';
9
import { ILogService } from '../../log/common/log.js';
10
import { IConfigurationService } from '../../configuration/common/configuration.js';
11
import { ISharedProcessService } from '../../ipc/electron-browser/services.js';
12
import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js';
13
import { IRemoteAgentHostService, RemoteAgentHostEntryType } from '../common/remoteAgentHostService.js';
14
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
15
import { SSHRelayTransport } from './sshRelayTransport.js';
16
import { RemoteAgentHostProtocolClient } from '../browser/remoteAgentHostProtocolClient.js';
17
import {
18
ISSHRemoteAgentHostService,
19
SSH_REMOTE_AGENT_HOST_CHANNEL,
20
type ISSHAgentHostConfig,
21
type ISSHAgentHostConnection,
22
type ISSHConnectResult,
23
type ISSHRemoteAgentHostMainService,
24
type ISSHResolvedConfig,
25
type ISSHConnectProgress,
26
} from '../common/sshRemoteAgentHost.js';
27
28
/**
29
* Renderer-side implementation of {@link ISSHRemoteAgentHostService} that
30
* delegates the actual SSH work to the main process via IPC, then registers
31
* the resulting connection with the renderer-local {@link IRemoteAgentHostService}.
32
*/
33
export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteAgentHostService {
34
declare readonly _serviceBrand: undefined;
35
36
private readonly _mainService: ISSHRemoteAgentHostMainService;
37
38
private readonly _onDidChangeConnections = this._register(new Emitter<void>());
39
readonly onDidChangeConnections: Event<void> = this._onDidChangeConnections.event;
40
41
readonly onDidReportConnectProgress: Event<ISSHConnectProgress>;
42
43
private readonly _connections = new Map<string, SSHAgentHostConnectionHandle>();
44
45
constructor(
46
@ISharedProcessService sharedProcessService: ISharedProcessService,
47
@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,
48
@ILogService private readonly _logService: ILogService,
49
@IInstantiationService private readonly _instantiationService: IInstantiationService,
50
@IConfigurationService private readonly _configurationService: IConfigurationService,
51
) {
52
super();
53
54
this._mainService = ProxyChannel.toService<ISSHRemoteAgentHostMainService>(
55
sharedProcessService.getChannel(SSH_REMOTE_AGENT_HOST_CHANNEL),
56
);
57
58
this.onDidReportConnectProgress = this._mainService.onDidReportConnectProgress;
59
60
// When shared process fires onDidCloseConnection, clean up the renderer-side handle.
61
// Do NOT remove the configured entry — it stays in settings so startup reconnect
62
// can re-establish the SSH tunnel on next launch.
63
this._register(this._mainService.onDidCloseConnection(connectionId => {
64
const handle = this._connections.get(connectionId);
65
if (handle) {
66
this._connections.delete(connectionId);
67
handle.fireClose();
68
handle.dispose();
69
this._onDidChangeConnections.fire();
70
}
71
}));
72
73
}
74
75
get connections(): readonly ISSHAgentHostConnection[] {
76
return [...this._connections.values()];
77
}
78
79
async connect(config: ISSHAgentHostConfig): Promise<ISSHAgentHostConnection> {
80
const augmentedConfig = this._augmentConfig(config);
81
this._logService.info(`[SSHRemoteAgentHost] Connecting to ${config.host}`);
82
const result = await this._mainService.connect(augmentedConfig);
83
this._logService.trace('[SSHRemoteAgentHost] SSH tunnel established, connectionId=' + result.connectionId);
84
return this._setupConnection(result);
85
}
86
87
async disconnect(host: string): Promise<void> {
88
await this._mainService.disconnect(host);
89
}
90
91
async listSSHConfigHosts(): Promise<string[]> {
92
return this._mainService.listSSHConfigHosts();
93
}
94
95
async ensureUserSSHConfig(): Promise<URI> {
96
return this._mainService.ensureUserSSHConfig();
97
}
98
99
async listSSHConfigFiles(): Promise<URI[]> {
100
return this._mainService.listSSHConfigFiles();
101
}
102
103
async resolveSSHConfig(host: string): Promise<ISSHResolvedConfig> {
104
return this._mainService.resolveSSHConfig(host);
105
}
106
107
async reconnect(sshConfigHost: string, name: string): Promise<ISSHAgentHostConnection> {
108
const commandOverride = this._getRemoteAgentHostCommand();
109
const agentForward = this._isSSHAgentForwardingEnabled();
110
this._logService.info(`[SSHRemoteAgentHost] Reconnecting to ${sshConfigHost}`);
111
const result = await this._mainService.reconnect(sshConfigHost, name, commandOverride, agentForward);
112
return this._setupConnection(result);
113
}
114
115
/**
116
* Build the renderer-side handle, do the protocol handshake, and register
117
* with IRemoteAgentHostService. Any failure after the shared-process tunnel
118
* was established tears it back down so we don't leak it.
119
*/
120
private async _setupConnection(result: ISSHConnectResult): Promise<ISSHAgentHostConnection> {
121
const existing = this._connections.get(result.connectionId);
122
if (existing) {
123
this._logService.trace('[SSHRemoteAgentHost] Returning existing connection handle');
124
return existing;
125
}
126
127
let protocolClient: RemoteAgentHostProtocolClient | undefined;
128
let handle: SSHAgentHostConnectionHandle | undefined;
129
let registeredHandle = false;
130
try {
131
protocolClient = this._createRelayClient(result);
132
await protocolClient.connect();
133
this._logService.trace('[SSHRemoteAgentHost] Protocol handshake completed');
134
135
handle = new SSHAgentHostConnectionHandle(
136
result.config,
137
result.address,
138
result.name,
139
() => this._mainService.disconnect(result.connectionId),
140
);
141
142
this._connections.set(result.connectionId, handle);
143
registeredHandle = true;
144
this._onDidChangeConnections.fire();
145
146
await this._remoteAgentHostService.addManagedConnection({
147
name: result.name,
148
connectionToken: result.connectionToken,
149
connection: {
150
type: RemoteAgentHostEntryType.SSH,
151
address: result.address,
152
sshConfigHost: result.sshConfigHost,
153
hostName: result.config.host,
154
user: result.config.username || undefined,
155
port: result.config.port,
156
},
157
}, protocolClient, this._createTransportDisposable(result.connectionId, handle));
158
159
return handle;
160
} catch (err) {
161
this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err);
162
if (registeredHandle && this._connections.get(result.connectionId) === handle) {
163
this._connections.delete(result.connectionId);
164
this._onDidChangeConnections.fire();
165
}
166
handle?.dispose();
167
protocolClient?.dispose();
168
this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ });
169
throw err;
170
}
171
}
172
173
/**
174
* Build a disposable that the {@link IRemoteAgentHostService} will own
175
* for the lifetime of this entry. When the entry is removed (either by
176
* the user via "Remove Remote" or by config reconciliation), this runs
177
* and tears down the renderer-side handle and the shared-process SSH
178
* tunnel together. Without this hookup, the SSH tunnel would leak and
179
* the next `connect()` would silently reuse it.
180
*/
181
private _createTransportDisposable(connectionId: string, handle: SSHAgentHostConnectionHandle): IDisposable {
182
return toDisposable(() => {
183
// Drop the renderer-side handle map entry first so a concurrent
184
// `connect()` for the same key doesn't latch onto a being-torn-down
185
// connection.
186
if (this._connections.get(connectionId) === handle) {
187
this._connections.delete(connectionId);
188
this._onDidChangeConnections.fire();
189
}
190
// Mark the handle as already closed-from-main so disposing it
191
// doesn't kick off a redundant second disconnect IPC. The actual
192
// disconnect is initiated below.
193
handle.fireClose();
194
handle.dispose();
195
this._mainService.disconnect(connectionId).catch(() => { /* best effort */ });
196
});
197
}
198
199
private _createRelayClient(result: { connectionId: string; address: string }): RemoteAgentHostProtocolClient {
200
const transport = new SSHRelayTransport(result.connectionId, this._mainService);
201
return this._instantiationService.createInstance(
202
RemoteAgentHostProtocolClient, result.address, transport,
203
);
204
}
205
206
private _augmentConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfig {
207
const result = { ...config };
208
const commandOverride = this._getRemoteAgentHostCommand();
209
if (commandOverride) {
210
result.remoteAgentHostCommand = commandOverride;
211
}
212
// Agent forwarding requires both the global setting (security opt-in)
213
// and the per-host SSH config `ForwardAgent yes` to be enabled.
214
if (this._isSSHAgentForwardingEnabled() && config.agentForward) {
215
result.agentForward = true;
216
}
217
return result;
218
}
219
220
private _getRemoteAgentHostCommand(): string | undefined {
221
return this._configurationService.getValue<string>('chat.sshRemoteAgentHostCommand') || undefined;
222
}
223
224
private _isSSHAgentForwardingEnabled(): boolean | undefined {
225
return this._configurationService.getValue<boolean>('chat.agentHost.forwardSSHAgent') || undefined;
226
}
227
}
228
229
/**
230
* Lightweight renderer-side handle that represents a connection
231
* managed by the main process.
232
*/
233
class SSHAgentHostConnectionHandle extends Disposable implements ISSHAgentHostConnection {
234
private readonly _onDidClose = this._register(new Emitter<void>());
235
readonly onDidClose = this._onDidClose.event;
236
237
private _closedByMain = false;
238
239
constructor(
240
readonly config: ISSHAgentHostConnection['config'],
241
readonly localAddress: string,
242
readonly name: string,
243
disconnectFn: () => Promise<void>,
244
) {
245
super();
246
247
// When this handle is disposed, tear down the main-process tunnel
248
// (skip if already closed from the main process side)
249
this._register(toDisposable(() => {
250
if (!this._closedByMain) {
251
disconnectFn().catch(() => { /* best effort */ });
252
}
253
}));
254
}
255
256
/** Called by the service when the main process signals connection closure. */
257
fireClose(): void {
258
this._closedByMain = true;
259
this._onDidClose.fire();
260
}
261
}
262
263