Add SSH remote agent host bootstrap (#304882)

* Add SSH remote agent host bootstrap

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>

* Address PR review feedback

- 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>

* SSH working through main process

Co-authored-by: Copilot <copilot@github.com>

* Resolve ssh configs

Co-authored-by: Copilot <copilot@github.com>

* progress

* Granular connect progress, test fix

Co-authored-by: Copilot <copilot@github.com>

* Test, refactor

Co-authored-by: Copilot <copilot@github.com>

* Resolve comments

Co-authored-by: Copilot <copilot@github.com>

* Get rid of cpu-features

* Move to shared process

Co-authored-by: Copilot <copilot@github.com>

* fixes

Co-authored-by: Copilot <copilot@github.com>

* add ssh2 to remote/package.json

* Cleanup and fixes

Co-authored-by: Copilot <copilot@github.com>

* fix

Co-authored-by: Copilot <copilot@github.com>

* fix

Co-authored-by: Copilot <copilot@github.com>

* resolve comments

Co-authored-by: Copilot <copilot@github.com>

* comments

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Raymond Zhao <7199958+rzhao271@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Rob Lourens
2026-03-31 21:37:07 -07:00
committed by GitHub
parent e0de3232da
commit e6e776a4b6
23 changed files with 2305 additions and 28 deletions

79
package-lock.json generated
View File

@@ -53,6 +53,7 @@
"node-pty": "^1.2.0-beta.12",
"open": "^10.1.2",
"playwright-core": "1.59.0-alpha-2026-02-20",
"ssh2": "^1.16.0",
"tas-client": "0.3.1",
"undici": "^7.24.0",
"v8-inspect-profiler": "^0.1.1",
@@ -78,6 +79,7 @@
"@types/node": "^22.18.10",
"@types/sinon": "^10.0.2",
"@types/sinon-test": "^2.4.2",
"@types/ssh2": "^1.15.4",
"@types/trusted-types": "^2.0.7",
"@types/vscode-notebook-renderer": "^1.72.0",
"@types/wicg-file-system-access": "^2023.10.7",
@@ -2774,6 +2776,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ssh2": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
"integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18"
}
},
"node_modules/@types/ssh2/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/ssh2/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/svgo": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@types/svgo/-/svgo-1.3.6.tgz",
@@ -5126,6 +5155,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/assign-symbols": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
@@ -5423,6 +5461,15 @@
"node": ">=10.0.0"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/before-after-hook": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz",
@@ -14016,10 +14063,10 @@
}
},
"node_modules/nan": {
"version": "2.14.2",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz",
"integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
"dev": true,
"version": "2.26.2",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
"license": "MIT",
"optional": true
},
"node_modules/nanomatch": {
@@ -16694,7 +16741,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/sax": {
@@ -17564,6 +17610,23 @@
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/stable": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
@@ -18651,6 +18714,12 @@
"node": "*"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/typanion": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz",

View File

@@ -125,6 +125,7 @@
"node-pty": "^1.2.0-beta.12",
"open": "^10.1.2",
"playwright-core": "1.59.0-alpha-2026-02-20",
"ssh2": "^1.16.0",
"tas-client": "0.3.1",
"undici": "^7.24.0",
"v8-inspect-profiler": "^0.1.1",
@@ -150,6 +151,7 @@
"@types/node": "^22.18.10",
"@types/sinon": "^10.0.2",
"@types/sinon-test": "^2.4.2",
"@types/ssh2": "^1.15.4",
"@types/trusted-types": "^2.0.7",
"@types/vscode-notebook-renderer": "^1.72.0",
"@types/wicg-file-system-access": "^2023.10.7",
@@ -239,7 +241,10 @@
"kerberos@2.1.1": {
"node-addon-api": "7.1.0"
},
"serialize-javascript": "^7.0.3"
"serialize-javascript": "^7.0.3",
"ssh2": {
"cpu-features": "0.0.0"
}
},
"repository": {
"type": "git",

View File

@@ -42,6 +42,7 @@
"kerberos": "2.1.1",
"minimist": "^1.2.8",
"node-pty": "^1.2.0-beta.12",
"ssh2": "^1.16.0",
"tas-client": "0.3.1",
"vscode-oniguruma": "1.7.0",
"vscode-regexpp": "^3.1.0",
@@ -828,6 +829,15 @@
"node": ">= 14"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -847,6 +857,15 @@
}
]
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@@ -1225,6 +1244,13 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/nan": {
"version": "2.26.2",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
"license": "MIT",
"optional": true
},
"node_modules/napi-build-utils": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
@@ -1382,6 +1408,12 @@
}
]
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
@@ -1491,6 +1523,23 @@
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -1560,6 +1609,12 @@
"node": "*"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/undici": {
"version": "7.24.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz",

View File

@@ -42,6 +42,7 @@
"vscode-regexpp": "^3.1.0",
"vscode-textmate": "^9.3.2",
"ws": "^8.19.0",
"ssh2": "^1.16.0",
"yauzl": "^3.0.0",
"yazl": "^2.4.3"
},
@@ -49,6 +50,9 @@
"node-gyp-build": "4.8.1",
"kerberos@2.1.1": {
"node-addon-api": "7.1.0"
},
"ssh2": {
"cpu-features": "0.0.0"
}
}
}

View File

