From 273f111793d2bace961e473ca3d1fadafe1a1c0c Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:27:23 -0700 Subject: [PATCH] sessions: add developer command to kill SSH remote agent host Add a Developer: Kill Remote Agent Host (SSH)... command (workbench.action.sessions.killRemoteAgentHostSSH) that lets developers kill a wedged remote agent host process and tear down the SSH tunnel. - Add killRemoteAgentHost(host) to ISSHRemoteAgentHostService and ISSHRemoteAgentHostMainService interfaces - Implement in SSHRemoteAgentHostMainService using the existing cleanupRemoteAgentHost helper, then dispose the connection - Proxy through the renderer-side service and add a no-op to the null browser implementation - Register a Developer category action that quick-picks from active SSH connections and calls killRemoteAgentHost Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/nullSshRemoteAgentHostService.ts | 2 + .../agentHost/common/sshRemoteAgentHost.ts | 13 +++++ .../sshRemoteAgentHostServiceImpl.ts | 4 ++ .../node/sshRemoteAgentHostService.ts | 16 ++++++ .../browser/remoteAgentHostActions.ts | 50 +++++++++++++++++++ 5 files changed, 85 insertions(+) diff --git a/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts b/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts index 8edaea2d95500..e8646f70265f2 100644 --- a/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts @@ -22,6 +22,8 @@ export class NullSSHRemoteAgentHostService implements ISSHRemoteAgentHostService async disconnect(_host: string): Promise { } + async killRemoteAgentHost(_host: string): Promise { } + async listSSHConfigHosts(): Promise { return []; } diff --git a/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts b/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts index 2a5f39d0775f5..bce4e44b3a4bb 100644 --- a/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts +++ b/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts @@ -104,6 +104,13 @@ export interface ISSHRemoteAgentHostService { */ disconnect(host: string): Promise; + /** + * Forcefully kill the remote agent host process tracked by our state file + * for an active SSH connection, then tear down the SSH tunnel. Useful as + * a developer escape hatch when the remote process is wedged. + */ + killRemoteAgentHost(host: string): Promise; + /** List SSH config host aliases (excluding wildcards). */ listSSHConfigHosts(): Promise; @@ -199,6 +206,12 @@ export interface ISSHRemoteAgentHostMainService { */ disconnect(host: string): Promise; + /** + * Kill the remote agent host process tracked by our state file for the + * SSH connection identified by {@link host}, then tear down the tunnel. + */ + killRemoteAgentHost(host: string): Promise; + /** List SSH config host aliases (excluding wildcards). */ listSSHConfigHosts(): Promise; diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts index 3b66d6418b155..3eed7761d76b5 100644 --- a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts @@ -87,6 +87,10 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA await this._mainService.disconnect(host); } + async killRemoteAgentHost(host: string): Promise { + await this._mainService.killRemoteAgentHost(host); + } + async listSSHConfigHosts(): Promise { return this._mainService.listSSHConfigHosts(); } diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts index 4435608777d59..f539b6c16e613 100644 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts @@ -590,6 +590,22 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem } } + async killRemoteAgentHost(host: string): Promise { + for (const [key, conn] of this._connections) { + if (key === host || conn.connectionId === host) { + const exec = bindSshExec(conn.sshClient); + try { + await cleanupRemoteAgentHost(exec, this._logService, this._quality); + } catch (err) { + this._logService.warn(`${LOG_PREFIX} Error killing remote agent host for ${key}: ${err}`); + } + conn.dispose(); + return; + } + } + this._logService.warn(`${LOG_PREFIX} killRemoteAgentHost: no active SSH connection found for ${host}`); + } + async relaySend(connectionId: string, message: string): Promise { for (const conn of this._connections.values()) { if (conn.connectionId === connectionId) { diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts index 1ea5543af0345..3545a66def6c6 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -5,6 +5,7 @@ import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostEntryType, RemoteAgentHostInputValidationError, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ISSHRemoteAgentHostService, SSHAuthMethod, type ISSHAgentHostConfig, type ISSHAgentHostConnection, type ISSHResolvedConfig } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js'; import { ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; @@ -479,6 +480,55 @@ registerAction2(class extends Action2 { } }); +interface ISSHConnectionPickItem extends IQuickPickItem { + readonly localAddress: string; +} + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.sessions.killRemoteAgentHostSSH', + title: localize2('killRemoteAgentHostSSH', "Kill Remote Agent Host (SSH)..."), + category: Categories.Developer, + f1: true, + precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sshService = accessor.get(ISSHRemoteAgentHostService); + const quickInputService = accessor.get(IQuickInputService); + const notificationService = accessor.get(INotificationService); + + const connections = sshService.connections; + if (connections.length === 0) { + notificationService.info(localize('killSSHNoConnections', "No active SSH remote agent host connections.")); + return; + } + + const picks: ISSHConnectionPickItem[] = connections.map(conn => ({ + label: conn.name, + description: conn.localAddress, + localAddress: conn.localAddress, + })); + + const picked = await quickInputService.pick(picks, { + title: localize('killSSHTitle', "Kill Remote Agent Host (SSH)"), + placeHolder: localize('killSSHPlaceholder', "Select an SSH connection to kill"), + }); + if (!picked) { + return; + } + + try { + await sshService.killRemoteAgentHost(picked.localAddress); + notificationService.info(localize('killSSHDone', "Killed remote agent host for {0}.", picked.label)); + } catch (err) { + notificationService.error(localize('killSSHFailed', "Failed to kill remote agent host for {0}: {1}", picked.label, String(err))); + } + } +}); + // ---- Connect via Dev Tunnel ------------------------------------------------- interface ITunnelPickItem extends IQuickPickItem {