Skip to content

Add SSH remote agent host bootstrap#304882

Draft
roblourens wants to merge 3 commits intomainfrom
rob/remote-ssh-agent-host
Draft

Add SSH remote agent host bootstrap#304882
roblourens wants to merge 3 commits intomainfrom
rob/remote-ssh-agent-host

Conversation

@roblourens
Copy link
Member

Connecting to a remote agent host currently requires the user to manually set up and start the agent host process on the remote machine, then provide the WebSocket address. This PR adds an SSH-based bootstrap flow that automates the entire process — SSH in, install the CLI, start code agent-host, and tunnel the port back.

Approach

Introduces ISSHRemoteAgentHostService with an implementation that uses the ssh2 library to:

  1. Connect to the remote via SSH (supports SSH agent, private key file, or password auth)
  2. Detect the remote platform via uname -s / uname -m
  3. Install the VS Code CLI if it's not already present (downloads from the update server)
  4. Start code agent-host --port 0 and parse the ws://127.0.0.1:<port>?tkn=<token> output
  5. Forward the remote agent host port to a local TCP port via SSH tunnel
  6. Register the local forwarded address with IRemoteAgentHostService so the sessions app connects seamlessly

The service lives in electron-browser/ to satisfy the sessions desktop entry point layering rules. Since this layer can't use static imports of Node.js builtins or npm packages, all ssh2/net/fs usage goes through await import() with locally-defined minimal interfaces cast at the boundary.

UI Integration

  • The remote agent host picker now shows "Connect via SSH..." alongside existing entries
  • When no remotes are configured, both SSH and direct WebSocket options are offered
  • A multi-step quick input flow collects host, username, auth method, and display name
  • A standalone workbench.action.sessions.connectViaSSH command is registered

Notable decisions

  • Dynamic imports with local interfaces: The electron-browser layer forbids static imports of ssh2, net, and fs. The implementation defines minimal typed interfaces locally and casts at the await import() boundary. This keeps the layer checker happy while providing type safety within the module.
  • SSH agent as default auth: The first option in the auth picker is SSH agent, which is the most common setup for developers.
  • CLI install location: Uses ~/.vscode-cli/ on the remote, matching the standard CLI install path.

Adds a new ISSHRemoteAgentHostService that automates connecting to a
remote machine via SSH, installing the VS Code CLI, starting
'code agent-host', and forwarding the agent host port back through
the SSH tunnel.

- New service interface and types in common/sshRemoteAgentHost.ts
- Full implementation using ssh2 in electron-browser/ with dynamic
  imports to respect layering rules
- Multi-step quick input flow for SSH connection details integrated
  into the remote agent host picker
- 'Connect via SSH' command registered in contributions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 25, 2026 19:58
@vs-code-engineering vs-code-engineering bot added this to the 1.114.0 milestone Mar 25, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an SSH-based bootstrap flow to the Sessions app’s remote agent host feature, enabling users to connect to a remote machine via SSH, automatically install/start code agent-host, and tunnel the connection back locally.

Changes:

  • Introduces ISSHRemoteAgentHostService (+ implementation) to manage SSH bootstrap, CLI install, remote agent-host startup, and local port forwarding.
  • Extends the Sessions remote agent host picker with a “Connect via SSH...” option and adds a dedicated workbench.action.sessions.connectViaSSH command.
  • Adds ssh2 (+ typings) dependencies to support SSH connectivity.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/vs/sessions/sessions.desktop.main.ts Wires the SSH remote agent host service into the Sessions desktop entrypoint.
src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostPicker.ts Adds SSH option to the picker and implements a multi-step quick input SSH connection flow.
src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts Registers a standalone “Connect via SSH” command that reuses the picker flow.
src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts Implements SSH bootstrap: connect, detect platform, install CLI, start agent-host, forward port, register with remote host service.
src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostService.ts Registers the SSH remote agent host service singleton.
src/vs/platform/agentHost/common/sshRemoteAgentHost.ts Defines the shared service contract/types for SSH remote agent host connections.
package.json Adds ssh2 and @types/ssh2.
package-lock.json Locks transitive dependencies for ssh2 and typings.
Comments suppressed due to low confidence (5)