@@ -1195,7 +1195,6 @@ export class CodeApplication extends Disposable {
services.set(INativeMcpDiscoveryHelperService, new SyncDescriptor(NativeMcpDiscoveryHelperService));
services.set(IMcpGatewayService, new SyncDescriptor(McpGatewayService));
// Dev Only: CSS service (for ESM)
services.set(ICSSDevelopmentService, new SyncDescriptor(CSSDevelopmentService, undefined, true));

View File

@@ -87,6 +87,8 @@ import { InspectProfilingService as V8InspectProfilingService } from '../../../p
import { IV8InspectProfilingService } from '../../../platform/profiling/common/profiling.js';
import { IExtensionsScannerService } from '../../../platform/extensionManagement/common/extensionsScannerService.js';
import { ExtensionsScannerService } from '../../../platform/extensionManagement/node/extensionsScannerService.js';
import { ISSHRemoteAgentHostMainService, SSH_REMOTE_AGENT_HOST_CHANNEL } from '../../../platform/agentHost/common/sshRemoteAgentHost.js';
import { SSHRemoteAgentHostMainService } from '../../../platform/agentHost/node/sshRemoteAgentHostService.js';
import { IUserDataProfilesService } from '../../../platform/userDataProfile/common/userDataProfile.js';
import { IExtensionsProfileScannerService } from '../../../platform/extensionManagement/common/extensionsProfileScannerService.js';
import { PolicyChannelClient } from '../../../platform/policy/common/policyIpc.js';
@@ -402,6 +404,9 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter {
// Web Content Extractor
services.set(ISharedWebContentExtractorService, new SyncDescriptor(SharedWebContentExtractorService));
// SSH Remote Agent Host
services.set(ISSHRemoteAgentHostMainService, new SyncDescriptor(SSHRemoteAgentHostMainService, undefined, true));
return new InstantiationService(services);
}
@@ -472,6 +477,10 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter {
// Playwright
const playwrightChannel = this._register(new PlaywrightChannel(this.server, accessor.get(IMainProcessService), accessor.get(ILogService)));
this.server.registerChannel('playwright', playwrightChannel);
// SSH Remote Agent Host
const sshRemoteAgentHostChannel = ProxyChannel.fromService(accessor.get(ISSHRemoteAgentHostMainService), this._store);
this.server.registerChannel(SSH_REMOTE_AGENT_HOST_CHANNEL, sshRemoteAgentHostChannel);
}
private registerErrorHandler(logService: ILogService): void {

View File

@@ -26,6 +26,8 @@ export interface IRemoteAgentHostEntry {
readonly address: string;
readonly name: string;
readonly connectionToken?: string;
/** SSH config host alias — if set, the tunnel is re-established on startup. */
readonly sshConfigHost?: string;
}
export const enum RemoteAgentHostInputValidationError {
@@ -89,6 +91,13 @@ export interface IRemoteAgentHostService {
* with reset backoff.
*/
reconnect(address: string): void;
/**
* Register a pre-connected SSH agent connection.
* Used by the SSH service to inject relay-backed connections
* without going through the WebSocket connect flow.
*/
addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise<IRemoteAgentHostConnectionInfo>;
}
/** Metadata about a single remote connection. */
@@ -111,6 +120,9 @@ export class NullRemoteAgentHostService implements IRemoteAgentHostService {
}
async removeRemoteAgentHost(_address: string): Promise<void> { }
reconnect(_address: string): void { }
async addSSHConnection(): Promise<IRemoteAgentHostConnectionInfo> {
throw new Error('Remote agent host connections are not supported in this environment.');
}
}
export function parseRemoteAgentHostInput(input: string): RemoteAgentHostInputParseResult {

View File

@@ -0,0 +1,64 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { ISSHResolvedConfig } from './sshRemoteAgentHost.js';
/** Strip inline comments from an SSH config value. */
export function stripSSHComment(s: string): string {
const idx = s.indexOf(' #');
return idx !== -1 ? s.substring(0, idx).trim() : s;
}
/**
* Extract Host aliases from SSH config content (without following Includes).
*/
export function parseSSHConfigHostEntries(content: string): string[] {
const hosts: string[] = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const hostMatch = trimmed.match(/^Host\s+(.+)$/i);
if (hostMatch) {
const hostValue = stripSSHComment(hostMatch[1]);
for (const h of hostValue.split(/\s+/)) {
if (!h.includes('*') && !h.includes('?') && !h.startsWith('!')) {
hosts.push(h);
}
}
}
}
return hosts;
}
/**
* Parse `ssh -G` output into a resolved config object.
*/
export function parseSSHGOutput(stdout: string): ISSHResolvedConfig {
const map = new Map<string, string>();
const identityFiles: string[] = [];
for (const line of stdout.split('\n')) {
const spaceIdx = line.indexOf(' ');
if (spaceIdx === -1) {
continue;
}
const key = line.substring(0, spaceIdx).toLowerCase();
const value = line.substring(spaceIdx + 1).trim();
if (key === 'identityfile') {
identityFiles.push(value);
} else {
map.set(key, value);
}
}
return {
hostname: map.get('hostname') ?? '',
user: map.get('user') || undefined,
port: parseInt(map.get('port') ?? '22', 10),
identityFile: identityFiles,
forwardAgent: map.get('forwardagent') === 'yes',
};
}

View File

@@ -0,0 +1,212 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Event } from '../../../base/common/event.js';
import { IDisposable } from '../../../base/common/lifecycle.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
export const ISSHRemoteAgentHostService = createDecorator<ISSHRemoteAgentHostService>('sshRemoteAgentHostService');
/**
* IPC channel name for the main-process SSH service.
*/
export const SSH_REMOTE_AGENT_HOST_CHANNEL = 'sshRemoteAgentHost';
export const enum SSHAuthMethod {
/** Use the local SSH agent for key-based auth. */
Agent = 'agent',
/** Authenticate with an explicit private key file. */
KeyFile = 'keyFile',
/** Authenticate with a password. */
Password = 'password',
}
export interface ISSHAgentHostConfig {
/** Remote hostname or IP. */
readonly host: string;
/** SSH port (default 22). */
readonly port?: number;
/** Username on the remote machine. */
readonly username: string;
/** Authentication method. */
readonly authMethod: SSHAuthMethod;
/** 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. */
readonly name: string;
/** SSH config host alias (e.g. "robfast2") for reconnection on restart. */
readonly sshConfigHost?: string;
/** Dev override: custom command to start the remote agent host instead of the default CLI. */
readonly remoteAgentHostCommand?: string;
}
/**
* A sanitized view of the SSH config that omits secret material
* (password, private key path). Exposed on active connections so
* consumers can inspect connection metadata without accessing credentials.
*/
export type ISSHAgentHostConfigSanitized = Omit<ISSHAgentHostConfig, 'password' | 'privateKeyPath'>;
export interface ISSHAgentHostConnection extends IDisposable {
/** The SSH config used to establish this connection (secrets stripped). */
readonly config: ISSHAgentHostConfigSanitized;
/** The connection address (e.g. `ssh:myhost` or `user@host:22`) registered with IRemoteAgentHostService. */
readonly localAddress: string;
/** The display name. */
readonly name: string;
/** Fires when this SSH connection is closed or lost. */
readonly onDidClose: Event<void>;
}
/**
* Manages SSH connections that bootstrap a remote agent host process.
*
* Each connection SSHs into a remote machine, ensures the VS Code CLI
* is installed, starts `code agent-host`, and creates a WebSocket relay
* over the SSH channel. Messages are forwarded between the renderer and
* the remote agent host via IPC through the shared process.
*/
export interface ISSHRemoteAgentHostService {
readonly _serviceBrand: undefined;
/** Fires when the set of active SSH connections changes. */
readonly onDidChangeConnections: Event<void>;
/** Progress messages during connect. */
readonly onDidReportConnectProgress: Event<ISSHConnectProgress>;
/** Currently active SSH-bootstrapped connections. */
readonly connections: readonly ISSHAgentHostConnection[];
/**
* Bootstrap a remote agent host over SSH.
*
* 1. Opens an SSH connection to the remote host
* 2. Downloads and installs the VS Code CLI if needed
* 3. Starts `code agent-host`
* 4. Creates a WebSocket relay over the SSH channel
* 5. Registers the connection with {@link IRemoteAgentHostService}
*
* Resolves with the connection handle once the agent host is reachable.
*/
connect(config: ISSHAgentHostConfig): Promise<ISSHAgentHostConnection>;
/**
* Disconnect an SSH-bootstrapped connection by host address.
* Tears down the SSH tunnel, stops the remote agent host, and
* removes the entry from {@link IRemoteAgentHostService}.
*/
disconnect(host: string): Promise<void>;
/** List SSH config host aliases (excluding wildcards). */
listSSHConfigHosts(): Promise<string[]>;
/** Resolve full SSH config for a host via `ssh -G`. */
resolveSSHConfig(host: string): Promise<ISSHResolvedConfig>;
/**
* Re-establish an SSH tunnel on startup for a previously connected host.
* Returns the new local forwarded address and registers it.
*/
reconnect(sshConfigHost: string, name: string): Promise<ISSHAgentHostConnection>;
}
/**
* Serializable result from a successful SSH connect operation.
* Returned over IPC from the main process.
*/
export interface ISSHConnectResult {
/** Unique identifier for this connection's relay channel. */
readonly connectionId: string;
/** Display-friendly address (e.g. "ssh:robfast2"). */
readonly address: string;
readonly name: string;
readonly connectionToken: string | undefined;
readonly config: ISSHAgentHostConfigSanitized;
/** SSH config host alias for reconnection on restart. */
readonly sshConfigHost?: string;
}
/**
* Resolved SSH configuration for a host, obtained from `ssh -G`.
*/
export interface ISSHResolvedConfig {
readonly hostname: string;
readonly user: string | undefined;
readonly port: number;
readonly identityFile: string[];
readonly forwardAgent: boolean;
}
export interface ISSHConnectProgress {
readonly connectionKey: string;
readonly message: string;
}
/**
* A message relayed from a remote agent host through the SSH tunnel.
* The shared process acts as a WebSocket proxy, forwarding JSON messages
* bidirectionally between the SSH channel and the renderer via IPC.
*/
export interface ISSHRelayMessage {
readonly connectionId: string;
readonly data: string;
}
/**
* Main-process service that performs the actual SSH work.
* The renderer calls this over IPC and handles registration
* with {@link IRemoteAgentHostService} locally.
*/
export const ISSHRemoteAgentHostMainService = createDecorator<ISSHRemoteAgentHostMainService>('sshRemoteAgentHostMainService');
export interface ISSHRemoteAgentHostMainService {
readonly _serviceBrand: undefined;
/** Fires when the set of active SSH connections changes. */
readonly onDidChangeConnections: Event<void>;
/** Fires when a connection is closed from the shared process side. */
readonly onDidCloseConnection: Event<string /* connectionId */>;
/** Progress messages during connect (e.g. "Installing CLI..."). */
readonly onDidReportConnectProgress: Event<ISSHConnectProgress>;
/** Fires when a message is received from a remote agent host via the SSH relay. */
readonly onDidRelayMessage: Event<ISSHRelayMessage>;
/** Fires when a relay connection to a remote agent host closes. */
readonly onDidRelayClose: Event<string /* connectionId */>;
/**
* Bootstrap a remote agent host over SSH. Returns serializable
* connection info for the renderer to register.
*/
connect(config: ISSHAgentHostConfig): Promise<ISSHConnectResult>;
/**
* Send a message to a remote agent host through the SSH relay.
*/
relaySend(connectionId: string, message: string): Promise<void>;
/**
* Disconnect an SSH-bootstrapped connection by host address.
*/
disconnect(host: string): Promise<void>;
/** List SSH config host aliases (excluding wildcards). */
listSSHConfigHosts(): Promise<string[]>;
/** Resolve full SSH config for a host via `ssh -G`. */
resolveSSHConfig(host: string): Promise<ISSHResolvedConfig>;
/**
* Re-establish an SSH tunnel for a previously connected host.
* Resolves the SSH config alias, connects, and returns fresh
* connection info with a new local forwarded port.
*/
reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string): Promise<ISSHConnectResult>;
}

View File

