Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts
13401 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 { localize, localize2 } from '../../../../nls.js';
7
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
8
import { Codicon } from '../../../../base/common/codicons.js';
9
import { DisposableStore } from '../../../../base/common/lifecycle.js';
10
import { URI } from '../../../../base/common/uri.js';
11
import { ICommandService } from '../../../../platform/commands/common/commands.js';
12
import { IFileService } from '../../../../platform/files/common/files.js';
13
import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js';
14
import { ICodeEditor, isCodeEditor } from '../../../../editor/browser/editorBrowser.js';
15
import { EndOfLinePreference } from '../../../../editor/common/model.js';
16
import { Range } from '../../../../editor/common/core/range.js';
17
import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js';
18
import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js';
19
import { IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostEntryType, RemoteAgentHostInputValidationError, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
20
import { ISSHRemoteAgentHostService, SSHAuthMethod, type ISSHAgentHostConfig, type ISSHAgentHostConnection, type ISSHResolvedConfig } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js';
21
import { ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js';
22
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
23
import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
24
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
25
import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
26
import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';
27
import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';
28
import { IProductService } from '../../../../platform/product/common/productService.js';
29
import { SessionsCategories } from '../../../common/categories.js';
30
import { SessionWorkspacePickerGroupContext } from '../../../common/contextkeys.js';
31
import { Menus } from '../../../browser/menus.js';
32
import { NewChatViewPane, SessionsViewId } from '../../chat/browser/newChatViewPane.js';
33
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
34
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
35
import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js';
36
import { SESSION_WORKSPACE_GROUP_REMOTE } from '../../../services/sessions/common/session.js';
37
38
/** Action / command IDs registered by this file. */
39
export const RemoteAgentHostCommandIds = {
40
addRemoteAgentHost: 'sessions.remoteAgentHost.add',
41
connectViaSSH: 'workbench.action.sessions.connectViaSSH',
42
addNewSSHHost: 'workbench.action.sessions.addNewSSHHost',
43
configureSSHHosts: 'workbench.action.sessions.configureSSHHosts',
44
connectViaTunnel: 'workbench.action.sessions.connectViaTunnel',
45
manageRemoteAgentHosts: 'workbench.action.sessions.manageRemoteAgentHosts',
46
} as const;
47
48
registerAction2(class extends Action2 {
49
constructor() {
50
super({
51
id: RemoteAgentHostCommandIds.addRemoteAgentHost,
52
title: localize2('addRemoteAgentHost', "Add Remote Agent Host..."),
53
category: SessionsCategories.Sessions,
54
f1: true,
55
precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true),
56
});
57
}
58
59
override async run(accessor: ServicesAccessor): Promise<void> {
60
const remoteAgentHostService = accessor.get(IRemoteAgentHostService);
61
const quickInputService = accessor.get(IQuickInputService);
62
const notificationService = accessor.get(INotificationService);
63
64
// Prompt for address
65
const address = await quickInputService.input({
66
title: localize('addRemoteTitle', "Add Remote Agent Host"),
67
prompt: localize('addRemotePrompt', "Paste a host, host:port, or WebSocket URL. Example: {0}", 'ws://127.0.0.1:8089'),
68
placeHolder: 'ws://127.0.0.1:8080?tkn=abc-123',
69
ignoreFocusLost: true,
70
validateInput: async value => {
71
const result = parseRemoteAgentHostInput(value);
72
if (result.error === RemoteAgentHostInputValidationError.Empty) {
73
return localize('addRemoteValidationEmpty', "Enter a remote agent host address.");
74
}
75
if (result.error === RemoteAgentHostInputValidationError.Invalid) {
76
return localize('addRemoteValidationInvalid', "Enter a valid host, host:port, or WebSocket URL.");
77
}
78
return undefined;
79
},
80
});
81
if (!address) {
82
return;
83
}
84
const parsed = parseRemoteAgentHostInput(address);
85
if (!parsed.parsed) {
86
return;
87
}
88
89
// Prompt for display name
90
const defaultName = parsed.parsed.suggestedName;
91
const name = await quickInputService.input({
92
title: localize('nameRemoteTitle', "Name Remote Agent Host"),
93
prompt: localize('nameRemotePrompt', "Enter a display name for this remote agent host."),
94
placeHolder: localize('nameRemotePlaceholder', "My Remote"),
95
value: defaultName,
96
valueSelection: [0, defaultName.length],
97
ignoreFocusLost: true,
98
validateInput: async value => value.trim() ? undefined : localize('nameRemoteValidationEmpty', "Enter a name for this remote agent host."),
99
});
100
if (!name?.trim()) {
101
return;
102
}
103
104
// Connect
105
try {
106
await remoteAgentHostService.addRemoteAgentHost({
107
name: name.trim(),
108
connectionToken: parsed.parsed.connectionToken,
109
connection: {
110
type: RemoteAgentHostEntryType.WebSocket,
111
address: parsed.parsed.address,
112
},
113
});
114
} catch {
115
notificationService.error(localize('addRemoteFailed', "Failed to connect to remote agent host {0}.", parsed.parsed.address));
116
}
117
}
118
});
119
120
// ---- Connect via SSH -------------------------------------------------------
121
122
interface ISSHAuthMethodPickItem extends IQuickPickItem {
123
readonly method: SSHAuthMethod;
124
}
125
126
/**
127
* Parse a free-form SSH connection string of the form `[user@]host[:port]`.
128
* Returns `undefined` for empty or invalid input.
129
*/
130
export function parseSSHHostInput(value: string): { host: string; username?: string; port?: number } | undefined {
131
const trimmed = value.trim();
132
if (!trimmed) {
133
return undefined;
134
}
135
const atIdx = trimmed.indexOf('@');
136
if (atIdx === 0 || atIdx === trimmed.length - 1) {
137
return undefined;
138
}
139
let username: string | undefined;
140
let hostPart: string;
141
if (atIdx !== -1) {
142
username = trimmed.substring(0, atIdx);
143
hostPart = trimmed.substring(atIdx + 1);
144
} else {
145
hostPart = trimmed;
146
}
147
if (!hostPart) {
148
return undefined;
149
}
150
let host: string;
151
let port: number | undefined;
152
const colonIdx = hostPart.lastIndexOf(':');
153
if (colonIdx !== -1) {
154
host = hostPart.substring(0, colonIdx);
155
const portStr = hostPart.substring(colonIdx + 1);
156
if (!host) {
157
return undefined;
158
}
159
if (portStr) {
160
const portNum = Number(portStr);
161
if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) {
162
return undefined;
163
}
164
port = portNum;
165
}
166
} else {
167
host = hostPart;
168
}
169
if (!host) {
170
return undefined;
171
}
172
return { host, username, port };
173
}
174
175
function validateSSHHostInput(value: string): string | undefined {
176
const v = value.trim();
177
if (!v) {
178
return localize('sshHostEmpty', "Enter an SSH host.");
179
}
180
const atIdx = v.indexOf('@');
181
if (atIdx === 0) {
182
return localize('sshUsernameMissingInHost', "Enter a username before '@'.");
183
}
184
if (atIdx === v.length - 1) {
185
return localize('sshHostMissingAfterAt', "Enter a host name after '@'.");
186
}
187
const hostPart = atIdx !== -1 ? v.substring(atIdx + 1) : v;
188
if (!hostPart) {
189
return localize('sshHostMissingAfterAt', "Enter a host name after '@'.");
190
}
191
const colonIdx = hostPart.lastIndexOf(':');
192
if (colonIdx !== -1) {
193
const hostName = hostPart.substring(0, colonIdx);
194
const portStr = hostPart.substring(colonIdx + 1);
195
if (!hostName) {
196
return localize('sshHostMissingAfterAt', "Enter a host name after '@'.");
197
}
198
if (portStr) {
199
const portNum = Number(portStr);
200
if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) {
201
return localize('sshHostInvalidPort', "Enter a valid port number.");
202
}
203
}
204
}
205
return undefined;
206
}
207
208
interface ISSHAliasPickItem extends IQuickPickItem {
209
readonly kind: 'alias';
210
readonly hostAlias: string;
211
}
212
213
interface ISSHNewHostPickItem extends IQuickPickItem {
214
kind: 'new-host';
215
hostInput: string;
216
}
217
218
interface ISSHFooterPickItem extends IQuickPickItem {
219
readonly kind: 'add-config' | 'configure';
220
}
221
222
type SSHHostPickerItem = ISSHAliasPickItem | ISSHNewHostPickItem | ISSHFooterPickItem;
223
224
async function promptToConnectViaSSH(
225
accessor: ServicesAccessor,
226
options: { showBackButton?: boolean } = {},
227
): Promise<'back' | void> {
228
const sshService = accessor.get(ISSHRemoteAgentHostService);
229
const quickInputService = accessor.get(IQuickInputService);
230
const notificationService = accessor.get(INotificationService);
231
const instantiationService = accessor.get(IInstantiationService);
232
const commandService = accessor.get(ICommandService);
233
234
const configHosts = await sshService.listSSHConfigHosts().catch(() => [] as string[]);
235
236
const aliasItems: ISSHAliasPickItem[] = configHosts.map(h => ({
237
kind: 'alias',
238
hostAlias: h,
239
label: h,
240
}));
241
const addHostItem: ISSHFooterPickItem = {
242
kind: 'add-config',
243
label: '$(plus) ' + localize('sshAddNewHost', "Add New SSH Host..."),
244
alwaysShow: true,
245
};
246
const configureHostsItem: ISSHFooterPickItem = {
247
kind: 'configure',
248
label: localize('sshConfigureHosts', "Configure SSH Hosts..."),
249
alwaysShow: true,
250
};
251
const newHostItem: ISSHNewHostPickItem = {
252
kind: 'new-host',
253
hostInput: '',
254
label: '',
255
alwaysShow: true,
256
};
257
258
const result = await new Promise<'back' | SSHHostPickerItem | undefined>((resolve) => {
259
const store = new DisposableStore();
260
const picker = store.add(quickInputService.createQuickPick<SSHHostPickerItem>());
261
picker.title = localize('sshHostTitle', "Connect via SSH");
262
picker.placeholder = localize('sshHostPickerPlaceholder', "Select configured SSH host or enter user@host");
263
picker.ignoreFocusOut = true;
264
picker.matchOnDescription = true;
265
if (options.showBackButton) {
266
picker.buttons = [quickInputService.backButton];
267
}
268
269
let newHostVisible = false;
270
const updateItems = () => {
271
const items: SSHHostPickerItem[] = [...aliasItems];
272
if (newHostVisible) {
273
items.push(newHostItem);
274
}
275
items.push(addHostItem);
276
items.push(configureHostsItem);
277
picker.items = items;
278
};
279
updateItems();
280
281
store.add(picker.onDidChangeValue(value => {
282
const parsed = parseSSHHostInput(value);
283
if (parsed) {
284
newHostItem.hostInput = value.trim();
285
newHostItem.label = `\u27a4 ${value.trim()}`;
286
if (!newHostVisible) {
287
newHostVisible = true;
288
updateItems();
289
} else {
290
// Force item refresh so the label updates
291
picker.items = picker.items;
292
}
293
} else if (newHostVisible) {
294
newHostVisible = false;
295
updateItems();
296
}
297
}));
298
299
store.add(picker.onDidTriggerButton(button => {
300
if (button === quickInputService.backButton) {
301
resolve('back');
302
picker.hide();
303
}
304
}));
305
store.add(picker.onDidAccept(() => {
306
const selected = picker.selectedItems[0];
307
resolve(selected);
308
picker.hide();
309
}));
310
store.add(picker.onDidHide(() => {
311
resolve(undefined);
312
store.dispose();
313
}));
314
picker.show();
315
});
316
317
if (result === 'back') {
318
return 'back';
319
}
320
321
if (!result) {
322
return;
323
}
324
325
if (result.kind === 'add-config' || result.kind === 'configure') {
326
const cmdId = result.kind === 'add-config'
327
? RemoteAgentHostCommandIds.addNewSSHHost
328
: RemoteAgentHostCommandIds.configureSSHHosts;
329
// Pass back callback so sub-picker can navigate back to this SSH picker
330
const onBackToSSH = () => instantiationService.invokeFunction(a => promptToConnectViaSSH(a, options));
331
await commandService.executeCommand(cmdId, onBackToSSH);
332
return;
333
}
334
335
if (result.kind === 'alias') {
336
await instantiationService.invokeFunction(accessor =>
337
connectToConfiguredSSHHost(accessor, result.hostAlias)
338
);
339
return;
340
}
341
342
// kind === 'new-host'
343
const newHost = result as ISSHNewHostPickItem;
344
const parsed = parseSSHHostInput(newHost.hostInput);
345
if (!parsed) {
346
notificationService.error(validateSSHHostInput(newHost.hostInput) ?? localize('sshHostInvalid', "Invalid SSH host."));
347
return;
348
}
349
await instantiationService.invokeFunction(accessor =>
350
promptForCredentialsAndConnect(accessor, parsed.host, parsed.username, parsed.port)
351
);
352
}
353
354
async function connectToConfiguredSSHHost(
355
accessor: ServicesAccessor,
356
hostAlias: string,
357
): Promise<void> {
358
const sshService = accessor.get(ISSHRemoteAgentHostService);
359
const notificationService = accessor.get(INotificationService);
360
const instantiationService = accessor.get(IInstantiationService);
361
362
let resolvedConfig: ISSHResolvedConfig;
363
try {
364
resolvedConfig = await sshService.resolveSSHConfig(hostAlias);
365
} catch (err) {
366
notificationService.error(localize('sshResolveConfigFailed', "Failed to resolve SSH config for {0}: {1}", hostAlias, String(err)));
367
return;
368
}
369
370
const host = resolvedConfig.hostname;
371
const username = resolvedConfig.user;
372
const port = resolvedConfig.port !== 22 ? resolvedConfig.port : undefined;
373
const suggestedName = hostAlias;
374
375
let defaultKeyPath: string | undefined;
376
if (resolvedConfig.identityFile.length > 0) {
377
const firstKey = resolvedConfig.identityFile[0];
378
const defaultKeys = ['~/.ssh/id_rsa', '~/.ssh/id_ecdsa', '~/.ssh/id_ed25519', '~/.ssh/id_dsa', '~/.ssh/id_xmss'];
379
if (!defaultKeys.includes(firstKey)) {
380
defaultKeyPath = firstKey;
381
}
382
}
383
384
if (username) {
385
const config: ISSHAgentHostConfig = {
386
host,
387
port,
388
username,
389
authMethod: SSHAuthMethod.Agent,
390
privateKeyPath: defaultKeyPath,
391
agentForward: resolvedConfig.forwardAgent || undefined,
392
name: suggestedName,
393
sshConfigHost: hostAlias,
394
};
395
const connection = await instantiationService.invokeFunction(accessor =>
396
connectWithProgress(accessor, config, suggestedName)
397
);
398
if (connection) {
399
await instantiationService.invokeFunction(accessor => promptForRemoteFolder(accessor, connection));
400
}
401
return;
402
}
403
404
// Fallback: alias resolved without a user โ€” fall through to manual flow
405
await instantiationService.invokeFunction(accessor =>
406
promptForCredentialsAndConnect(accessor, host, undefined, port, suggestedName, defaultKeyPath)
407
);
408
}
409
410
async function promptForCredentialsAndConnect(
411
accessor: ServicesAccessor,
412
host: string,
413
username: string | undefined,
414
port: number | undefined,
415
suggestedName?: string,
416
defaultKeyPath?: string,
417
): Promise<void> {
418
const quickInputService = accessor.get(IQuickInputService);
419
const instantiationService = accessor.get(IInstantiationService);
420
421
if (!username) {
422
const usernameInput = await quickInputService.input({
423
title: localize('sshUsernameTitle', "SSH Username"),
424
prompt: localize('sshUsernamePrompt', "Enter the username for {0}.", host),
425
placeHolder: 'root',
426
ignoreFocusLost: true,
427
validateInput: async value => value.trim() ? undefined : localize('sshUsernameEmpty', "Enter a username."),
428
});
429
if (!usernameInput) {
430
return;
431
}
432
username = usernameInput.trim();
433
}
434
435
const authPicks: ISSHAuthMethodPickItem[] = [
436
{
437
method: SSHAuthMethod.Agent,
438
label: localize('sshAuthAgent', "SSH Agent"),
439
description: localize('sshAuthAgentDesc', "Use the running SSH agent for authentication"),
440
},
441
{
442
method: SSHAuthMethod.KeyFile,
443
label: localize('sshAuthKey', "Private Key File"),
444
description: localize('sshAuthKeyDesc', "Authenticate with a private key file"),
445
},
446
{
447
method: SSHAuthMethod.Password,
448
label: localize('sshAuthPassword', "Password"),
449
description: localize('sshAuthPasswordDesc', "Authenticate with a password"),
450
},
451
];
452
453
const authPicked = await quickInputService.pick(authPicks, {
454
title: localize('sshAuthTitle', "Authentication Method"),
455
placeHolder: localize('sshAuthPlaceholder', "Choose how to authenticate with {0}", host),
456
});
457
if (!authPicked) {
458
return;
459
}
460
const authMethod = authPicked.method;
461
462
let privateKeyPath: string | undefined;
463
let password: string | undefined;
464
465
if (authMethod === SSHAuthMethod.KeyFile) {
466
const keyPath = await quickInputService.input({
467
title: localize('sshKeyTitle', "Private Key Path"),
468
prompt: localize('sshKeyPrompt', "Enter the path to your SSH private key."),
469
placeHolder: '~/.ssh/id_rsa',
470
value: defaultKeyPath ?? '~/.ssh/id_rsa',
471
ignoreFocusLost: true,
472
validateInput: async value => value.trim() ? undefined : localize('sshKeyEmpty', "Enter a key file path."),
473
});
474
if (!keyPath) {
475
return;
476
}
477
privateKeyPath = keyPath.trim();
478
} else if (authMethod === SSHAuthMethod.Password) {
479
const pw = await quickInputService.input({
480
title: localize('sshPasswordTitle', "SSH Password"),
481
prompt: localize('sshPasswordPrompt', "Enter the password for {0}@{1}.", username, host),
482
password: true,
483
ignoreFocusLost: true,
484
validateInput: async value => value ? undefined : localize('sshPasswordEmpty', "Enter a password."),
485
});
486
if (!pw) {
487
return;
488
}
489
password = pw;
490
}
491
492
const defaultName = suggestedName ?? `${username}@${host}`;
493
const name = await quickInputService.input({
494
title: localize('sshNameTitle', "Name Remote"),
495
prompt: localize('sshNamePrompt', "Enter a display name for this SSH remote."),
496
placeHolder: localize('sshNamePlaceholder', "My Remote"),
497
value: defaultName,
498
valueSelection: [0, defaultName.length],
499
ignoreFocusLost: true,
500
validateInput: async value => value.trim() ? undefined : localize('sshNameEmpty', "Enter a name."),
501
});
502
if (!name) {
503
return;
504
}
505
506
const config: ISSHAgentHostConfig = {
507
host,
508
port,
509
username,
510
authMethod,
511
privateKeyPath,
512
password,
513
name: name.trim(),
514
};
515
516
const connection = await instantiationService.invokeFunction(accessor =>
517
connectWithProgress(accessor, config, host)
518
);
519
if (connection) {
520
await instantiationService.invokeFunction(accessor => promptForRemoteFolder(accessor, connection));
521
}
522
}
523
524
async function connectWithProgress(
525
accessor: ServicesAccessor,
526
config: ISSHAgentHostConfig,
527
displayHost: string,
528
): Promise<ISSHAgentHostConnection | undefined> {
529
const sshService = accessor.get(ISSHRemoteAgentHostService);
530
const notificationService = accessor.get(INotificationService);
531
532
const handle = notificationService.notify({
533
severity: Severity.Info,
534
message: localize('sshConnecting', "Connecting to {0} via SSH...", displayHost),
535
progress: { infinite: true },
536
});
537
538
// Build the expected connection key to filter progress events.
539
// Must match the key logic in the shared process service.
540
const expectedKey = config.sshConfigHost
541
? `ssh:${config.sshConfigHost}`
542
: `${config.username}@${config.host}:${config.port ?? 22}`;
543
544
const progressListener = sshService.onDidReportConnectProgress?.(progress => {
545
if (progress.connectionKey === expectedKey) {
546
handle.updateMessage(progress.message);
547
}
548
});
549
550
try {
551
const connection = await sshService.connect(config);
552
handle.close();
553
return connection;
554
} catch (err) {
555
handle.close();
556
notificationService.error(localize('sshConnectFailed', "Failed to connect via SSH to {0}: {1}", displayHost, String(err)));
557
return undefined;
558
} finally {
559
progressListener?.dispose();
560
}
561
}
562
563
/**
564
* After a successful SSH connection, show the remote folder picker and
565
* pre-select the chosen folder in the workspace picker.
566
*/
567
async function promptForRemoteFolder(
568
accessor: ServicesAccessor,
569
connection: ISSHAgentHostConnection,
570
): Promise<void> {
571
const viewsService = accessor.get(IViewsService);
572
const sessionsProvidersService = accessor.get(ISessionsProvidersService);
573
const sessionsManagementService = accessor.get(ISessionsManagementService);
574
575
// The provider is created synchronously during addManagedConnection's
576
// onDidChangeConnections event, so it should exist by now.
577
const provider = sessionsProvidersService.getProviders().find((p): p is IAgentHostSessionsProvider => isAgentHostProvider(p) && p.remoteAddress === connection.localAddress);
578
if (!provider) {
579
return;
580
}
581
582
// Use the provider's existing browse action to show the folder picker
583
const browseAction = provider.browseActions[0];
584
if (!browseAction) {
585
return;
586
}
587
588
const workspace = await browseAction.run();
589
if (!workspace) {
590
return;
591
}
592
593
sessionsManagementService.openNewSessionView();
594
const view = await viewsService.openView<NewChatViewPane>(SessionsViewId, true);
595
view?.selectWorkspace({ providerId: provider.id, workspace });
596
}
597
598
registerAction2(class extends Action2 {
599
constructor() {
600
super({
601
id: RemoteAgentHostCommandIds.connectViaSSH,
602
title: localize2('connectViaSSH', "Connect to Remote Agent Host via SSH"),
603
shortTitle: localize2('connectViaSSHShort', "SSH..."),
604
category: SessionsCategories.Sessions,
605
f1: true,
606
icon: Codicon.remote,
607
precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true),
608
menu: {
609
id: Menus.SessionWorkspaceManage,
610
order: 20,
611
when: SessionWorkspacePickerGroupContext.isEqualTo(SESSION_WORKSPACE_GROUP_REMOTE),
612
},
613
});
614
}
615
616
override async run(accessor: ServicesAccessor, onBack?: () => void): Promise<void> {
617
const result = await promptToConnectViaSSH(accessor, { showBackButton: !!onBack });
618
if (result === 'back') {
619
onBack?.();
620
}
621
}
622
});
623
624
registerAction2(class extends Action2 {
625
constructor() {
626
super({
627
id: RemoteAgentHostCommandIds.addNewSSHHost,
628
title: localize2('addNewSSHHost', "Add New SSH Host..."),
629
category: SessionsCategories.Sessions,
630
f1: true,
631
precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true),
632
});
633
}
634
635
override async run(accessor: ServicesAccessor): Promise<void> {
636
const sshService = accessor.get(ISSHRemoteAgentHostService);
637
const editorService = accessor.get(IEditorService);
638
const fileService = accessor.get(IFileService);
639
const notificationService = accessor.get(INotificationService);
640
641
let configUri;
642
try {
643
configUri = await sshService.ensureUserSSHConfig();
644
} catch (err) {
645
notificationService.error(localize('sshConfigCreateFailed', "Failed to create SSH config file: {0}", String(err)));
646
return;
647
}
648
649
const editorPane = await editorService.openEditor({ resource: configUri, options: { pinned: true } satisfies ITextEditorOptions });
650
if (!editorPane) {
651
return;
652
}
653
const control = editorPane.getControl();
654
if (!isCodeEditor(control) || !control.hasModel()) {
655
return;
656
}
657
const editor = control as ICodeEditor;
658
const model = editor.getModel();
659
if (!model) {
660
return;
661
}
662
663
// Append a snippet at end of document. Read file content for length;
664
// fall back to model length to avoid races.
665
let appendNewline = false;
666
try {
667
const stat = await fileService.stat(configUri);
668
if (stat.size > 0) {
669
const content = model.getValueInRange(model.getFullModelRange(), EndOfLinePreference.LF);
670
appendNewline = content.length > 0 && !content.endsWith('\n');
671
}
672
} catch {
673
// ignore
674
}
675
const lastLine = model.getLineCount();
676
const lastCol = model.getLineMaxColumn(lastLine);
677
editor.setSelection(new Range(lastLine, lastCol, lastLine, lastCol));
678
679
const snippet = (appendNewline ? '\n' : '') + 'Host ${1:alias}\n HostName ${2:hostname}\n User ${3:user}\n';
680
SnippetController2.get(editor)?.insert(snippet);
681
editor.focus();
682
}
683
});
684
685
registerAction2(class extends Action2 {
686
constructor() {
687
super({
688
id: RemoteAgentHostCommandIds.configureSSHHosts,
689
title: localize2('configureSSHHosts', "Configure SSH Hosts..."),
690
category: SessionsCategories.Sessions,
691
f1: true,
692
precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true),
693
});
694
}
695
696
override async run(accessor: ServicesAccessor, onBack?: () => void): Promise<void> {
697
const sshService = accessor.get(ISSHRemoteAgentHostService);
698
const editorService = accessor.get(IEditorService);
699
const quickInputService = accessor.get(IQuickInputService);
700
const notificationService = accessor.get(INotificationService);
701
702
let configFiles: URI[];
703
try {
704
configFiles = await sshService.listSSHConfigFiles();
705
} catch (err) {
706
notificationService.error(localize('sshConfigListFailed', "Failed to list SSH config files: {0}", String(err)));
707
return;
708
}
709
710
// Always offer the user-config fallback so we have something openable.
711
if (configFiles.length === 0) {
712
try {
713
const uri = await sshService.ensureUserSSHConfig();
714
await editorService.openEditor({ resource: uri, options: { pinned: true } satisfies ITextEditorOptions });
715
} catch (err) {
716
notificationService.error(localize('sshConfigOpenFailed', "Failed to open SSH config file: {0}", String(err)));
717
}
718
return;
719
}
720
721
interface ISSHConfigFilePickItem extends IQuickPickItem {
722
readonly uri: URI;
723
readonly isUserConfig: boolean;
724
}
725
const userConfigUri = configFiles[0];
726
const items: ISSHConfigFilePickItem[] = configFiles.map((uri, index) => ({
727
label: uri.fsPath,
728
uri,
729
isUserConfig: index === 0,
730
}));
731
732
// If there's only one file, skip the picker and open it directly.
733
// If onBack is provided we still need to show the picker to offer navigation.
734
if (items.length === 1 && !onBack) {
735
const picked = items[0];
736
try {
737
const uri = picked.isUserConfig
738
? await sshService.ensureUserSSHConfig().catch(() => userConfigUri)
739
: picked.uri;
740
await editorService.openEditor({ resource: uri, options: { pinned: true } satisfies ITextEditorOptions });
741
} catch (err) {
742
notificationService.error(localize('sshConfigOpenFailed', "Failed to open SSH config file: {0}", String(err)));
743
}
744
return;
745
}
746
747
const picked = await new Promise<'back' | ISSHConfigFilePickItem | undefined>(resolve => {
748
const store = new DisposableStore();
749
const picker = store.add(quickInputService.createQuickPick<ISSHConfigFilePickItem>());
750
picker.title = localize('sshConfigPickTitle', "Select SSH configuration file to edit");
751
picker.placeholder = localize('sshConfigPickPlaceholder', "Select an SSH configuration file");
752
picker.items = items;
753
if (onBack) {
754
picker.buttons = [quickInputService.backButton];
755
}
756
store.add(picker.onDidTriggerButton(button => {
757
if (button === quickInputService.backButton) {
758
resolve('back');
759
picker.hide();
760
}
761
}));
762
store.add(picker.onDidAccept(() => {
763
resolve(picker.selectedItems[0]);
764
picker.hide();
765
}));
766
store.add(picker.onDidHide(() => {
767
resolve(undefined);
768
store.dispose();
769
}));
770
picker.show();
771
});
772
773
if (picked === 'back') {
774
onBack?.();
775
return;
776
}
777
if (!picked) {
778
return;
779
}
780
781
try {
782
// If the user picked the user config, ensure it exists (creating it on demand)
783
// before opening so we don't try to open a file that's not there yet.
784
const uri = picked.isUserConfig
785
? await sshService.ensureUserSSHConfig().catch(() => userConfigUri)
786
: picked.uri;
787
await editorService.openEditor({ resource: uri, options: { pinned: true } satisfies ITextEditorOptions });
788
} catch (err) {
789
notificationService.error(localize('sshConfigOpenFailed', "Failed to open SSH config file: {0}", String(err)));
790
}
791
}
792
});
793
794
// ---- Connect via Dev Tunnel -------------------------------------------------
795
796
interface ITunnelPickItem extends IQuickPickItem {
797
readonly tunnel: ITunnelInfo;
798
}
799
800
interface IAuthProviderPickItem extends IQuickPickItem {
801
readonly provider: 'github' | 'microsoft';
802
}
803
804
async function promptToConnectViaTunnel(
805
accessor: ServicesAccessor,
806
options: { showBackButton?: boolean } = {},
807
): Promise<'back' | void> {
808
const tunnelService = accessor.get(ITunnelAgentHostService);
809
const quickInputService = accessor.get(IQuickInputService);
810
const notificationService = accessor.get(INotificationService);
811
const authenticationService = accessor.get(IAuthenticationService);
812
const instantiationService = accessor.get(IInstantiationService);
813
const productService = accessor.get(IProductService);
814
815
// Step 1: Determine auth provider โ€” try cached sessions first, then prompt
816
let authProvider = await tunnelService.getAuthProvider({ silent: true });
817
818
if (!authProvider) {
819
// No cached session โ€” prompt user to choose auth provider
820
const authPicks: IAuthProviderPickItem[] = [
821
{
822
provider: 'github',
823
label: localize('tunnelAuthGitHub', "GitHub"),
824
description: localize('tunnelAuthGitHubDesc', "Sign in with your GitHub account"),
825
},
826
{
827
provider: 'microsoft',
828
label: localize('tunnelAuthMicrosoft', "Microsoft Account"),
829
description: localize('tunnelAuthMicrosoftDesc', "Sign in with your Microsoft account"),
830
},
831
];
832
833
const authPicked = await quickInputService.pick(authPicks, {
834
title: localize('tunnelAuthTitle', "Sign In for Dev Tunnels"),
835
placeHolder: localize('tunnelAuthPlaceholder', "Choose an authentication provider"),
836
});
837
if (!authPicked) {
838
return;
839
}
840
authProvider = authPicked.provider;
841
842
// Trigger interactive auth for the chosen provider
843
const scopes = productService.tunnelApplicationConfig?.authenticationProviders?.[authProvider]?.scopes ?? [];
844
try {
845
if (!(await authenticationService.getSessions(authProvider, scopes)).length) {
846
await authenticationService.createSession(authProvider, scopes, { activateImmediate: true });
847
}
848
} catch {
849
notificationService.error(localize('tunnelAuthFailed', "Authentication failed. Please try again."));
850
return;
851
}
852
}
853
854
// Step 2: Show tunnel picker immediately in busy state while enumerating
855
const store = new DisposableStore();
856
const tunnelPicker = store.add(quickInputService.createQuickPick<ITunnelPickItem>());
857
tunnelPicker.title = localize('tunnelPickTitle', "Connect via Dev Tunnel");
858
tunnelPicker.placeholder = localize('tunnelPickPlaceholder', "Select a dev tunnel to connect to");
859
tunnelPicker.busy = true;
860
if (options.showBackButton) {
861
tunnelPicker.buttons = [quickInputService.backButton];
862
}
863
tunnelPicker.show();
864
865
let tunnels: ITunnelInfo[];
866
try {
867
tunnels = await tunnelService.listTunnels();
868
} catch (err) {
869
store.dispose();
870
notificationService.error(localize('tunnelListFailed', "Failed to list dev tunnels: {0}", err instanceof Error ? err.message : String(err)));
871
return;
872
}
873
874
if (tunnels.length === 0) {
875
store.dispose();
876
notificationService.info(localize('tunnelNoneFound', "No dev tunnels with agent host support were found. Start a tunnel with 'code tunnel' on another machine."));
877
return;
878
}
879
880
tunnelPicker.items = tunnels.map(t => ({
881
label: t.name,
882
description: `${t.tunnelId} ยท protocol v${t.protocolVersion}`,
883
tunnel: t,
884
}));
885
tunnelPicker.busy = false;
886
887
// Step 3: Wait for user selection
888
const picked = await new Promise<'back' | ITunnelPickItem | undefined>(resolve => {
889
store.add(tunnelPicker.onDidTriggerButton(button => {
890
if (button === quickInputService.backButton) {
891
resolve('back');
892
tunnelPicker.hide();
893
}
894
}));
895
store.add(tunnelPicker.onDidAccept(() => {
896
resolve(tunnelPicker.selectedItems[0]);
897
tunnelPicker.hide();
898
}));
899
store.add(tunnelPicker.onDidHide(() => {
900
resolve(undefined);
901
store.dispose();
902
}));
903
});
904
905
if (picked === 'back') {
906
return 'back';
907
}
908
if (!picked) {
909
return;
910
}
911
912
// Step 4: Connect to the tunnel with progress notification
913
const handle = notificationService.notify({
914
severity: Severity.Info,
915
message: localize('tunnelConnecting', "Connecting to tunnel '{0}'...", picked.tunnel.name),
916
progress: { infinite: true },
917
});
918
919
try {
920
await tunnelService.connect(picked.tunnel, authProvider);
921
handle.close();
922
} catch (err) {
923
handle.close();
924
notificationService.error(localize('tunnelConnectFailed', "Failed to connect to tunnel '{0}': {1}", picked.tunnel.name, err instanceof Error ? err.message : String(err)));
925
return;
926
}
927
928
// Cache the tunnel for future reconnections
929
tunnelService.cacheTunnel(picked.tunnel, authProvider);
930
931
// Step 5: Open folder picker (same pattern as SSH)
932
await instantiationService.invokeFunction(accessor => promptForTunnelFolder(accessor, picked.tunnel));
933
}
934
935
/**
936
* After a successful tunnel connection, show the remote folder picker and
937
* pre-select the chosen folder in the workspace picker.
938
*/
939
async function promptForTunnelFolder(
940
accessor: ServicesAccessor,
941
tunnel: ITunnelInfo,
942
): Promise<void> {
943
const viewsService = accessor.get(IViewsService);
944
const sessionsProvidersService = accessor.get(ISessionsProvidersService);
945
const sessionsManagementService = accessor.get(ISessionsManagementService);
946
947
const tunnelAddress = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`;
948
949
// The provider is created by TunnelAgentHostContribution when the
950
// tunnel is cached (via onDidChangeTunnels / _reconcileProviders).
951
const provider = sessionsProvidersService.getProviders().find((p): p is IAgentHostSessionsProvider => isAgentHostProvider(p) && p.remoteAddress === tunnelAddress);
952
if (!provider) {
953
return;
954
}
955
956
// Use the provider's existing browse action to show the folder picker
957
const browseAction = provider.browseActions[0];
958
if (!browseAction) {
959
return;
960
}
961
962
const workspace = await browseAction.run();
963
if (!workspace) {
964
return;
965
}
966
967
sessionsManagementService.openNewSessionView();
968
const view = await viewsService.openView<NewChatViewPane>(SessionsViewId, true);
969
view?.selectWorkspace({ providerId: provider.id, workspace });
970
}
971
972
registerAction2(class extends Action2 {
973
constructor() {
974
super({
975
id: RemoteAgentHostCommandIds.connectViaTunnel,
976
title: localize2('connectViaTunnel', "Connect to Remote Agent Host via Dev Tunnel"),
977
shortTitle: localize2('connectViaTunnelShort', "Tunnels..."),
978
category: SessionsCategories.Sessions,
979
f1: true,
980
icon: Codicon.cloud,
981
precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true),
982
menu: {
983
id: Menus.SessionWorkspaceManage,
984
order: 10,
985
when: SessionWorkspacePickerGroupContext.isEqualTo(SESSION_WORKSPACE_GROUP_REMOTE),
986
},
987
});
988
}
989
990
override async run(accessor: ServicesAccessor, onBack?: () => void): Promise<void> {
991
const result = await promptToConnectViaTunnel(accessor, { showBackButton: !!onBack });
992
if (result === 'back') {
993
onBack?.();
994
}
995
}
996
});
997
998