src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts
1548 LOC · 1161 covered · 387 uncovered · 220 ranges · 100 concepts · 79 introducers · 62 tests
File neighbourhood
The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.
Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file
In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.
Graph controls are ready.
Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.
/*---------------------------------------------------------------------------------------------
sshRemoteAgentHostService.ts ×49
* 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 type { AnyAuthMethod, AuthenticationType, ConnectConfig } from 'ssh2';
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, DisposableMap, 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 { localize } from '../../../nls.js';
import { ILogService } from '../../log/common/log.js';
import { IProductService } from '../../product/common/productService.js';
import {
ISSHRemoteAgentHostMainService,
SSHAuthMethod,
type ISSHAgentHostConfig,
type ISSHAgentHostConfigSanitized,
type ISSHConnectProgress,
type ISSHConnectResult,
type ISSHKeyboardInteractivePrompt,
type ISSHKeyboardInteractiveRequest,
type ISSHResolvedConfig,
} from '../common/sshRemoteAgentHost.js';
import type { IRelayMessage } from '../common/relayTransport.js';
import {
buildAgentHostBaseCommand,
buildCLIDownloadUrl,
buildCleanupOldCLIsCommand,
buildFindFallbackCLICommand,
cleanupRemoteAgentHost,
extractAgentHostWebSocketURL,
findRunningAgentHost,
getRemoteCLIBin,
getRemoteCLIDataDir,
getRemoteCLIInstallRoot,
isValidFallbackCLIPath,
redactToken,
resolveRemotePlatform,
shellEscape,
writeAgentHostState,
} from './sshRemoteAgentHostHelpers.js';
import { parseSSHConfigHostEntries, parseSSHGOutput, stripSSHComment } from '../common/sshConfigParsing.js';
import { removeAnsiEscapeCodes } from '../../../base/common/strings.js';
/** 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;
removeListener(event: 'close', listener: () => void): SSHClient;
removeListener(event: 'error', listener: (err: Error) => void): SSHClient;
connect(config: ConnectConfig): 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]';
/**
* Maximum time to wait for {@link SSHRemoteAgentHostMainService._createWebSocketRelay}
* to settle on the `replaceRelay` reconnect path before giving up. A silently
* dead SSH client (TCP half-open, ssh2 keepalive hasn't fired yet) can leave
* `forwardOut`'s callback unfired, hanging the whole `connect()` call. Bounding
* this surfaces a clean failure so the renderer can clear its pending-reconnect
* flag and retry, and so the dead SSH client gets ended (purging it from the
* shared-process `_connections` map).
*
* The value is just slightly larger than ssh2's default keepalive failure
* window (`keepaliveInterval * keepaliveCountMax` ~= 15s * 3 = 45s) so that in
* practice the SSH client itself will surface its own `'close'` first when
* the network is hard-down. Tests override this to a much smaller value.
*/
const RECONNECT_RELAY_TIMEOUT_MS = 60_000;
/**
* One entry in the queue of authentication attempts handed to ssh2's
* `authHandler`. Each attempt corresponds to one of the auth method shapes
* documented at https://www.npmjs.com/package/ssh2#client-methods.
*
* `keyPath` is internal-only metadata for logging — it is stripped before the
* attempt is returned to ssh2.
*/
export type SSHAuthAttempt =
| { readonly type: 'publickey'; readonly username: string; readonly key: Buffer; readonly keyPath: string; readonly encrypted?: boolean }
| { readonly type: 'agent'; readonly username: string; readonly agent: string }
| { readonly type: 'password'; readonly username: string; readonly password: string }
| { readonly type: 'keyboard-interactive'; readonly username: string };
function describeAuthAttempt(attempt: SSHAuthAttempt): string {
sshRemoteAgentHostService.ts ×5
switch (attempt.type) {
case 'publickey': return `publickey ${attempt.keyPath}`;
case 'agent': return 'agent';
case 'password': return 'password';
case 'keyboard-interactive': return 'keyboard-interactive';
}
}
/**
* Callback invoked when the SSH server requests keyboard-interactive
* authentication. The handler must eventually call `finish` with the
* user's responses (or an empty array to fail this attempt).
*/
export type SSHKeyboardInteractivePromptHandler = (
name: string,
instructions: string,
prompts: readonly ISSHKeyboardInteractivePrompt[],
finish: (responses: readonly string[]) => void,
) => void;
export type SSHKeyPassphrasePromptHandler = (
keyPath: string,
finish: (passphrase: string | undefined) => void,
) => void;
/**
* Translate a {@link SSHAuthAttempt} into the payload shape ssh2 expects in
* its `authHandler` callback. Returns `undefined` when the attempt cannot be
* realized (currently only `keyboard-interactive` without a prompt handler).
*
* The kbi case is the one place where we still need a callback-bridge: ssh2
* calls our `prompt` with a `finish(string[])` and we hand the responses to
* `kbiHandler`. Isolating that here keeps it out of the iteration loop below.
*/
attempt: SSHAuthAttempt,
kbiHandler: SSHKeyboardInteractivePromptHandler | undefined,
keyPassphraseHandler: SSHKeyPassphrasePromptHandler | undefined,
callback: (next: AnyAuthMethod | false) => void,
): AnyAuthMethod | undefined {
switch (attempt.type) {
case 'publickey': {
// Strip our internal `keyPath` metadata before handing to ssh2.
sshRemoteAgentHostService.ts ×1
const { keyPath: _kp, encrypted: _encrypted, ...payload } = attempt;
if (attempt.encrypted) {
return undefined;
}
if (passphrase === undefined) {
callback(false);
return;
}
});
return undefined;
}
}
case 'password':
}
type: 'keyboard-interactive',
username: attempt.username,
prompt: (name, instructions, _lang, prompts, finish) => {
const normalized = prompts.map(p => ({ prompt: p.prompt, echo: p.echo ?? true }));
kbiHandler(name, instructions, normalized, responses => finish([...responses]));
},
};
}
}
/**
* `agent` is a publickey-flavored method at the SSH protocol level — servers
* advertise `publickey`, not `agent`, in `methodsLeft`. Returns true when the
* server still has the underlying protocol method on offer.
*/
function isMethodAllowedByServer(attempt: SSHAuthAttempt, methodsLeft: AuthenticationType[] | null): boolean {
sshRemoteAgentHostService.ts ×5
if (!methodsLeft) {
}
const protocolMethod: AuthenticationType = attempt.type === 'agent' ? 'publickey' : attempt.type;
sshRemoteAgentHostService.ts ×5
return methodsLeft.includes(protocolMethod);
}
/**
* Build an ssh2 `authHandler` callback that walks the given attempts in order,
* filtering by the server-advertised `methodsLeft` when ssh2 provides one.
* Returns `false` when the queue is exhausted, which causes ssh2 to surface
* an authentication failure to the caller.
*
* `kbiHandler` (when provided) is invoked by ssh2 if the server picks the
* `keyboard-interactive` attempt, and is responsible for collecting
* responses (e.g. by prompting the user).
*/
export function makeAuthHandler(
logService: ILogService,
kbiHandler?: SSHKeyboardInteractivePromptHandler,
keyPassphraseHandler?: SSHKeyPassphrasePromptHandler,
): (methodsLeft: AuthenticationType[] | null, partialSuccess: boolean, callback: (next: AnyAuthMethod | false) => void) => void {
let index = 0;
return (methodsLeft, _partialSuccess, callback) => {
while (index < attempts.length) {
const attempt = attempts[index++];
if (!isMethodAllowedByServer(attempt, methodsLeft)) {
logService.info(`${LOG_PREFIX} Skipping ${describeAuthAttempt(attempt)} — server only allows ${methodsLeft!.join(', ')}`);
sshRemoteAgentHostService.ts ×1
continue;
}
const method = toAuthMethod(attempt, kbiHandler, keyPassphraseHandler, callback);
sshRemoteAgentHostService.ts ×5
if (!method) {
if (attempt.type === 'publickey' && attempt.encrypted && keyPassphraseHandler) {
sshRemoteAgentHostService.ts ×1
logService.info(`${LOG_PREFIX} Trying auth: ${describeAuthAttempt(attempt)}`);
sshRemoteAgentHostService.ts ×4
return;
}
logService.warn(`${LOG_PREFIX} ${describeAuthAttempt(attempt)} skipped: no prompt handler available`);
sshRemoteAgentHostService.ts ×2
continue;
}
logService.info(`${LOG_PREFIX} Trying auth: ${describeAuthAttempt(attempt)}`);
sshRemoteAgentHostService.ts ×1
callback(method);
return;
}
logService.info(`${LOG_PREFIX} No more auth methods to try; giving up`);
sshRemoteAgentHostService.ts ×1
callback(false);
}
function readSSHString(buffer: Buffer, offset: number): { value: string; offset: number } | undefined {
sshRemoteAgentHostService.ts ×5
if (offset + 4 > buffer.length) {
return undefined;
}
const valueOffset = offset + 4;
const nextOffset = valueOffset + length;
if (nextOffset > buffer.length) {
return undefined;
}
return { value: buffer.toString('utf8', valueOffset, nextOffset), offset: nextOffset };
sshRemoteAgentHostService.ts ×5
}
const text = key.toString('utf8');
if (/-----BEGIN ENCRYPTED PRIVATE KEY-----/.test(text) || /Proc-Type:\s*4,ENCRYPTED/i.test(text)) {
return true;
}
const openSSHKey = /-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END OPENSSH PRIVATE KEY-----/.exec(text);
sshRemoteAgentHostService.ts ×4
if (!openSSHKey) {
}
const data = Buffer.from(openSSHKey[1].replace(/\s+/g, ''), 'base64');
sshRemoteAgentHostService.ts ×5
const magic = Buffer.from('openssh-key-v1\0', 'utf8');
if (data.length < magic.length || !data.subarray(0, magic.length).equals(magic)) {
sshRemoteAgentHostService.ts ×4
return false;
}
return !!cipher && cipher.value !== 'none';
function sshExec(client: SSHClient, command: string, opts?: { ignoreExitCode?: boolean }): Promise<{ stdout: string; stderr: string; code: number }> {
sshRemoteAgentHostService.ts ×15
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;
}
if (error) {
reject(error);
return;
}
reject(new Error(`SSH command failed (exit ${code}): ${command}\nstderr: ${stderr}`));
sshRemoteAgentHostService.ts ×5
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));
});
});
}
/** Create a bound exec function for the given SSH client. */
function bindSshExec(client: SSHClient): (command: string, opts?: { ignoreExitCode?: boolean }) => Promise<{ stdout: string; stderr: string; code: number }> {
sshRemoteAgentHostService.ts ×15
return (command, opts) => sshExec(client, command, opts);
}
function startRemoteAgentHost(
client: SSHClient,
logService: ILogService,
cliBin: string | undefined,
cliDataDir: string | undefined,
commandOverride?: string,
): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> {
return new Promise((resolve, reject) => {
if (!commandOverride && (!cliBin || !cliDataDir)) {
reject(new Error(`${LOG_PREFIX} startRemoteAgentHost requires either a cliBin+cliDataDir pair or a commandOverride`));
return;
}
const baseCmd = commandOverride ?? buildAgentHostBaseCommand(cliBin!, cliDataDir!);
// 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).
// Echo the PID so we can record it for process reuse detection.
const cmd = `bash -l -c ${shellEscape(`echo VSCODE_PID=$$ && exec ${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 = '';
let pid: number | undefined;
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 checkForOutput = () => {
const clean = removeAnsiEscapeCodes(outputBuf);
if (pid === undefined) {
const pidMatch = clean.match(/VSCODE_PID=(\d+)/);
if (pidMatch) {
pid = parseInt(pidMatch[1], 10);
logService.info(`${LOG_PREFIX} Remote agent host PID: ${pid}`);
}
}
if (!resolved) {
const match = extractAgentHostWebSocketURL(clean);
if (match) {
resolved = true;
clearTimeout(timeout);
logService.info(`${LOG_PREFIX} Remote agent host listening on port ${match.port}`);
resolve({ port: match.port, connectionToken: match.token, pid, stream });
}
}
};
stream.stderr.on('data', (data: Buffer) => {
const text = data.toString();
outputBuf += text;
logService.trace(`${LOG_PREFIX} remote stderr: ${redactToken(text.trimEnd())}`);
checkForOutput();
});
stream.on('data', (data: Buffer) => {
const text = data.toString();
outputBuf += text;
logService.trace(`${LOG_PREFIX} remote stdout: ${redactToken(text.trimEnd())}`);
checkForOutput();
});
stream.on('error', (streamErr: Error) => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(streamErr);
}
});
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(
nativeRequire: NodeJS.Require,
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 = nativeRequire('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 {
sshRemoteAgentHostService.ts ×6
const { password: _p, privateKeyPath: _k, ...sanitized } = config;
return sanitized;
}
/**
* State for a single active SSH relay connection.
* Immutable and dispose-once — follows the same pattern as TunnelConnection.
* On reconnect, the old SSHConnection is disposed and a fresh one is created;
* the SSH client can be detached first so only the WebSocket relay is torn down.
*/
class SSHConnection extends Disposable {
private readonly _onDidClose = new Emitter<void>();
readonly onDidClose = this._onDidClose.event;
readonly config: ISSHAgentHostConfigSanitized;
private _closed = false;
private _sshClientDetached = false;
private readonly _sshCloseListener = () => {
this._logService.info(`${LOG_PREFIX} SSH client closed for connection ${this.connectionId} (address ${this.address}); disposing connection`);
sshRemoteAgentHostService.ts ×1
this.dispose();
};
this._logService.info(`${LOG_PREFIX} SSH client error for connection ${this.connectionId} (address ${this.address}): ${err instanceof Error ? err.message : String(err)}; disposing connection`);
sshRemoteAgentHostService.ts ×1
this.dispose();
};
constructor(
readonly connectionId: string,
readonly address: string,
readonly name: string,
readonly connectionToken: string | undefined,
readonly remotePort: number,
readonly sshClient: SSHClient,
private readonly _relay: { send: (data: string) => void; close: () => void },
private readonly _remoteStream: SSHChannel | undefined,
private readonly _logService: ILogService,
) {
super();
this.config = sanitizeConfig(fullConfig);
// Register cleanup first so it fires _onDidClose *before* the Emitter is disposed.
this._register(toDisposable(() => {
if (this._closed) {
return;
}
this._relay.close();
if (!this._sshClientDetached) {
sshClient.end();
}
}));
this._register(this._onDidClose);
sshClient.on('close', this._sshCloseListener);
sshClient.on('error', this._sshErrorListener);
}
/**
* Detach the SSH client from this connection so that `dispose()`
* only closes the WebSocket relay without ending the SSH session.
* Also removes event listeners from the SSH client so the old
* connection object is not retained by the shared client.
*/
detachSshClient(): void {
this.sshClient.removeListener('close', this._sshCloseListener);
this.sshClient.removeListener('error', this._sshErrorListener);
}
relaySend(data: string): void {
}
export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRemoteAgentHostMainService {
declare readonly _serviceBrand: undefined;
private readonly _onDidChangeConnections = this._register(new Emitter<void>());
readonly onDidChangeConnections: Event<void> = this._onDidChangeConnections.event;
private readonly _onDidCloseConnection = this._register(new Emitter<string>());
readonly onDidCloseConnection: Event<string> = this._onDidCloseConnection.event;
private readonly _onDidReportConnectProgress = this._register(new Emitter<ISSHConnectProgress>());
readonly onDidReportConnectProgress: Event<ISSHConnectProgress> = this._onDidReportConnectProgress.event;
private readonly _onDidRelayMessage = this._register(new Emitter<IRelayMessage>());
readonly onDidRelayMessage: Event<IRelayMessage> = this._onDidRelayMessage.event;
private readonly _onDidRelayClose = this._register(new Emitter<string>());
readonly onDidRelayClose: Event<string> = this._onDidRelayClose.event;
private readonly _onDidRequestKeyboardInteractive = this._register(new Emitter<ISSHKeyboardInteractiveRequest>());
readonly onDidRequestKeyboardInteractive: Event<ISSHKeyboardInteractiveRequest> = this._onDidRequestKeyboardInteractive.event;
private readonly _onDidCancelKeyboardInteractive = this._register(new Emitter<string>());
readonly onDidCancelKeyboardInteractive: Event<string> = this._onDidCancelKeyboardInteractive.event;
/**
* Pending keyboard-interactive prompts awaiting a response from the renderer.
* Keyed by `requestId`. Each entry can either finish the ssh2 prompt with
* responses or cancel the owning connect attempt when the user dismisses it.
*/
private readonly _pendingKbiRequests = new Map<string, { finish: (responses: readonly string[]) => void; cancelConnect: () => void }>();
private _kbiRequestCounter = 0;
private readonly _connections = this._register(new DisposableMap<string, SSHConnection>());
private _nativeRequire: NodeJS.Require | undefined;
/**
* Override hook for tests to shorten the relay-creation timeout used on
* the `replaceRelay` reconnect path. See {@link RECONNECT_RELAY_TIMEOUT_MS}.
*/
protected relayCreationTimeoutMs: number = RECONNECT_RELAY_TIMEOUT_MS;
constructor(
@IProductService private readonly _productService: IProductService,
) {
super();
}
/**
* Lazily load a `require` function for native modules (`ssh2`, `ws`).
* Uses a dynamic `import('node:module')` so the module is only resolved
* when actually needed at runtime — not at file-load time. This matters
* because tests override the methods that call this and never trigger
* the import, avoiding issues with Electron's ESM loader which cannot
* resolve `node:` specifiers.
*/
private async _getNativeRequire(): Promise<NodeJS.Require> {
if (!this._nativeRequire) {
const nodeModule = await import('node:module');
this._nativeRequire = nodeModule.createRequire(import.meta.url);
}
return this._nativeRequire;
}
async connect(config: ISSHAgentHostConfig, replaceRelay?: boolean): Promise<ISSHConnectResult> {
const existing = this._connections.get(connectionKey);
if (existing) {
// the same dispose-and-recreate pattern as TunnelAgentHostMainService.
// The SSH client is detached so only the WebSocket relay is closed.
this._logService.info(`${LOG_PREFIX} Reconnecting relay for existing SSH tunnel ${connectionKey}`);
const { sshClient, remotePort, connectionToken } = existing;
// Remove from map and detach SSH client before disposing so
// the old relay's close handler (conn?.dispose()) is a no-op.
this._connections.deleteAndLeak(connectionKey);
existing.detachSshClient();
existing.dispose();
// Create fresh relay and connection. If relay creation fails,
// clean up the detached SSH client so it doesn't leak.
const connectionId = connectionKey;
try {
let conn: SSHConnection | undefined; // eslint-disable-line prefer-const
// Bound the relay creation: a silently dead SSH client
// (TCP half-open, ssh2 keepalive hasn't fired yet) can
// leave forwardOut's callback unfired, hanging the whole
// promise chain. raceTimeout returns undefined on timeout.
const timeoutMs = this.relayCreationTimeoutMs;
const relay = await raceTimeout(
this._createWebSocketRelay(
sshClient, '127.0.0.1', remotePort, connectionToken,
(data: string) => this._onDidRelayMessage.fire({ connectionId, data }),
() => { conn?.dispose(); },
),
timeoutMs,
);
throw new Error(`SSH relay creation timed out after ${timeoutMs}ms (SSH client appears unresponsive)`);
sshRemoteAgentHostService.ts ×1
}
conn = new SSHConnection(
config, connectionId, connectionKey, config.name,
connectionToken, remotePort, sshClient, relay, undefined,
this._logService,
);
Event.once(conn.onDidClose)(() => {
if (this._connections.get(connectionKey) === conn) {
this._connections.deleteAndDispose(connectionKey);
this._onDidRelayClose.fire(connectionId);
this._onDidCloseConnection.fire(connectionId);
this._onDidChangeConnections.fire();
}
});
this._connections.set(connectionKey, conn);
return {
connectionId: conn.connectionId,
address: conn.address,
name: conn.name,
connectionToken: conn.connectionToken,
config: conn.config,
sshConfigHost: config.sshConfigHost,
};
this._onDidRelayClose.fire(connectionId);
this._onDidCloseConnection.fire(connectionId);
this._onDidChangeConnections.fire();
throw err;
}
return {
connectionId: existing.connectionId,
address: existing.address,
name: existing.name,
connectionToken: existing.connectionToken,
config: existing.config,
sshConfigHost: config.sshConfigHost,
};
}
this._logService.info(`${LOG_PREFIX} ${replaceRelay ? 'Reconnecting' : 'Connecting'} to ${connectionKey}`);
let sshClient: SSHClient | undefined;
try {
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);
let cliBin: string | undefined;
let cliResolved = false;
// Resolve the remote CLI lazily: platform detection and CLI
// install/refresh only run when we're actually about to spawn
// an agent host. Reconnects that reuse a live AH via the
// lockfile skip this work entirely, since the running AH was
// spawned from whatever CLI was current at the time.
const ensureCliResolved = async (): Promise<void> => {
return;
}
if (config.remoteAgentHostCommand) {
this._logService.info(`${LOG_PREFIX} Using custom agent host command: ${config.remoteAgentHostCommand}`);
sshRemoteAgentHostService.ts ×1
return;
}
const { stdout: unameS } = await sshExec(sshClient!, 'uname -s');
sshRemoteAgentHostService.ts ×5
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}`);
sshRemoteAgentHostService.ts ×5
reportProgress(localize('sshProgressInstallingCLI', "Checking remote CLI installation..."));
cliBin = await this._ensureCLIInstalled(sshClient!, platform, reportProgress);
// 2. Check for an already-running agent host on the remote first.
// This prevents accumulating orphaned processes when the SSH
// connection drops and we reconnect — and avoids paying for
// platform detection + CLI install on every reconnect.
let remoteHost: string = '127.0.0.1';
let remotePort: number | undefined;
let connectionToken: string | undefined;
let agentStream: SSHChannel | undefined;
reportProgress(localize('sshProgressCheckingAgent', "Checking for existing agent host..."));
const exec = bindSshExec(sshClient);
const existingAH = await findRunningAgentHost(exec, this._logService, this._serverDataFolderName, this._quality);
if (existingAH.kind === 'compatible') {
remotePort = existingAH.port;
connectionToken = existingAH.connectionToken;
}
if (remotePort === undefined) {
await ensureCliResolved();
// 4. Start agent-host and capture port/token
reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host..."));
const result = await this._startRemoteAgentHost(sshClient, cliBin, getRemoteCLIDataDir(this._serverDataFolderName), config.remoteAgentHostCommand);
remotePort = result.port;
connectionToken = result.connectionToken;
agentStream = result.stream;
// Record state for future reuse
await writeAgentHostState(exec, this._logService, this._serverDataFolderName, this._quality, result.pid, remotePort, connectionToken);
}
// 6. Connect to remote agent host via WebSocket relay (no local TCP port)
reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host..."));
const connectionId = connectionKey;
let conn: SSHConnection | undefined; // eslint-disable-line prefer-const
let relay: { send: (data: string) => void; close: () => void };
try {
relay = await this._createWebSocketRelay(
sshClient, remoteHost, remotePort, connectionToken,
(data: string) => this._onDidRelayMessage.fire({ connectionId, data }),
() => { conn?.dispose(); },
);
} catch (relayErr) {
}
// The reused agent host is not connectable — kill it and start fresh.
sshRemoteAgentHostService.ts ×2
// Resolve the CLI now (we skipped it on the reuse path).
const relayErrorMessage = relayErr instanceof Error ? relayErr.message : String(relayErr);
sshRemoteAgentHostService.ts ×2
this._logService.warn(`${LOG_PREFIX} Failed to connect to reused agent host on ${remoteHost}:${remotePort}: ${relayErrorMessage}. Starting fresh`);
await cleanupRemoteAgentHost(exec, this._logService, this._serverDataFolderName, this._quality);
reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host..."));
const result = await this._startRemoteAgentHost(sshClient, cliBin, getRemoteCLIDataDir(this._serverDataFolderName), config.remoteAgentHostCommand);
remoteHost = '127.0.0.1';
remotePort = result.port;
connectionToken = result.connectionToken;
agentStream = result.stream;
await writeAgentHostState(exec, this._logService, this._serverDataFolderName, this._quality, result.pid, remotePort, connectionToken);
reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host..."));
relay = await this._createWebSocketRelay(
sshClient, remoteHost, remotePort, connectionToken,
(data: string) => this._onDidRelayMessage.fire({ connectionId, data }),
() => { conn?.dispose(); },
);
}
// 7. Create connection object
const address = connectionKey;
conn = new SSHConnection(
config,
connectionId,
address,
config.name,
connectionToken,
remotePort,
sshClient,
relay,
agentStream,
this._logService,
);
Event.once(conn.onDidClose)(() => {
if (this._connections.get(connectionKey) === conn) {
this._onDidRelayClose.fire(connectionId);
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,
};
throw err;
}
async disconnect(host: string): Promise<void> {
if (key === host || conn.connectionId === host) {
conn.dispose();
return;
}
}
}
async relaySend(connectionId: string, message: string): Promise<void> {
if (conn.connectionId === connectionId) {
return;
}
}
async reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string, agentForward?: boolean): Promise<ISSHConnectResult> {
this._logService.info(`${LOG_PREFIX} Reconnecting via SSH config host: ${sshConfigHost}`);
sshRemoteAgentHostService.ts ×2
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)'}`);
sshRemoteAgentHostService.ts ×2
return this.connect({
host: resolved.hostname,
port: resolved.port !== 22 ? resolved.port : undefined,
username: resolved.user ?? sshConfigHost,
authMethod: SSHAuthMethod.Agent,
privateKeyPath,
identityAgent: resolved.identityAgent,
name,
sshConfigHost,
remoteAgentHostCommand,
agentForward: agentForward && resolved.forwardAgent ? true : undefined,
}, /* replaceRelay */ true);
}
async listSSHConfigHosts(): Promise<string[]> {
const configPath = join(os.homedir(), '.ssh', 'config');
try {
const content = await fsp.readFile(configPath, 'utf-8');
return this._parseSSHConfigHosts(content, dirname(configPath));
} catch {
this._logService.info(`${LOG_PREFIX} Could not read SSH config at ${configPath}`);
return [];
}
}
async ensureUserSSHConfig(): Promise<URI> {
const sshDir = join(os.homedir(), '.ssh');
const configPath = join(sshDir, 'config');
const isPosix = process.platform !== 'win32';
try {
await fsp.mkdir(sshDir, { recursive: true, mode: isPosix ? 0o700 : undefined });
} catch (err) {
this._logService.warn(`${LOG_PREFIX} Failed to ensure ~/.ssh directory: ${err}`);
throw err;
}
try {
await fsp.access(configPath);
} catch {
try {
const handle = await fsp.open(configPath, 'a', isPosix ? 0o600 : undefined);
await handle.close();
} catch (err) {
this._logService.warn(`${LOG_PREFIX} Failed to create ${configPath}: ${err}`);
throw err;
}
}
return URI.file(configPath);
}
async listSSHConfigFiles(): Promise<URI[]> {
const isWindows = process.platform === 'win32';
const userConfigPath = join(os.homedir(), '.ssh', 'config');
const systemConfigPath = isWindows
? join(process.env['ProgramData'] ?? 'C:\\ProgramData', 'ssh', 'ssh_config')
: '/etc/ssh/ssh_config';
const result: URI[] = [URI.file(userConfigPath)];
try {
await fsp.access(systemConfigPath);
result.push(URI.file(systemConfigPath));
} catch {
// system config file does not exist — skip
}
return result;
}
async resolveSSHConfig(host: string): Promise<ISSHResolvedConfig> {
return new Promise<ISSHResolvedConfig>((resolve, reject) => {
cp.execFile('ssh', ['-G', host], { timeout: 5000 }, (err, stdout) => {
if (err) {
reject(new Error(`${LOG_PREFIX} ssh -G failed for ${host}: ${err.message}`));
return;
}
const config = this._parseSSHGOutput(stdout);
resolve(config);
});
});
}
private async _parseSSHConfigHosts(content: string, configDir: string, visited?: Set<string>): Promise<string[]> {
const seen = visited ?? new Set<string>();
const hosts: string[] = [];
// Extract hosts from this file directly
hosts.push(...parseSSHConfigHostEntries(content));
// Follow Include directives
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const includeMatch = trimmed.match(/^Include\s+(.+)$/i);
if (!includeMatch) {
continue;
}
const rawValue = stripSSHComment(includeMatch[1]);
const patterns = rawValue.split(/\s+/).filter(Boolean);
for (const rawPattern of patterns) {
const pattern = rawPattern.replace(/^~/, os.homedir());
const resolvedPattern = isAbsolute(pattern) ? pattern : join(configDir, pattern);
if (seen.has(resolvedPattern)) {
continue;
}
seen.add(resolvedPattern);
try {
const stat = await fsp.stat(resolvedPattern);
if (stat.isDirectory()) {
const files = await fsp.readdir(resolvedPattern);
for (const file of files) {
try {
const sub = await fsp.readFile(join(resolvedPattern, file), 'utf-8');
hosts.push(...await this._parseSSHConfigHosts(sub, resolvedPattern, seen));
} catch { /* skip unreadable files */ }
}
} else {
const sub = await fsp.readFile(resolvedPattern, 'utf-8');
hosts.push(...await this._parseSSHConfigHosts(sub, dirname(resolvedPattern), seen));
}
} catch {
const dir = dirname(resolvedPattern);
const base = basename(resolvedPattern);
if (base.includes('*')) {
try {
const files = await fsp.readdir(dir);
for (const file of files) {
const regex = new RegExp('^' + base.replace(/\*/g, '.*') + '$');
if (regex.test(file)) {
try {
const sub = await fsp.readFile(join(dir, file), 'utf-8');
hosts.push(...await this._parseSSHConfigHosts(sub, dir, seen));
} catch { /* skip */ }
}
}
} catch { /* skip unreadable dirs */ }
}
}
}
}
return hosts;
}
private _parseSSHGOutput(stdout: string): ISSHResolvedConfig {
return parseSSHGOutput(stdout);
}
protected async _connectSSH(
connectionKey?: string,
): Promise<SSHClient> {
const connectConfig: ConnectConfig = {
host: config.host,
port: config.port ?? 22,
username: config.username,
readyTimeout: 30_000,
keepaliveInterval: 15_000,
};
const attempts = await this._buildAuthAttempts(config);
this._logService.info(`${LOG_PREFIX} Built ${attempts.length} auth attempt(s): ${attempts.map(a => describeAuthAttempt(a)).join(', ')}`);
const displayHost = config.sshConfigHost ?? `${config.username}@${config.host}`;
// Track requestIds we created during this connect so we can fire
// onDidCancelKeyboardInteractive for any still-pending prompts when
// the connect attempt fails or completes.
const liveKbiRequests = new Set<string>();
let cancelConnectFromKbi: (() => void) | undefined;
const kbiHandler: SSHKeyboardInteractivePromptHandler | undefined = attempts.some(a => a.type === 'keyboard-interactive')
? (name, instructions, prompts, finish) => {
const requestId = this._handleKeyboardInteractive(connectionKey ?? displayHost, displayHost, config.username, name, instructions, prompts, finish, () => cancelConnectFromKbi?.());
liveKbiRequests.add(requestId);
}
: undefined;
const keyPassphraseHandler: SSHKeyPassphrasePromptHandler | undefined = attempts.some(a => a.type === 'publickey' && a.encrypted)
sshRemoteAgentHostService.ts ×8
? (keyPath, finish) => {
const requestId = this._handleKeyboardInteractive(
connectionKey ?? displayHost,
displayHost,
config.username,
localize('sshKeyPassphraseName', "SSH Key Passphrase"),
'',
[{ prompt: localize('sshKeyPassphrasePrompt', "Enter passphrase for SSH key {0}.", keyPath), echo: false }],
responses => finish(responses[0]),
() => cancelConnectFromKbi?.(),
);
liveKbiRequests.add(requestId);
}
// Cast: the ssh2 @types don't model `false` (give-up) for the
// callback nor `null` for the first invocation's `methodsLeft`,
// even though the runtime supports both per the ssh2 docs.
connectConfig.authHandler = makeAuthHandler(attempts, this._logService, kbiHandler, keyPassphraseHandler) as unknown as ConnectConfig['authHandler'];
const cancelLiveKbiRequests = () => {
for (const requestId of liveKbiRequests) {
// Pull the pending finish callback (if any) and invoke it with
// empty responses so ssh2 stops waiting on this attempt — without
// this, ssh2 hangs until `readyTimeout` elapses when a connect
// attempt is aborted mid-prompt. The renderer also gets notified
// so it can dismiss any open quick-input UI.
const pending = this._pendingKbiRequests.get(requestId);
this._pendingKbiRequests.delete(requestId);
this._onDidCancelKeyboardInteractive.fire(requestId);
pending?.finish([]);
}
liveKbiRequests.clear();
};
if (config.agentForward) {
const agentSock = this._getAgentSocket(config);
if (agentSock) {
// ssh2 needs `connectConfig.agent` set so it knows which local
// agent socket to forward to. Without it, agent forwarding is a
// no-op even if `agentForward: true` is set.
connectConfig.agent = agentSock;
connectConfig.agentForward = true;
this._logService.info(`${LOG_PREFIX} SSH agent forwarding enabled`);
} else {
this._logService.warn(`${LOG_PREFIX} SSH agent forwarding requested, but no SSH agent endpoint is available; agent forwarding disabled`);
}
}
const client = await this._createSSHClient();
return new Promise<SSHClient>((resolve, reject) => {
let settled = false;
const resolveConnect = () => {
if (settled) {
return;
}
settled = true;
this._logService.info(`${LOG_PREFIX} SSH connection established to ${config.host}`);
cancelLiveKbiRequests();
resolve(client);
};
const rejectConnect = (err: Error, endClient: boolean) => {
if (settled) {
return;
}
settled = true;
cancelLiveKbiRequests();
if (endClient) {
client.end();
}
reject(err);
};
cancelConnectFromKbi = () => {
this._logService.info(`${LOG_PREFIX} SSH keyboard-interactive prompt cancelled by user for ${displayHost}`);
rejectConnect(new CancellationError(), true);
};
client.on('ready', () => {
resolveConnect();
client.on('error', (err: Error) => {
this._logService.error(`${LOG_PREFIX} SSH connection error: ${err.message}`);
rejectConnect(err, false);
});
client.connect(connectConfig);
});
}
protected async _createSSHClient(): Promise<SSHClient> {
const nativeRequire = await this._getNativeRequire();
const ssh2Module = nativeRequire('ssh2') as { Client: new () => unknown };
return new ssh2Module.Client() as SSHClient;
}
/**
* Build the ordered list of authentication attempts to feed to ssh2's
* `authHandler`. In `Agent` mode we try the configured agent first (so a
* loaded identity short-circuits before we ever touch an encrypted key
* file), then any non-default explicit `IdentityFile`, then each readable
* default identity in turn. A host that accepts `~/.ssh/id_rsa` still
* works even if the agent doesn't have it loaded — without needing an
* explicit `IdentityFile` entry in `~/.ssh/config`.
*/
protected async _buildAuthAttempts(config: ISSHAgentHostConfig): Promise<SSHAuthAttempt[]> {
const username = config.username;
switch (config.authMethod) {
case SSHAuthMethod.Agent: {
// Try the agent first: if it has any of the configured identities
sshRemoteAgentHostService.ts ×6
// loaded, auth succeeds without ever touching on-disk keys. This
// matches OpenSSH's IdentityAgent semantics and avoids an
// unnecessary passphrase prompt when an encrypted key file is
// configured but the agent already holds its unlocked copy.
const agentSock = this._getAgentSocket(config);
if (agentSock) {
attempts.push({ type: 'agent', username, agent: agentSock });
sshRemoteAgentHostService.ts ×1
}
const explicitIsDefault = explicitKeyPath !== undefined && SSHRemoteAgentHostMainService._isDefaultKeyPath(explicitKeyPath);
if (explicitKeyPath && !explicitIsDefault) {
const explicit = await this._readKeyFileIfExists(explicitKeyPath);
sshRemoteAgentHostService.ts ×1
if (explicit) {
attempts.push({ type: 'publickey', username, key: explicit, keyPath: explicitKeyPath, ...(isEncryptedPrivateKey(explicit) ? { encrypted: true } : undefined) });
}
}
for (const keyPath of SSHRemoteAgentHostMainService._defaultKeyPaths) {
sshRemoteAgentHostService.ts ×6
const contents = await this._readKeyFileIfExists(keyPath);
if (contents) {
attempts.push({ type: 'publickey', username, key: contents, keyPath, ...(isEncryptedPrivateKey(contents) ? { encrypted: true } : undefined) });
sshRemoteAgentHostService.ts ×1
}
// Final fallback: keyboard-interactive (typically a password prompt).
// Only meaningful if the server advertises it; the auth handler
// will skip it otherwise. The prompt is forwarded to the renderer
// via {@link onDidRequestKeyboardInteractive}.
attempts.push({ type: 'keyboard-interactive', username });
break;
}
// KeyFile mode has no fallbacks — fail fast with a clear error if
sshRemoteAgentHostService.ts ×2
// the key is missing or unreadable, rather than letting it surface
// downstream as a generic auth failure.
if (!config.privateKeyPath) {
throw new Error(localize('ssh.keyFileAuthRequiresPath', "Key file authentication requires a private key path."));
sshRemoteAgentHostService.ts ×1
}
const explicit = await this._readKeyFileIfExists(config.privateKeyPath);
sshRemoteAgentHostService.ts ×1
if (!explicit) {
throw new Error(localize('ssh.failedToReadPrivateKey', "Failed to read private key file: {0}", config.privateKeyPath));
sshRemoteAgentHostService.ts ×1
}
attempts.push({ type: 'publickey', username, key: explicit, keyPath: config.privateKeyPath, ...(isEncryptedPrivateKey(explicit) ? { encrypted: true } : undefined) });
sshRemoteAgentHostService.ts ×2
break;
}
attempts.push({ type: 'password', username, password: config.password });
}
break;
}
return attempts;
private static readonly _defaultKeyPaths = [
'~/.ssh/id_ed25519',
'~/.ssh/id_rsa',
'~/.ssh/id_ecdsa',
'~/.ssh/id_dsa',
'~/.ssh/id_xmss',
];
/**
* Expand a leading `~` to the current user's home directory so that paths
* coming back from `ssh -G` (always absolute) compare equal to our
* `~`-prefixed defaults.
*/
private static _normalizeKeyPath(keyPath: string): string {
}
private static _isDefaultKeyPath(keyPath: string): boolean {
const normalized = SSHRemoteAgentHostMainService._normalizeKeyPath(keyPath);
sshRemoteAgentHostService.ts ×2
return SSHRemoteAgentHostMainService._defaultKeyPaths.some(p => SSHRemoteAgentHostMainService._normalizeKeyPath(p) === normalized);
}
/** Test seam: returns the SSH agent socket path, or undefined when no agent is available. */
protected _isAgentAvailable(): string | undefined {
return process.env['SSH_AUTH_SOCK'];
}
protected _getAgentSocket(config: ISSHAgentHostConfig): string | undefined {
}
private _resolveIdentityAgent(identityAgent: string): string | undefined {
if (!trimmed || trimmed.toLowerCase() === 'none') {
}
}
const envMatch = /^\$\{(?<braced>[A-Za-z_][A-Za-z0-9_]*)\}$|^\$(?<plain>[A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed);
return envMatch?.groups ? process.env[envMatch.groups.braced ?? envMatch.groups.plain] || undefined : undefined;
}
/**
* Forward a keyboard-interactive challenge from ssh2 to the renderer and
* register the `finish` callback so {@link respondKeyboardInteractive} can
* supply the user's responses when they arrive. Returns the generated
* `requestId` so the caller can track in-flight prompts.
*/
protected _handleKeyboardInteractive(
displayHost: string,
username: string,
name: string,
instructions: string,
prompts: readonly ISSHKeyboardInteractivePrompt[],
finish: (responses: readonly string[]) => void,
cancelConnect: () => void,
): string {
const requestId = `kbi-${++this._kbiRequestCounter}`;
// Wrap finish so it can only fire once — ssh2 ignores duplicate calls,
// but we also want to ensure we drop the pending entry exactly once.
let settled = false;
const finishOnce = (responses: readonly string[]) => {
if (settled) {
}
this._pendingKbiRequests.delete(requestId);
finish(responses);
};
this._pendingKbiRequests.set(requestId, { finish: finishOnce, cancelConnect });
this._logService.info(`${LOG_PREFIX} keyboard-interactive challenge from ${displayHost}: ${prompts.length} prompt(s)`);
this._onDidRequestKeyboardInteractive.fire({
requestId,
connectionKey,
displayHost,
username,
name,
instructions,
prompts: prompts.map(p => ({ prompt: p.prompt, echo: p.echo })),
});
return requestId;
}
async respondKeyboardInteractive(requestId: string, responses: readonly string[] | undefined): Promise<void> {
if (!pending) {
this._logService.warn(`${LOG_PREFIX} respondKeyboardInteractive: no pending request for ${requestId}`);
return;
}
pending.finish([]);
return;
}
/**
* Test seam: read a private key file from disk. Returns `undefined` if the
* file doesn't exist; logs and returns `undefined` for any other read error
* so a single broken key doesn't abort the whole auth flow.
*/
protected async _readKeyFileIfExists(keyPath: string): Promise<Buffer | undefined> {
const resolved = keyPath.replace(/^~/, os.homedir());
try {
return await fsp.readFile(resolved);
} catch (error) {
const errorCode = (error as NodeJS.ErrnoException).code;
if (errorCode === 'ENOENT' || errorCode === 'ENOTDIR') {
return undefined;
}
this._logService.warn(`${LOG_PREFIX} Failed to read SSH key file ${resolved}`, error);
return undefined;
}
}
private get _quality(): string {
}
private get _serverDataFolderName(): string {
return this._productService.serverDataFolderName ?? '.vscode-server-oss';
sshRemoteAgentHostService.ts ×15
}
private get _commit(): string | undefined {
}
protected _startRemoteAgentHost(
client: SSHClient, cliBin: string | undefined, cliDataDir: string | undefined, commandOverride?: string,
): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> {
return startRemoteAgentHost(client, this._logService, cliBin, cliDataDir, commandOverride);
}
protected async _createWebSocketRelay(
client: SSHClient, dstHost: string, dstPort: number, connectionToken: string | undefined,
onMessage: (data: string) => void, onClose: () => void,
): Promise<{ send: (data: string) => void; close: () => void }> {
const nativeRequire = await this._getNativeRequire();
return createWebSocketRelay(nativeRequire, client, dstHost, dstPort, connectionToken, this._logService, onMessage, onClose);
}
/**
* Resolve which CLI binary to run on the remote.
*
* When the desktop has a `productService.commit` (release builds), we
* pin to that commit: install at `~/<serverDataFolderName>/<archive>-<commit>`
* (sharing the install root with Remote-SSH), reuse on file existence,
* download from the commit-pinned URL on miss, and clean up older
* commit-keyed CLIs (keep last 5). The agent host CLI does not
* self-update on this path, so the desktop pushes freshness on every
* fresh start — but tolerantly: if the download fails and any other
* usable CLI is present (other commit-keyed or the legacy
* `~/.vscode-cli{,-<quality>}/<archive>`), we fall back to the newest
* one rather than refusing to connect.
*
* In dev/OSS builds with no commit, we keep the loose, non-pinned
* behavior: install `~/<serverDataFolderName>/<archive>` from the
* `latest` URL, with a `--version`-based reuse check.
*
* Returns the resolved CLI binary path to run.
*/
private async _ensureCLIInstalled(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise<string> {
if (!commit) {
return this._ensureCLIInstalledLoose(client, platform, reportProgress);
sshRemoteAgentHostService.ts ×3
}
return this._ensureCLIInstalledPinned(client, platform, reportProgress, commit);
sshRemoteAgentHostService.ts ×3
/**
* Commit-pinned install path. See {@link _ensureCLIInstalled}.
*/
private async _ensureCLIInstalledPinned(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void, commit: string): Promise<string> {
const cliBin = getRemoteCLIBin(this._serverDataFolderName, this._quality, commit);
sshRemoteAgentHostService.ts ×3
const installRoot = getRemoteCLIInstallRoot(this._serverDataFolderName);
// Primary reuse check: pure file existence on the commit-keyed path.
// No `--version` parsing — we know the file is ours and matches the
// desktop commit.
const { code: existsCode } = await sshExec(client, `test -x ${cliBin}`, { ignoreExitCode: true });
if (existsCode === 0) {
this._logService.info(`${LOG_PREFIX} Reusing remote CLI at ${cliBin}`);
sshRemoteAgentHostService.ts ×2
// Bump mtime so the retention pass below doesn't prune the
// binary we just decided to reuse. Without this, a user
// rotating between several desktop builds could see their
// currently-used CLI fall out of the 5-newest window and
// get deleted just before the next reconnect.
const { code: touchCode } = await sshExec(client, `touch -- ${cliBin}`, { ignoreExitCode: true });
if (touchCode === 0) {
// Now that the in-use binary is the newest by mtime, prune
// older commit-keyed installs. Best-effort.
await sshExec(client, buildCleanupOldCLIsCommand(this._serverDataFolderName, this._quality), { ignoreExitCode: true });
} else {
// If we couldn't refresh mtime, skip the retention pass —
// running it now could prune the binary we just decided
// to reuse. We'll retry retention on the next reconnect.
this._logService.warn(`${LOG_PREFIX} Skipping CLI retention cleanup: touch exited ${touchCode}`);
}
}
reportProgress(localize('sshProgressDownloadingCLI', "Installing VS Code CLI on remote..."));
const url = buildCLIDownloadUrl(platform.os, platform.arch, this._quality, commit);
// Extract into a temp dir inside the install root so the final `mv`
// is a same-filesystem atomic rename. Concurrent SSH sessions racing
// here both end up with a valid binary for the same commit; the
// trailing `rm -rf` of the tmp dir is idempotent.
const installCmd = [
`mkdir -p ${installRoot}`,
`tmpdir=$(mktemp -d ${installRoot}/.cli-install-XXXXXX)`,
`(cd "$tmpdir" && curl -fsSL ${shellEscape(url)} | tar xz)`,
// The archive contains exactly one file: the CLI binary, named per quality.
`mv "$tmpdir"/* ${cliBin}`,
`chmod +x ${cliBin}`,
`rm -rf "$tmpdir"`,
].join(' && ');
try {
await sshExec(client, installCmd);
// Validate the installed binary actually runs. If the archive was
sshRemoteAgentHostService.ts ×2
// for the wrong platform / corrupted, this surfaces immediately.
const { code: versionCode } = await sshExec(client, `${cliBin} --version`, { ignoreExitCode: true });
if (versionCode !== 0) {
throw new Error(`CLI at ${cliBin} failed --version check after install (exit code ${versionCode})`);
}
this._logService.info(`${LOG_PREFIX} Installed remote CLI at ${cliBin}`);
sshRemoteAgentHostService.ts ×2
// Prune older commit-keyed installs now that the new binary is
// in place and is the newest by mtime.
await sshExec(client, buildCleanupOldCLIsCommand(this._serverDataFolderName, this._quality), { ignoreExitCode: true });
return cliBin;
// commit-pinned download fails (offline, 404, etc.) but another
// usable CLI is already on the box, use that instead of refusing
// to connect. The agent host has no strict commit-lock with the
// desktop — the protocol handshake will catch genuine
// incompatibilities.
const installErrorMessage = installErr instanceof Error ? installErr.message : String(installErr);
this._logService.warn(`${LOG_PREFIX} Could not install matching CLI for commit ${commit}: ${installErrorMessage}. Looking for a fallback CLI on the remote...`);
const fallback = await this._findFallbackCLI(client);
if (fallback) {
this._logService.warn(`${LOG_PREFIX} Using fallback CLI at ${fallback} (does not match desktop commit ${commit}).`);
sshRemoteAgentHostService.ts ×4
return fallback;
}
}
/**
* Loose dev-build install: no commit pin. See {@link _ensureCLIInstalled}.
*/
private async _ensureCLIInstalledLoose(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise<string> {
const cliBin = getRemoteCLIBin(this._serverDataFolderName, this._quality);
sshRemoteAgentHostService.ts ×3
const installRoot = getRemoteCLIInstallRoot(this._serverDataFolderName);
this._logService.warn(`${LOG_PREFIX} Desktop has no product commit; falling back to non-pinned CLI install at ${cliBin}.`);
const { code } = await sshExec(client, `${cliBin} --version`, { ignoreExitCode: true });
if (code === 0) {
this._logService.info(`${LOG_PREFIX} Reusing remote CLI at ${cliBin} (dev build, --version check passed)`);
sshRemoteAgentHostService.ts ×1
return cliBin;
}
reportProgress(localize('sshProgressDownloadingCLI', "Installing VS Code CLI on remote..."));
const url = buildCLIDownloadUrl(platform.os, platform.arch, this._quality);
const installCmd = [
`mkdir -p ${installRoot}`,
`curl -fsSL ${shellEscape(url)} | tar xz -C ${installRoot}`,
`chmod +x ${cliBin}`,
].join(' && ');
await sshExec(client, installCmd);
this._logService.info(`${LOG_PREFIX} Installed remote CLI at ${cliBin}`);
return cliBin;
/**
* List remote CLI candidates that could be used as a fallback when the
* commit-pinned download fails, and return the newest one that passes
* a `--version` check. Returns `undefined` if no candidate works.
*/
private async _findFallbackCLI(client: SSHClient): Promise<string | undefined> {
const { stdout } = await sshExec(client, buildFindFallbackCLICommand(this._serverDataFolderName, this._quality), { ignoreExitCode: true });
sshRemoteAgentHostService.ts ×5
const rawCandidates = stdout.split('\n').map(s => s.trim()).filter(s => s.length > 0);
// Defensive validation: the finder shell snippet emits paths we
// trust by construction, but the output is still data coming back
// over SSH that we then interpolate into a follow-up command
// (`<candidate> --version`). Filter to the exact shapes we expect
// — `<root>/<archive>-<40 hex>` or `<legacyDir>/<archive>` — so a
// malicious or junk file in the install root can never become a
// shell argument.
const candidates: string[] = [];
for (const candidate of rawCandidates) {
if (isValidFallbackCLIPath(candidate, this._serverDataFolderName, this._quality)) {
sshRemoteAgentHostService.ts ×4
candidates.push(candidate);
} else {
this._logService.info(`${LOG_PREFIX} Ignoring fallback CLI candidate with unexpected path shape: ${candidate}`);
}
const { code } = await sshExec(client, `${candidate} --version`, { ignoreExitCode: true });
sshRemoteAgentHostService.ts ×4
if (code === 0) {
return candidate;
}
this._logService.info(`${LOG_PREFIX} Fallback CLI candidate ${candidate} failed --version check (exit ${code}); trying next.`);
}