@@ -36,6 +36,20 @@ export interface IProtocolTransport extends IDisposable {
send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse | IJsonRpcRequest): void;
}
/**
* A client-side transport that requires an explicit connection step
* before messages can be exchanged.
*/
export interface IClientTransport extends IProtocolTransport {
/** Establish the underlying connection (e.g. open a WebSocket). */
connect(): Promise<void>;
}
/** Type guard for transports that require an explicit connection step. */
export function isClientTransport(transport: IProtocolTransport): transport is IClientTransport {
return typeof (transport as IClientTransport).connect === 'function';
}
/**
* Server-side transport that accepts multiple client connections.
* Each connected client gets its own {@link IProtocolTransport}.

View File

@@ -21,9 +21,9 @@ import type { IClientNotificationMap, ICommandMap } from '../common/state/protoc
import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js';
import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js';
import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type IJsonRpcResponse, type IProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js';
import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js';
import { ContentEncoding } from '../common/state/protocol/commands.js';
import type { ISessionSummary } from '../common/state/sessionState.js';
import { WebSocketClientTransport } from './webSocketClientTransport.js';
import { encodeBase64 } from '../../../base/common/buffer.js';
/**
@@ -39,7 +39,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
declare readonly _serviceBrand: undefined;
private readonly _clientId = generateUuid();
private readonly _transport: WebSocketClientTransport;
private readonly _address: string;
private readonly _transport: IProtocolTransport;
private readonly _connectionAuthority: string;
private _serverSeq = 0;
private _nextClientSeq = 1;
@@ -63,7 +64,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
}
get address(): string {
return this._transport['_address'];
return this._address;
}
get defaultDirectory(): string | undefined {
@@ -72,13 +73,15 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
constructor(
address: string,
connectionToken: string | undefined,
transport: IProtocolTransport,
@ILogService private readonly _logService: ILogService,
@IFileService private readonly _fileService: IFileService,
) {
super();
this._address = address;
this._connectionAuthority = agentHostAuthority(address);
this._transport = this._register(new WebSocketClientTransport(address, connectionToken));
this._transport = transport;
this._register(this._transport);
this._register(this._transport.onMessage(msg => this._handleMessage(msg)));
this._register(this._transport.onClose(() => this._onDidClose.fire()));
}
@@ -87,7 +90,9 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
* Connect to the remote agent host and perform the protocol handshake.
*/
async connect(): Promise<void> {
if (isClientTransport(this._transport)) {
await this._transport.connect();
}
const result = await this._sendRequest('initialize', {
protocolVersion: PROTOCOL_VERSION,

View File

@@ -24,6 +24,7 @@ import {
type IRemoteAgentHostEntry,
} from '../common/remoteAgentHostService.js';
import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js';
import { WebSocketClientTransport } from './webSocketClientTransport.js';
import { normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js';
/** Tracks a single remote connection through its lifecycle. */
@@ -100,6 +101,15 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
reconnect(address: string): void {
const normalized = normalizeRemoteAgentHostAddress(address);
// SSH entries are reconnected by the SSH service, not via WebSocket
const configuredEntry = this._getConfiguredEntries().find(
e => normalizeRemoteAgentHostAddress(e.address) === normalized
);
if (configuredEntry?.sshConfigHost) {
return;
}
const token = this._tokens.get(normalized);
// Cancel any pending reconnect
@@ -149,6 +159,52 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
return connection;
}
async addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise<IRemoteAgentHostConnectionInfo> {
const address = entry.address;
// Dispose any existing entry for this address to avoid leaking
// old protocol clients and relay transports on reconnect.
const existingEntry = this._entries.get(address);
if (existingEntry) {
this._entries.delete(address);
existingEntry.store.dispose();
}
const store = new DisposableStore();
// Create a connection entry wrapping the pre-connected client
const protocolClient = connection as RemoteAgentHostProtocolClient;
store.add(protocolClient);
const connEntry: IConnectionEntry = { store, client: protocolClient, connected: true, status: RemoteAgentHostConnectionStatus.Connected };
this._entries.set(address, connEntry);
this._names.set(address, entry.name);
if (entry.connectionToken) {
this._tokens.set(address, entry.connectionToken);
}
store.add(protocolClient.onDidClose(() => {
if (this._entries.get(address) === connEntry) {
connEntry.connected = false;
connEntry.status = RemoteAgentHostConnectionStatus.Disconnected;
this._onDidChangeConnections.fire();
}
}));
// Persist SSH entries — await so that the config is written before
// onDidChangeConnections fires, ensuring _reconcile creates the provider.
await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry));
this._onDidChangeConnections.fire();
return {
address,
name: entry.name,
clientId: protocolClient.clientId,
defaultDirectory: protocolClient.defaultDirectory,
status: RemoteAgentHostConnectionStatus.Connected,
};
}
async removeRemoteAgentHost(address: string): Promise<void> {
const normalized = normalizeRemoteAgentHostAddress(address);
// This setting is only used in the sessions app (user scope), so we
@@ -219,9 +275,9 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
}
}
// Add new connections
// Add new connections (skip SSH entries — those are handled by ISSHRemoteAgentHostService)
for (const entry of entries) {
if (!this._entries.has(entry.address)) {
if (!this._entries.has(entry.address) && !entry.sshConfigHost) {
this._connectTo(entry.address, entry.connectionToken);
}
}
@@ -242,7 +298,8 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
}
const store = new DisposableStore();
const client = store.add(this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, connectionToken));
const transport = store.add(new WebSocketClientTransport(address, connectionToken));
const client = store.add(this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, transport));
const entry: IConnectionEntry = { store, client, connected: false, status: RemoteAgentHostConnectionStatus.Connecting };
this._entries.set(address, entry);

View File

@@ -0,0 +1,58 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js';
import type { IProtocolTransport } from '../common/state/sessionTransport.js';
import type { ISSHRelayMessage, ISSHRemoteAgentHostMainService } from '../common/sshRemoteAgentHost.js';
/**
* A protocol transport that relays messages through the shared process
* SSH tunnel via IPC, instead of using a direct WebSocket connection.
*
* The shared process manages the actual WebSocket-over-SSH connection
* and forwards messages bidirectionally through this IPC channel.
*/
export class SSHRelayTransport extends Disposable implements IProtocolTransport {
private readonly _onMessage = this._register(new Emitter<IProtocolMessage>());
readonly onMessage = this._onMessage.event;
private readonly _onClose = this._register(new Emitter<void>());
readonly onClose = this._onClose.event;
constructor(
private readonly _connectionId: string,
private readonly _sshService: ISSHRemoteAgentHostMainService,
) {
super();
// Listen for relay messages from the shared process
this._register(this._sshService.onDidRelayMessage((msg: ISSHRelayMessage) => {
if (msg.connectionId === this._connectionId) {
try {
const parsed = JSON.parse(msg.data) as IProtocolMessage;
this._onMessage.fire(parsed);
} catch {
// Malformed message — drop
}
}
}));
// Listen for relay close
this._register(this._sshService.onDidRelayClose((closedId: string) => {
if (closedId === this._connectionId) {
this._onClose.fire();
}
}));
}
send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void {
this._sshService.relaySend(this._connectionId, JSON.stringify(message)).catch(() => {
// Send failed — connection probably closed
});
}
}

View File

@@ -0,0 +1,10 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js';
import { ISSHRemoteAgentHostService } from '../common/sshRemoteAgentHost.js';
import { SSHRemoteAgentHostService } from './sshRemoteAgentHostServiceImpl.js';
registerSingleton(ISSHRemoteAgentHostService, SSHRemoteAgentHostService, InstantiationType.Delayed);

View File

@@ -0,0 +1,215 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../base/common/event.js';
import { Disposable, toDisposable } from '../../../base/common/lifecycle.js';
import { ILogService } from '../../log/common/log.js';
import { IConfigurationService } from '../../configuration/common/configuration.js';
import { ISharedProcessService } from '../../ipc/electron-browser/services.js';
import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js';
import { IRemoteAgentHostService } from '../common/remoteAgentHostService.js';
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
import { SSHRelayTransport } from './sshRelayTransport.js';
import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js';
import {
ISSHRemoteAgentHostService,
SSH_REMOTE_AGENT_HOST_CHANNEL,
type ISSHAgentHostConfig,
type ISSHAgentHostConnection,
type ISSHRemoteAgentHostMainService,
type ISSHResolvedConfig,
type ISSHConnectProgress,
} from '../common/sshRemoteAgentHost.js';
/**
* Renderer-side implementation of {@link ISSHRemoteAgentHostService} that
* delegates the actual SSH work to the main process via IPC, then registers
* the resulting connection with the renderer-local {@link IRemoteAgentHostService}.
*/
export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteAgentHostService {
declare readonly _serviceBrand: undefined;
private readonly _mainService: ISSHRemoteAgentHostMainService;
private readonly _onDidChangeConnections = this._register(new Emitter<void>());
readonly onDidChangeConnections: Event<void> = this._onDidChangeConnections.event;
readonly onDidReportConnectProgress: Event<ISSHConnectProgress>;
private readonly _connections = new Map<string, SSHAgentHostConnectionHandle>();
constructor(
@ISharedProcessService sharedProcessService: ISharedProcessService,
@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,
@ILogService private readonly _logService: ILogService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
) {
super();
this._mainService = ProxyChannel.toService<ISSHRemoteAgentHostMainService>(
sharedProcessService.getChannel(SSH_REMOTE_AGENT_HOST_CHANNEL),
);
this.onDidReportConnectProgress = this._mainService.onDidReportConnectProgress;
// When shared process fires onDidCloseConnection, clean up the renderer-side handle.
// Do NOT remove the configured entry — it stays in settings so startup reconnect
// can re-establish the SSH tunnel on next launch.
this._register(this._mainService.onDidCloseConnection(connectionId => {
const handle = this._connections.get(connectionId);
if (handle) {
this._connections.delete(connectionId);
handle.fireClose();
handle.dispose();
this._onDidChangeConnections.fire();
}
}));
}
get connections(): readonly ISSHAgentHostConnection[] {
return [...this._connections.values()];
}
async connect(config: ISSHAgentHostConfig): Promise<ISSHAgentHostConnection> {
this._logService.info('[SSHRemoteAgentHost] Connecting to ' + config.host);
const augmentedConfig = this._augmentConfig(config);
const result = await this._mainService.connect(augmentedConfig);
this._logService.trace('[SSHRemoteAgentHost] SSH tunnel established, connectionId=' + result.connectionId);
const existing = this._connections.get(result.connectionId);
if (existing) {
this._logService.trace('[SSHRemoteAgentHost] Returning existing connection handle');
return existing;
}
// Create relay transport + protocol client, then register with RemoteAgentHostService
try {
const protocolClient = this._createRelayClient(result);
await protocolClient.connect();
this._logService.trace('[SSHRemoteAgentHost] Protocol handshake completed');
await this._remoteAgentHostService.addSSHConnection({
address: result.address,
name: result.name,
connectionToken: result.connectionToken,
sshConfigHost: result.sshConfigHost,
}, protocolClient);
} catch (err) {
this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err);
this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ });
throw err;
}
const handle = new SSHAgentHostConnectionHandle(
result.config,
result.address,
result.name,
() => this._mainService.disconnect(result.connectionId),
);
this._connections.set(result.connectionId, handle);
this._onDidChangeConnections.fire();
return handle;
}
async disconnect(host: string): Promise<void> {
await this._mainService.disconnect(host);
}
async listSSHConfigHosts(): Promise<string[]> {
return this._mainService.listSSHConfigHosts();
}
async resolveSSHConfig(host: string): Promise<ISSHResolvedConfig> {
return this._mainService.resolveSSHConfig(host);
}
async reconnect(sshConfigHost: string, name: string): Promise<ISSHAgentHostConnection> {
const commandOverride = this._getRemoteAgentHostCommand();
const result = await this._mainService.reconnect(sshConfigHost, name, commandOverride);
const existing = this._connections.get(result.connectionId);
if (existing) {
return existing;
}
const protocolClient = this._createRelayClient(result);
await protocolClient.connect();
await this._remoteAgentHostService.addSSHConnection({
address: result.address,
name: result.name,
connectionToken: result.connectionToken,
sshConfigHost: result.sshConfigHost,
}, protocolClient);
const handle = new SSHAgentHostConnectionHandle(
result.config,
result.address,
result.name,
() => this._mainService.disconnect(result.connectionId),
);
this._connections.set(result.connectionId, handle);
this._onDidChangeConnections.fire();
return handle;
}
private _createRelayClient(result: { connectionId: string; address: string }): RemoteAgentHostProtocolClient {
const transport = new SSHRelayTransport(result.connectionId, this._mainService);
return this._instantiationService.createInstance(
RemoteAgentHostProtocolClient, result.address, transport,
);
}
private _augmentConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfig {
const commandOverride = this._getRemoteAgentHostCommand();
if (commandOverride) {
return { ...config, remoteAgentHostCommand: commandOverride };
}
return config;
}
private _getRemoteAgentHostCommand(): string | undefined {
return this._configurationService.getValue<string>('chat.sshRemoteAgentHostCommand') || undefined;
}
}
/**
* Lightweight renderer-side handle that represents a connection
* managed by the main process.
*/
class SSHAgentHostConnectionHandle extends Disposable implements ISSHAgentHostConnection {
private readonly _onDidClose = this._register(new Emitter<void>());
readonly onDidClose = this._onDidClose.event;
private _closedByMain = false;
constructor(
readonly config: ISSHAgentHostConnection['config'],
readonly localAddress: string,
readonly name: string,
disconnectFn: () => Promise<void>,
) {
super();
// When this handle is disposed, tear down the main-process tunnel
// (skip if already closed from the main process side)
this._register(toDisposable(() => {
if (!this._closedByMain) {
disconnectFn().catch(() => { /* best effort */ });
}
}));
}
/** Called by the service when the main process signals connection closure. */
fireClose(): void {
this._closedByMain = true;
this._onDidClose.fire();
}
}

