Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export class NullSSHRemoteAgentHostService implements ISSHRemoteAgentHostService

async disconnect(_host: string): Promise<void> { }

async killRemoteAgentHost(_host: string): Promise<void> { }

async listSSHConfigHosts(): Promise<string[]> {
return [];
}
Expand Down
13 changes: 13 additions & 0 deletions src/vs/platform/agentHost/common/sshRemoteAgentHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ export interface ISSHRemoteAgentHostService {
*/
disconnect(host: string): Promise<void>;

/**
* 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<void>;

/** List SSH config host aliases (excluding wildcards). */
listSSHConfigHosts(): Promise<string[]>;

Expand Down Expand Up @@ -199,6 +206,12 @@ export interface ISSHRemoteAgentHostMainService {
*/
disconnect(host: string): Promise<void>;

/**
* 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<void>;

/** List SSH config host aliases (excluding wildcards). */
listSSHConfigHosts(): Promise<string[]>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA
await this._mainService.disconnect(host);
}

async killRemoteAgentHost(host: string): Promise<void> {
await this._mainService.killRemoteAgentHost(host);
}

async listSSHConfigHosts(): Promise<string[]> {
return this._mainService.listSSHConfigHosts();
}
Expand Down
16 changes: 16 additions & 0 deletions src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,22 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
}
}

async killRemoteAgentHost(host: string): Promise<void> {
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}`);
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warn log interpolates the raw caught value (${err}), which can produce unhelpful output like [object Object] and may omit stack traces. Use the same pattern as elsewhere in this file (e.g., err instanceof Error ? err.message : String(err)) or a shared helper like toErrorMessage so logs are actionable.

Suggested change
this._logService.warn(`${LOG_PREFIX} Error killing remote agent host for ${key}: ${err}`);
this._logService.warn(`${LOG_PREFIX} Error killing remote agent host for ${key}: ${err instanceof Error ? err.message : String(err)}`);

Copilot uses AI. Check for mistakes.
}
conn.dispose();
return;
}
}
this._logService.warn(`${LOG_PREFIX} killRemoteAgentHost: no active SSH connection found for ${host}`);
Comment on lines +601 to +606
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

killRemoteAgentHost swallows cleanupRemoteAgentHost errors and also only logs (then returns) when no connection matches. Because this method always resolves, the renderer action will always show a success notification even when nothing was killed. Consider propagating failures (rethrow) and throwing when the connection isn't found so the UI can show the intended error notification (or change the API to return a boolean/result that the UI can interpret).

Suggested change
}
conn.dispose();
return;
}
}
this._logService.warn(`${LOG_PREFIX} killRemoteAgentHost: no active SSH connection found for ${host}`);
throw err;
} finally {
conn.dispose();
}
return;
}
}
const message = localize('sshRemoteAgentHost.killRemoteAgentHost.noActiveConnection', "No active SSH connection found for {0}.", host);
this._logService.warn(`${LOG_PREFIX} killRemoteAgentHost: ${message}`);
throw new Error(message);

Copilot uses AI. Check for mistakes.
Comment on lines +601 to +606
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior in killRemoteAgentHost isn't covered by the existing SSHRemoteAgentHostMainService unit tests. Adding a test that (1) connects, (2) calls killRemoteAgentHost, and (3) asserts cleanup commands ran and the connection was disposed (and that errors/not-found cases surface to callers) would help prevent regressions in this developer escape hatch.

Suggested change
}
conn.dispose();
return;
}
}
this._logService.warn(`${LOG_PREFIX} killRemoteAgentHost: no active SSH connection found for ${host}`);
throw err;
} finally {
conn.dispose();
}
return;
}
}
const error = new Error(`${LOG_PREFIX} killRemoteAgentHost: no active SSH connection found for ${host}`);
this._logService.warn(error.message);
throw error;

Copilot uses AI. Check for mistakes.
}

async relaySend(connectionId: string, message: string): Promise<void> {
for (const conn of this._connections.values()) {
if (conn.connectionId === connectionId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
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 {
Expand Down
Loading