sshRemoteAgentHostService.ts ×49

Frontier kind: Code frontier

unlabeled · c_769f09be7435

62 tests · 10508 LOC · 48 files · introduces 0 tests · 658 LOC · 2 files

Introduces — evidence that enters the hierarchy at this concept

Code
50 ranges658 lines · 2 files
Tests
0 tests

Contains — complete concept membership

All code (extent)
1541 ranges10508 lines · 48 files · Browse complete extent
All tests (intent)
62 testsBrowse complete intent

Neighbourhood graph

The orange circle is the focus. Violet and green circles are every ancestor and descendant, broader and narrower, at any distance; blue squares and pink diamonds are the introduced files and exact introduced tests of every visible concept, not only the focus's. Arrows point from broader to narrower concepts and bridge only concepts omitted from this view. Undirected links show source or test introduction. Concept and file size follows LOC; exact test nodes use test-count units.

Introduced files, introduced tests, and structurally relevant concept specialization

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 native relationship evidence on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the native relationship evidence on this page while the interactive map is unavailable.

Native relationship evidence

Every exact file and test below is linked only from the concept that introduces it.

Introduced tests

Every collected test enters the hierarchy at exactly one concept.

No tests are introduced at this concept. Its intent tests are introduced by other concepts.

Introduced code

Every collected source range enters the hierarchy at exactly one concept.

2 files ranked by introduced lines: 658 introduced LOC across 50 ranges. Expand a file to inspect source; the > gutter marks introduced lines.