View File

@@ -10,16 +10,16 @@ import { Emitter } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { connectionTokenQueryName } from '../../../base/common/network.js';
import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js';
import type { IProtocolTransport } from '../common/state/sessionTransport.js';
import type { IClientTransport } from '../common/state/sessionTransport.js';
// ---- Client transport -------------------------------------------------------
/**
* A WebSocket client transport that connects to a remote agent host server.
* Uses the native browser WebSocket API (available in Electron renderer).
* Implements {@link IProtocolTransport} with JSON serialization and URI revival.
* Implements {@link IClientTransport} with JSON serialization and URI revival.
*/
export class WebSocketClientTransport extends Disposable implements IProtocolTransport {
export class WebSocketClientTransport extends Disposable implements IClientTransport {
private readonly _onMessage = this._register(new Emitter<IProtocolMessage>());
readonly onMessage = this._onMessage.event;

View File

@@ -0,0 +1,658 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type WebSocket from 'ws';
import { createRequire } from 'node:module';
import { promises as fsp } from 'fs';
import * as os from 'os';
import * as cp from 'child_process';
import { dirname, join, isAbsolute, basename } from '../../../base/common/path.js';
import { Emitter, Event } from '../../../base/common/event.js';
import { Disposable, toDisposable } from '../../../base/common/lifecycle.js';
import { localize } from '../../../nls.js';
import { ILogService } from '../../log/common/log.js';
import {
ISSHRemoteAgentHostMainService,
SSHAuthMethod,
type ISSHAgentHostConfig,
type ISSHAgentHostConfigSanitized,
type ISSHConnectProgress,
type ISSHConnectResult,
type ISSHRelayMessage,
type ISSHResolvedConfig,
} from '../common/sshRemoteAgentHost.js';
const _require = createRequire(import.meta.url);
/** Minimal subset of ssh2.ClientChannel used by this module (duplex stream). */
interface SSHChannel extends NodeJS.ReadWriteStream {
on(event: 'data', listener: (data: Buffer) => void): this;
on(event: 'close', listener: (code: number) => void): this;
on(event: 'error', listener: (err: Error) => void): this;
on(event: string, listener: (...args: unknown[]) => void): this;
stderr: { on(event: 'data', listener: (data: Buffer) => void): void };
close(): void;
}
/** Minimal subset of ssh2.Client used by this module. */
interface SSHClient {
on(event: 'ready', listener: () => void): SSHClient;
on(event: 'error', listener: (err: Error) => void): SSHClient;
on(event: 'close', listener: () => void): SSHClient;
connect(config: Record<string, unknown>): void;
exec(command: string, callback: (err: Error | undefined, stream: SSHChannel) => void): SSHClient;
forwardOut(srcIP: string, srcPort: number, dstIP: string, dstPort: number, callback: (err: Error | undefined, channel: SSHChannel) => void): SSHClient;
end(): void;
}
const LOG_PREFIX = '[SSHRemoteAgentHost]';
/** Install location for the VS Code CLI on the remote machine. */
const REMOTE_CLI_DIR = '~/.vscode-cli';
const REMOTE_CLI_BIN = `${REMOTE_CLI_DIR}/code`;
/** Escape a string for use as a single shell argument (single-quote wrapping). */
function shellEscape(s: string): string {
// Wrap in single quotes; escape embedded single quotes as: '\''
const escaped = s.replace(/'/g, '\'\\\'\'');
return `'${escaped}'`;
}
function resolveRemotePlatform(unameS: string, unameM: string): { os: string; arch: string } | undefined {
const os = unameS.trim().toLowerCase();
const machine = unameM.trim().toLowerCase();
let platformOs: string;
if (os === 'linux') {
platformOs = 'linux';
} else if (os === 'darwin') {
platformOs = 'darwin';
} else {
return undefined;
}
let arch: string;
if (machine === 'x86_64' || machine === 'amd64') {
arch = 'x64';
} else if (machine === 'aarch64' || machine === 'arm64') {
arch = 'arm64';
} else if (machine === 'armv7l') {
arch = 'armhf';
} else {
return undefined;
}
return { os: platformOs, arch };
}
function buildCLIDownloadUrl(os: string, arch: string, quality: string): string {
return `https://update.code.visualstudio.com/latest/cli-${os}-${arch}/${quality}`;
}
function sshExec(client: SSHClient, command: string, opts?: { ignoreExitCode?: boolean }): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {
client.exec(command, (err: Error | undefined, stream: SSHChannel) => {
if (err) {
reject(err);
return;
}
let stdout = '';
let stderr = '';
let settled = false;
const finish = (error: Error | undefined, code: number | undefined) => {
if (settled) {
return;
}
settled = true;
if (error) {
reject(error);
return;
}
if (code !== 0 && !opts?.ignoreExitCode) {
reject(new Error(`SSH command failed (exit ${code}): ${command}\nstderr: ${stderr}`));
} else {
resolve({ stdout, stderr, code: code ?? 0 });
}
};
stream.on('data', (data: Buffer) => { stdout += data.toString(); });
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
stream.on('error', (streamErr: Error) => finish(streamErr, undefined));
stream.on('close', (code: number) => finish(undefined, code));
});
});
}
/** Redact connection tokens from log output. */
function redactToken(text: string): string {
return text.replace(/\?tkn=[^\s&]+/g, '?tkn=***');
}
function startRemoteAgentHost(
client: SSHClient,
logService: ILogService,
commandOverride?: string,
): Promise<{ port: number; connectionToken: string | undefined; stream: SSHChannel }> {
return new Promise((resolve, reject) => {
const baseCmd = commandOverride ?? `${REMOTE_CLI_BIN} agent-host --port 0 --accept-server-license-terms`;
// Wrap in a login shell so the agent host process inherits the
// user's PATH and environment from ~/.bash_profile / ~/.bashrc
// (ssh2 exec runs a non-interactive non-login shell by default).
const cmd = `bash -l -c ${shellEscape(baseCmd)}`;
logService.info(`${LOG_PREFIX} Starting remote agent host: ${cmd}`);
client.exec(cmd, (err: Error | undefined, stream: SSHChannel) => {
if (err) {
reject(err);
return;
}
let resolved = false;
let outputBuf = '';
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
reject(new Error(`${LOG_PREFIX} Timed out waiting for agent host to start.\noutput so far: ${redactToken(outputBuf)}`));
}
}, 60_000);
const checkForAddress = () => {
if (!resolved) {
const match = outputBuf.match(/ws:\/\/127\.0\.0\.1:(\d+)(?:\?tkn=([^\s&]+))?/);
if (match) {
resolved = true;
clearTimeout(timeout);
const port = parseInt(match[1], 10);
const connectionToken = match[2] || undefined;
logService.info(`${LOG_PREFIX} Remote agent host listening on port ${port}`);
resolve({ port, connectionToken, stream });
}
}
};
stream.stderr.on('data', (data: Buffer) => {
const text = data.toString();
outputBuf += text;
logService.trace(`${LOG_PREFIX} remote stderr: ${redactToken(text.trimEnd())}`);
checkForAddress();
});
stream.on('data', (data: Buffer) => {
const text = data.toString();
outputBuf += text;
logService.trace(`${LOG_PREFIX} remote stdout: ${redactToken(text.trimEnd())}`);
checkForAddress();
});
stream.on('close', (code: number) => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(new Error(`${LOG_PREFIX} Agent host process exited with code ${code} before becoming ready.\noutput: ${redactToken(outputBuf)}`));
}
});
});
});
}
/**
* Create a WebSocket connection to the remote agent host via an SSH forwarded channel.
* Uses the `ws` library to speak WebSocket over the SSH channel.
* Messages are relayed to the renderer via IPC events.
*/
function createWebSocketRelay(
client: SSHClient,
dstHost: string,
dstPort: number,
connectionToken: string | undefined,
logService: ILogService,
onMessage: (data: string) => void,
onClose: () => void,
): Promise<{ send: (data: string) => void; close: () => void }> {
return new Promise((resolve, reject) => {
client.forwardOut('127.0.0.1', 0, dstHost, dstPort, (err: Error | undefined, channel: SSHChannel) => {
if (err) {
reject(err);
return;
}
const WS = _require('ws') as typeof WebSocket;
let url = `ws://${dstHost}:${dstPort}`;
if (connectionToken) {
url += `?tkn=${encodeURIComponent(connectionToken)}`;
}
// The SSH channel is a duplex stream compatible with ws's createConnection,
// but our minimal SSHChannel interface doesn't carry the full Node Duplex shape.
const ws = new WS(url, { createConnection: (() => channel) as unknown as WebSocket.ClientOptions['createConnection'] });
ws.on('open', () => {
logService.info(`${LOG_PREFIX} WebSocket relay connected to remote agent host`);
resolve({
send: (data: string) => {
if (ws.readyState === ws.OPEN) {
ws.send(data);
}
},
close: () => ws.close(),
});
});
ws.on('message', (data: WebSocket.RawData) => {
if (Array.isArray(data)) {
onMessage(Buffer.concat(data).toString());
} else if (data instanceof ArrayBuffer) {
onMessage(Buffer.from(new Uint8Array(data)).toString());
} else {
onMessage(data.toString());
}
});
ws.on('close', onClose);
ws.on('error', (wsErr: unknown) => {
logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`);
reject(wsErr);
});
});
});
}
function sanitizeConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfigSanitized {
const { password: _p, privateKeyPath: _k, ...sanitized } = config;
return sanitized;
}
class SSHConnection extends Disposable {
private readonly _onDidClose = this._register(new Emitter<void>());
readonly onDidClose = this._onDidClose.event;
readonly config: ISSHAgentHostConfigSanitized;
private _closed = false;
constructor(
fullConfig: ISSHAgentHostConfig,
readonly connectionId: string,
readonly address: string,
readonly name: string,
readonly connectionToken: string | undefined,
sshClient: SSHClient,
private readonly _relay: { send: (data: string) => void; close: () => void },
remoteStream: SSHChannel,
) {
super();
this.config = sanitizeConfig(fullConfig);
this._register(toDisposable(() => {
if (this._closed) {
return;
}
this._closed = true;
this._relay.close();
remoteStream.close();
sshClient.end();
this._onDidClose.fire();
}));
sshClient.on('close', () => {
this.dispose();
});
sshClient.on('error', () => {
this.dispose();
});
}
relaySend(data: string): void {
this._relay.send(data);
}
}
import { parseSSHConfigHostEntries, parseSSHGOutput, stripSSHComment } from '../common/sshConfigParsing.js';
export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRemoteAgentHostMainService {
declare readonly _serviceBrand: undefined;
private readonly _onDidChangeConnections = this._register(new Emitter<void>());
readonly onDidChangeConnections: Event<void> = this._onDidChangeConnections.event;
private readonly _onDidCloseConnection = this._register(new Emitter<string>());
readonly onDidCloseConnection: Event<string> = this._onDidCloseConnection.event;
private readonly _onDidReportConnectProgress = this._register(new Emitter<ISSHConnectProgress>());
readonly onDidReportConnectProgress: Event<ISSHConnectProgress> = this._onDidReportConnectProgress.event;
private readonly _onDidRelayMessage = this._register(new Emitter<ISSHRelayMessage>());
readonly onDidRelayMessage: Event<ISSHRelayMessage> = this._onDidRelayMessage.event;
private readonly _onDidRelayClose = this._register(new Emitter<string>());
readonly onDidRelayClose: Event<string> = this._onDidRelayClose.event;
private readonly _connections = new Map<string, SSHConnection>();
constructor(
@ILogService private readonly _logService: ILogService,
) {
super();
}
async connect(config: ISSHAgentHostConfig): Promise<ISSHConnectResult> {
const connectionKey = config.sshConfigHost
? `ssh:${config.sshConfigHost}`
: `${config.username}@${config.host}:${config.port ?? 22}`;
const existing = this._connections.get(connectionKey);
if (existing) {
return {
connectionId: existing.connectionId,
address: existing.address,
name: existing.name,
connectionToken: existing.connectionToken,
config: existing.config,
};
}
this._logService.info(`${LOG_PREFIX} Connecting to ${connectionKey}...`);
let sshClient: SSHClient | undefined;
try {
const ssh2Module = _require('ssh2') as { Client: new () => unknown };
const reportProgress = (message: string) => {
this._onDidReportConnectProgress.fire({ connectionKey, message });
};
// 1. Establish SSH connection
reportProgress(localize('sshProgressConnecting', "Establishing SSH connection..."));
sshClient = await this._connectSSH(config, ssh2Module.Client);
if (config.remoteAgentHostCommand) {
// Dev override: skip platform detection and CLI install,
// use the provided command directly.
this._logService.info(`${LOG_PREFIX} Using custom agent host command: ${config.remoteAgentHostCommand}`);
} else {
// 2. Detect remote platform
const { stdout: unameS } = await sshExec(sshClient, 'uname -s');
const { stdout: unameM } = await sshExec(sshClient, 'uname -m');
const platform = resolveRemotePlatform(unameS, unameM);
if (!platform) {
throw new Error(`${LOG_PREFIX} Unsupported remote platform: ${unameS.trim()} ${unameM.trim()}`);
}
this._logService.info(`${LOG_PREFIX} Remote platform: ${platform.os}-${platform.arch}`);
// 3. Install CLI if needed
reportProgress(localize('sshProgressInstallingCLI', "Checking remote CLI installation..."));
await this._ensureCLIInstalled(sshClient, platform, reportProgress);
}
// 4. Start agent-host and capture port/token
reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host..."));
const { port: remotePort, connectionToken, stream: agentStream } = await startRemoteAgentHost(sshClient, this._logService, config.remoteAgentHostCommand);
// 5. Connect to remote agent host via WebSocket relay (no local TCP port)
reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host..."));
const connectionId = connectionKey;
const relay = await createWebSocketRelay(
sshClient, '127.0.0.1', remotePort, connectionToken, this._logService,
(data: string) => this._onDidRelayMessage.fire({ connectionId, data }),
() => this._onDidRelayClose.fire(connectionId),
);
// 6. Create connection object
const address = connectionKey;
const conn = new SSHConnection(
config,
connectionId,
address,
config.name,
connectionToken,
sshClient,
relay,
agentStream,
);
conn.onDidClose(() => {
this._connections.delete(connectionKey);
this._onDidCloseConnection.fire(connectionId);
this._onDidChangeConnections.fire();
});
this._connections.set(connectionKey, conn);
sshClient = undefined; // ownership transferred to SSHConnection
this._onDidChangeConnections.fire();
return {
connectionId,
address,
name: config.name,
connectionToken,
config: conn.config,
sshConfigHost: config.sshConfigHost,
};
} catch (err) {
sshClient?.end();
throw err;
}
}
async disconnect(host: string): Promise<void> {
for (const [key, conn] of this._connections) {
if (key === host || conn.connectionId === host) {
conn.dispose();
return;
}
}
}
async relaySend(connectionId: string, message: string): Promise<void> {
for (const conn of this._connections.values()) {
if (conn.connectionId === connectionId) {
conn.relaySend(message);
return;
}
}
}
async reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string): Promise<ISSHConnectResult> {
this._logService.info(`${LOG_PREFIX} Reconnecting via SSH config host: ${sshConfigHost}`);
const resolved = await this.resolveSSHConfig(sshConfigHost);
let authMethod: SSHAuthMethod = SSHAuthMethod.Agent;
let privateKeyPath: string | undefined;
const defaultKeys = ['~/.ssh/id_rsa', '~/.ssh/id_ecdsa', '~/.ssh/id_ed25519', '~/.ssh/id_dsa', '~/.ssh/id_xmss'];
if (resolved.identityFile.length > 0 && !defaultKeys.includes(resolved.identityFile[0])) {
authMethod = SSHAuthMethod.KeyFile;
privateKeyPath = resolved.identityFile[0];
}
return this.connect({
host: resolved.hostname,
port: resolved.port !== 22 ? resolved.port : undefined,
username: resolved.user ?? sshConfigHost,
authMethod,
privateKeyPath,
name,
sshConfigHost,
remoteAgentHostCommand,
});
}
async listSSHConfigHosts(): Promise<string[]> {
const configPath = join(os.homedir(), '.ssh', 'config');
try {
const content = await fsp.readFile(configPath, 'utf-8');
return this._parseSSHConfigHosts(content, dirname(configPath));
} catch {
this._logService.info(`${LOG_PREFIX} Could not read SSH config at ${configPath}`);
return [];
}
}
async resolveSSHConfig(host: string): Promise<ISSHResolvedConfig> {
return new Promise<ISSHResolvedConfig>((resolve, reject) => {
cp.execFile('ssh', ['-G', host], { timeout: 5000 }, (err, stdout) => {
if (err) {
reject(new Error(`${LOG_PREFIX} ssh -G failed for ${host}: ${err.message}`));
return;
}
const config = this._parseSSHGOutput(stdout);
resolve(config);
});
});
}
private async _parseSSHConfigHosts(content: string, configDir: string, visited?: Set<string>): Promise<string[]> {
const seen = visited ?? new Set<string>();
const hosts: string[] = [];
// Extract hosts from this file directly
hosts.push(...parseSSHConfigHostEntries(content));
// Follow Include directives
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const includeMatch = trimmed.match(/^Include\s+(.+)$/i);
if (!includeMatch) {
continue;
}
const rawValue = stripSSHComment(includeMatch[1]);
const patterns = rawValue.split(/\s+/).filter(Boolean);
for (const rawPattern of patterns) {
const pattern = rawPattern.replace(/^~/, os.homedir());
const resolvedPattern = isAbsolute(pattern) ? pattern : join(configDir, pattern);
if (seen.has(resolvedPattern)) {
continue;
}
seen.add(resolvedPattern);
try {
const stat = await fsp.stat(resolvedPattern);
if (stat.isDirectory()) {
const files = await fsp.readdir(resolvedPattern);
for (const file of files) {
try {
const sub = await fsp.readFile(join(resolvedPattern, file), 'utf-8');
hosts.push(...await this._parseSSHConfigHosts(sub, resolvedPattern, seen));
} catch { /* skip unreadable files */ }
}
} else {
const sub = await fsp.readFile(resolvedPattern, 'utf-8');
hosts.push(...await this._parseSSHConfigHosts(sub, dirname(resolvedPattern), seen));
}
} catch {
const dir = dirname(resolvedPattern);
const base = basename(resolvedPattern);
if (base.includes('*')) {
try {
const files = await fsp.readdir(dir);
for (const file of files) {
const regex = new RegExp('^' + base.replace(/\*/g, '.*') + '$');
if (regex.test(file)) {
try {
const sub = await fsp.readFile(join(dir, file), 'utf-8');
hosts.push(...await this._parseSSHConfigHosts(sub, dir, seen));
} catch { /* skip */ }
}
}
} catch { /* skip unreadable dirs */ }
}
}
}
}
return hosts;
}
private _parseSSHGOutput(stdout: string): ISSHResolvedConfig {
return parseSSHGOutput(stdout);
}
private async _connectSSH(
config: ISSHAgentHostConfig,
SSHClientCtor: new () => unknown,
): Promise<SSHClient> {
const connectConfig: Record<string, unknown> = {
host: config.host,
port: config.port ?? 22,
username: config.username,
readyTimeout: 30_000,
keepaliveInterval: 15_000,
};
switch (config.authMethod) {
case SSHAuthMethod.Agent: {
const agentSock = process.env['SSH_AUTH_SOCK'];
this._logService.info(`${LOG_PREFIX} Using SSH agent: ${agentSock ?? '(not set)'}`);
connectConfig.agent = agentSock;
break;
}
case SSHAuthMethod.KeyFile:
if (config.privateKeyPath) {
const keyPath = config.privateKeyPath.replace(/^~/, os.homedir());
connectConfig.privateKey = await fsp.readFile(keyPath);
}
break;
case SSHAuthMethod.Password:
connectConfig.password = config.password;
break;
}
return new Promise<SSHClient>((resolve, reject) => {
const client = new SSHClientCtor() as SSHClient;
client.on('ready', () => {
this._logService.info(`${LOG_PREFIX} SSH connection established to ${config.host}`);
resolve(client);
});
client.on('error', (err: Error) => {
this._logService.error(`${LOG_PREFIX} SSH connection error: ${err.message}`);
reject(err);
});
client.connect(connectConfig);
});
}
private async _ensureCLIInstalled(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise<void> {
const { code } = await sshExec(client, `${REMOTE_CLI_BIN} --version`, { ignoreExitCode: true });
if (code === 0) {
this._logService.info(`${LOG_PREFIX} VS Code CLI already installed on remote`);
return;
}
reportProgress(localize('sshProgressDownloadingCLI', "Installing VS Code CLI on remote..."));
const quality = 'stable';
const url = buildCLIDownloadUrl(platform.os, platform.arch, quality);
const installCmd = [
`mkdir -p ${REMOTE_CLI_DIR}`,
`curl -fsSL '${url}' | tar xz -C ${REMOTE_CLI_DIR}`,
`chmod +x ${REMOTE_CLI_BIN}`,
].join(' && ');
await sshExec(client, installCmd);
this._logService.info(`${LOG_PREFIX} VS Code CLI installed successfully`);
}
override dispose(): void {
for (const conn of this._connections.values()) {
conn.dispose();
}
this._connections.clear();
super.dispose();
}
}

View File

@@ -0,0 +1,226 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { parseSSHConfigHostEntries, parseSSHGOutput } from '../../common/sshConfigParsing.js';
suite('SSH Config Parsing', () => {
ensureNoDisposablesAreLeakedInTestSuite();
suite('parseSSHConfigHostEntries', () => {
test('extracts simple host entries', () => {
const config = [
'Host myserver',
' HostName 10.0.0.1',
' User admin',
].join('\n');
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
});
test('extracts multiple hosts from a single Host line', () => {
const config = 'Host server1 server2 server3';
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['server1', 'server2', 'server3']);
});
test('extracts hosts from multiple Host directives', () => {
const config = [
'Host work',
' HostName work.example.com',
'',
'Host personal',
' HostName home.example.com',
].join('\n');
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['work', 'personal']);
});
test('skips wildcard hosts', () => {
const config = [
'Host *',
' ForwardAgent yes',
'',
'Host myserver',
' HostName 10.0.0.1',
'',
'Host *.example.com',
' User admin',
].join('\n');
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
});
test('skips negation patterns', () => {
const config = 'Host !internal myserver';
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
});
test('skips question mark wildcards', () => {
const config = 'Host server? myserver';
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
});
test('skips comment lines', () => {
const config = [
'# This is a comment',
'Host myserver',
' # Another comment',
' HostName 10.0.0.1',
].join('\n');
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
});
test('strips inline comments from Host values', () => {
const config = 'Host myserver # my favorite server';
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
});
test('handles empty content', () => {
assert.deepStrictEqual(parseSSHConfigHostEntries(''), []);
});
test('handles content with only comments and blanks', () => {
const config = [
'# comment',
'',
' # indented comment',
'',
].join('\n');
assert.deepStrictEqual(parseSSHConfigHostEntries(config), []);
});
test('is case-insensitive for Host keyword', () => {
const config = [
'host lower',
'HOST upper',
'Host mixed',
].join('\n');
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['lower', 'upper', 'mixed']);
});
test('ignores non-Host directives', () => {
const config = [
'Host myserver',
' HostName 10.0.0.1',
' User admin',
' Port 2222',
' IdentityFile ~/.ssh/mykey',
' ForwardAgent yes',
].join('\n');
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
});
});
suite('parseSSHGOutput', () => {
test('parses standard ssh -G output', () => {
const output = [
'hostname 10.0.0.1',
'user admin',
'port 22',
'identityfile ~/.ssh/id_rsa',
'identityfile ~/.ssh/id_ed25519',
'forwardagent no',
].join('\n');
assert.deepStrictEqual(parseSSHGOutput(output), {
hostname: '10.0.0.1',
user: 'admin',
port: 22,
identityFile: ['~/.ssh/id_rsa', '~/.ssh/id_ed25519'],
forwardAgent: false,
});
});
test('parses forwardagent yes', () => {
const output = [
'hostname example.com',
'user root',
'port 22',
'forwardagent yes',
].join('\n');
const result = parseSSHGOutput(output);
assert.strictEqual(result.forwardAgent, true);
});
test('parses non-standard port', () => {
const output = [
'hostname example.com',
'user deploy',
'port 2222',
].join('\n');
const result = parseSSHGOutput(output);
assert.strictEqual(result.port, 2222);
});
test('handles missing user', () => {
const output = [
'hostname example.com',
'port 22',
].join('\n');
const result = parseSSHGOutput(output);
assert.strictEqual(result.user, undefined);
});
test('handles empty user', () => {
const output = [
'hostname example.com',
'user ',
'port 22',
].join('\n');
const result = parseSSHGOutput(output);
assert.strictEqual(result.user, undefined);
});
test('defaults port to 22 when missing', () => {
const output = 'hostname example.com\nuser root';
const result = parseSSHGOutput(output);
assert.strictEqual(result.port, 22);
});
test('collects multiple identity files', () => {
const output = [
'hostname example.com',
'port 22',
'identityfile ~/.ssh/id_rsa',
'identityfile ~/.ssh/work_key',
'identityfile ~/.ssh/id_ed25519',
].join('\n');
assert.deepStrictEqual(parseSSHGOutput(output).identityFile, [
'~/.ssh/id_rsa',
'~/.ssh/work_key',
'~/.ssh/id_ed25519',
]);
});
test('handles empty output', () => {
assert.deepStrictEqual(parseSSHGOutput(''), {
hostname: '',
user: undefined,
port: 22,
identityFile: [],
forwardAgent: false,
});
});
test('handles values with spaces', () => {
const output = 'hostname my host with spaces\nport 22';
const result = parseSSHGOutput(output);
assert.strictEqual(result.hostname, 'my host with spaces');
});
});
});

View File

@@ -217,11 +217,10 @@ suite('RemoteAgentHostService', () => {
configService.setEntries([{ address: 'ws://bad:9999', name: 'Bad' }]);
assert.strictEqual(createdClients.length, 1);
// Fail the connection
// Fail the connection and wait for the service to react
const connectionChanged = Event.toPromise(service.onDidChangeConnections);
createdClients[0].connectDeferred.error(new Error('Connection refused'));
// Wait for async error handling
await new Promise(r => setTimeout(r, 10));
await connectionChanged;
assert.strictEqual(service.connections.length, 0);
assert.strictEqual(service.getConnection('ws://bad:9999'), undefined);

View File

@@ -0,0 +1,174 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { Emitter, Event } from '../../../../base/common/event.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { SSHRelayTransport } from '../../electron-browser/sshRelayTransport.js';
import type { ISSHRelayMessage, ISSHRemoteAgentHostMainService, ISSHConnectProgress, ISSHConnectResult, ISSHAgentHostConfig, ISSHResolvedConfig } from '../../common/sshRemoteAgentHost.js';
/**
* Minimal mock of ISSHRemoteAgentHostMainService for testing the relay transport.
*/
class MockSSHMainService {
private readonly _onDidRelayMessage = new Emitter<ISSHRelayMessage>();
readonly onDidRelayMessage = this._onDidRelayMessage.event;
private readonly _onDidRelayClose = new Emitter<string>();
readonly onDidRelayClose = this._onDidRelayClose.event;
readonly onDidChangeConnections = Event.None;
readonly onDidCloseConnection = Event.None;
readonly onDidReportConnectProgress = Event.None as Event<ISSHConnectProgress>;
readonly sentMessages: { connectionId: string; message: string }[] = [];
async relaySend(connectionId: string, message: string): Promise<void> {
this.sentMessages.push({ connectionId, message });
}
async connect(_config: ISSHAgentHostConfig): Promise<ISSHConnectResult> {
throw new Error('Not implemented');
}
async disconnect(_host: string): Promise<void> { }
async listSSHConfigHosts(): Promise<string[]> { return []; }
async resolveSSHConfig(_host: string): Promise<ISSHResolvedConfig> {
throw new Error('Not implemented');
}
async reconnect(_sshConfigHost: string, _name: string): Promise<ISSHConnectResult> {
throw new Error('Not implemented');
}
// Test helpers
fireRelayMessage(msg: ISSHRelayMessage): void {
this._onDidRelayMessage.fire(msg);
}
fireRelayClose(connectionId: string): void {
this._onDidRelayClose.fire(connectionId);
}
dispose(): void {
this._onDidRelayMessage.dispose();
this._onDidRelayClose.dispose();
}
}
suite('SSHRelayTransport', () => {
const disposables = new DisposableStore();
let mockService: MockSSHMainService;
setup(() => {
mockService = new MockSSHMainService();
disposables.add({ dispose: () => mockService.dispose() });
});
teardown(() => disposables.clear());
ensureNoDisposablesAreLeakedInTestSuite();
test('receives messages matching connectionId', () => {
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
const received: unknown[] = [];
disposables.add(transport.onMessage(msg => received.push(msg)));
mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"jsonrpc":"2.0","id":1}' });
assert.strictEqual(received.length, 1);
assert.deepStrictEqual(received[0], { jsonrpc: '2.0', id: 1 });
});
test('ignores messages for other connectionIds', () => {
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
const received: unknown[] = [];
disposables.add(transport.onMessage(msg => received.push(msg)));
mockService.fireRelayMessage({ connectionId: 'conn-2', data: '{"jsonrpc":"2.0","id":1}' });
assert.strictEqual(received.length, 0);
});
test('drops malformed JSON messages', () => {
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
const received: unknown[] = [];
disposables.add(transport.onMessage(msg => received.push(msg)));
// Should not throw
mockService.fireRelayMessage({ connectionId: 'conn-1', data: 'not-json{{{' });
assert.strictEqual(received.length, 0);
});
test('fires onClose when relay closes for matching connectionId', () => {
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
let closed = false;
disposables.add(transport.onClose(() => { closed = true; }));
mockService.fireRelayClose('conn-1');
assert.strictEqual(closed, true);
});
test('does not fire onClose for other connectionIds', () => {
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
let closed = false;
disposables.add(transport.onClose(() => { closed = true; }));
mockService.fireRelayClose('conn-2');
assert.strictEqual(closed, false);
});
test('send() calls relaySend with correct connectionId', async () => {
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
const msg = { jsonrpc: '2.0' as const, method: 'test', id: 42 };
transport.send(msg as never);
// Give the async relaySend a tick to register
await new Promise<void>(r => queueMicrotask(r));
assert.strictEqual(mockService.sentMessages.length, 1);
assert.strictEqual(mockService.sentMessages[0].connectionId, 'conn-1');
assert.deepStrictEqual(JSON.parse(mockService.sentMessages[0].message), msg);
});
test('receives multiple messages in order', () => {
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
const received: unknown[] = [];
disposables.add(transport.onMessage(msg => received.push(msg)));
mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":1}' });
mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":2}' });
mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":3}' });
assert.strictEqual(received.length, 3);
assert.deepStrictEqual(received, [{ id: 1 }, { id: 2 }, { id: 3 }]);
});
test('no events after dispose', () => {
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
const received: unknown[] = [];
let closed = false;
disposables.add(transport.onMessage(msg => received.push(msg)));
disposables.add(transport.onClose(() => { closed = true; }));
transport.dispose();
mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":1}' });
mockService.fireRelayClose('conn-1');
assert.strictEqual(received.length, 0);
assert.strictEqual(closed, false);
});
});

View File

@@ -16,7 +16,7 @@ import { isSessionAction } from '../../../../platform/agentHost/common/state/ses
import { SessionClientState } from '../../../../platform/agentHost/common/state/sessionClientState.js';
import { ROOT_STATE_URI, type IAgentInfo, type ICustomizationRef, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../../platform/log/common/log.js';
@@ -40,6 +40,7 @@ import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvid
import { createRemoteAgentHarnessDescriptor, RemoteAgentCustomizationItemProvider } from './remoteAgentHostCustomizationHarness.js';
import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js';
import { SyncedCustomizationBundler } from './syncedCustomizationBundler.js';
import { ISSHRemoteAgentHostService } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js';
/** Per-connection state bundle, disposed when a connection is removed. */
class ConnectionState extends Disposable {
@@ -80,6 +81,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
/** Per-address sessions providers, registered for all configured entries. */
private readonly _providerStores = this._register(new DisposableMap<string, DisposableStore>());
private readonly _providerInstances = new Map<string, RemoteAgentHostSessionsProvider>();
private readonly _pendingSSHReconnects = new Set<string>();
constructor(
@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,
@@ -93,6 +95,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IAgentHostFileSystemService private readonly _agentHostFileSystemService: IAgentHostFileSystemService,
@ISSHRemoteAgentHostService private readonly _sshService: ISSHRemoteAgentHostService,
@ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService,
@IStorageService private readonly _storageService: IStorageService,
@IAgentPluginService private readonly _agentPluginService: IAgentPluginService,
@@ -122,6 +125,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
private _reconcile(): void {
this._reconcileProviders();
this._reconcileConnections();
this._reconnectSSHEntries();
// Ensure every live connection is wired to its provider.
// This covers the case where a provider was recreated (e.g. name
@@ -182,6 +186,35 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
this._providerStores.set(entry.address, store);
}
/**
* Re-establish SSH connections for configured entries that have an
* sshConfigHost but no active connection.
*/
private _reconnectSSHEntries(): void {
const entries = this._remoteAgentHostService.configuredEntries;
for (const entry of entries) {
if (!entry.sshConfigHost) {
continue;
}
// Skip if already connected or reconnecting
const hasConnection = this._remoteAgentHostService.connections.some(
c => c.address === entry.address && c.status === RemoteAgentHostConnectionStatus.Connected
);
if (hasConnection || this._pendingSSHReconnects.has(entry.sshConfigHost)) {
continue;
}
this._pendingSSHReconnects.add(entry.sshConfigHost);
this._logService.info(`[RemoteAgentHost] Re-establishing SSH tunnel for ${entry.sshConfigHost}`);
this._sshService.reconnect(entry.sshConfigHost, entry.name).then(() => {
this._pendingSSHReconnects.delete(entry.sshConfigHost!);
this._logService.info(`[RemoteAgentHost] SSH tunnel re-established for ${entry.sshConfigHost}`);
}).catch(err => {
this._pendingSSHReconnects.delete(entry.sshConfigHost!);
this._logService.error(`[RemoteAgentHost] SSH reconnect failed for ${entry.sshConfigHost}`, err);
});
}
}
private _reconcileConnections(): void {
const currentConnections = this._remoteAgentHostService.connections;
const connectedAddresses = new Set(
@@ -537,7 +570,15 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis
type: 'boolean',
description: nls.localize('chat.remoteAgentHosts.enabled', "Enable connecting to remote agent hosts."),
default: false,
tags: ['experimental'],
scope: ConfigurationScope.APPLICATION,
tags: ['experimental', 'advanced'],
},
'chat.sshRemoteAgentHostCommand': {
type: 'string',
description: nls.localize('chat.sshRemoteAgentHostCommand', "For development: Override the command used to start the remote agent host over SSH. When set, skips automatic CLI installation and runs this command instead. The command must print a WebSocket URL matching ws://127.0.0.1:PORT (optionally with ?tkn=TOKEN) to stdout or stderr./"),
default: '',
scope: ConfigurationScope.APPLICATION,
tags: ['experimental', 'advanced'],
},
[RemoteAgentHostsSettingId]: {
type: 'array',
@@ -547,11 +588,13 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis
address: { type: 'string', description: nls.localize('chat.remoteAgentHosts.address', "The address of the remote agent host (e.g. \"localhost:3000\").") },
name: { type: 'string', description: nls.localize('chat.remoteAgentHosts.name', "A display name for this remote agent host.") },
connectionToken: { type: 'string', description: nls.localize('chat.remoteAgentHosts.connectionToken', "An optional connection token for authenticating with the remote agent host.") },
sshConfigHost: { type: 'string', description: nls.localize('chat.remoteAgentHosts.sshConfigHost', "SSH config host alias for automatic reconnection via SSH tunnel.") },
},
required: ['address', 'name'],
},
description: nls.localize('chat.remoteAgentHosts', "A list of remote agent host addresses to connect to (e.g. \"localhost:3000\")."),
default: [],
scope: ConfigurationScope.APPLICATION,
tags: ['experimental', 'advanced'],
},
},

View File

@@ -6,11 +6,16 @@
import { localize, localize2 } from '../../../../nls.js';
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostInputValidationError, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { ISSHRemoteAgentHostService, SSHAuthMethod, type ISSHAgentHostConfig, type ISSHAgentHostConnection, type ISSHResolvedConfig } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js';
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { INotificationService } from '../../../../platform/notification/common/notification.js';
import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js';
import { ServicesAccessor, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js';
import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';
import { NewChatViewPane, SessionsViewId } from '../../chat/browser/newChatViewPane.js';
import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js';
import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js';
registerAction2(class extends Action2 {
constructor() {
@@ -80,3 +85,386 @@ registerAction2(class extends Action2 {
}
}
});
// ---- Connect via SSH -------------------------------------------------------
interface ISSHAuthMethodPickItem extends IQuickPickItem {
readonly method: SSHAuthMethod;
}
interface ISSHHostPickItem extends IQuickPickItem {
readonly hostAlias?: string;
}
async function promptToConnectViaSSH(
accessor: ServicesAccessor,
): Promise<void> {
const sshService = accessor.get(ISSHRemoteAgentHostService);
const quickInputService = accessor.get(IQuickInputService);
const notificationService = accessor.get(INotificationService);
const instantiationService = accessor.get(IInstantiationService);
let host: string;
let username: string | undefined;
let port: number | undefined;
let resolvedConfig: ISSHResolvedConfig | undefined;
let suggestedName: string | undefined;
let defaultAuthMethod: SSHAuthMethod | undefined;
let defaultKeyPath: string | undefined;
const configHosts = await sshService.listSSHConfigHosts().catch(() => [] as string[]);
if (configHosts.length > 0) {
const hostPicks: ISSHHostPickItem[] = configHosts.map(h => ({
label: h,
hostAlias: h,
}));
hostPicks.push({
label: localize('sshEnterManually', "Enter Manually..."),
description: localize('sshEnterManuallyDesc', "Type in host, username, and port"),
});
const picked = await quickInputService.pick(hostPicks, {
title: localize('sshHostTitle', "Connect via SSH"),
placeHolder: localize('sshPickHostPlaceholder', "Select an SSH host or enter manually"),
});
if (!picked) {
return;
}
if (picked.hostAlias) {
try {
resolvedConfig = await sshService.resolveSSHConfig(picked.hostAlias);
} catch (err) {
notificationService.error(localize('sshResolveConfigFailed', "Failed to resolve SSH config for {0}: {1}", picked.hostAlias, String(err)));
return;
}
host = resolvedConfig.hostname;
username = resolvedConfig.user;
port = resolvedConfig.port !== 22 ? resolvedConfig.port : undefined;
suggestedName = picked.hostAlias;
// Determine auth method from resolved config
if (resolvedConfig.identityFile.length > 0) {
const firstKey = resolvedConfig.identityFile[0];
const defaultKeys = ['~/.ssh/id_rsa', '~/.ssh/id_ecdsa', '~/.ssh/id_ed25519', '~/.ssh/id_dsa', '~/.ssh/id_xmss'];
if (!defaultKeys.includes(firstKey)) {
defaultAuthMethod = SSHAuthMethod.KeyFile;
defaultKeyPath = firstKey;
}
}
// If no explicit key, default to SSH agent
if (!defaultAuthMethod) {
defaultAuthMethod = SSHAuthMethod.Agent;
}
// Config host has enough info — connect directly, skip all prompts
if (username) {
const config: ISSHAgentHostConfig = {
host,
port,
username,
authMethod: defaultAuthMethod,
privateKeyPath: defaultKeyPath,
name: suggestedName,
sshConfigHost: picked.hostAlias,
};
const connection = await instantiationService.invokeFunction(accessor =>
connectWithProgress(accessor, config, suggestedName!)
);
if (connection) {
await instantiationService.invokeFunction(accessor => promptForRemoteFolder(accessor, connection));
}
return;
}
} else {
const manualResult = await promptForManualHost(quickInputService);
if (!manualResult) {
return;
}
host = manualResult.host;
username = manualResult.username;
port = manualResult.port;
}
} else {
const manualResult = await promptForManualHost(quickInputService);
if (!manualResult) {
return;
}
host = manualResult.host;
username = manualResult.username;
port = manualResult.port;
}
if (!username) {
const usernameInput = await quickInputService.input({
title: localize('sshUsernameTitle', "SSH Username"),
prompt: localize('sshUsernamePrompt', "Enter the username for {0}.", host),
placeHolder: 'root',
ignoreFocusLost: true,
validateInput: async value => value.trim() ? undefined : localize('sshUsernameEmpty', "Enter a username."),
});
if (!usernameInput) {
return;
}
username = usernameInput.trim();
}
const authPicks: ISSHAuthMethodPickItem[] = [
{
method: SSHAuthMethod.Agent,
label: localize('sshAuthAgent', "SSH Agent"),
description: localize('sshAuthAgentDesc', "Use the running SSH agent for authentication"),
},
{
method: SSHAuthMethod.KeyFile,
label: localize('sshAuthKey', "Private Key File"),
description: localize('sshAuthKeyDesc', "Authenticate with a private key file"),
},
{
method: SSHAuthMethod.Password,
label: localize('sshAuthPassword', "Password"),
description: localize('sshAuthPasswordDesc', "Authenticate with a password"),
},
];
let authMethod: SSHAuthMethod;
if (defaultAuthMethod) {
authMethod = defaultAuthMethod;
} else {
const authPicked = await quickInputService.pick(authPicks, {
title: localize('sshAuthTitle', "Authentication Method"),
placeHolder: localize('sshAuthPlaceholder', "Choose how to authenticate with {0}", host),
});
if (!authPicked) {
return;
}
authMethod = authPicked.method;
}
let privateKeyPath: string | undefined;
let password: string | undefined;
if (authMethod === SSHAuthMethod.KeyFile) {
const keyPath = await quickInputService.input({
title: localize('sshKeyTitle', "Private Key Path"),
prompt: localize('sshKeyPrompt', "Enter the path to your SSH private key."),
placeHolder: '~/.ssh/id_rsa',
value: defaultKeyPath ?? '~/.ssh/id_rsa',
ignoreFocusLost: true,
validateInput: async value => value.trim() ? undefined : localize('sshKeyEmpty', "Enter a key file path."),
});
if (!keyPath) {
return;
}
privateKeyPath = keyPath.trim();
} else if (authMethod === SSHAuthMethod.Password) {
const pw = await quickInputService.input({
title: localize('sshPasswordTitle', "SSH Password"),
prompt: localize('sshPasswordPrompt', "Enter the password for {0}@{1}.", username, host),
password: true,
ignoreFocusLost: true,
validateInput: async value => value ? undefined : localize('sshPasswordEmpty', "Enter a password."),
});
if (!pw) {
return;
}
password = pw;
}
const defaultName = suggestedName ?? `${username}@${host}`;
const name = await quickInputService.input({
title: localize('sshNameTitle', "Name Remote"),
prompt: localize('sshNamePrompt', "Enter a display name for this SSH remote."),
placeHolder: localize('sshNamePlaceholder', "My Remote"),
value: defaultName,
valueSelection: [0, defaultName.length],
ignoreFocusLost: true,
validateInput: async value => value.trim() ? undefined : localize('sshNameEmpty', "Enter a name."),
});
if (!name) {
return;
}
const config: ISSHAgentHostConfig = {
host,
port,
username,
authMethod,
privateKeyPath,
password,
name: name.trim(),
};
const connection = await instantiationService.invokeFunction(accessor =>
connectWithProgress(accessor, config, host)
);
if (connection) {
await instantiationService.invokeFunction(accessor => promptForRemoteFolder(accessor, connection));
}
}
async function connectWithProgress(
accessor: ServicesAccessor,
config: ISSHAgentHostConfig,
displayHost: string,
): Promise<ISSHAgentHostConnection | undefined> {
const sshService = accessor.get(ISSHRemoteAgentHostService);
const notificationService = accessor.get(INotificationService);
const handle = notificationService.notify({
severity: Severity.Info,
message: localize('sshConnecting', "Connecting to {0} via SSH...", displayHost),
progress: { infinite: true },
});
// Build the expected connection key to filter progress events.
// Must match the key logic in the shared process service.
const expectedKey = config.sshConfigHost
? `ssh:${config.sshConfigHost}`
: `${config.username}@${config.host}:${config.port ?? 22}`;
const progressListener = sshService.onDidReportConnectProgress?.(progress => {
if (progress.connectionKey === expectedKey) {
handle.updateMessage(progress.message);
}
});
try {
const connection = await sshService.connect(config);
handle.close();
return connection;
} catch (err) {
handle.close();
notificationService.error(localize('sshConnectFailed', "Failed to connect via SSH to {0}: {1}", displayHost, String(err)));
return undefined;
} finally {
progressListener?.dispose();
}
}
/**
* After a successful SSH connection, show the remote folder picker and
* pre-select the chosen folder in the workspace picker.
*/
async function promptForRemoteFolder(
accessor: ServicesAccessor,
connection: ISSHAgentHostConnection,
): Promise<void> {
const viewsService = accessor.get(IViewsService);
const sessionsProvidersService = accessor.get(ISessionsProvidersService);
const sessionsManagementService = accessor.get(ISessionsManagementService);
// The provider is created synchronously during addSSHConnection's
// onDidChangeConnections event, so it should exist by now.
const provider = sessionsProvidersService.getProviders().find(p => p.remoteAddress === connection.localAddress);
if (!provider) {
return;
}
// Use the provider's existing browse action to show the folder picker
const browseAction = provider.browseActions[0];
if (!browseAction) {
return;
}
const workspace = await browseAction.execute();
if (!workspace) {
return;
}
sessionsManagementService.openNewSessionView();
const view = await viewsService.openView<NewChatViewPane>(SessionsViewId, true);
view?.selectWorkspace({ providerId: provider.id, workspace });
}
async function promptForManualHost(
quickInputService: IQuickInputService,
): Promise<{ host: string; username: string | undefined; port: number | undefined } | undefined> {
const validateSshHostInput = (value: string): string | undefined => {
const v = value.trim();
if (!v) {
return localize('sshHostEmpty', "Enter an SSH host.");
}
const atIdx = v.indexOf('@');
if (atIdx === 0) {
return localize('sshUsernameMissingInHost', "Enter a username before '@'.");
}
if (atIdx === v.length - 1) {
return localize('sshHostMissingAfterAt', "Enter a host name after '@'.");
}
const hostPart = atIdx !== -1 ? v.substring(atIdx + 1) : v;
if (!hostPart) {
return localize('sshHostMissingAfterAt', "Enter a host name after '@'.");
}
const colonIdx = hostPart.lastIndexOf(':');
if (colonIdx !== -1) {
const hostName = hostPart.substring(0, colonIdx);
const portStr = hostPart.substring(colonIdx + 1);
if (!hostName) {
return localize('sshHostMissingAfterAt', "Enter a host name after '@'.");
}
if (portStr) {
const portNum = Number(portStr);
if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) {
return localize('sshHostInvalidPort', "Enter a valid port number.");
}
}
}
return undefined;
};
const hostInput = await quickInputService.input({
title: localize('sshManualHostTitle', "Connect via SSH"),
prompt: localize('sshHostPrompt', "Enter the SSH host (e.g. user@hostname or user@hostname:port)."),
placeHolder: 'user@myserver.example.com',
ignoreFocusLost: true,
validateInput: async value => validateSshHostInput(value),
});
if (!hostInput) {
return undefined;
}
const trimmed = hostInput.trim();
let username: string | undefined;
let host: string;
let port: number | undefined;
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);
const portStr = hostPart.substring(colonIndex + 1);
if (portStr) {
port = Number(portStr);
}
} else {
host = hostPart;
}
return { host, username, port };
}
registerAction2(class extends Action2 {
constructor() {
super({
id: 'workbench.action.sessions.connectViaSSH',
title: localize2('connectViaSSH', "Connect to Remote Agent Host via SSH"),
category: CHAT_CATEGORY,
f1: true,
precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true),
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
await promptToConnectViaSSH(accessor);
}
});

View File

@@ -200,6 +200,7 @@ import '../workbench/contrib/policyExport/electron-browser/policyExport.contribu
// Remote Agent Host
import '../platform/agentHost/electron-browser/agentHostService.js';
import '../platform/agentHost/electron-browser/remoteAgentHostService.js';
import '../platform/agentHost/electron-browser/sshRemoteAgentHostService.js';
import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js';
import './contrib/remoteAgentHost/browser/remoteAgentHostActions.js';