src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts:407

  • fs.readFileSync(config.privateKeyPath) will not expand ~, but the UI flow suggests ~/.ssh/id_rsa as the default value. This will fail on most platforms unless the user manually expands it. Consider expanding a leading ~ to the user home directory (or switch the UI to use a file picker / require an absolute path).
				case SSHAuthMethod.KeyFile:
					if (config.privateKeyPath) {
						connectConfig.privateKey = fs.readFileSync(config.privateKeyPath);
					}

src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts:403

  • For SSHAuthMethod.Agent, the code unconditionally sets connectConfig.agent = process.env['SSH_AUTH_SOCK']. If no agent is running / SSH_AUTH_SOCK is unset (common on Windows or when the agent isn’t configured), this fails with a low-level ssh2 error. Consider detecting this case up front and throwing a clearer, user-actionable error (or falling back to prompting for a different auth method).
			switch (config.authMethod) {
				case SSHAuthMethod.Agent:
					connectConfig.agent = process.env['SSH_AUTH_SOCK'];
					break;

src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts:369

  • disconnect(host) matches connections via key.includes(host), which can disconnect the wrong connection when host strings overlap (e.g. host matching part of otherhost, or passing 22). Prefer an exact match against conn.config.host, the full connectionKey, or conn.localAddress (with explicit parsing if you want to accept multiple input formats).
	async disconnect(host: string): Promise<void> {
		for (const [key, conn] of this._connections) {
			if (key.includes(host) || conn.localAddress === host) {
				conn.dispose();
				return;

src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts:427

  • The CLI download quality is hardcoded to 'stable'. In insiders/exploration builds this may install a mismatched CLI on the remote, which can be confusing and may affect compatibility/debugging. Consider deriving the quality from IProductService (or equivalent) so it matches the running build’s channel.
		this._logService.info(`${LOG_PREFIX} Installing VS Code CLI on remote...`);
		const quality = 'stable';
		const url = buildCLIDownloadUrl(platform.os, platform.arch, quality);

src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts:350

  • If addRemoteAgentHost(...) throws (e.g. feature flag disabled, config write failure), the code has already transferred ownership to SSHAgentHostConnection and inserted it into _connections, but the catch block only disposes store. That will leak the SSH session/forwarder and leave a stale entry in _connections. Consider wrapping the registration step in its own try/catch that disposes conn and removes the map entry on failure, or delaying store.dispose()/_connections.set(...) until after registration succeeds.
			store.dispose(); // ownership transferred to SSHAgentHostConnection

			// 7. Register with IRemoteAgentHostService
			this._logService.info(`${LOG_PREFIX} Registering remote agent host at ${localAddress}`);
			await this._remoteAgentHostService.addRemoteAgentHost({

Comment on lines +30 to +34
/** Path to the private key file (when {@link authMethod} is KeyFile). */
readonly privateKeyPath?: string;
/** Password string (when {@link authMethod} is Password). */
readonly password?: string;
/** Display name for this connection. */
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

password is part of ISSHAgentHostConfig, and connection objects expose that config. This makes plaintext credentials retrievable by any consumer of the service/connection list and keeps them in memory longer than needed. Consider keeping secrets out of the exposed config (e.g., omit password from the connection’s config, or store auth material separately and clear it after connect).

Copilot uses AI. Check for mistakes.
Comment on lines +155 to +159
stream.stderr.on('data', (data: Buffer) => {
const text = data.toString();
stderrBuf += text;
logService.trace(`${LOG_PREFIX} remote stderr: ${text.trimEnd()}`);

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The remote agent host prints the full WebSocket URL including ?tkn=... to stderr. Logging raw stderr chunks at trace level will leak the connection token into logs. Consider redacting the token (e.g., replace it with ***) before calling logService.trace / including it in error messages.

Copilot uses AI. Check for mistakes.
Comment on lines +285 to +307
// Step 1: SSH host
const hostInput = await quickInputService.input({
title: localize('sshHostTitle', "Connect via SSH"),
prompt: localize('sshHostPrompt', "Enter the SSH host (e.g. user@hostname or hostname)."),
placeHolder: 'user@myserver.example.com',
ignoreFocusLost: true,
validateInput: async value => value.trim() ? undefined : localize('sshHostEmpty', "Enter an SSH host."),
});
if (!hostInput) {
return undefined;
}

// Parse user@host format
const trimmed = hostInput.trim();
let username: string;
let host: string;
const atIndex = trimmed.indexOf('@');
if (atIndex !== -1) {
username = trimmed.substring(0, atIndex);
host = trimmed.substring(atIndex + 1);
} else {
host = trimmed;

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The SSH host parsing only splits on '@' and treats the remainder as the host verbatim. Inputs like user@host:2222 (common SSH syntax) will end up with host set to host:2222 and port still defaulting to 22, which will fail DNS resolution. Consider parsing/validating an optional :port segment and also validating that both username/host are non-empty after splitting.

Suggested change
// Step 1: SSH host
const hostInput = await quickInputService.input({
title: localize('sshHostTitle', "Connect via SSH"),
prompt: localize('sshHostPrompt', "Enter the SSH host (e.g. user@hostname or hostname)."),
placeHolder: 'user@myserver.example.com',
ignoreFocusLost: true,
validateInput: async value => value.trim() ? undefined : localize('sshHostEmpty', "Enter an SSH host."),
});
if (!hostInput) {
return undefined;
}
// Parse user@host format
const trimmed = hostInput.trim();
let username: string;
let host: string;
const atIndex = trimmed.indexOf('@');
if (atIndex !== -1) {
username = trimmed.substring(0, atIndex);
host = trimmed.substring(atIndex + 1);
} else {
host = trimmed;
const validateSshHostInput = (value: string): string | undefined => {
const trimmed = value.trim();
if (!trimmed) {
return localize('sshHostEmpty', "Enter an SSH host.");
}
const atIndex = trimmed.indexOf('@');
if (atIndex === 0) {
return localize('sshUsernameMissingInHost', "Enter a username before '@'.");
}
if (atIndex === trimmed.length - 1) {
return localize('sshHostMissingInHost', "Enter a host name after '@'.");
}
let hostPart = atIndex !== -1 ? trimmed.substring(atIndex + 1) : trimmed;
if (!hostPart) {
return localize('sshHostMissingInHost', "Enter a host name after '@'.");
}
const colonIndex = hostPart.lastIndexOf(':');
if (colonIndex !== -1) {
const hostName = hostPart.substring(0, colonIndex);
const portStr = hostPart.substring(colonIndex + 1);
if (!hostName) {
return localize('sshHostMissingInHost', "Enter a host name after '@'.");
}
if (!portStr) {
return localize('sshHostInvalidPort', "Enter a valid port number.");
}
const port = Number(portStr);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return localize('sshHostInvalidPort', "Enter a valid port number.");
}
}
return undefined;
};
// Step 1: SSH host
const hostInput = await quickInputService.input({
title: localize('sshHostTitle', "Connect via SSH"),
prompt: localize('sshHostPrompt', "Enter the SSH host (e.g. user@hostname or hostname)."),
placeHolder: 'user@myserver.example.com',
ignoreFocusLost: true,
validateInput: async value => validateSshHostInput(value),
});
if (!hostInput) {
return undefined;
}
// Parse user@host[:port] format
const trimmed = hostInput.trim();
let username: string;
let host: string;
const atIndex = trimmed.indexOf('@');
let hostPart: string;
if (atIndex !== -1) {
username = trimmed.substring(0, atIndex);
hostPart = trimmed.substring(atIndex + 1);
} else {
hostPart = trimmed;
}
const colonIndex = hostPart.lastIndexOf(':');
if (colonIndex !== -1) {
host = hostPart.substring(0, colonIndex);
} else {
host = hostPart;
}
if (atIndex === -1) {

Copilot uses AI. Check for mistakes.
Comment on lines +242 to +246
this._register(toDisposable(() => {
remoteStream.close();
localServer.close();
sshClient.end();
this._onDidClose.fire();
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

When disposing a connection, _onDidClose.fire() is invoked here, but the SSH client's 'close'/'error' handlers below also fire the same event and call dispose(). This can result in onDidClose firing multiple times for a single disconnect. Consider guarding with a closed flag / Event.once, and ensuring the event is emitted from a single code path.

This issue also appears in the following locations of the same file:

  • line 346
  • line 365
  • line 400
  • line 404
  • line 425

Copilot uses AI. Check for mistakes.
roblourens and others added 2 commits March 25, 2026 15:08
- Strip password/privateKeyPath from ISSHAgentHostConnection.config
  so secrets are not exposed to consumers after connect
- Redact connection tokens (?tkn=...) in all log output and error
  messages to prevent credential leakage
- Parse user@host:port format in SSH host input with proper validation
  for port range and missing components
- Guard onDidClose with a closed flag to prevent double-fire when
  dispose and SSH close/error events overlap

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants