Path: blob/main/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type WebSocket from 'ws';6import type { AnyAuthMethod, AuthenticationType, ConnectConfig } from 'ssh2';7import { promises as fsp } from 'fs';8import * as os from 'os';9import * as cp from 'child_process';10import { dirname, join, isAbsolute, basename } from '../../../base/common/path.js';11import { Emitter, Event } from '../../../base/common/event.js';12import { Disposable, DisposableMap, toDisposable } from '../../../base/common/lifecycle.js';13import { URI } from '../../../base/common/uri.js';14import { localize } from '../../../nls.js';15import { ILogService } from '../../log/common/log.js';16import { IProductService } from '../../product/common/productService.js';17import {18ISSHRemoteAgentHostMainService,19SSHAuthMethod,20type ISSHAgentHostConfig,21type ISSHAgentHostConfigSanitized,22type ISSHConnectProgress,23type ISSHConnectResult,24type ISSHRelayMessage,25type ISSHResolvedConfig,26} from '../common/sshRemoteAgentHost.js';27import {28buildCLIDownloadUrl,29cleanupRemoteAgentHost,30findRunningAgentHost,31getRemoteCLIBin,32getRemoteCLIDir,33redactToken,34resolveRemotePlatform,35shellEscape,36writeAgentHostState,37} from './sshRemoteAgentHostHelpers.js';38import { parseSSHConfigHostEntries, parseSSHGOutput, stripSSHComment } from '../common/sshConfigParsing.js';3940/** Minimal subset of ssh2.ClientChannel used by this module (duplex stream). */41interface SSHChannel extends NodeJS.ReadWriteStream {42on(event: 'data', listener: (data: Buffer) => void): this;43on(event: 'close', listener: (code: number) => void): this;44on(event: 'error', listener: (err: Error) => void): this;45on(event: string, listener: (...args: unknown[]) => void): this;46stderr: { on(event: 'data', listener: (data: Buffer) => void): void };47close(): void;48}4950/** Minimal subset of ssh2.Client used by this module. */51interface SSHClient {52on(event: 'ready', listener: () => void): SSHClient;53on(event: 'error', listener: (err: Error) => void): SSHClient;54on(event: 'close', listener: () => void): SSHClient;55removeListener(event: 'close', listener: () => void): SSHClient;56removeListener(event: 'error', listener: (err: Error) => void): SSHClient;57connect(config: ConnectConfig): void;58exec(command: string, callback: (err: Error | undefined, stream: SSHChannel) => void): SSHClient;59forwardOut(srcIP: string, srcPort: number, dstIP: string, dstPort: number, callback: (err: Error | undefined, channel: SSHChannel) => void): SSHClient;60end(): void;61}6263const LOG_PREFIX = '[SSHRemoteAgentHost]';6465/**66* One entry in the queue of authentication attempts handed to ssh2's67* `authHandler`. Each attempt corresponds to one of the auth method shapes68* documented at https://www.npmjs.com/package/ssh2#client-methods.69*70* `keyPath` is internal-only metadata for logging — it is stripped before the71* attempt is returned to ssh2.72*/73export type SSHAuthAttempt =74| { readonly type: 'publickey'; readonly username: string; readonly key: Buffer; readonly keyPath: string }75| { readonly type: 'agent'; readonly username: string; readonly agent: string }76| { readonly type: 'password'; readonly username: string; readonly password: string };7778function describeAuthAttempt(attempt: SSHAuthAttempt): string {79switch (attempt.type) {80case 'publickey': return `publickey ${attempt.keyPath}`;81case 'agent': return 'agent';82case 'password': return 'password';83}84}8586/**87* Build an ssh2 `authHandler` callback that walks the given attempts in order,88* filtering by the server-advertised `methodsLeft` when ssh2 provides one.89* Returns `false` when the queue is exhausted, which causes ssh2 to surface90* an authentication failure to the caller.91*/92export function makeAuthHandler(93attempts: readonly SSHAuthAttempt[],94logService: ILogService,95): (methodsLeft: AuthenticationType[] | null, partialSuccess: boolean, callback: (next: AnyAuthMethod | false) => void) => void {96let index = 0;97return (methodsLeft, _partialSuccess, callback) => {98while (index < attempts.length) {99const attempt = attempts[index++];100// `agent` is a publickey-flavored method at the SSH protocol level —101// servers advertise `publickey`, not `agent`, in `methodsLeft`.102const protocolMethod: AuthenticationType = attempt.type === 'agent' ? 'publickey' : attempt.type;103if (methodsLeft && !methodsLeft.includes(protocolMethod)) {104logService.info(`${LOG_PREFIX} Skipping ${describeAuthAttempt(attempt)} — server only allows ${methodsLeft.join(', ')}`);105continue;106}107logService.info(`${LOG_PREFIX} Trying auth: ${describeAuthAttempt(attempt)}`);108// Strip our internal `keyPath` metadata before handing to ssh2.109if (attempt.type === 'publickey') {110const { keyPath: _kp, ...payload } = attempt;111callback(payload);112} else {113callback(attempt);114}115return;116}117logService.info(`${LOG_PREFIX} No more auth methods to try; giving up`);118callback(false);119};120}121122function sshExec(client: SSHClient, command: string, opts?: { ignoreExitCode?: boolean }): Promise<{ stdout: string; stderr: string; code: number }> {123return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {124client.exec(command, (err: Error | undefined, stream: SSHChannel) => {125if (err) {126reject(err);127return;128}129130let stdout = '';131let stderr = '';132let settled = false;133134const finish = (error: Error | undefined, code: number | undefined) => {135if (settled) {136return;137}138settled = true;139if (error) {140reject(error);141return;142}143if (code !== 0 && !opts?.ignoreExitCode) {144reject(new Error(`SSH command failed (exit ${code}): ${command}\nstderr: ${stderr}`));145} else {146resolve({ stdout, stderr, code: code ?? 0 });147}148};149150stream.on('data', (data: Buffer) => { stdout += data.toString(); });151stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });152stream.on('error', (streamErr: Error) => finish(streamErr, undefined));153stream.on('close', (code: number) => finish(undefined, code));154});155});156}157158/** Create a bound exec function for the given SSH client. */159function bindSshExec(client: SSHClient): (command: string, opts?: { ignoreExitCode?: boolean }) => Promise<{ stdout: string; stderr: string; code: number }> {160return (command, opts) => sshExec(client, command, opts);161}162163function startRemoteAgentHost(164client: SSHClient,165logService: ILogService,166quality: string,167commandOverride?: string,168): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> {169return new Promise((resolve, reject) => {170const baseCmd = commandOverride ?? `${getRemoteCLIBin(quality)} agent host --port 0 --accept-server-license-terms`;171// Wrap in a login shell so the agent host process inherits the172// user's PATH and environment from ~/.bash_profile / ~/.bashrc173// (ssh2 exec runs a non-interactive non-login shell by default).174// Echo the PID so we can record it for process reuse detection.175const cmd = `bash -l -c ${shellEscape(`echo VSCODE_PID=$$ && exec ${baseCmd}`)}`;176logService.info(`${LOG_PREFIX} Starting remote agent host: ${cmd}`);177178client.exec(cmd, (err: Error | undefined, stream: SSHChannel) => {179if (err) {180reject(err);181return;182}183184let resolved = false;185let outputBuf = '';186let pid: number | undefined;187188const timeout = setTimeout(() => {189if (!resolved) {190resolved = true;191reject(new Error(`${LOG_PREFIX} Timed out waiting for agent host to start.\noutput so far: ${redactToken(outputBuf)}`));192}193}, 60_000);194195const checkForOutput = () => {196if (pid === undefined) {197const pidMatch = outputBuf.match(/VSCODE_PID=(\d+)/);198if (pidMatch) {199pid = parseInt(pidMatch[1], 10);200logService.info(`${LOG_PREFIX} Remote agent host PID: ${pid}`);201}202}203204if (!resolved) {205const match = outputBuf.match(/ws:\/\/127\.0\.0\.1:(\d+)(?:\?tkn=([^\s&]+))?/);206if (match) {207resolved = true;208clearTimeout(timeout);209const port = parseInt(match[1], 10);210const connectionToken = match[2] || undefined;211logService.info(`${LOG_PREFIX} Remote agent host listening on port ${port}`);212resolve({ port, connectionToken, pid, stream });213}214}215};216217stream.stderr.on('data', (data: Buffer) => {218const text = data.toString();219outputBuf += text;220logService.trace(`${LOG_PREFIX} remote stderr: ${redactToken(text.trimEnd())}`);221checkForOutput();222});223224stream.on('data', (data: Buffer) => {225const text = data.toString();226outputBuf += text;227logService.trace(`${LOG_PREFIX} remote stdout: ${redactToken(text.trimEnd())}`);228checkForOutput();229});230231stream.on('error', (streamErr: Error) => {232if (!resolved) {233resolved = true;234clearTimeout(timeout);235reject(streamErr);236}237});238239stream.on('close', (code: number) => {240if (!resolved) {241resolved = true;242clearTimeout(timeout);243reject(new Error(`${LOG_PREFIX} Agent host process exited with code ${code} before becoming ready.\noutput: ${redactToken(outputBuf)}`));244}245});246});247});248}249250/**251* Create a WebSocket connection to the remote agent host via an SSH forwarded channel.252* Uses the `ws` library to speak WebSocket over the SSH channel.253* Messages are relayed to the renderer via IPC events.254*/255function createWebSocketRelay(256nativeRequire: NodeJS.Require,257client: SSHClient,258dstHost: string,259dstPort: number,260connectionToken: string | undefined,261logService: ILogService,262onMessage: (data: string) => void,263onClose: () => void,264): Promise<{ send: (data: string) => void; close: () => void }> {265return new Promise((resolve, reject) => {266client.forwardOut('127.0.0.1', 0, dstHost, dstPort, (err: Error | undefined, channel: SSHChannel) => {267if (err) {268reject(err);269return;270}271272const WS = nativeRequire('ws') as typeof WebSocket;273let url = `ws://${dstHost}:${dstPort}`;274if (connectionToken) {275url += `?tkn=${encodeURIComponent(connectionToken)}`;276}277278// The SSH channel is a duplex stream compatible with ws's createConnection,279// but our minimal SSHChannel interface doesn't carry the full Node Duplex shape.280const ws = new WS(url, { createConnection: (() => channel) as unknown as WebSocket.ClientOptions['createConnection'] });281282ws.on('open', () => {283logService.info(`${LOG_PREFIX} WebSocket relay connected to remote agent host`);284resolve({285send: (data: string) => {286if (ws.readyState === ws.OPEN) {287ws.send(data);288}289},290close: () => ws.close(),291});292});293294ws.on('message', (data: WebSocket.RawData) => {295if (Array.isArray(data)) {296onMessage(Buffer.concat(data).toString());297} else if (data instanceof ArrayBuffer) {298onMessage(Buffer.from(new Uint8Array(data)).toString());299} else {300onMessage(data.toString());301}302});303304ws.on('close', onClose);305306ws.on('error', (wsErr: unknown) => {307logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`);308reject(wsErr);309});310});311});312}313314function sanitizeConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfigSanitized {315const { password: _p, privateKeyPath: _k, ...sanitized } = config;316return sanitized;317}318319/**320* State for a single active SSH relay connection.321* Immutable and dispose-once — follows the same pattern as TunnelConnection.322* On reconnect, the old SSHConnection is disposed and a fresh one is created;323* the SSH client can be detached first so only the WebSocket relay is torn down.324*/325class SSHConnection extends Disposable {326private readonly _onDidClose = new Emitter<void>();327readonly onDidClose = this._onDidClose.event;328329readonly config: ISSHAgentHostConfigSanitized;330private _closed = false;331private _sshClientDetached = false;332private readonly _sshCloseListener = () => { this.dispose(); };333private readonly _sshErrorListener = () => { this.dispose(); };334335constructor(336fullConfig: ISSHAgentHostConfig,337readonly connectionId: string,338readonly address: string,339readonly name: string,340readonly connectionToken: string | undefined,341readonly remotePort: number,342readonly sshClient: SSHClient,343private readonly _relay: { send: (data: string) => void; close: () => void },344private readonly _remoteStream: SSHChannel | undefined,345) {346super();347348this.config = sanitizeConfig(fullConfig);349350// Register cleanup first so it fires _onDidClose *before* the Emitter is disposed.351this._register(toDisposable(() => {352if (this._closed) {353return;354}355this._closed = true;356this._relay.close();357if (!this._sshClientDetached) {358this._remoteStream?.close();359sshClient.end();360}361this._onDidClose.fire();362}));363364this._register(this._onDidClose);365366sshClient.on('close', this._sshCloseListener);367sshClient.on('error', this._sshErrorListener);368}369370/**371* Detach the SSH client from this connection so that `dispose()`372* only closes the WebSocket relay without ending the SSH session.373* Also removes event listeners from the SSH client so the old374* connection object is not retained by the shared client.375*/376detachSshClient(): void {377this._sshClientDetached = true;378this.sshClient.removeListener('close', this._sshCloseListener);379this.sshClient.removeListener('error', this._sshErrorListener);380}381382relaySend(data: string): void {383this._relay.send(data);384}385}386387export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRemoteAgentHostMainService {388declare readonly _serviceBrand: undefined;389390private readonly _onDidChangeConnections = this._register(new Emitter<void>());391readonly onDidChangeConnections: Event<void> = this._onDidChangeConnections.event;392393private readonly _onDidCloseConnection = this._register(new Emitter<string>());394readonly onDidCloseConnection: Event<string> = this._onDidCloseConnection.event;395396private readonly _onDidReportConnectProgress = this._register(new Emitter<ISSHConnectProgress>());397readonly onDidReportConnectProgress: Event<ISSHConnectProgress> = this._onDidReportConnectProgress.event;398399private readonly _onDidRelayMessage = this._register(new Emitter<ISSHRelayMessage>());400readonly onDidRelayMessage: Event<ISSHRelayMessage> = this._onDidRelayMessage.event;401402private readonly _onDidRelayClose = this._register(new Emitter<string>());403readonly onDidRelayClose: Event<string> = this._onDidRelayClose.event;404405private readonly _connections = this._register(new DisposableMap<string, SSHConnection>());406407private _nativeRequire: NodeJS.Require | undefined;408409constructor(410@ILogService private readonly _logService: ILogService,411@IProductService private readonly _productService: IProductService,412) {413super();414}415416/**417* Lazily load a `require` function for native modules (`ssh2`, `ws`).418* Uses a dynamic `import('node:module')` so the module is only resolved419* when actually needed at runtime — not at file-load time. This matters420* because tests override the methods that call this and never trigger421* the import, avoiding issues with Electron's ESM loader which cannot422* resolve `node:` specifiers.423*/424private async _getNativeRequire(): Promise<NodeJS.Require> {425if (!this._nativeRequire) {426const nodeModule = await import('node:module');427this._nativeRequire = nodeModule.createRequire(import.meta.url);428}429return this._nativeRequire;430}431432async connect(config: ISSHAgentHostConfig, replaceRelay?: boolean): Promise<ISSHConnectResult> {433const connectionKey = config.sshConfigHost434? `ssh:${config.sshConfigHost}`435: `${config.username}@${config.host}:${config.port ?? 22}`;436437const existing = this._connections.get(connectionKey);438if (existing) {439if (replaceRelay) {440// Tear down the old relay and create a fresh one, following441// the same dispose-and-recreate pattern as TunnelAgentHostMainService.442// The SSH client is detached so only the WebSocket relay is closed.443this._logService.info(`${LOG_PREFIX} Reconnecting relay for existing SSH tunnel ${connectionKey}`);444const { sshClient, remotePort, connectionToken } = existing;445446// Remove from map and detach SSH client before disposing so447// the old relay's close handler (conn?.dispose()) is a no-op.448this._connections.deleteAndLeak(connectionKey);449existing.detachSshClient();450existing.dispose();451452// Create fresh relay and connection. If relay creation fails,453// clean up the detached SSH client so it doesn't leak.454const connectionId = connectionKey;455try {456let conn: SSHConnection | undefined; // eslint-disable-line prefer-const457const relay = await this._createWebSocketRelay(458sshClient, '127.0.0.1', remotePort, connectionToken,459(data: string) => this._onDidRelayMessage.fire({ connectionId, data }),460() => { conn?.dispose(); },461);462463conn = new SSHConnection(464config, connectionId, connectionKey, config.name,465connectionToken, remotePort, sshClient, relay, undefined,466);467468Event.once(conn.onDidClose)(() => {469if (this._connections.get(connectionKey) === conn) {470this._connections.deleteAndDispose(connectionKey);471this._onDidRelayClose.fire(connectionId);472this._onDidCloseConnection.fire(connectionId);473this._onDidChangeConnections.fire();474}475});476477this._connections.set(connectionKey, conn);478479return {480connectionId: conn.connectionId,481address: conn.address,482name: conn.name,483connectionToken: conn.connectionToken,484config: conn.config,485sshConfigHost: config.sshConfigHost,486};487} catch (err) {488sshClient.end();489this._onDidRelayClose.fire(connectionId);490this._onDidCloseConnection.fire(connectionId);491this._onDidChangeConnections.fire();492throw err;493}494}495496return {497connectionId: existing.connectionId,498address: existing.address,499name: existing.name,500connectionToken: existing.connectionToken,501config: existing.config,502sshConfigHost: config.sshConfigHost,503};504}505506this._logService.info(`${LOG_PREFIX} ${replaceRelay ? 'Reconnecting' : 'Connecting'} to ${connectionKey}`);507let sshClient: SSHClient | undefined;508509try {510const reportProgress = (message: string) => {511this._onDidReportConnectProgress.fire({ connectionKey, message });512};513514// 1. Establish SSH connection515reportProgress(localize('sshProgressConnecting', "Establishing SSH connection..."));516sshClient = await this._connectSSH(config);517518if (config.remoteAgentHostCommand) {519// Dev override: skip platform detection and CLI install,520// use the provided command directly.521this._logService.info(`${LOG_PREFIX} Using custom agent host command: ${config.remoteAgentHostCommand}`);522} else {523// 2. Detect remote platform524const { stdout: unameS } = await sshExec(sshClient, 'uname -s');525const { stdout: unameM } = await sshExec(sshClient, 'uname -m');526const platform = resolveRemotePlatform(unameS, unameM);527if (!platform) {528throw new Error(`${LOG_PREFIX} Unsupported remote platform: ${unameS.trim()} ${unameM.trim()}`);529}530this._logService.info(`${LOG_PREFIX} Remote platform: ${platform.os}-${platform.arch}`);531532// 3. Install CLI if needed533reportProgress(localize('sshProgressInstallingCLI', "Checking remote CLI installation..."));534await this._ensureCLIInstalled(sshClient, platform, reportProgress);535}536537// 4. Check for an already-running agent host on the remote.538// This prevents accumulating orphaned processes when the SSH539// connection drops and we reconnect.540let remotePort: number | undefined;541let connectionToken: string | undefined;542let agentStream: SSHChannel | undefined;543544reportProgress(localize('sshProgressCheckingAgent', "Checking for existing agent host..."));545const exec = bindSshExec(sshClient);546const existingAH = await findRunningAgentHost(exec, this._logService, this._quality);547if (existingAH) {548remotePort = existingAH.port;549connectionToken = existingAH.connectionToken;550}551552if (remotePort === undefined) {553// 5. Start agent-host and capture port/token554reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host..."));555const result = await this._startRemoteAgentHost(sshClient, this._quality, config.remoteAgentHostCommand);556remotePort = result.port;557connectionToken = result.connectionToken;558agentStream = result.stream;559560// Record state for future reuse561await writeAgentHostState(exec, this._logService, this._quality, result.pid, remotePort, connectionToken);562}563564// 6. Connect to remote agent host via WebSocket relay (no local TCP port)565reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host..."));566const connectionId = connectionKey;567let conn: SSHConnection | undefined; // eslint-disable-line prefer-const568let relay: { send: (data: string) => void; close: () => void };569try {570relay = await this._createWebSocketRelay(571sshClient, '127.0.0.1', remotePort, connectionToken,572(data: string) => this._onDidRelayMessage.fire({ connectionId, data }),573() => { conn?.dispose(); },574);575} catch (relayErr) {576if (!existingAH) {577throw relayErr;578}579// The reused agent host is not connectable — kill it and start fresh580const relayErrorMessage = relayErr instanceof Error ? relayErr.message : String(relayErr);581this._logService.warn(`${LOG_PREFIX} Failed to connect to reused agent host on port ${remotePort}: ${relayErrorMessage}. Starting fresh`);582await cleanupRemoteAgentHost(exec, this._logService, this._quality);583584reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host..."));585const result = await this._startRemoteAgentHost(sshClient, this._quality, config.remoteAgentHostCommand);586remotePort = result.port;587connectionToken = result.connectionToken;588agentStream = result.stream;589await writeAgentHostState(exec, this._logService, this._quality, result.pid, remotePort, connectionToken);590591reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host..."));592relay = await this._createWebSocketRelay(593sshClient, '127.0.0.1', remotePort, connectionToken,594(data: string) => this._onDidRelayMessage.fire({ connectionId, data }),595() => { conn?.dispose(); },596);597}598599// 7. Create connection object600const address = connectionKey;601conn = new SSHConnection(602config,603connectionId,604address,605config.name,606connectionToken,607remotePort,608sshClient,609relay,610agentStream,611);612613Event.once(conn.onDidClose)(() => {614if (this._connections.get(connectionKey) === conn) {615this._connections.deleteAndDispose(connectionKey);616this._onDidRelayClose.fire(connectionId);617this._onDidCloseConnection.fire(connectionId);618this._onDidChangeConnections.fire();619}620});621622this._connections.set(connectionKey, conn);623sshClient = undefined; // ownership transferred to SSHConnection624625this._onDidChangeConnections.fire();626627return {628connectionId,629address,630name: config.name,631connectionToken,632config: conn.config,633sshConfigHost: config.sshConfigHost,634};635636} catch (err) {637sshClient?.end();638throw err;639}640}641642async disconnect(host: string): Promise<void> {643for (const [key, conn] of this._connections) {644if (key === host || conn.connectionId === host) {645conn.dispose();646return;647}648}649}650651async relaySend(connectionId: string, message: string): Promise<void> {652for (const conn of this._connections.values()) {653if (conn.connectionId === connectionId) {654conn.relaySend(message);655return;656}657}658}659660async reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string, agentForward?: boolean): Promise<ISSHConnectResult> {661this._logService.info(`${LOG_PREFIX} Reconnecting via SSH config host: ${sshConfigHost}`);662const resolved = await this.resolveSSHConfig(sshConfigHost);663664// Always use Agent auth — the auth handler will walk through the SSH665// agent and any default identities. If the user pinned a non-default666// `IdentityFile` in their ssh config, surface it as the explicit key667// so it gets tried first.668let privateKeyPath: string | undefined;669if (resolved.identityFile.length > 0 && !SSHRemoteAgentHostMainService._isDefaultKeyPath(resolved.identityFile[0])) {670privateKeyPath = resolved.identityFile[0];671}672this._logService.info(`${LOG_PREFIX} reconnect: identityFiles=${JSON.stringify(resolved.identityFile)}, explicit key=${privateKeyPath ?? '(none)'}`);673674return this.connect({675host: resolved.hostname,676port: resolved.port !== 22 ? resolved.port : undefined,677username: resolved.user ?? sshConfigHost,678authMethod: SSHAuthMethod.Agent,679privateKeyPath,680name,681sshConfigHost,682remoteAgentHostCommand,683agentForward: agentForward && resolved.forwardAgent ? true : undefined,684}, /* replaceRelay */ true);685}686687async listSSHConfigHosts(): Promise<string[]> {688const configPath = join(os.homedir(), '.ssh', 'config');689try {690const content = await fsp.readFile(configPath, 'utf-8');691return this._parseSSHConfigHosts(content, dirname(configPath));692} catch {693this._logService.info(`${LOG_PREFIX} Could not read SSH config at ${configPath}`);694return [];695}696}697698async ensureUserSSHConfig(): Promise<URI> {699const sshDir = join(os.homedir(), '.ssh');700const configPath = join(sshDir, 'config');701const isPosix = process.platform !== 'win32';702try {703await fsp.mkdir(sshDir, { recursive: true, mode: isPosix ? 0o700 : undefined });704} catch (err) {705this._logService.warn(`${LOG_PREFIX} Failed to ensure ~/.ssh directory: ${err}`);706throw err;707}708try {709await fsp.access(configPath);710} catch {711try {712const handle = await fsp.open(configPath, 'a', isPosix ? 0o600 : undefined);713await handle.close();714} catch (err) {715this._logService.warn(`${LOG_PREFIX} Failed to create ${configPath}: ${err}`);716throw err;717}718}719return URI.file(configPath);720}721722async listSSHConfigFiles(): Promise<URI[]> {723const isWindows = process.platform === 'win32';724const userConfigPath = join(os.homedir(), '.ssh', 'config');725const systemConfigPath = isWindows726? join(process.env['ProgramData'] ?? 'C:\\ProgramData', 'ssh', 'ssh_config')727: '/etc/ssh/ssh_config';728729const result: URI[] = [URI.file(userConfigPath)];730try {731await fsp.access(systemConfigPath);732result.push(URI.file(systemConfigPath));733} catch {734// system config file does not exist — skip735}736return result;737}738739async resolveSSHConfig(host: string): Promise<ISSHResolvedConfig> {740return new Promise<ISSHResolvedConfig>((resolve, reject) => {741cp.execFile('ssh', ['-G', host], { timeout: 5000 }, (err, stdout) => {742if (err) {743reject(new Error(`${LOG_PREFIX} ssh -G failed for ${host}: ${err.message}`));744return;745}746const config = this._parseSSHGOutput(stdout);747resolve(config);748});749});750}751752private async _parseSSHConfigHosts(content: string, configDir: string, visited?: Set<string>): Promise<string[]> {753const seen = visited ?? new Set<string>();754const hosts: string[] = [];755756// Extract hosts from this file directly757hosts.push(...parseSSHConfigHostEntries(content));758759// Follow Include directives760for (const line of content.split('\n')) {761const trimmed = line.trim();762if (!trimmed || trimmed.startsWith('#')) {763continue;764}765const includeMatch = trimmed.match(/^Include\s+(.+)$/i);766if (!includeMatch) {767continue;768}769770const rawValue = stripSSHComment(includeMatch[1]);771const patterns = rawValue.split(/\s+/).filter(Boolean);772773for (const rawPattern of patterns) {774const pattern = rawPattern.replace(/^~/, os.homedir());775const resolvedPattern = isAbsolute(pattern) ? pattern : join(configDir, pattern);776777if (seen.has(resolvedPattern)) {778continue;779}780seen.add(resolvedPattern);781782try {783const stat = await fsp.stat(resolvedPattern);784if (stat.isDirectory()) {785const files = await fsp.readdir(resolvedPattern);786for (const file of files) {787try {788const sub = await fsp.readFile(join(resolvedPattern, file), 'utf-8');789hosts.push(...await this._parseSSHConfigHosts(sub, resolvedPattern, seen));790} catch { /* skip unreadable files */ }791}792} else {793const sub = await fsp.readFile(resolvedPattern, 'utf-8');794hosts.push(...await this._parseSSHConfigHosts(sub, dirname(resolvedPattern), seen));795}796} catch {797const dir = dirname(resolvedPattern);798const base = basename(resolvedPattern);799if (base.includes('*')) {800try {801const files = await fsp.readdir(dir);802for (const file of files) {803const regex = new RegExp('^' + base.replace(/\*/g, '.*') + '$');804if (regex.test(file)) {805try {806const sub = await fsp.readFile(join(dir, file), 'utf-8');807hosts.push(...await this._parseSSHConfigHosts(sub, dir, seen));808} catch { /* skip */ }809}810}811} catch { /* skip unreadable dirs */ }812}813}814}815}816return hosts;817}818819private _parseSSHGOutput(stdout: string): ISSHResolvedConfig {820return parseSSHGOutput(stdout);821}822823protected async _connectSSH(824config: ISSHAgentHostConfig,825): Promise<SSHClient> {826const nativeRequire = await this._getNativeRequire();827const ssh2Module = nativeRequire('ssh2') as { Client: new () => unknown };828const SSHClientCtor = ssh2Module.Client;829830const connectConfig: ConnectConfig = {831host: config.host,832port: config.port ?? 22,833username: config.username,834readyTimeout: 30_000,835keepaliveInterval: 15_000,836};837838const attempts = await this._buildAuthAttempts(config);839this._logService.info(`${LOG_PREFIX} Built ${attempts.length} auth attempt(s): ${attempts.map(a => describeAuthAttempt(a)).join(', ')}`);840// Cast: the ssh2 @types don't model `false` (give-up) for the841// callback nor `null` for the first invocation's `methodsLeft`,842// even though the runtime supports both per the ssh2 docs.843connectConfig.authHandler = makeAuthHandler(attempts, this._logService) as unknown as ConnectConfig['authHandler'];844845if (config.agentForward) {846const agentSock = this._isAgentAvailable();847if (agentSock) {848// ssh2 needs `connectConfig.agent` set so it knows which local849// agent socket to forward to. Without it, agent forwarding is a850// no-op even if `agentForward: true` is set.851connectConfig.agent = agentSock;852connectConfig.agentForward = true;853this._logService.info(`${LOG_PREFIX} SSH agent forwarding enabled`);854} else {855this._logService.warn(`${LOG_PREFIX} SSH agent forwarding requested, but SSH_AUTH_SOCK is not set; agent forwarding disabled`);856}857}858859return new Promise<SSHClient>((resolve, reject) => {860const client = new SSHClientCtor() as SSHClient;861862client.on('ready', () => {863this._logService.info(`${LOG_PREFIX} SSH connection established to ${config.host}`);864resolve(client);865});866867client.on('error', (err: Error) => {868this._logService.error(`${LOG_PREFIX} SSH connection error: ${err.message}`);869reject(err);870});871872client.connect(connectConfig);873});874}875876/**877* Build the ordered list of authentication attempts to feed to ssh2's878* `authHandler`. Mirrors OpenSSH's behavior: try the explicitly configured879* key first (if any), then the SSH agent (if `SSH_AUTH_SOCK` is set), then880* each readable default identity file in turn. This means a host that881* accepts `~/.ssh/id_rsa` still works even if the agent doesn't have it882* loaded — without needing an explicit `IdentityFile` entry in `~/.ssh/config`.883*/884protected async _buildAuthAttempts(config: ISSHAgentHostConfig): Promise<SSHAuthAttempt[]> {885const attempts: SSHAuthAttempt[] = [];886const username = config.username;887888switch (config.authMethod) {889case SSHAuthMethod.Agent: {890if (config.privateKeyPath) {891const explicit = await this._readKeyFileIfExists(config.privateKeyPath);892if (explicit) {893attempts.push({ type: 'publickey', username, key: explicit, keyPath: config.privateKeyPath });894}895}896const agentSock = this._isAgentAvailable();897if (agentSock) {898attempts.push({ type: 'agent', username, agent: agentSock });899}900for (const keyPath of SSHRemoteAgentHostMainService._defaultKeyPaths) {901if (config.privateKeyPath === keyPath) {902continue; // Already added as the explicit attempt above903}904const contents = await this._readKeyFileIfExists(keyPath);905if (contents) {906attempts.push({ type: 'publickey', username, key: contents, keyPath });907}908}909break;910}911case SSHAuthMethod.KeyFile: {912// KeyFile mode has no fallbacks — fail fast with a clear error if913// the key is missing or unreadable, rather than letting it surface914// downstream as a generic auth failure.915if (!config.privateKeyPath) {916throw new Error(localize('ssh.keyFileAuthRequiresPath', "Key file authentication requires a private key path."));917}918const explicit = await this._readKeyFileIfExists(config.privateKeyPath);919if (!explicit) {920throw new Error(localize('ssh.failedToReadPrivateKey', "Failed to read private key file: {0}", config.privateKeyPath));921}922attempts.push({ type: 'publickey', username, key: explicit, keyPath: config.privateKeyPath });923break;924}925case SSHAuthMethod.Password: {926if (config.password !== undefined) {927attempts.push({ type: 'password', username, password: config.password });928}929break;930}931}932933return attempts;934}935936private static readonly _defaultKeyPaths = [937'~/.ssh/id_ed25519',938'~/.ssh/id_rsa',939'~/.ssh/id_ecdsa',940'~/.ssh/id_dsa',941'~/.ssh/id_xmss',942];943944private static _isDefaultKeyPath(keyPath: string): boolean {945return SSHRemoteAgentHostMainService._defaultKeyPaths.includes(keyPath);946}947948/** Test seam: returns the SSH agent socket path, or undefined when no agent is available. */949protected _isAgentAvailable(): string | undefined {950return process.env['SSH_AUTH_SOCK'];951}952953/**954* Test seam: read a private key file from disk. Returns `undefined` if the955* file doesn't exist; logs and returns `undefined` for any other read error956* so a single broken key doesn't abort the whole auth flow.957*/958protected async _readKeyFileIfExists(keyPath: string): Promise<Buffer | undefined> {959const resolved = keyPath.replace(/^~/, os.homedir());960try {961return await fsp.readFile(resolved);962} catch (error) {963const errorCode = (error as NodeJS.ErrnoException).code;964if (errorCode === 'ENOENT' || errorCode === 'ENOTDIR') {965return undefined;966}967this._logService.warn(`${LOG_PREFIX} Failed to read SSH key file ${resolved}`, error);968return undefined;969}970}971972private get _quality(): string {973return this._productService.quality || 'insider';974}975976protected _startRemoteAgentHost(977client: SSHClient, quality: string, commandOverride?: string,978): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> {979return startRemoteAgentHost(client, this._logService, quality, commandOverride);980}981982protected async _createWebSocketRelay(983client: SSHClient, dstHost: string, dstPort: number, connectionToken: string | undefined,984onMessage: (data: string) => void, onClose: () => void,985): Promise<{ send: (data: string) => void; close: () => void }> {986const nativeRequire = await this._getNativeRequire();987return createWebSocketRelay(nativeRequire, client, dstHost, dstPort, connectionToken, this._logService, onMessage, onClose);988}989990private async _ensureCLIInstalled(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise<void> {991const cliDir = getRemoteCLIDir(this._quality);992const cliBin = getRemoteCLIBin(this._quality);993const { code } = await sshExec(client, `${cliBin} --version`, { ignoreExitCode: true });994if (code === 0) {995this._logService.info(`${LOG_PREFIX} VS Code CLI already installed on remote`);996return;997}998999reportProgress(localize('sshProgressDownloadingCLI', "Installing VS Code CLI on remote..."));1000const url = buildCLIDownloadUrl(platform.os, platform.arch, this._quality);10011002const installCmd = [1003`mkdir -p ${cliDir}`,1004`curl -fsSL ${shellEscape(url)} | tar xz -C ${cliDir}`,1005`chmod +x ${cliBin}`,1006].join(' && ');10071008await sshExec(client, installCmd);1009this._logService.info(`${LOG_PREFIX} VS Code CLI installed successfully`);1010}1011}101210131014