-
Notifications
You must be signed in to change notification settings - Fork 39.3k
Expand file tree
/
Copy pathremoteAgentHostService.ts
More file actions
337 lines (296 loc) · 11.6 KB
/
remoteAgentHostService.ts
File metadata and controls
337 lines (296 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
/*---------------------------------------------------------------------------------------------
* 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 { connectionTokenQueryName } from '../../../base/common/network.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import type { IAgentConnection } from './agentService.js';
import { TUNNEL_ADDRESS_PREFIX } from './tunnelAgentHost.js';
/** Connection status for a remote agent host. */
export const enum RemoteAgentHostConnectionStatus {
Connected = 'connected',
Connecting = 'connecting',
Disconnected = 'disconnected',
}
/** Configuration key for the list of remote agent host addresses. */
export const RemoteAgentHostsSettingId = 'chat.remoteAgentHosts';
/** Configuration key to enable remote agent host connections. */
export const RemoteAgentHostsEnabledSettingId = 'chat.remoteAgentHostsEnabled';
/**
* Configuration key that controls whether online dev tunnels and
* configured SSH remote agent hosts are auto-connected at startup.
*/
export const RemoteAgentHostAutoConnectSettingId = 'chat.remoteAgentHostsAutoConnect';
export const enum RemoteAgentHostEntryType {
WebSocket = 'websocket',
SSH = 'ssh',
Tunnel = 'tunnel',
}
export interface IRemoteAgentHostWebSocketConnection {
readonly type: RemoteAgentHostEntryType.WebSocket;
readonly address: string;
}
export interface IRemoteAgentHostSSHConnection {
readonly type: RemoteAgentHostEntryType.SSH;
/**
* The WebSocket address used by the agent host protocol client to
* communicate with the remote agent host process. This is typically a
* forwarded local port (e.g. `localhost:4321`) established by the SSH
* tunnel — it is NOT the SSH hostname itself.
*/
readonly address: string;
/**
* SSH config host alias (e.g. `myserver`). When set, the SSH tunnel is
* automatically re-established on startup using the user's SSH config.
* This takes precedence over {@link hostName} when constructing the
* VS Code Remote SSH authority.
*/
readonly sshConfigHost?: string;
/**
* The actual SSH hostname or IP address of the remote machine
* (e.g. `myserver.example.com`). This is the host that the SSH
* client connects to, and is used to construct the VS Code Remote
* SSH authority when {@link sshConfigHost} is not available.
*/
readonly hostName: string;
/** SSH username for the remote machine. */
readonly user?: string;
/** SSH port on the remote machine (default 22). */
readonly port?: number;
}
export interface IRemoteAgentHostTunnelConnection {
readonly type: RemoteAgentHostEntryType.Tunnel;
/** Dev tunnel ID. */
readonly tunnelId: string;
/** Dev tunnel cluster region. */
readonly clusterId: string;
/**
* User-defined display name for this tunnel (derived from tunnel tags).
* Used as the tunnel name in the VS Code Remote Tunnels authority
* (e.g. `tunnel+<label>`). Falls back to {@link tunnelId} if not set.
*/
readonly label?: string;
/** Auth provider used to connect to this tunnel. */
readonly authProvider?: 'github' | 'microsoft';
}
export type RemoteAgentHostConnection = IRemoteAgentHostWebSocketConnection | IRemoteAgentHostSSHConnection | IRemoteAgentHostTunnelConnection;
/** An entry in the {@link RemoteAgentHostsSettingId} setting. */
export interface IRemoteAgentHostEntry {
readonly name: string;
readonly connectionToken?: string;
readonly connection: RemoteAgentHostConnection;
}
export function getEntryAddress(entry: IRemoteAgentHostEntry): string {
switch (entry.connection.type) {
case RemoteAgentHostEntryType.WebSocket:
case RemoteAgentHostEntryType.SSH:
return entry.connection.address;
case RemoteAgentHostEntryType.Tunnel:
return `${TUNNEL_ADDRESS_PREFIX}${entry.connection.tunnelId}`;
}
}
export const enum RemoteAgentHostInputValidationError {
Empty = 'empty',
Invalid = 'invalid',
}
export interface IParsedRemoteAgentHostInput {
readonly address: string;
readonly connectionToken?: string;
readonly suggestedName: string;
}
export type RemoteAgentHostInputParseResult =
| { readonly parsed: IParsedRemoteAgentHostInput; readonly error?: undefined }
| { readonly parsed?: undefined; readonly error: RemoteAgentHostInputValidationError };
export const IRemoteAgentHostService = createDecorator<IRemoteAgentHostService>('remoteAgentHostService');
/**
* Manages connections to one or more remote agent host processes over
* WebSocket. Each connection is identified by its address string and
* exposed as an {@link IAgentConnection}, the same interface used for
* the local agent host.
*/
export interface IRemoteAgentHostService {
readonly _serviceBrand: undefined;
/** Fires when a remote connection is established or lost. */
readonly onDidChangeConnections: Event<void>;
/** Currently connected remote addresses with metadata. */
readonly connections: readonly IRemoteAgentHostConnectionInfo[];
/** All configured remote agent host entries from settings, regardless of connection status. */
readonly configuredEntries: readonly IRemoteAgentHostEntry[];
/**
* Get a per-connection {@link IAgentConnection} for subscribing to
* state, dispatching actions, creating sessions, etc.
*
* Returns `undefined` if no active connection exists for the address.
*/
getConnection(address: string): IAgentConnection | undefined;
/**
* Adds or updates a configured remote host and resolves once a connection
* to that host is available.
*/
addRemoteAgentHost(entry: IRemoteAgentHostEntry): Promise<IRemoteAgentHostConnectionInfo>;
/**
* Removes a configured remote host entry by address.
* Disconnects any active connection and removes the entry from settings.
*/
removeRemoteAgentHost(address: string): Promise<void>;
/**
* Forcefully reconnect to a configured remote host.
* Tears down any existing connection and starts a fresh connect attempt
* with reset backoff.
*/
reconnect(address: string): void;
/**
* Register a pre-connected agent connection.
* Used by the SSH and tunnel services to inject relay-backed connections
* without going through the WebSocket connect flow.
*/
addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise<IRemoteAgentHostConnectionInfo>;
/**
* Look up the {@link IRemoteAgentHostEntry} for a given address.
* Checks both configured entries from settings and dynamically
* registered entries (e.g. tunnel connections).
*/
getEntryByAddress(address: string): IRemoteAgentHostEntry | undefined;
}
/** Metadata about a single remote connection. */
export interface IRemoteAgentHostConnectionInfo {
readonly address: string;
readonly name: string;
readonly clientId: string;
readonly defaultDirectory?: string;
readonly status: RemoteAgentHostConnectionStatus;
}
export class NullRemoteAgentHostService implements IRemoteAgentHostService {
declare readonly _serviceBrand: undefined;
readonly onDidChangeConnections = Event.None;
readonly connections: readonly IRemoteAgentHostConnectionInfo[] = [];
readonly configuredEntries: readonly IRemoteAgentHostEntry[] = [];
getConnection(): IAgentConnection | undefined { return undefined; }
async addRemoteAgentHost(): Promise<IRemoteAgentHostConnectionInfo> {
throw new Error('Remote agent host connections are not supported in this environment.');
}
async removeRemoteAgentHost(_address: string): Promise<void> { }
reconnect(_address: string): void { }
async addSSHConnection(): Promise<IRemoteAgentHostConnectionInfo> {
throw new Error('Remote agent host connections are not supported in this environment.');
}
getEntryByAddress(): IRemoteAgentHostEntry | undefined { return undefined; }
}
export function parseRemoteAgentHostInput(input: string): RemoteAgentHostInputParseResult {
const trimmedInput = input.trim();
if (!trimmedInput) {
return { error: RemoteAgentHostInputValidationError.Empty };
}
const candidate = extractRemoteAgentHostCandidate(trimmedInput);
if (!candidate) {
return { error: RemoteAgentHostInputValidationError.Invalid };
}
const hasExplicitScheme = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(candidate);
try {
const url = new URL(hasExplicitScheme ? candidate : `ws://${candidate}`);
const normalizedProtocol = normalizeRemoteAgentHostProtocol(url.protocol);
if (!normalizedProtocol || !url.host) {
return { error: RemoteAgentHostInputValidationError.Invalid };
}
const connectionToken = url.searchParams.get(connectionTokenQueryName) ?? undefined;
url.searchParams.delete(connectionTokenQueryName);
// Only preserve wss: in the address - the transport defaults to ws:
const address = formatRemoteAgentHostAddress(url, normalizedProtocol === 'wss:' ? normalizedProtocol : undefined);
if (!address) {
return { error: RemoteAgentHostInputValidationError.Invalid };
}
return {
parsed: {
address,
connectionToken,
suggestedName: url.host,
},
};
} catch {
return { error: RemoteAgentHostInputValidationError.Invalid };
}
}
function extractRemoteAgentHostCandidate(input: string): string | undefined {
const urlMatch = input.match(/(?<url>(?:https?|wss?):\/\/\S+)/i);
const candidate = urlMatch?.groups?.url ?? input;
const trimmedCandidate = candidate.trim().replace(/[),.;\]]+$/, '');
return trimmedCandidate || undefined;
}
function normalizeRemoteAgentHostProtocol(protocol: string): 'ws:' | 'wss:' | undefined {
switch (protocol.toLowerCase()) {
case 'ws:':
case 'http:':
return 'ws:';
case 'wss:':
case 'https:':
return 'wss:';
default:
return undefined;
}
}
function formatRemoteAgentHostAddress(url: URL, protocol: 'ws:' | 'wss:' | undefined): string | undefined {
if (!url.host) {
return undefined;
}
const path = url.pathname !== '/' ? url.pathname : '';
const query = url.search;
const base = protocol ? `${protocol}//${url.host}` : url.host;
return `${base}${path}${query}`;
}
/** Raw shape of entries persisted in the {@link RemoteAgentHostsSettingId} setting. */
export interface IRawRemoteAgentHostEntry {
readonly address: string;
readonly name: string;
readonly connectionToken?: string;
readonly sshConfigHost?: string;
readonly sshHostName?: string;
readonly sshUser?: string;
readonly sshPort?: number;
}
export function rawEntryToEntry(raw: IRawRemoteAgentHostEntry): IRemoteAgentHostEntry | undefined {
if (raw.sshConfigHost || raw.sshHostName || raw.sshUser || raw.sshPort) {
return {
name: raw.name,
connectionToken: raw.connectionToken,
connection: {
type: RemoteAgentHostEntryType.SSH,
address: raw.address,
sshConfigHost: raw.sshConfigHost,
hostName: raw.sshHostName ?? raw.address,
user: raw.sshUser,
port: raw.sshPort,
},
};
}
return {
name: raw.name,
connectionToken: raw.connectionToken,
connection: {
type: RemoteAgentHostEntryType.WebSocket,
address: raw.address,
},
};
}
export function entryToRawEntry(entry: IRemoteAgentHostEntry): IRawRemoteAgentHostEntry | undefined {
switch (entry.connection.type) {
case RemoteAgentHostEntryType.SSH:
return {
address: entry.connection.address,
name: entry.name,
connectionToken: entry.connectionToken,
sshConfigHost: entry.connection.sshConfigHost,
sshHostName: entry.connection.hostName,
sshUser: entry.connection.user,
sshPort: entry.connection.port,
};
case RemoteAgentHostEntryType.WebSocket:
return {
address: entry.connection.address,
name: entry.name,
connectionToken: entry.connectionToken,
};
case RemoteAgentHostEntryType.Tunnel:
return undefined;
}
}