src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts 373 introduced LOC · 49 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- sshRemoteAgentHostService.ts
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import type WebSocket from 'ws';
7 > import type { AnyAuthMethod, AuthenticationType, ConnectConfig } from 'ssh2';
8 > import { promises as fsp } from 'fs';
9 > import * as os from 'os';
10 > import * as cp from 'child_process';
11 > import { dirname, join, isAbsolute, basename } from '../../../base/common/path.js';
12 > import { Emitter, Event } from '../../../base/common/event.js';
13 > import { Disposable, DisposableMap, toDisposable } from '../../../base/common/lifecycle.js';
14 > import { raceTimeout } from '../../../base/common/async.js';
15 > import { CancellationError } from '../../../base/common/errors.js';
16 > import { URI } from '../../../base/common/uri.js';
17 > import { localize } from '../../../nls.js';
18 > import { ILogService } from '../../log/common/log.js';
19 > import { IProductService } from '../../product/common/productService.js';
20 > import {
21 > ISSHRemoteAgentHostMainService,
22 > SSHAuthMethod,
23 > type ISSHAgentHostConfig,
24 > type ISSHAgentHostConfigSanitized,
25 > type ISSHConnectProgress,
26 > type ISSHConnectResult,
27 > type ISSHKeyboardInteractivePrompt,
28 > type ISSHKeyboardInteractiveRequest,
29 > type ISSHResolvedConfig,
30 > } from '../common/sshRemoteAgentHost.js';
31 > import type { IRelayMessage } from '../common/relayTransport.js';
32 > import {
33 > buildAgentHostBaseCommand,
34 > buildCLIDownloadUrl,
35 > buildCleanupOldCLIsCommand,
36 > buildFindFallbackCLICommand,
37 > cleanupRemoteAgentHost,
38 > extractAgentHostWebSocketURL,
39 > findRunningAgentHost,
40 > getRemoteCLIBin,
41 > getRemoteCLIDataDir,
42 > getRemoteCLIInstallRoot,
43 > isValidFallbackCLIPath,
44 > redactToken,
45 > resolveRemotePlatform,
46 > shellEscape,
47 > writeAgentHostState,
48 > } from './sshRemoteAgentHostHelpers.js';
49 > import { parseSSHConfigHostEntries, parseSSHGOutput, stripSSHComment } from '../common/sshConfigParsing.js';
50 > import { removeAnsiEscapeCodes } from '../../../base/common/strings.js';
51 >
52 > /** Minimal subset of ssh2.ClientChannel used by this module (duplex stream). */
53 > interface SSHChannel extends NodeJS.ReadWriteStream {
54 > on(event: 'data', listener: (data: Buffer) => void): this;
55 > on(event: 'close', listener: (code: number) => void): this;
56 > on(event: 'error', listener: (err: Error) => void): this;
57 > on(event: string, listener: (...args: unknown[]) => void): this;
58 > stderr: { on(event: 'data', listener: (data: Buffer) => void): void };
59 > close(): void;
60 > }
61 >
62 > /** Minimal subset of ssh2.Client used by this module. */
63 > interface SSHClient {
64 > on(event: 'ready', listener: () => void): SSHClient;
65 > on(event: 'error', listener: (err: Error) => void): SSHClient;
66 > on(event: 'close', listener: () => void): SSHClient;
67 > removeListener(event: 'close', listener: () => void): SSHClient;
68 > removeListener(event: 'error', listener: (err: Error) => void): SSHClient;
69 > connect(config: ConnectConfig): void;
70 > exec(command: string, callback: (err: Error | undefined, stream: SSHChannel) => void): SSHClient;
71 > forwardOut(srcIP: string, srcPort: number, dstIP: string, dstPort: number, callback: (err: Error | undefined, channel: SSHChannel) => void): SSHClient;
72 > end(): void;
73 > }
74 >
75 > const LOG_PREFIX = '[SSHRemoteAgentHost]';
76 >
77 > /**
78 > * Maximum time to wait for {@link SSHRemoteAgentHostMainService._createWebSocketRelay}
79 > * to settle on the `replaceRelay` reconnect path before giving up. A silently
80 > * dead SSH client (TCP half-open, ssh2 keepalive hasn't fired yet) can leave
81 > * `forwardOut`'s callback unfired, hanging the whole `connect()` call. Bounding
82 > * this surfaces a clean failure so the renderer can clear its pending-reconnect
83 > * flag and retry, and so the dead SSH client gets ended (purging it from the
84 > * shared-process `_connections` map).
85 > *
86 > * The value is just slightly larger than ssh2's default keepalive failure
87 > * window (`keepaliveInterval * keepaliveCountMax` ~= 15s * 3 = 45s) so that in
88 > * practice the SSH client itself will surface its own `'close'` first when
89 > * the network is hard-down. Tests override this to a much smaller value.
90 > */
91 > const RECONNECT_RELAY_TIMEOUT_MS = 60_000;
92 >
93 > /**
94 > * One entry in the queue of authentication attempts handed to ssh2's
95 > * `authHandler`. Each attempt corresponds to one of the auth method shapes
96 > * documented at https://www.npmjs.com/package/ssh2#client-methods.
97 > *
98 > * `keyPath` is internal-only metadata for logging — it is stripped before the
99 > * attempt is returned to ssh2.
100 > */
101 > export type SSHAuthAttempt =
102 > | { readonly type: 'publickey'; readonly username: string; readonly key: Buffer; readonly keyPath: string; readonly encrypted?: boolean }
103 > | { readonly type: 'agent'; readonly username: string; readonly agent: string }
104 > | { readonly type: 'password'; readonly username: string; readonly password: string }
105 > | { readonly type: 'keyboard-interactive'; readonly username: string };
106 >
107 function describeAuthAttempt(attempt: SSHAuthAttempt): string {
108 switch (attempt.type) {
113 }
114 }
116 > /**
117 > * Callback invoked when the SSH server requests keyboard-interactive
118 > * authentication. The handler must eventually call `finish` with the
119 > * user's responses (or an empty array to fail this attempt).
120 > */
121 > export type SSHKeyboardInteractivePromptHandler = (
122 > name: string,
123 > instructions: string,
124 > prompts: readonly ISSHKeyboardInteractivePrompt[],
125 > finish: (responses: readonly string[]) => void,
126 > ) => void;
127 >
128 > export type SSHKeyPassphrasePromptHandler = (
129 > keyPath: string,
130 > finish: (passphrase: string | undefined) => void,
131 > ) => void;
132 >
133 > /**
134 > * Translate a {@link SSHAuthAttempt} into the payload shape ssh2 expects in
135 > * its `authHandler` callback. Returns `undefined` when the attempt cannot be
136 > * realized (currently only `keyboard-interactive` without a prompt handler).
137 > *
138 > * The kbi case is the one place where we still need a callback-bridge: ssh2
139 > * calls our `prompt` with a `finish(string[])` and we hand the responses to
140 > * `kbiHandler`. Isolating that here keeps it out of the iteration loop below.
141 > */
142 function toAuthMethod(
143 attempt: SSHAuthAttempt,
183 }
184 }
186 > /**
187 > * `agent` is a publickey-flavored method at the SSH protocol level — servers
188 > * advertise `publickey`, not `agent`, in `methodsLeft`. Returns true when the
189 > * server still has the underlying protocol method on offer.
190 > */
191 function isMethodAllowedByServer(attempt: SSHAuthAttempt, methodsLeft: AuthenticationType[] | null): boolean {
192 if (!methodsLeft) {
196 return methodsLeft.includes(protocolMethod);
197 }
199 > /**
200 > * Build an ssh2 `authHandler` callback that walks the given attempts in order,
201 > * filtering by the server-advertised `methodsLeft` when ssh2 provides one.
202 > * Returns `false` when the queue is exhausted, which causes ssh2 to surface
203 > * an authentication failure to the caller.
204 > *
205 > * `kbiHandler` (when provided) is invoked by ssh2 if the server picks the
206 > * `keyboard-interactive` attempt, and is responsible for collecting
207 > * responses (e.g. by prompting the user).
208 > */
209 > export function makeAuthHandler(
210 attempts: readonly SSHAuthAttempt[],
211 logService: ILogService,
238 };
239 }
241 function readSSHString(buffer: Buffer, offset: number): { value: string; offset: number } | undefined {
242 if (offset + 4 > buffer.length) {
251 return { value: buffer.toString('utf8', valueOffset, nextOffset), offset: nextOffset };
252 }
254 function isEncryptedPrivateKey(key: Buffer): boolean {
255 const text = key.toString('utf8');
269 return !!cipher && cipher.value !== 'none';
270 }
272 function sshExec(client: SSHClient, command: string, opts?: { ignoreExitCode?: boolean }): Promise<{ stdout: string; stderr: string; code: number }> {
273 return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {
305 });
306 }
308 > /** Create a bound exec function for the given SSH client. */
309 function bindSshExec(client: SSHClient): (command: string, opts?: { ignoreExitCode?: boolean }) => Promise<{ stdout: string; stderr: string; code: number }> {
310 return (command, opts) => sshExec(client, command, opts);
311 }
313 function startRemoteAgentHost(
314 client: SSHClient,
401 });
402 }
404 > /**
405 > * Create a WebSocket connection to the remote agent host via an SSH forwarded channel.
406 > * Uses the `ws` library to speak WebSocket over the SSH channel.
407 > * Messages are relayed to the renderer via IPC events.
408 > */
409 function createWebSocketRelay(
410 nativeRequire: NodeJS.Require,
465 });
466 }
468 function sanitizeConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfigSanitized {
469 const { password: _p, privateKeyPath: _k, ...sanitized } = config;
470 return sanitized;
471 }
473 > /**
474 > * State for a single active SSH relay connection.
475 > * Immutable and dispose-once — follows the same pattern as TunnelConnection.
476 > * On reconnect, the old SSHConnection is disposed and a fresh one is created;
477 > * the SSH client can be detached first so only the WebSocket relay is torn down.
478 > */
479 > class SSHConnection extends Disposable {
480 > private readonly _onDidClose = new Emitter<void>();
481 > readonly onDidClose = this._onDidClose.event;
482 >
483 > readonly config: ISSHAgentHostConfigSanitized;
484 > private _closed = false;
485 > private _sshClientDetached = false;
486 > private readonly _sshCloseListener = () => {
487 this._logService.info(`${LOG_PREFIX} SSH client closed for connection ${this.connectionId} (address ${this.address}); disposing connection`);
488 this.dispose();
489 };
490 > private readonly _sshErrorListener = (err?: Error) => { sshRemoteAgentHostService.ts
491 this._logService.info(`${LOG_PREFIX} SSH client error for connection ${this.connectionId} (address ${this.address}): ${err instanceof Error ? err.message : String(err)}; disposing connection`);
492 this.dispose();
493 };
495 > constructor(
496 fullConfig: ISSHAgentHostConfig,
497 readonly connectionId: string,
528 sshClient.on('error', this._sshErrorListener);
529 }
531 > /**
532 > * Detach the SSH client from this connection so that `dispose()`
533 > * only closes the WebSocket relay without ending the SSH session.
534 > * Also removes event listeners from the SSH client so the old
535 > * connection object is not retained by the shared client.
536 > */
537 > detachSshClient(): void {
538 this._sshClientDetached = true;
539 this.sshClient.removeListener('close', this._sshCloseListener);
540 this.sshClient.removeListener('error', this._sshErrorListener);
541 }
543 > relaySend(data: string): void {
544 this._relay.send(data);
545 }
547 >
548 > export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRemoteAgentHostMainService {
549 > declare readonly _serviceBrand: undefined;
550 >
551 > private readonly _onDidChangeConnections = this._register(new Emitter<void>());
552 > readonly onDidChangeConnections: Event<void> = this._onDidChangeConnections.event;
553 >
554 > private readonly _onDidCloseConnection = this._register(new Emitter<string>());
555 > readonly onDidCloseConnection: Event<string> = this._onDidCloseConnection.event;
556 >
557 > private readonly _onDidReportConnectProgress = this._register(new Emitter<ISSHConnectProgress>());
558 > readonly onDidReportConnectProgress: Event<ISSHConnectProgress> = this._onDidReportConnectProgress.event;
559 >
560 > private readonly _onDidRelayMessage = this._register(new Emitter<IRelayMessage>());
561 > readonly onDidRelayMessage: Event<IRelayMessage> = this._onDidRelayMessage.event;
562 >
563 > private readonly _onDidRelayClose = this._register(new Emitter<string>());
564 > readonly onDidRelayClose: Event<string> = this._onDidRelayClose.event;
565 >
566 > private readonly _onDidRequestKeyboardInteractive = this._register(new Emitter<ISSHKeyboardInteractiveRequest>());
567 > readonly onDidRequestKeyboardInteractive: Event<ISSHKeyboardInteractiveRequest> = this._onDidRequestKeyboardInteractive.event;
568 >
569 > private readonly _onDidCancelKeyboardInteractive = this._register(new Emitter<string>());
570 > readonly onDidCancelKeyboardInteractive: Event<string> = this._onDidCancelKeyboardInteractive.event;
571 >
572 > /**
573 > * Pending keyboard-interactive prompts awaiting a response from the renderer.
574 > * Keyed by `requestId`. Each entry can either finish the ssh2 prompt with
575 > * responses or cancel the owning connect attempt when the user dismisses it.
576 > */
577 > private readonly _pendingKbiRequests = new Map<string, { finish: (responses: readonly string[]) => void; cancelConnect: () => void }>();
578 > private _kbiRequestCounter = 0;
579 >
580 > private readonly _connections = this._register(new DisposableMap<string, SSHConnection>());
581 >
582 > private _nativeRequire: NodeJS.Require | undefined;
583 >
584 > /**
585 > * Override hook for tests to shorten the relay-creation timeout used on
586 > * the `replaceRelay` reconnect path. See {@link RECONNECT_RELAY_TIMEOUT_MS}.
587 > */
588 > protected relayCreationTimeoutMs: number = RECONNECT_RELAY_TIMEOUT_MS;
589 >
590 > constructor(
591 @ILogService private readonly _logService: ILogService,
592 @IProductService private readonly _productService: IProductService,
594 super();
595 }
597 > /**
598 > * Lazily load a `require` function for native modules (`ssh2`, `ws`).
599 > * Uses a dynamic `import('node:module')` so the module is only resolved
600 > * when actually needed at runtime — not at file-load time. This matters
601 > * because tests override the methods that call this and never trigger
602 > * the import, avoiding issues with Electron's ESM loader which cannot
603 > * resolve `node:` specifiers.
604 > */
605 > private async _getNativeRequire(): Promise<NodeJS.Require> {
606 if (!this._nativeRequire) {
607 const nodeModule = await import('node:module');
610 return this._nativeRequire;
611 }
613 > async connect(config: ISSHAgentHostConfig, replaceRelay?: boolean): Promise<ISSHConnectResult> {
614 const connectionKey = config.sshConfigHost
615 ? `ssh:${config.sshConfigHost}`
850 }
851 }
853 > async disconnect(host: string): Promise<void> {
854 for (const [key, conn] of this._connections) {
855 if (key === host || conn.connectionId === host) {
859 }
860 }
862 > async relaySend(connectionId: string, message: string): Promise<void> {
863 for (const conn of this._connections.values()) {
864 if (conn.connectionId === connectionId) {
868 }
869 }
871 > async reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string, agentForward?: boolean): Promise<ISSHConnectResult> {
872 this._logService.info(`${LOG_PREFIX} Reconnecting via SSH config host: ${sshConfigHost}`);
873 const resolved = await this.resolveSSHConfig(sshConfigHost);
896 }, /* replaceRelay */ true);
897 }
899 > async listSSHConfigHosts(): Promise<string[]> {
900 const configPath = join(os.homedir(), '.ssh', 'config');
901 try {
907 }
908 }
910 > async ensureUserSSHConfig(): Promise<URI> {
911 const sshDir = join(os.homedir(), '.ssh');
912 const configPath = join(sshDir, 'config');
931 return URI.file(configPath);
932 }
934 > async listSSHConfigFiles(): Promise<URI[]> {
935 const isWindows = process.platform === 'win32';
936 const userConfigPath = join(os.homedir(), '.ssh', 'config');
948 return result;
949 }
951 > async resolveSSHConfig(host: string): Promise<ISSHResolvedConfig> {
952 return new Promise<ISSHResolvedConfig>((resolve, reject) => {
953 cp.execFile('ssh', ['-G', host], { timeout: 5000 }, (err, stdout) => {
961 });
962 }
964 > private async _parseSSHConfigHosts(content: string, configDir: string, visited?: Set<string>): Promise<string[]> {
965 const seen = visited ?? new Set<string>();
966 const hosts: string[] = [];
1028 return hosts;
1029 }
1031 > private _parseSSHGOutput(stdout: string): ISSHResolvedConfig {
1032 return parseSSHGOutput(stdout);
1033 }
1035 > protected async _connectSSH(
1036 config: ISSHAgentHostConfig,
1037 connectionKey?: string,
1151 });
1152 }
1154 > protected async _createSSHClient(): Promise<SSHClient> {
1155 const nativeRequire = await this._getNativeRequire();
1156 const ssh2Module = nativeRequire('ssh2') as { Client: new () => unknown };
1157 return new ssh2Module.Client() as SSHClient;
1158 }
1160 > /**
1161 > * Build the ordered list of authentication attempts to feed to ssh2's
1162 > * `authHandler`. In `Agent` mode we try the configured agent first (so a
1163 > * loaded identity short-circuits before we ever touch an encrypted key
1164 > * file), then any non-default explicit `IdentityFile`, then each readable
1165 > * default identity in turn. A host that accepts `~/.ssh/id_rsa` still
1166 > * works even if the agent doesn't have it loaded — without needing an
1167 > * explicit `IdentityFile` entry in `~/.ssh/config`.
1168 > */
1169 > protected async _buildAuthAttempts(config: ISSHAgentHostConfig): Promise<SSHAuthAttempt[]> {
1170 const attempts: SSHAuthAttempt[] = [];
1171 const username = config.username;
1227 return attempts;
1228 }
1230 > private static readonly _defaultKeyPaths = [
1231 > '~/.ssh/id_ed25519',
1232 > '~/.ssh/id_rsa',
1233 > '~/.ssh/id_ecdsa',
1234 > '~/.ssh/id_dsa',
1235 > '~/.ssh/id_xmss',
1236 > ];
1237 >
1238 > /**
1239 > * Expand a leading `~` to the current user's home directory so that paths
1240 > * coming back from `ssh -G` (always absolute) compare equal to our
1241 > * `~`-prefixed defaults.
1242 > */
1243 > private static _normalizeKeyPath(keyPath: string): string {
1244 return keyPath.replace(/^~/, os.homedir());
1245 }
1247 > private static _isDefaultKeyPath(keyPath: string): boolean {
1248 const normalized = SSHRemoteAgentHostMainService._normalizeKeyPath(keyPath);
1249 return SSHRemoteAgentHostMainService._defaultKeyPaths.some(p => SSHRemoteAgentHostMainService._normalizeKeyPath(p) === normalized);
1250 }
1252 > /** Test seam: returns the SSH agent socket path, or undefined when no agent is available. */
1253 > protected _isAgentAvailable(): string | undefined {
1254 return process.env['SSH_AUTH_SOCK'];
1255 }
1257 > protected _getAgentSocket(config: ISSHAgentHostConfig): string | undefined {
1258 if (config.identityAgent !== undefined) {
1259 return this._resolveIdentityAgent(config.identityAgent);
1261 return this._isAgentAvailable();
1262 }
1264 > private _resolveIdentityAgent(identityAgent: string): string | undefined {
1265 const trimmed = identityAgent.trim();
1266 if (!trimmed || trimmed.toLowerCase() === 'none') {
1276 return trimmed.replace(/^~/, os.homedir());
1277 }
1279 > /**
1280 > * Forward a keyboard-interactive challenge from ssh2 to the renderer and
1281 > * register the `finish` callback so {@link respondKeyboardInteractive} can
1282 > * supply the user's responses when they arrive. Returns the generated
1283 > * `requestId` so the caller can track in-flight prompts.
1284 > */
1285 > protected _handleKeyboardInteractive(
1286 connectionKey: string,
1287 displayHost: string,
1318 return requestId;
1319 }
1321 > async respondKeyboardInteractive(requestId: string, responses: readonly string[] | undefined): Promise<void> {
1322 const pending = this._pendingKbiRequests.get(requestId);
1323 if (!pending) {
1332 pending.finish(responses);
1333 }
1335 > /**
1336 > * Test seam: read a private key file from disk. Returns `undefined` if the
1337 > * file doesn't exist; logs and returns `undefined` for any other read error
1338 > * so a single broken key doesn't abort the whole auth flow.
1339 > */
1340 > protected async _readKeyFileIfExists(keyPath: string): Promise<Buffer | undefined> {
1341 const resolved = keyPath.replace(/^~/, os.homedir());
1342 try {
1351 }
1352 }
1354 > private get _quality(): string {
1355 return this._productService.quality || 'insider';
1356 }
1358 > private get _serverDataFolderName(): string {
1359 return this._productService.serverDataFolderName ?? '.vscode-server-oss';
1360 }
1362 > private get _commit(): string | undefined {
1363 return this._productService.commit;
1364 }
1366 > protected _startRemoteAgentHost(
1367 client: SSHClient, cliBin: string | undefined, cliDataDir: string | undefined, commandOverride?: string,
1368 ): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> {
1369 return startRemoteAgentHost(client, this._logService, cliBin, cliDataDir, commandOverride);
1370 }
1372 > protected async _createWebSocketRelay(
1373 client: SSHClient, dstHost: string, dstPort: number, connectionToken: string | undefined,
1374 onMessage: (data: string) => void, onClose: () => void,
1377 return createWebSocketRelay(nativeRequire, client, dstHost, dstPort, connectionToken, this._logService, onMessage, onClose);
1378 }
1380 > /**
1381 > * Resolve which CLI binary to run on the remote.
1382 > *
1383 > * When the desktop has a `productService.commit` (release builds), we
1384 > * pin to that commit: install at `~/<serverDataFolderName>/<archive>-<commit>`
1385 > * (sharing the install root with Remote-SSH), reuse on file existence,
1386 > * download from the commit-pinned URL on miss, and clean up older
1387 > * commit-keyed CLIs (keep last 5). The agent host CLI does not
1388 > * self-update on this path, so the desktop pushes freshness on every
1389 > * fresh start — but tolerantly: if the download fails and any other
1390 > * usable CLI is present (other commit-keyed or the legacy
1391 > * `~/.vscode-cli{,-<quality>}/<archive>`), we fall back to the newest
1392 > * one rather than refusing to connect.
1393 > *
1394 > * In dev/OSS builds with no commit, we keep the loose, non-pinned
1395 > * behavior: install `~/<serverDataFolderName>/<archive>` from the
1396 > * `latest` URL, with a `--version`-based reuse check.
1397 > *
1398 > * Returns the resolved CLI binary path to run.
1399 > */
1400 > private async _ensureCLIInstalled(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise<string> {
1401 const commit = this._commit;
1402 if (!commit) {
1405 return this._ensureCLIInstalledPinned(client, platform, reportProgress, commit);
1406 }
1408 > /**
1409 > * Commit-pinned install path. See {@link _ensureCLIInstalled}.
1410 > */
1411 > private async _ensureCLIInstalledPinned(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void, commit: string): Promise<string> {
1412 const cliBin = getRemoteCLIBin(this._serverDataFolderName, this._quality, commit);
1413 const installRoot = getRemoteCLIInstallRoot(this._serverDataFolderName);
1485 }
1486 }
1488 > /**
1489 > * Loose dev-build install: no commit pin. See {@link _ensureCLIInstalled}.
1490 > */
1491 > private async _ensureCLIInstalledLoose(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise<string> {
1492 const cliBin = getRemoteCLIBin(this._serverDataFolderName, this._quality);
1493 const installRoot = getRemoteCLIInstallRoot(this._serverDataFolderName);
1513 return cliBin;
1514 }
1516 > /**
1517 > * List remote CLI candidates that could be used as a fallback when the
1518 > * commit-pinned download fails, and return the newest one that passes
1519 > * a `--version` check. Returns `undefined` if no candidate works.
1520 > */
1521 > private async _findFallbackCLI(client: SSHClient): Promise<string | undefined> {
1522 const { stdout } = await sshExec(client, buildFindFallbackCLICommand(this._serverDataFolderName, this._quality), { ignoreExitCode: true });
1523 const rawCandidates = stdout.split('\n').map(s => s.trim()).filter(s => s.length > 0);
src/vs/platform/agentHost/common/sshRemoteAgentHost.ts 285 introduced LOC · 1 range

Open complete file

1 > /*--------------------------------------------------------------------------------------------- sshRemoteAgentHost.ts
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { Event } from '../../../base/common/event.js';
7 > import { IDisposable } from '../../../base/common/lifecycle.js';
8 > import { URI } from '../../../base/common/uri.js';
9 > import { createDecorator } from '../../instantiation/common/instantiation.js';
10 > import type { IRelayMessage } from './relayTransport.js';
11 >
12 > export type { IRelayMessage } from './relayTransport.js';
13 >
14 > export const ISSHRemoteAgentHostService = createDecorator<ISSHRemoteAgentHostService>('sshRemoteAgentHostService');
15 >
16 > /**
17 > * IPC channel name for the main-process SSH service.
18 > */
19 > export const SSH_REMOTE_AGENT_HOST_CHANNEL = 'sshRemoteAgentHost';
20 >
21 > export const enum SSHAuthMethod {
22 > /** Use the local SSH agent for key-based auth. */
23 > Agent = 'agent',
24 > /** Authenticate with an explicit private key file. */
25 > KeyFile = 'keyFile',
26 > /** Authenticate with a password. */
27 > Password = 'password',
28 > }
29 >
30 > export interface ISSHAgentHostConfig {
31 > /** Remote hostname or IP. */
32 > readonly host: string;
33 > /** SSH port (default 22). */
34 > readonly port?: number;
35 > /** Username on the remote machine. */
36 > readonly username: string;
37 > /** Authentication method. */
38 > readonly authMethod: SSHAuthMethod;
39 > /** Path to the private key file (when {@link authMethod} is KeyFile). */
40 > readonly privateKeyPath?: string;
41 > /** Raw IdentityAgent value from resolved SSH config; may be a socket path, `none`, `SSH_AUTH_SOCK`, or an environment reference. */
42 > readonly identityAgent?: string;
43 > /** Password string (when {@link authMethod} is Password). */
44 > readonly password?: string;
45 > /** Display name for this connection. */
46 > readonly name: string;
47 > /** SSH config host alias (e.g. "robfast2") for reconnection on restart. */
48 > readonly sshConfigHost?: string;
49 > /** Dev override: custom command to start the remote agent host instead of the default CLI. */
50 > readonly remoteAgentHostCommand?: string;
51 > /** When true, enables OpenSSH agent forwarding ([email protected]) for this connection. Requires {@link authMethod} to be Agent. */
52 > readonly agentForward?: boolean;
53 > }
54 >
55 > /**
56 > * A sanitized view of the SSH config that omits secret material
57 > * (password, private key path). Exposed on active connections so
58 > * consumers can inspect connection metadata without accessing credentials.
59 > */
60 > export type ISSHAgentHostConfigSanitized = Omit<ISSHAgentHostConfig, 'password' | 'privateKeyPath'>;
61 >
62 > export interface ISSHAgentHostConnection extends IDisposable {
63 > /** The SSH config used to establish this connection (secrets stripped). */
64 > readonly config: ISSHAgentHostConfigSanitized;
65 > /** The connection address (e.g. `ssh:myhost` or `user@host:22`) registered with IRemoteAgentHostService. */
66 > readonly localAddress: string;
67 > /** The display name. */
68 > readonly name: string;
69 > /** Fires when this SSH connection is closed or lost. */
70 > readonly onDidClose: Event<void>;
71 > }
72 >
73 > /**
74 > * Manages SSH connections that bootstrap a remote agent host process.
75 > *
76 > * Each connection SSHs into a remote machine, ensures the VS Code CLI
77 > * is installed, starts `code agent-host`, and creates a WebSocket relay
78 > * over the SSH channel. Messages are forwarded between the renderer and
79 > * the remote agent host via IPC through the shared process.
80 > */
81 > export interface ISSHRemoteAgentHostService {
82 > readonly _serviceBrand: undefined;
83 >
84 > /** Fires when the set of active SSH connections changes. */
85 > readonly onDidChangeConnections: Event<void>;
86 >
87 > /** Progress messages during connect. */
88 > readonly onDidReportConnectProgress: Event<ISSHConnectProgress>;
89 >
90 > /** Currently active SSH-bootstrapped connections. */
91 > readonly connections: readonly ISSHAgentHostConnection[];
92 >
93 > /**
94 > * Bootstrap a remote agent host over SSH.
95 > *
96 > * 1. Opens an SSH connection to the remote host
97 > * 2. Downloads and installs the VS Code CLI if needed
98 > * 3. Starts `code agent-host`
99 > * 4. Creates a WebSocket relay over the SSH channel
100 > * 5. Registers the connection with {@link IRemoteAgentHostService}
101 > *
102 > * Resolves with the connection handle once the agent host is reachable.
103 > */
104 > connect(config: ISSHAgentHostConfig): Promise<ISSHAgentHostConnection>;
105 >
106 > /**
107 > * Disconnect an SSH-bootstrapped connection by host address.
108 > * Tears down the SSH tunnel, stops the remote agent host, and
109 > * removes the entry from {@link IRemoteAgentHostService}.
110 > */
111 > disconnect(host: string): Promise<void>;
112 >
113 > /** List SSH config host aliases (excluding wildcards). */
114 > listSSHConfigHosts(): Promise<string[]>;
115 >
116 > /**
117 > * Ensure `~/.ssh/config` exists (creating it with the right permissions if
118 > * missing) and return its URI. The parent `~/.ssh` directory is created
119 > * with mode 0700 and the config file with mode 0600 on POSIX systems.
120 > */
121 > ensureUserSSHConfig(): Promise<URI>;
122 >
123 > /**
124 > * List the known SSH configuration file URIs in priority order — typically the
125 > * per-user `~/.ssh/config` (always returned, even if it does not yet exist) and
126 > * the system-wide `/etc/ssh/ssh_config` (only when present on disk).
127 > */
128 > listSSHConfigFiles(): Promise<URI[]>;
129 >
130 > /** Resolve full SSH config for a host via `ssh -G`. */
131 > resolveSSHConfig(host: string): Promise<ISSHResolvedConfig>;
132 >
133 > /**
134 > * Re-establish an SSH tunnel on startup for a previously connected host.
135 > * Returns the new local forwarded address and registers it.
136 > */
137 > reconnect(sshConfigHost: string, name: string): Promise<ISSHAgentHostConnection>;
138 > }
139 > /**
140 > * Serializable result from a successful SSH connect operation.
141 > * Returned over IPC from the main process.
142 > */
143 > export interface ISSHConnectResult {
144 > /** Unique identifier for this connection's relay channel. */
145 > readonly connectionId: string;
146 > /** Display-friendly address (e.g. "ssh:robfast2"). */
147 > readonly address: string;
148 > readonly name: string;
149 > readonly connectionToken: string | undefined;
150 > readonly config: ISSHAgentHostConfigSanitized;
151 > /** SSH config host alias for reconnection on restart. */
152 > readonly sshConfigHost?: string;
153 > }
154 >
155 > /**
156 > * Resolved SSH configuration for a host, obtained from `ssh -G`.
157 > */
158 > export interface ISSHResolvedConfig {
159 > readonly hostname: string;
160 > readonly user: string | undefined;
161 > readonly port: number;
162 > readonly identityFile: string[];
163 > readonly identityAgent: string | undefined;
164 > readonly forwardAgent: boolean;
165 > }
166 >
167 > export interface ISSHConnectProgress {
168 > readonly connectionKey: string;
169 > readonly message: string;
170 > }
171 >
172 > /**
173 > * A single prompt within a keyboard-interactive authentication request.
174 > * Mirrors the shape ssh2 hands us — `echo: false` means the user input
175 > * should be hidden (typically a password).
176 > */
177 > export interface ISSHKeyboardInteractivePrompt {
178 > readonly prompt: string;
179 > readonly echo: boolean;
180 > }
181 >
182 > /**
183 > * Request from the main process for the renderer to gather responses to
184 > * a keyboard-interactive auth challenge from the SSH server. The renderer
185 > * is expected to respond with {@link ISSHRemoteAgentHostMainService.respondKeyboardInteractive}
186 > * within a reasonable time, or the underlying SSH connect attempt will time out.
187 > */
188 > export interface ISSHKeyboardInteractiveRequest {
189 > readonly requestId: string;
190 > readonly connectionKey: string;
191 > /** Display-friendly host (e.g. SSH config alias or `user@host`). */
192 > readonly displayHost: string;
193 > readonly username: string;
194 > /** Optional name field from the server (often empty). */
195 > readonly name: string;
196 > /** Optional instructions field from the server (often empty). */
197 > readonly instructions: string;
198 > readonly prompts: readonly ISSHKeyboardInteractivePrompt[];
199 > }
200 >
201 > /**
202 > * Main-process service that performs the actual SSH work.
203 > * The renderer calls this over IPC and handles registration
204 > * with {@link IRemoteAgentHostService} locally.
205 > */
206 > export const ISSHRemoteAgentHostMainService = createDecorator<ISSHRemoteAgentHostMainService>('sshRemoteAgentHostMainService');
207 >
208 > export interface ISSHRemoteAgentHostMainService {
209 > readonly _serviceBrand: undefined;
210 >
211 > /** Fires when the set of active SSH connections changes. */
212 > readonly onDidChangeConnections: Event<void>;
213 >
214 > /** Fires when a connection is closed from the shared process side. */
215 > readonly onDidCloseConnection: Event<string /* connectionId */>;
216 >
217 > /** Progress messages during connect (e.g. "Installing CLI..."). */
218 > readonly onDidReportConnectProgress: Event<ISSHConnectProgress>;
219 >
220 > /** Fires when a message is received from a remote agent host via the SSH relay. */
221 > readonly onDidRelayMessage: Event<IRelayMessage>;
222 >
223 > /** Fires when a relay connection to a remote agent host closes. */
224 > readonly onDidRelayClose: Event<string /* connectionId */>;
225 >
226 > /**
227 > * Fires when the SSH server requests keyboard-interactive auth (typically
228 > * a password prompt). The renderer must answer via {@link respondKeyboardInteractive}
229 > * with the same `requestId`, otherwise the auth attempt will hang until the
230 > * SSH `readyTimeout` elapses.
231 > */
232 > readonly onDidRequestKeyboardInteractive: Event<ISSHKeyboardInteractiveRequest>;
233 >
234 > /**
235 > * Fires when a previously requested keyboard-interactive prompt is no
236 > * longer needed (e.g. the underlying SSH connect attempt failed or was
237 > * aborted). The renderer should dismiss any UI it opened for `requestId`.
238 > */
239 > readonly onDidCancelKeyboardInteractive: Event<string /* requestId */>;
240 >
241 > /**
242 > * Provide responses for a previously fired keyboard-interactive request.
243 > * Pass `undefined` when the user cancels the prompt; this aborts the
244 > * owning SSH connection attempt.
245 > */
246 > respondKeyboardInteractive(requestId: string, responses: readonly string[] | undefined): Promise<void>;
247 >
248 > /**
249 > * Bootstrap a remote agent host over SSH. Returns serializable
250 > * connection info for the renderer to register.
251 > */
252 > connect(config: ISSHAgentHostConfig): Promise<ISSHConnectResult>;
253 >
254 > /**
255 > * Send a message to a remote agent host through the SSH relay.
256 > */
257 > relaySend(connectionId: string, message: string): Promise<void>;
258 >
259 > /**
260 > * Disconnect an SSH-bootstrapped connection by host address.
261 > */
262 > disconnect(host: string): Promise<void>;
263 >
264 > /** List SSH config host aliases (excluding wildcards). */
265 > listSSHConfigHosts(): Promise<string[]>;
266 >
267 > /**
268 > * Ensure `~/.ssh/config` exists (creating it with the right permissions if
269 > * missing) and return its URI.
270 > */
271 > ensureUserSSHConfig(): Promise<URI>;
272 >
273 > /** List the known SSH configuration file URIs (user config always included). */
274 > listSSHConfigFiles(): Promise<URI[]>;
275 >
276 > /** Resolve full SSH config for a host via `ssh -G`. */
277 > resolveSSHConfig(host: string): Promise<ISSHResolvedConfig>;
278 >
279 > /**
280 > * Re-establish an SSH tunnel for a previously connected host.
281 > * Resolves the SSH config alias, connects, and returns fresh
282 > * connection info with a new local forwarded port.
283 > */
284 > reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string, agentForward?: boolean): Promise<ISSHConnectResult>;
285 > }