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