diff --git a/package-lock.json b/package-lock.json index 8812ac13471..030e7e4da9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f4611267783..12b0764d166 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/remote/package-lock.json b/remote/package-lock.json index 2d56c522431..6383b020d64 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -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", diff --git a/remote/package.json b/remote/package.json index 37e0dd5354d..343c7a6a412 100644 --- a/remote/package.json +++ b/remote/package.json @@ -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" } } } diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 7ea93f3824d..033bcfd6e38 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -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)); diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index 23253ba21ea..2dee14681ea 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -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 { diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts index cf3775070bd..143b0b9a3ab 100644 --- a/src/vs/platform/agentHost/common/remoteAgentHostService.ts +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -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; } /** Metadata about a single remote connection. */ @@ -111,6 +120,9 @@ export class NullRemoteAgentHostService implements IRemoteAgentHostService { } async removeRemoteAgentHost(_address: string): Promise { } reconnect(_address: string): void { } + async addSSHConnection(): Promise { + throw new Error('Remote agent host connections are not supported in this environment.'); + } } export function parseRemoteAgentHostInput(input: string): RemoteAgentHostInputParseResult { diff --git a/src/vs/platform/agentHost/common/sshConfigParsing.ts b/src/vs/platform/agentHost/common/sshConfigParsing.ts new file mode 100644 index 00000000000..858d7fe2c7b --- /dev/null +++ b/src/vs/platform/agentHost/common/sshConfigParsing.ts @@ -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(); + 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', + }; +} diff --git a/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts b/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts new file mode 100644 index 00000000000..857e30a3122 --- /dev/null +++ b/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts @@ -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('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; + +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; +} + +/** + * 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; + + /** Progress messages during connect. */ + readonly onDidReportConnectProgress: Event; + + /** 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; + + /** + * 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; + + /** List SSH config host aliases (excluding wildcards). */ + listSSHConfigHosts(): Promise; + + /** Resolve full SSH config for a host via `ssh -G`. */ + resolveSSHConfig(host: string): Promise; + + /** + * 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; +} +/** + * 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('sshRemoteAgentHostMainService'); + +export interface ISSHRemoteAgentHostMainService { + readonly _serviceBrand: undefined; + + /** Fires when the set of active SSH connections changes. */ + readonly onDidChangeConnections: Event; + + /** Fires when a connection is closed from the shared process side. */ + readonly onDidCloseConnection: Event; + + /** Progress messages during connect (e.g. "Installing CLI..."). */ + readonly onDidReportConnectProgress: Event; + + /** Fires when a message is received from a remote agent host via the SSH relay. */ + readonly onDidRelayMessage: Event; + + /** Fires when a relay connection to a remote agent host closes. */ + readonly onDidRelayClose: Event; + + /** + * Bootstrap a remote agent host over SSH. Returns serializable + * connection info for the renderer to register. + */ + connect(config: ISSHAgentHostConfig): Promise; + + /** + * Send a message to a remote agent host through the SSH relay. + */ + relaySend(connectionId: string, message: string): Promise; + + /** + * Disconnect an SSH-bootstrapped connection by host address. + */ + disconnect(host: string): Promise; + + /** List SSH config host aliases (excluding wildcards). */ + listSSHConfigHosts(): Promise; + + /** Resolve full SSH config for a host via `ssh -G`. */ + resolveSSHConfig(host: string): Promise; + + /** + * 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; +} diff --git a/src/vs/platform/agentHost/common/state/sessionTransport.ts b/src/vs/platform/agentHost/common/state/sessionTransport.ts index 12373260187..823f06cb1b8 100644 --- a/src/vs/platform/agentHost/common/state/sessionTransport.ts +++ b/src/vs/platform/agentHost/common/state/sessionTransport.ts @@ -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; +} + +/** 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}. diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index 4e97723f9a8..30c7870bda4 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -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 { - await this._transport.connect(); + if (isClientTransport(this._transport)) { + await this._transport.connect(); + } const result = await this._sendRequest('initialize', { protocolVersion: PROTOCOL_VERSION, diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts index 504f52d2ff7..20bf4ff37cc 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts @@ -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 { + 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 { 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); diff --git a/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts new file mode 100644 index 00000000000..88eea907e56 --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts @@ -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()); + readonly onMessage = this._onMessage.event; + + private readonly _onClose = this._register(new Emitter()); + 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 + }); + } +} diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostService.ts new file mode 100644 index 00000000000..9e1930a98e8 --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostService.ts @@ -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); diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts new file mode 100644 index 00000000000..21595d6adb0 --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts @@ -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()); + readonly onDidChangeConnections: Event = this._onDidChangeConnections.event; + + readonly onDidReportConnectProgress: Event; + + private readonly _connections = new Map(); + + 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( + 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 { + 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 { + await this._mainService.disconnect(host); + } + + async listSSHConfigHosts(): Promise { + return this._mainService.listSSHConfigHosts(); + } + + async resolveSSHConfig(host: string): Promise { + return this._mainService.resolveSSHConfig(host); + } + + async reconnect(sshConfigHost: string, name: string): Promise { + 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('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()); + readonly onDidClose = this._onDidClose.event; + + private _closedByMain = false; + + constructor( + readonly config: ISSHAgentHostConnection['config'], + readonly localAddress: string, + readonly name: string, + disconnectFn: () => Promise, + ) { + 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(); + } +} diff --git a/src/vs/platform/agentHost/electron-browser/webSocketClientTransport.ts b/src/vs/platform/agentHost/electron-browser/webSocketClientTransport.ts index 2a8ce7370a2..457ef7ad2c5 100644 --- a/src/vs/platform/agentHost/electron-browser/webSocketClientTransport.ts +++ b/src/vs/platform/agentHost/electron-browser/webSocketClientTransport.ts @@ -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()); readonly onMessage = this._onMessage.event; diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts new file mode 100644 index 00000000000..ee6881ce49a --- /dev/null +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts @@ -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): 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()); + 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()); + readonly onDidChangeConnections: Event = this._onDidChangeConnections.event; + + private readonly _onDidCloseConnection = this._register(new Emitter()); + readonly onDidCloseConnection: Event = this._onDidCloseConnection.event; + + private readonly _onDidReportConnectProgress = this._register(new Emitter()); + readonly onDidReportConnectProgress: Event = this._onDidReportConnectProgress.event; + + private readonly _onDidRelayMessage = this._register(new Emitter()); + readonly onDidRelayMessage: Event = this._onDidRelayMessage.event; + + private readonly _onDidRelayClose = this._register(new Emitter()); + readonly onDidRelayClose: Event = this._onDidRelayClose.event; + + private readonly _connections = new Map(); + + constructor( + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + async connect(config: ISSHAgentHostConfig): Promise { + 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 { + for (const [key, conn] of this._connections) { + if (key === host || conn.connectionId === host) { + conn.dispose(); + return; + } + } + } + + async relaySend(connectionId: string, message: string): Promise { + for (const conn of this._connections.values()) { + if (conn.connectionId === connectionId) { + conn.relaySend(message); + return; + } + } + } + + async reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string): Promise { + 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 { + 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 { + return new Promise((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): Promise { + const seen = visited ?? new Set(); + 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 { + const connectConfig: Record = { + 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((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 { + 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(); + } +} diff --git a/src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts b/src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts new file mode 100644 index 00000000000..857bd970398 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts @@ -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'); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index 108c36789fa..de5c932de3d 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -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); diff --git a/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts b/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts new file mode 100644 index 00000000000..ca074d5c589 --- /dev/null +++ b/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts @@ -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(); + readonly onDidRelayMessage = this._onDidRelayMessage.event; + + private readonly _onDidRelayClose = new Emitter(); + readonly onDidRelayClose = this._onDidRelayClose.event; + + readonly onDidChangeConnections = Event.None; + readonly onDidCloseConnection = Event.None; + readonly onDidReportConnectProgress = Event.None as Event; + + readonly sentMessages: { connectionId: string; message: string }[] = []; + + async relaySend(connectionId: string, message: string): Promise { + this.sentMessages.push({ connectionId, message }); + } + + async connect(_config: ISSHAgentHostConfig): Promise { + throw new Error('Not implemented'); + } + async disconnect(_host: string): Promise { } + async listSSHConfigHosts(): Promise { return []; } + async resolveSSHConfig(_host: string): Promise { + throw new Error('Not implemented'); + } + async reconnect(_sshConfigHost: string, _name: string): Promise { + 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(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); + }); +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 76433cd8d92..4ab0792014b 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -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()); private readonly _providerInstances = new Map(); + private readonly _pendingSSHReconnects = new Set(); 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(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(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'], }, }, diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts index 5996c556b0d..ef9d05269f9 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -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 { + 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 { + 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 { + 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(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 { + await promptToConnectViaSSH(accessor); + } +}); diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 5286d42c6b1..b31dcc2d32f 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -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';