From fa17773fe20a00534aab11f8ba9d76b2baa0f856 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 15 Sep 2026 17:02:40 -0700 Subject: [PATCH] agentHost: support SSH ProxyCommand and ProxyJump Resolve executable proxy configuration inside the shared process, bridge ProxyCommand or ProxyJump stdio into ssh2, and preserve existing final-host authentication and verification. Add focused security, lifecycle, and cross-platform regression coverage for the new transport path. Fixes #313164.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/sshProxyTransport.ts | 189 ++++++++ .../node/sshRemoteAgentHostService.ts | 309 +++++++++++-- .../test/node/sshProxyTransport.test.ts | 142 ++++++ .../node/sshRemoteAgentHostService.test.ts | 423 +++++++++++++++++- 4 files changed, 1002 insertions(+), 61 deletions(-) create mode 100644 src/vs/platform/agentHost/node/sshProxyTransport.ts create mode 100644 src/vs/platform/agentHost/test/node/sshProxyTransport.test.ts diff --git a/src/vs/platform/agentHost/node/sshProxyTransport.ts b/src/vs/platform/agentHost/node/sshProxyTransport.ts new file mode 100644 index 00000000000000..acff40bff1ac13 --- /dev/null +++ b/src/vs/platform/agentHost/node/sshProxyTransport.ts @@ -0,0 +1,189 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { posix, win32 } from '../../../base/common/path.js'; +import { localize } from '../../../nls.js'; + +export type SSHProxyTransport = + | { readonly type: 'command'; readonly command: string } + | { readonly type: 'jump'; readonly proxyJump: string }; + +export interface ISSHProxyConnectionParameters { + readonly host: string; + readonly originalHost: string; + readonly port: number; + readonly username: string; +} + +export interface ISSHProxySpawnSpec { + readonly command: string; + readonly args: readonly string[]; + readonly shell: boolean; +} + +const safeProxyTokenValue = /^[A-Za-z0-9._:@,+-]+$/; + +export function getSSHExecutableCandidates(platform: NodeJS.Platform, pathValue: string | undefined, windowsDirectory = 'C:\\Windows'): string[] { + const path = platform === 'win32' ? win32 : posix; + const executableName = platform === 'win32' ? 'ssh.exe' : 'ssh'; + const candidates = platform === 'win32' + ? [path.join(windowsDirectory, 'System32', 'OpenSSH', executableName)] + : []; + for (const pathEntry of (pathValue ?? '').split(path.delimiter)) { + if (path.isAbsolute(pathEntry)) { + candidates.push(path.join(pathEntry, executableName)); + } + } + return [...new Set(candidates)]; +} + +export function validateSSHConfigHost(host: string): void { + if (!isSafeProxyTokenValue(host)) { + throw new Error(localize('ssh.invalidConfigHost', "The SSH config host contains characters that cannot be used safely.")); + } +} + +export function parseSSHProxyTransport(stdout: string): SSHProxyTransport | undefined { + let proxyCommand: string | undefined; + let proxyJump: string | undefined; + + for (const line of stdout.split('\n')) { + const separator = line.indexOf(' '); + if (separator === -1) { + continue; + } + + const key = line.substring(0, separator).toLowerCase(); + const value = line.substring(separator + 1).trim(); + if (!value || value.toLowerCase() === 'none') { + continue; + } + + if (key === 'proxycommand') { + if (proxyCommand !== undefined && proxyCommand !== value) { + throw new Error(localize('ssh.ambiguousProxyCommand', "The resolved SSH configuration contains multiple ProxyCommand values.")); + } + proxyCommand = value; + } else if (key === 'proxyjump') { + if (proxyJump !== undefined && proxyJump !== value) { + throw new Error(localize('ssh.ambiguousProxyJump', "The resolved SSH configuration contains multiple ProxyJump values.")); + } + proxyJump = value; + } + } + + if (proxyCommand !== undefined && proxyJump !== undefined) { + throw new Error(localize('ssh.ambiguousProxy', "The resolved SSH configuration contains both ProxyCommand and ProxyJump.")); + } + if (proxyCommand !== undefined) { + return { type: 'command', command: proxyCommand }; + } + if (proxyJump !== undefined) { + return { type: 'jump', proxyJump }; + } + return undefined; +} + +export function createSSHProxySpawnSpec(transport: SSHProxyTransport, parameters: ISSHProxyConnectionParameters): ISSHProxySpawnSpec { + if (transport.type === 'command') { + return { + command: expandSSHProxyCommand(transport.command, parameters), + args: [], + shell: true, + }; + } + + const jumps = parseProxyJumps(transport.proxyJump); + const lastJump = jumps[jumps.length - 1]; + const args: string[] = []; + if (jumps.length > 1) { + args.push('-J', jumps.slice(0, -1).join(',')); + } + args.push('-W', formatHostAndPort(parameters.host, parameters.port), '--', lastJump); + return { command: 'ssh', args, shell: false }; +} + +function expandSSHProxyCommand(command: string, parameters: ISSHProxyConnectionParameters): string { + const replacements = new Map([ + ['h', parameters.host], + ['n', parameters.originalHost], + ['p', validatePort(parameters.port)], + ['r', parameters.username], + ]); + let result = ''; + + for (let index = 0; index < command.length; index++) { + const character = command[index]; + if (character !== '%') { + result += character; + continue; + } + + const token = command[++index]; + if (token === undefined) { + throw new Error(localize('ssh.incompleteProxyToken', "The SSH ProxyCommand ends with an incomplete token.")); + } + if (token === '%') { + result += '%'; + continue; + } + + const replacement = replacements.get(token); + if (replacement === undefined) { + throw new Error(localize('ssh.unsupportedProxyToken', "The SSH ProxyCommand contains an unsupported token: %{0}.", token)); + } + if (!isSafeProxyTokenValue(replacement)) { + throw new Error(localize('ssh.unsafeProxyTokenValue', "An SSH ProxyCommand token expands to characters that cannot be used safely.")); + } + result += replacement; + } + + return result; +} + +function parseProxyJumps(proxyJump: string): string[] { + const jumps = proxyJump.split(','); + if (jumps.length === 0) { + throw new Error(localize('ssh.emptyProxyJump', "The SSH ProxyJump configuration is empty.")); + } + for (const jump of jumps) { + validateProxyJump(jump); + } + return jumps; +} + +function validateProxyJump(jump: string): void { + if (!jump || jump.startsWith('-') || /[\u0000-\u0020\u007f]/.test(jump)) { + throw new Error(localize('ssh.invalidProxyJump', "The SSH ProxyJump configuration contains an invalid host.")); + } + + const match = /^(?:(?[^@,:\s]+)@)?(?\[[^\],\s]+\]|[^,:\s]+)(?::(?\d+))?$/.exec(jump); + if (!match?.groups?.host || match.groups.host.startsWith('-')) { + throw new Error(localize('ssh.invalidProxyJump', "The SSH ProxyJump configuration contains an invalid host.")); + } + if (match.groups.port !== undefined) { + validatePort(Number(match.groups.port)); + } +} + +function formatHostAndPort(host: string, port: number): string { + validatePort(port); + if (/[\u0000-\u0020\u007f]/.test(host)) { + throw new Error(localize('ssh.invalidProxyTarget', "The resolved SSH proxy target contains invalid characters.")); + } + const formattedHost = host.includes(':') && !(host.startsWith('[') && host.endsWith(']')) ? `[${host}]` : host; + return `${formattedHost}:${port}`; +} + +function validatePort(port: number): string { + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error(localize('ssh.invalidProxyPort', "The resolved SSH proxy port is invalid.")); + } + return String(port); +} + +function isSafeProxyTokenValue(value: string): boolean { + return !!value && !value.startsWith('-') && safeProxyTokenValue.test(value); +} diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts index 6aaa253288ad30..4e06b4f20304bd 100644 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts @@ -5,15 +5,17 @@ import type WebSocket from 'ws'; import type { AnyAuthMethod, AuthenticationType, ConnectConfig } from 'ssh2'; -import { promises as fsp } from 'fs'; +import { constants as fsConstants, promises as fsp } from 'fs'; import * as os from 'os'; import * as cp from 'child_process'; +import { Duplex } from 'stream'; import { dirname, join, isAbsolute, basename } from '../../../base/common/path.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, DisposableMap, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { raceTimeout } from '../../../base/common/async.js'; import { CancellationError } from '../../../base/common/errors.js'; import { URI } from '../../../base/common/uri.js'; +import { killTree } from '../../../base/node/processes.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; @@ -73,6 +75,7 @@ import { import { ensureRemoteAgentHostCliInstalled, type IRemoteAgentHostCliInstallResult } from './remoteAgentHostCliInstaller.js'; import { parseSSHConfigHostEntries, parseSSHGOutput, stripSSHComment } from '../common/sshConfigParsing.js'; import { removeAnsiEscapeCodes } from '../../../base/common/strings.js'; +import { createSSHProxySpawnSpec, getSSHExecutableCandidates, parseSSHProxyTransport, validateSSHConfigHost, type ISSHProxySpawnSpec, type SSHProxyTransport } from './sshProxyTransport.js'; /** Minimal subset of ssh2.ClientChannel used by this module (duplex stream). */ interface SSHChannel extends NodeJS.ReadWriteStream { @@ -103,6 +106,224 @@ interface SSHClient { end(): void; } +interface ISSHConnectedClient { + readonly client: SSHClient; + readonly proxyProcess: IDisposable | undefined; +} + +interface ISSHEffectiveSSHConfig { + readonly resolved: ISSHResolvedConfig; + readonly proxyTransport: SSHProxyTransport | undefined; +} + +class SSHProxyProcess extends Disposable { + readonly socket: Duplex; + private readonly _onDidFail = this._register(new Emitter()); + readonly onDidFail = this._onDidFail.event; + + private _disposed = false; + private _exited = false; + private _failure: Error | undefined; + + get failure(): Error | undefined { + return this._failure; + } + + constructor( + _child: cp.ChildProcessWithoutNullStreams, + private readonly _terminate: (pid: number) => Promise, + private readonly _logService: ILogService, + ) { + super(); + + this.socket = Duplex.from({ readable: _child.stdout, writable: _child.stdin }); + _child.stderr.resume(); + const onSocketError = () => { }; + this.socket.on('error', onSocketError); + + const onError = () => { + this._fail(new Error(localize('ssh.proxyProcessStartFailed', "The SSH proxy process failed to start."))); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + this._exited = true; + if (this._disposed) { + return; + } + const message = code !== null + ? localize('ssh.proxyProcessExitedCode', "The SSH proxy process exited before the SSH connection closed (exit code {0}).", code) + : signal + ? localize('ssh.proxyProcessExitedSignal', "The SSH proxy process exited before the SSH connection closed (signal {0}).", signal) + : localize('ssh.proxyProcessExited', "The SSH proxy process exited before the SSH connection closed."); + this._fail(new Error(message)); + }; + _child.once('error', onError); + _child.once('exit', onExit); + + this._register(toDisposable(() => { + this._disposed = true; + _child.removeListener('error', onError); + _child.removeListener('exit', onExit); + this.socket.destroy(); + const pid = _child.pid; + if (!this._exited && pid !== undefined) { + void this._terminate(pid).catch(() => { + this._logService.warn(`${LOG_PREFIX} Failed to terminate SSH proxy process tree`); + }); + } + })); + } + + private _fail(error: Error): void { + if (this._failure || this._disposed) { + return; + } + this._failure = error; + this._onDidFail.fire(error); + this.socket.destroy(error); + } +} + +export interface ISSHProxyHostOptions { + readonly executeSSHConfig?: (sshExecutable: string, host: string) => Promise; + readonly spawn?: (spec: ISSHProxySpawnSpec, shell: string | false) => cp.ChildProcessWithoutNullStreams; + readonly terminate?: (pid: number) => Promise; + readonly resolveSSHExecutable?: () => Promise; +} + +export interface ISSHProxyHost { + resolveSSHConfig(host: string): Promise; + resolveConnectionConfig(config: ISSHAgentHostConfig): Promise; + createProxyProcess(config: ISSHAgentHostConfig): Promise; +} + +class SSHProxyHost implements ISSHProxyHost { + private _sshExecutable: Promise | undefined; + private readonly _proxyTransports = new WeakMap(); + + constructor( + private readonly _logService: ILogService, + private readonly _options: ISSHProxyHostOptions, + ) { } + + async resolveSSHConfig(host: string): Promise { + return (await this._resolveEffectiveSSHConfig(host)).resolved; + } + + async resolveConnectionConfig(config: ISSHAgentHostConfig): Promise { + if (!config.sshConfigHost) { + return config; + } + + const effective = await this._resolveEffectiveSSHConfig(config.sshConfigHost); + if (!effective.resolved.hostname) { + throw new Error(localize('ssh.resolvedHostEmpty', "The resolved SSH configuration does not contain a host name.")); + } + + const resolvedConfig: ISSHAgentHostConfig = { + ...config, + host: effective.resolved.hostname, + port: effective.resolved.port !== 22 ? effective.resolved.port : undefined, + username: effective.resolved.user ?? config.username, + privateKeyPath: effective.resolved.identityFile[0], + identityAgent: effective.resolved.identityAgent, + agentForward: config.agentForward && effective.resolved.forwardAgent ? true : undefined, + }; + if (effective.proxyTransport) { + this._proxyTransports.set(resolvedConfig, effective.proxyTransport); + } + return resolvedConfig; + } + + async createProxyProcess(config: ISSHAgentHostConfig): Promise { + const transport = this._proxyTransports.get(config); + this._proxyTransports.delete(config); + if (!transport) { + return undefined; + } + + let spec = createSSHProxySpawnSpec(transport, { + host: config.host, + originalHost: config.sshConfigHost ?? config.host, + port: config.port ?? 22, + username: config.username, + }); + if (transport.type === 'jump') { + spec = { ...spec, command: await this._getSSHExecutable() }; + } + + const shell = spec.shell + ? process.platform === 'win32' + ? join(process.env['WINDIR'] ?? 'C:\\Windows', 'System32', 'cmd.exe') + : '/bin/sh' + : false; + let child: cp.ChildProcessWithoutNullStreams; + try { + child = this._options.spawn + ? this._options.spawn(spec, shell) + : cp.spawn(spec.command, [...spec.args], { + shell, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + cwd: os.homedir(), + }); + } catch { + throw new Error(localize('ssh.proxyProcessStartFailed', "The SSH proxy process failed to start.")); + } + + return new SSHProxyProcess( + child, + this._options.terminate ?? (pid => killTree(pid, true)), + this._logService, + ); + } + + private async _resolveEffectiveSSHConfig(host: string): Promise { + validateSSHConfigHost(host); + const sshExecutable = await this._getSSHExecutable(); + const stdout = this._options.executeSSHConfig + ? await this._options.executeSSHConfig(sshExecutable, host) + : await new Promise((resolve, reject) => { + cp.execFile(sshExecutable, ['-G', '--', host], { timeout: 5000 }, (err, stdout) => { + if (err) { + reject(new Error(localize('ssh.resolveConfigFailed', "Failed to resolve the SSH configuration."))); + return; + } + resolve(stdout); + }); + }); + + return { + resolved: parseSSHGOutput(stdout), + proxyTransport: parseSSHProxyTransport(stdout), + }; + } + + private _getSSHExecutable(): Promise { + this._sshExecutable ??= this._options.resolveSSHExecutable?.() ?? this._findSSHExecutable(); + return this._sshExecutable; + } + + private async _findSSHExecutable(): Promise { + const candidates = getSSHExecutableCandidates(process.platform, process.env['PATH'], process.env['WINDIR']); + for (const candidate of candidates) { + try { + const stat = await fsp.stat(candidate); + await fsp.access(candidate, fsConstants.X_OK); + if (stat.isFile()) { + return candidate; + } + } catch { + // Continue through trusted absolute candidates. + } + } + throw new Error(localize('ssh.executableNotFound', "The OpenSSH client could not be found.")); + } +} + +export function createSSHProxyHost(logService: ILogService, options: ISSHProxyHostOptions = {}): ISSHProxyHost { + return new SSHProxyHost(logService, options); +} + const LOG_PREFIX = '[SSHRemoteAgentHost]'; /** @@ -645,6 +866,7 @@ class SSHConnection extends Disposable { /** Remote user-data path the endpoint registry was resolved against; empty for the `remoteAgentHostCommand` override path (not applicable). */ readonly userDataPath: string, readonly sshClient: SSHClient, + readonly proxyProcess: IDisposable | undefined, private readonly _relay: { send: (data: string) => void; close: () => void }, private readonly _remoteStream: SSHChannel | undefined, private readonly _logService: ILogService, @@ -662,6 +884,7 @@ class SSHConnection extends Disposable { this._relay.close(); if (!this._sshClientDetached) { this._remoteStream?.close(); + this.proxyProcess?.dispose(); sshClient.end(); } this._onDidClose.fire(); @@ -692,6 +915,7 @@ class SSHConnection extends Disposable { export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRemoteAgentHostMainService { declare readonly _serviceBrand: undefined; + private readonly _sshProxyHost: ISSHProxyHost; private readonly _onDidChangeConnections = this._register(new Emitter()); readonly onDidChangeConnections: Event = this._onDidChangeConnections.event; @@ -771,8 +995,10 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem @ILogService private readonly _logService: ILogService, @IProductService private readonly _productService: IProductService, @ITelemetryService private readonly _telemetryService: ITelemetryService, + sshProxyHost?: ISSHProxyHost, ) { super(); + this._sshProxyHost = sshProxyHost ?? createSSHProxyHost(_logService); } /** @@ -806,7 +1032,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem // never silently promote a different candidate or spawn a // duplicate standalone (requirement 7). this._logService.info(`${LOG_PREFIX} Reconnecting relay for existing SSH tunnel ${connectionKey}`); - const { sshClient, endpoint, connectionToken, serverType, instanceId, lifecycle, cliBin, cliDataDir, userDataPath } = existing; + const { sshClient, proxyProcess, endpoint, connectionToken, serverType, instanceId, lifecycle, cliBin, cliDataDir, userDataPath } = existing; // Remove from map and detach SSH client before disposing so // the old relay's close handler (conn?.dispose()) is a no-op. @@ -839,7 +1065,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem conn = new SSHConnection( config, connectionId, connectionKey, config.name, connectionToken, endpoint, serverType, instanceId, lifecycle, cliBin, cliDataDir, userDataPath, - sshClient, relay, undefined, + sshClient, proxyProcess, relay, undefined, this._logService, ); @@ -867,6 +1093,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem lifecycle: conn.lifecycle, }; } catch (err) { + proxyProcess?.dispose(); sshClient.end(); this._onDidRelayClose.fire(connectionId); this._onDidCloseConnection.fire(connectionId); @@ -892,15 +1119,20 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem this._logService.info(`${LOG_PREFIX} ${replaceRelay ? 'Reconnecting' : 'Connecting'} to ${connectionKey}`); const displayHost = config.sshConfigHost ?? `${config.username}@${config.host}`; let sshClient: SSHClient | undefined; + let proxyProcess: IDisposable | undefined; try { + config = await this._sshProxyHost.resolveConnectionConfig(config); + const reportProgress = (message: string) => { this._onDidReportConnectProgress.fire({ connectionKey, message }); }; // 1. Establish SSH connection reportProgress(localize('sshProgressConnecting', "Establishing SSH connection...")); - sshClient = await this._connectSSH(config, connectionKey); + const connectedClient = await this._connectSSH(config, connectionKey); + sshClient = connectedClient.client; + proxyProcess = connectedClient.proxyProcess; let endpoint: AgentHostEndpointAddress; let connectionToken: string | undefined; @@ -1116,6 +1348,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem cliDataDir, userDataPath, sshClient, + proxyProcess, relay, agentStream, this._logService, @@ -1149,6 +1382,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem }; } catch (err) { + proxyProcess?.dispose(); sshClient?.end(); if (!(err instanceof CancellationError)) { this._logService.error(`${LOG_PREFIX} Failed to connect to ${displayHost}`, err); @@ -1178,29 +1412,14 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem async reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string, agentForward?: boolean, userInitiated?: boolean, preferredAgentLocation?: RemoteAgentHostLocationPreference): Promise { this._logService.info(`${LOG_PREFIX} Reconnecting via SSH config host: ${sshConfigHost} (userInitiated=${userInitiated ?? true})`); - const resolved = await this.resolveSSHConfig(sshConfigHost); - - // Always use Agent auth — the auth handler will walk through the SSH - // agent and any default identities. If the user pinned a non-default - // `IdentityFile` in their ssh config, surface it as the explicit key - // so it gets tried first. - let privateKeyPath: string | undefined; - if (resolved.identityFile.length > 0 && !SSHRemoteAgentHostMainService._isDefaultKeyPath(resolved.identityFile[0])) { - privateKeyPath = resolved.identityFile[0]; - } - this._logService.info(`${LOG_PREFIX} reconnect: identityFiles=${JSON.stringify(resolved.identityFile)}, explicit key=${privateKeyPath ?? '(none)'}`); - return this.connect({ - host: resolved.hostname, - port: resolved.port !== 22 ? resolved.port : undefined, - username: resolved.user ?? sshConfigHost, + host: sshConfigHost, + username: sshConfigHost, authMethod: SSHAuthMethod.Agent, - privateKeyPath, - identityAgent: resolved.identityAgent, name, sshConfigHost, remoteAgentHostCommand, - agentForward: agentForward && resolved.forwardAgent ? true : undefined, + agentForward, userInitiated, preferredAgentLocation, }, /* replaceRelay */ true); @@ -1259,16 +1478,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem } 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); - }); - }); + return this._sshProxyHost.resolveSSHConfig(host); } private async _parseSSHConfigHosts(content: string, configDir: string, visited?: Set): Promise { @@ -1338,14 +1548,10 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem return hosts; } - private _parseSSHGOutput(stdout: string): ISSHResolvedConfig { - return parseSSHGOutput(stdout); - } - protected async _connectSSH( config: ISSHAgentHostConfig, connectionKey?: string, - ): Promise { + ): Promise { const port = config.port ?? 22; const connectConfig: ConnectConfig = { host: config.host, @@ -1484,8 +1690,20 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem ); }; - const client = await this._createSSHClient(); - return new Promise((resolve, reject) => { + const proxyProcess = await this._sshProxyHost.createProxyProcess(config); + if (proxyProcess) { + connectConfig.sock = proxyProcess.socket; + } + + let client: SSHClient; + try { + client = await this._createSSHClient(); + } catch (error) { + proxyProcess?.dispose(); + throw error; + } + + return new Promise((resolve, reject) => { let settled = false; let deadlineTimer: IHandshakeDeadlineHandle | undefined; @@ -1512,10 +1730,11 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem } settled = true; clearDeadline(); + proxyFailureListener?.dispose(); this._logService.info(`${LOG_PREFIX} SSH connection established to ${config.host}`); cancelLiveKbiRequests(); cancelLiveHostKeyRequests(); - resolve(client); + resolve({ client, proxyProcess }); }; const rejectConnect = (err: Error, endClient: boolean) => { @@ -1524,14 +1743,22 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem } settled = true; clearDeadline(); + proxyFailureListener?.dispose(); cancelLiveKbiRequests(); cancelLiveHostKeyRequests(); + proxyProcess?.dispose(); if (endClient) { client.end(); } reject(err); }; + const proxyFailureListener = proxyProcess?.onDidFail(error => rejectConnect(error, true)); + if (proxyProcess?.failure) { + rejectConnect(proxyProcess.failure, true); + return; + } + cancelConnectFromKbi = () => { this._logService.info(`${LOG_PREFIX} SSH keyboard-interactive prompt cancelled by user for ${displayHost}`); rejectConnect(new CancellationError(), true); diff --git a/src/vs/platform/agentHost/test/node/sshProxyTransport.test.ts b/src/vs/platform/agentHost/test/node/sshProxyTransport.test.ts new file mode 100644 index 00000000000000..d5f235e471ccac --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sshProxyTransport.test.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createSSHProxySpawnSpec, getSSHExecutableCandidates, parseSSHProxyTransport, validateSSHConfigHost } from '../../node/sshProxyTransport.js'; + +suite('SSH Proxy Transport', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('parses the effective proxy transport', () => { + assert.deepStrictEqual({ + command: parseSSHProxyTransport('hostname internal\nproxycommand gh codespace ssh --stdio'), + jump: parseSSHProxyTransport('hostname internal\nproxyjump user@jump.example:2222'), + commandNone: parseSSHProxyTransport('proxycommand none'), + jumpNone: parseSSHProxyTransport('proxyjump none'), + absent: parseSSHProxyTransport('hostname example.com'), + }, { + command: { type: 'command', command: 'gh codespace ssh --stdio' }, + jump: { type: 'jump', proxyJump: 'user@jump.example:2222' }, + commandNone: undefined, + jumpNone: undefined, + absent: undefined, + }); + }); + + test('rejects ambiguous proxy configuration', () => { + assert.throws(() => parseSSHProxyTransport([ + 'proxycommand ssh -W %h:%p jump', + 'proxyjump jump', + ].join('\n'))); + }); + + test('expands ProxyCommand tokens once', () => { + const spec = createSSHProxySpawnSpec( + { type: 'command', command: 'proxy %%h %%%h %h %n %p %r' }, + { host: 'internal.example', originalHost: 'work', port: 2222, username: 'alice' }, + ); + + assert.deepStrictEqual(spec, { + command: 'proxy %h %internal.example internal.example work 2222 alice', + args: [], + shell: true, + }); + }); + + test('rejects malformed, unsupported, or unsafe ProxyCommand expansions', () => { + const parameters = { host: 'internal.example', originalHost: 'work', port: 22, username: 'alice' }; + const failures = [ + () => createSSHProxySpawnSpec({ type: 'command', command: 'proxy %' }, parameters), + () => createSSHProxySpawnSpec({ type: 'command', command: 'proxy %C' }, parameters), + () => createSSHProxySpawnSpec({ type: 'command', command: 'proxy %h' }, { ...parameters, host: 'host; whoami' }), + () => createSSHProxySpawnSpec({ type: 'command', command: 'proxy %h' }, { ...parameters, host: '-oProxyCommand=whoami' }), + () => createSSHProxySpawnSpec({ type: 'command', command: 'proxy %n' }, { ...parameters, originalHost: '%PATH%' }), + () => createSSHProxySpawnSpec({ type: 'command', command: 'proxy %r' }, { ...parameters, username: 'alice\nwhoami' }), + () => createSSHProxySpawnSpec({ type: 'command', command: 'proxy %p' }, { ...parameters, port: 0 }), + ]; + + assert.deepStrictEqual(failures.map(run => { + try { + run(); + return false; + } catch { + return true; + } + }), [true, true, true, true, true, true, true]); + }); + + test('builds single and chained ProxyJump commands without a shell', () => { + const parameters = { host: 'internal.example', originalHost: 'work', port: 2222, username: 'alice' }; + assert.deepStrictEqual({ + single: createSSHProxySpawnSpec({ type: 'jump', proxyJump: 'jump.example' }, parameters), + chained: createSSHProxySpawnSpec({ type: 'jump', proxyJump: 'alice@first:2200,[2001:db8::1]:2201' }, parameters), + ipv6Target: createSSHProxySpawnSpec( + { type: 'jump', proxyJump: 'jump.example' }, + { ...parameters, host: '2001:db8::2' }, + ), + }, { + single: { + command: 'ssh', + args: ['-W', 'internal.example:2222', '--', 'jump.example'], + shell: false, + }, + chained: { + command: 'ssh', + args: ['-J', 'alice@first:2200', '-W', 'internal.example:2222', '--', '[2001:db8::1]:2201'], + shell: false, + }, + ipv6Target: { + command: 'ssh', + args: ['-W', '[2001:db8::2]:2222', '--', 'jump.example'], + shell: false, + }, + }); + }); + + test('rejects invalid ProxyJump hosts', () => { + const parameters = { host: 'internal.example', originalHost: 'work', port: 22, username: 'alice' }; + const values = ['', '-oBatchMode=no', 'user@-oBatchMode=no', 'first,,last', 'jump:not-a-port', 'unbracketed:ipv6:host']; + assert.deepStrictEqual(values.map(proxyJump => { + try { + createSSHProxySpawnSpec({ type: 'jump', proxyJump }, parameters); + return false; + } catch { + return true; + } + }), [true, true, true, true, true, true]); + }); + + test('rejects unsafe SSH config hosts', () => { + const hosts = ['work', 'user@host', '-oProxyCommand=whoami', 'host;whoami', 'host name', 'host\nname']; + assert.deepStrictEqual(hosts.map(host => { + try { + validateSSHConfigHost(host); + return true; + } catch { + return false; + } + }), [true, true, false, false, false, false]); + }); + + test('resolves OpenSSH only from trusted absolute candidates', () => { + assert.deepStrictEqual({ + posix: getSSHExecutableCandidates('darwin', '/usr/local/bin:relative::/usr/bin'), + windows: getSSHExecutableCandidates( + 'win32', + String.raw`C:\Tools;relative;.;D:\OpenSSH`, + String.raw`C:\Windows`, + ), + }, { + posix: ['/usr/local/bin/ssh', '/usr/bin/ssh'], + windows: [ + String.raw`C:\Windows\System32\OpenSSH\ssh.exe`, + String.raw`C:\Tools\ssh.exe`, + String.raw`D:\OpenSSH\ssh.exe`, + ], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts index 9143912f2b1c93..5870b1fa28bb48 100644 --- a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts @@ -5,18 +5,21 @@ import assert from 'assert'; import * as os from 'os'; +import * as cp from 'child_process'; import { DeferredPromise } from '../../../../base/common/async.js'; import { isCancellationError } from '../../../../base/common/errors.js'; import { Event } from '../../../../base/common/event.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; -import { TelemetryConfiguration } from '../../../telemetry/common/telemetry.js'; +import { ITelemetryService, TelemetryConfiguration } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { AGENT_HOST_ENDPOINT_REGISTRY_SCHEMA_VERSION, type AgentHostEndpointAddress, type IAgentHostEndpointMetadata } from '../../common/agentHostEndpointRegistry.js'; import { SSHAuthMethod, type ISSHAgentHostConfig, type ISSHConnectProgress, type ISSHEndpointSelection, type ISSHEndpointSelectionRequest, type ISSHKeyboardInteractivePrompt, type ISSHKeyboardInteractiveRequest } from '../../common/sshRemoteAgentHost.js'; -import { SSHRemoteAgentHostMainService, makeAuthHandler, type SSHAuthAttempt } from '../../node/sshRemoteAgentHostService.js'; +import { createSSHProxyHost, SSHRemoteAgentHostMainService, makeAuthHandler, type ISSHProxyHost, type SSHAuthAttempt } from '../../node/sshRemoteAgentHostService.js'; +import type { ISSHProxySpawnSpec, SSHProxyTransport } from '../../node/sshProxyTransport.js'; import type { AnyAuthMethod, AuthenticationType, ConnectConfig } from 'ssh2'; const dataFolderName = '.vscode-insiders'; @@ -232,6 +235,49 @@ class KeyboardInteractiveMockSSHClient { } } +class ReadyMockSSHClient { + connectConfig: ConnectConfig | undefined; + ended = false; + emitReady = true; + + private readonly _readyListeners: Array<() => void> = []; + private readonly _errorListeners: Array<(error: Error) => void> = []; + + on(event: string, listener: ((error: Error) => void) | (() => void)): this { + if (event === 'ready') { + this._readyListeners.push(listener as () => void); + } else if (event === 'error') { + this._errorListeners.push(listener as (error: Error) => void); + } + return this; + } + + removeListener(): this { + return this; + } + + connect(config: ConnectConfig): void { + this.connectConfig = config; + config.sock?.on('error', error => { + for (const listener of this._errorListeners) { + listener(error); + } + }); + if (!this.emitReady) { + return; + } + queueMicrotask(() => { + for (const listener of this._readyListeners) { + listener(); + } + }); + } + + end(): void { + this.ended = true; + } +} + function makeConfig(overrides?: Partial): ISSHAgentHostConfig { return { host: '10.0.0.1', @@ -242,6 +288,44 @@ function makeConfig(overrides?: Partial): ISSHAgentHostConf }; } +class TestSSHProxyHost implements ISSHProxyHost { + proxyTransport: SSHProxyTransport | undefined; + + async resolveSSHConfig() { + return { + hostname: '10.0.0.1', + port: 22, + user: 'testuser', + identityFile: [], + identityAgent: undefined, + forwardAgent: false, + userKnownHostsFiles: [], + globalKnownHostsFiles: [], + strictHostKeyChecking: undefined, + }; + } + + async resolveConnectionConfig(config: ISSHAgentHostConfig) { + if (!config.sshConfigHost) { + return config; + } + const resolved = await this.resolveSSHConfig(); + return { + ...config, + host: resolved.hostname, + port: undefined, + username: resolved.user ?? config.username, + privateKeyPath: undefined, + identityAgent: resolved.identityAgent, + agentForward: undefined, + }; + } + + async createProxyProcess(): Promise { + return undefined; + } +} + /** * Testable subclass of SSHRemoteAgentHostMainService. * Overrides the SSH/WebSocket layer so the entire connect flow runs in-process @@ -249,7 +333,25 @@ function makeConfig(overrides?: Partial): ISSHAgentHostConf */ class TestableSSHRemoteAgentHostMainService extends SSHRemoteAgentHostMainService { + private readonly _testProxyHost: TestSSHProxyHost; readonly mockClients: MockSSHClient[] = []; + readonly connectConfigs: ISSHAgentHostConfig[] = []; + readonly proxyTransports: Array = []; + proxyDisposeCount = 0; + + constructor(logService: ILogService, productService: IProductService, telemetryService: ITelemetryService) { + const proxyHost = new TestSSHProxyHost(); + super(logService, productService, telemetryService, proxyHost); + this._testProxyHost = proxyHost; + } + + get proxyTransport(): SSHProxyTransport | undefined { + return this._testProxyHost.proxyTransport; + } + + set proxyTransport(value: SSHProxyTransport | undefined) { + this._testProxyHost.proxyTransport = value; + } /** * Responses that `_connectSSH`'s MockSSHClient hands out for its exec @@ -295,11 +397,18 @@ class TestableSSHRemoteAgentHostMainService extends SSHRemoteAgentHostMainServic private readonly _relayResults: Array<{ send: (data: string) => void; close: () => void }> = []; protected override async _connectSSH( - _config: ISSHAgentHostConfig, + config: ISSHAgentHostConfig, + _connectionKey?: string, ) { + const proxyTransport = this.proxyTransport; + this.connectConfigs.push(config); + this.proxyTransports.push(proxyTransport); const client = new MockSSHClient(this.execResponses); this.mockClients.push(client); - return client as never; + return { + client: client as never, + proxyProcess: proxyTransport ? toDisposable(() => this.proxyDisposeCount++) : undefined, + }; } protected override async _startRemoteAgentHost( @@ -346,20 +455,6 @@ class TestableSSHRemoteAgentHostMainService extends SSHRemoteAgentHostMainServic return relayObj; } - override async resolveSSHConfig(_host: string): ReturnType { - return { - hostname: '10.0.0.1', - port: 22, - user: 'testuser', - identityFile: [], - identityAgent: undefined, - forwardAgent: false, - userKnownHostsFiles: [], - globalKnownHostsFiles: [], - strictHostKeyChecking: undefined, - }; - } - /** * Simulate the old (superseded) relay's WebSocket close event firing. * This calls the onClose callback of the second-to-last relay. @@ -450,6 +545,99 @@ class KeyboardInteractiveConnectTestService extends SSHRemoteAgentHostMainServic } } +class ProxyConnectTestService extends SSHRemoteAgentHostMainService { + readonly client = new ReadyMockSSHClient(); + readonly spawnSpecs: ISSHProxySpawnSpec[]; + readonly spawnShells: Array; + readonly terminatedPids: number[]; + private readonly _proxyHost: ISSHProxyHost; + private readonly _state: { childScript: string; config: ISSHAgentHostConfig | undefined; proxyTransport: SSHProxyTransport | undefined }; + + constructor(logService: ILogService, productService: IProductService, telemetryService: ITelemetryService) { + const spawnSpecs: ISSHProxySpawnSpec[] = []; + const spawnShells: Array = []; + const terminatedPids: number[] = []; + const children: cp.ChildProcessWithoutNullStreams[] = []; + const state = { + childScript: 'process.stdin.pipe(process.stdout); process.stdin.resume();', + config: undefined as ISSHAgentHostConfig | undefined, + proxyTransport: undefined as SSHProxyTransport | undefined, + }; + const childDisposables = new DisposableStore(); + const proxyHost = createSSHProxyHost(logService, { + executeSSHConfig: async () => { + if (!state.config) { + throw new Error('Missing test SSH config'); + } + const proxyLine = state.proxyTransport?.type === 'command' + ? `proxycommand ${state.proxyTransport.command}` + : state.proxyTransport?.type === 'jump' + ? `proxyjump ${state.proxyTransport.proxyJump}` + : ''; + return [ + `hostname ${state.config.host}`, + `port ${state.config.port ?? 22}`, + `user ${state.config.username}`, + proxyLine, + ].join('\n'); + }, + spawn: (spec, shell) => { + spawnSpecs.push(spec); + spawnShells.push(shell); + const child = cp.spawn(process.execPath, ['-e', state.childScript], { + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + }); + children.push(child); + childDisposables.add(toDisposable(() => child.kill('SIGKILL'))); + return child; + }, + terminate: async pid => { + terminatedPids.push(pid); + children.find(child => child.pid === pid)?.kill('SIGKILL'); + }, + resolveSSHExecutable: async () => '/absolute/ssh', + }); + super(logService, productService, telemetryService, proxyHost); + this.spawnSpecs = spawnSpecs; + this.spawnShells = spawnShells; + this.terminatedPids = terminatedPids; + this._proxyHost = proxyHost; + this._state = state; + this._register(childDisposables); + } + + get childScript(): string { + return this._state.childScript; + } + + set childScript(value: string) { + this._state.childScript = value; + } + + protected override async _createSSHClient() { + return this.client as never; + } + + protected override async _buildAuthAttempts(): Promise { + return []; + } + + async connectSSHForTest(config: ISSHAgentHostConfig, proxyTransport?: SSHProxyTransport) { + if (!proxyTransport) { + return this._connectSSH(config, 'ssh:test-host'); + } + this._state.config = config; + this._state.proxyTransport = proxyTransport; + const resolvedConfig = await this._proxyHost.resolveConnectionConfig({ + ...config, + sshConfigHost: config.sshConfigHost ?? 'test-host', + }); + return this._connectSSH(resolvedConfig, 'ssh:test-host'); + } +} + suite('SSHRemoteAgentHostMainService - connect flow', () => { const disposables = new DisposableStore(); @@ -476,6 +664,25 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { // --- Duplicate connect / reconnect on an already-connected host --- + test('does not expose executable proxy helpers over the service IPC channel', async () => { + const channel = ProxyChannel.fromService(service, disposables); + const commands = [ + '_resolveEffectiveSSHConfig', + '_resolveConnectionConfig', + '_createSSHProxyProcess', + '_spawnSSHProxyProcess', + ]; + const rejected = await Promise.all(commands.map(async command => { + try { + await channel.call(undefined, command); + return false; + } catch { + return true; + } + })); + assert.deepStrictEqual(rejected, [true, true, true, true]); + }); + test('returns existing connection on duplicate connect without replacing relay', async () => { service.execResponses = discoveryResponses([makeEndpoint({ type: 'standalone', pid: 1234, instanceId: 'inst-1' })]); @@ -512,6 +719,18 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { assert.strictEqual(service.relayCalled, 2); // fresh relay }); + test('preserves the proxy process across relay replacement and disposes it with the SSH connection', async () => { + service.proxyTransport = { type: 'command', command: 'gh codespace ssh --stdio' }; + service.execResponses = discoveryResponses([makeEndpoint({ type: 'standalone', pid: 1234, instanceId: 'inst-1' })]); + + const result = await service.connect(makeConfig({ sshConfigHost: 'myalias' })); + await service.reconnect('myalias', 'test-agent'); + assert.strictEqual(service.proxyDisposeCount, 0); + + await service.disconnect(result.connectionId); + assert.strictEqual(service.proxyDisposeCount, 1); + }); + test('reconnect does not fire onDidRelayClose for superseded relay', async () => { service.execResponses = discoveryResponses([makeEndpoint({ type: 'standalone', pid: 1234, instanceId: 'inst-1' })]); @@ -555,6 +774,61 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { assert.strictEqual(result.sshConfigHost, 'myhost'); }); + test('re-resolves configured hosts in the node service without exposing proxy configuration', async () => { + service.proxyTransport = { type: 'command', command: 'gh codespace ssh --stdio' }; + + const publicResolved = await service.resolveSSHConfig('myalias'); + const result = await service.connect(makeConfig({ + host: 'renderer.invalid', + username: 'renderer-user', + sshConfigHost: 'myalias', + remoteAgentHostCommand: '/agent', + })); + + assert.deepStrictEqual({ + publicResolved, + connectConfig: service.connectConfigs[0], + internalProxyTransport: service.proxyTransports[0], + resultConfig: result.config, + }, { + publicResolved: { + hostname: '10.0.0.1', + port: 22, + user: 'testuser', + identityFile: [], + identityAgent: undefined, + forwardAgent: false, + userKnownHostsFiles: [], + globalKnownHostsFiles: [], + strictHostKeyChecking: undefined, + }, + connectConfig: { + host: '10.0.0.1', + port: undefined, + username: 'testuser', + authMethod: SSHAuthMethod.Agent, + privateKeyPath: undefined, + identityAgent: undefined, + agentForward: undefined, + name: 'test-host', + sshConfigHost: 'myalias', + remoteAgentHostCommand: '/agent', + }, + internalProxyTransport: { type: 'command', command: 'gh codespace ssh --stdio' }, + resultConfig: { + host: '10.0.0.1', + port: undefined, + username: 'testuser', + authMethod: SSHAuthMethod.Agent, + identityAgent: undefined, + agentForward: undefined, + name: 'test-host', + sshConfigHost: 'myalias', + remoteAgentHostCommand: '/agent', + }, + }); + }); + // --- remoteAgentHostCommand override skips discovery entirely --- test('skips endpoint discovery and CLI install with remoteAgentHostCommand', async () => { @@ -1211,6 +1485,115 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { }); }); + test('passes the ProxyCommand process stream to ssh2 without affecting direct connections', async () => { + const directService = disposables.add(new ProxyConnectTestService( + new NullLogService(), + { + _serviceBrand: undefined, + quality, + dataFolderName, + } as IProductService, + NullTelemetryService, + )); + const direct = await directService.connectSSHForTest(makeConfig()); + + const proxyService = disposables.add(new ProxyConnectTestService( + new NullLogService(), + { + _serviceBrand: undefined, + quality, + dataFolderName, + } as IProductService, + NullTelemetryService, + )); + const proxied = await proxyService.connectSSHForTest( + makeConfig({ sshConfigHost: 'codespace' }), + { type: 'command', command: 'gh codespace ssh --stdio' }, + ); + if (proxied.proxyProcess) { + disposables.add(proxied.proxyProcess); + proxied.proxyProcess.dispose(); + proxied.proxyProcess.dispose(); + } + + assert.deepStrictEqual({ + directSock: directService.client.connectConfig?.sock, + directProxyProcess: direct.proxyProcess, + proxySockSet: proxyService.client.connectConfig?.sock !== undefined, + proxyProcessSet: proxied.proxyProcess !== undefined, + spawnSpecs: proxyService.spawnSpecs, + spawnShells: proxyService.spawnShells, + terminatedProcessTrees: proxyService.terminatedPids.length, + }, { + directSock: undefined, + directProxyProcess: undefined, + proxySockSet: true, + proxyProcessSet: true, + spawnSpecs: [{ + command: 'gh codespace ssh --stdio', + args: [], + shell: true, + }], + spawnShells: [process.platform === 'win32' ? `${process.env['WINDIR'] ?? 'C:\\Windows'}\\System32\\cmd.exe` : '/bin/sh'], + terminatedProcessTrees: 1, + }); + }); + + test('uses an absolute OpenSSH executable for ProxyJump', async () => { + const proxyService = disposables.add(new ProxyConnectTestService( + new NullLogService(), + { + _serviceBrand: undefined, + quality, + dataFolderName, + } as IProductService, + NullTelemetryService, + )); + const proxied = await proxyService.connectSSHForTest( + makeConfig({ host: 'internal.example', sshConfigHost: 'work' }), + { type: 'jump', proxyJump: 'jump.example' }, + ); + proxied.proxyProcess?.dispose(); + + assert.deepStrictEqual({ + spawnSpecs: proxyService.spawnSpecs, + spawnShells: proxyService.spawnShells, + }, { + spawnSpecs: [{ + command: '/absolute/ssh', + args: ['-W', 'internal.example:22', '--', 'jump.example'], + shell: false, + }], + spawnShells: [false], + }); + }); + + test('surfaces an early proxy exit without leaking the command', async () => { + const proxyService = disposables.add(new ProxyConnectTestService( + new NullLogService(), + { + _serviceBrand: undefined, + quality, + dataFolderName, + } as IProductService, + NullTelemetryService, + )); + proxyService.client.emitReady = false; + proxyService.childScript = 'process.exit(7);'; + const secretCommand = 'proxy --token super-secret'; + + await assert.rejects( + proxyService.connectSSHForTest( + makeConfig({ sshConfigHost: 'work' }), + { type: 'command', command: secretCommand }, + ), + error => error instanceof Error + && error.message.includes('exit code 7') + && !error.message.includes(secretCommand) + && !error.message.includes('super-secret'), + ); + }); + test('responding to keyboard-interactive prompt does not cancel connection attempt', async () => { let finished: readonly string[] | undefined; let cancelled = false;