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.

1 > /*--------------------------------------------------------------------------------------------- sshRemoteAgentHostService.ts ×49
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 { sshRemoteAgentHostService.ts ×5
108 > switch (attempt.type) {
109 > case 'publickey': return `publickey ${attempt.keyPath}`;
110 > case 'agent': return 'agent';
111 > case 'password': return 'password';
112 > case 'keyboard-interactive': return 'keyboard-interactive';
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( sshRemoteAgentHostService.ts ×5
143 > attempt: SSHAuthAttempt,
144 > kbiHandler: SSHKeyboardInteractivePromptHandler | undefined,
145 > keyPassphraseHandler: SSHKeyPassphrasePromptHandler | undefined,
146 > callback: (next: AnyAuthMethod | false) => void,
147 > ): AnyAuthMethod | undefined {
148 > switch (attempt.type) {
149 > case 'publickey': {
150 > // Strip our internal `keyPath` metadata before handing to ssh2. sshRemoteAgentHostService.ts ×1
151 > const { keyPath: _kp, encrypted: _encrypted, ...payload } = attempt;
152 > if (attempt.encrypted) {
153 > if (!keyPassphraseHandler) { sshRemoteAgentHostService.ts ×4
154 return undefined;
155 }
156 > keyPassphraseHandler(attempt.keyPath, passphrase => { sshRemoteAgentHostService.ts ×4
157 > if (passphrase === undefined) {
158 callback(false);
159 return;
160 }
161 > callback({ ...payload, passphrase }); sshRemoteAgentHostService.ts ×4
162 > });
163 > return undefined;
164 > }
165 > return payload; sshRemoteAgentHostService.ts ×1
166 > }
167 > case 'agent': sshRemoteAgentHostService.ts ×5
168 > case 'password':
169 > return attempt; sshRemoteAgentHostService.ts ×1
170 > case 'keyboard-interactive': { sshRemoteAgentHostService.ts ×5
171 > if (!kbiHandler) { sshRemoteAgentHostService.ts ×2
172 > return undefined; sshRemoteAgentHostService.ts ×2
173 > }
175 > type: 'keyboard-interactive',
176 > username: attempt.username,
177 > prompt: (name, instructions, _lang, prompts, finish) => {
178 > const normalized = prompts.map(p => ({ prompt: p.prompt, echo: p.echo ?? true }));
179 > kbiHandler(name, instructions, normalized, responses => finish([...responses]));
180 > },
181 > };
182 > }
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 { sshRemoteAgentHostService.ts ×5
192 > if (!methodsLeft) {
194 > }
195 > const protocolMethod: AuthenticationType = attempt.type === 'agent' ? 'publickey' : attempt.type; sshRemoteAgentHostService.ts ×5
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[], sshRemoteAgentHostService.ts ×5
211 > logService: ILogService,
212 > kbiHandler?: SSHKeyboardInteractivePromptHandler,
213 > keyPassphraseHandler?: SSHKeyPassphrasePromptHandler,
214 > ): (methodsLeft: AuthenticationType[] | null, partialSuccess: boolean, callback: (next: AnyAuthMethod | false) => void) => void {
215 > let index = 0;
216 > return (methodsLeft, _partialSuccess, callback) => {
217 > while (index < attempts.length) {
218 > const attempt = attempts[index++];
219 > if (!isMethodAllowedByServer(attempt, methodsLeft)) {
220 > logService.info(`${LOG_PREFIX} Skipping ${describeAuthAttempt(attempt)} — server only allows ${methodsLeft!.join(', ')}`); sshRemoteAgentHostService.ts ×1
221 > continue;
222 > }
223 > const method = toAuthMethod(attempt, kbiHandler, keyPassphraseHandler, callback); sshRemoteAgentHostService.ts ×5
224 > if (!method) {
225 > if (attempt.type === 'publickey' && attempt.encrypted && keyPassphraseHandler) { sshRemoteAgentHostService.ts ×1
226 > logService.info(`${LOG_PREFIX} Trying auth: ${describeAuthAttempt(attempt)}`); sshRemoteAgentHostService.ts ×4
227 > return;
228 > }
229 > logService.warn(`${LOG_PREFIX} ${describeAuthAttempt(attempt)} skipped: no prompt handler available`); sshRemoteAgentHostService.ts ×2
230 > continue;
231 > }
232 > logService.info(`${LOG_PREFIX} Trying auth: ${describeAuthAttempt(attempt)}`); sshRemoteAgentHostService.ts ×1
233 > callback(method);
234 > return;
235 > }
236 > logService.info(`${LOG_PREFIX} No more auth methods to try; giving up`); sshRemoteAgentHostService.ts ×1
237 > callback(false);
239 > }
241 > function readSSHString(buffer: Buffer, offset: number): { value: string; offset: number } | undefined { sshRemoteAgentHostService.ts ×5
242 > if (offset + 4 > buffer.length) {
243 return undefined;
244 }
245 > const length = buffer.readUInt32BE(offset); sshRemoteAgentHostService.ts ×5
246 > const valueOffset = offset + 4;
247 > const nextOffset = valueOffset + length;
248 > if (nextOffset > buffer.length) {
249 return undefined;
250 }
251 > return { value: buffer.toString('utf8', valueOffset, nextOffset), offset: nextOffset }; sshRemoteAgentHostService.ts ×5
252 > }
254 > function isEncryptedPrivateKey(key: Buffer): boolean { sshRemoteAgentHostService.ts ×4
255 > const text = key.toString('utf8');
256 > if (/-----BEGIN ENCRYPTED PRIVATE KEY-----/.test(text) || /Proc-Type:\s*4,ENCRYPTED/i.test(text)) {
257 return true;
258 }
259 > const openSSHKey = /-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END OPENSSH PRIVATE KEY-----/.exec(text); sshRemoteAgentHostService.ts ×4
260 > if (!openSSHKey) {
261 > return false; sshRemoteAgentHostService.ts ×1
262 > }
263 > const data = Buffer.from(openSSHKey[1].replace(/\s+/g, ''), 'base64'); sshRemoteAgentHostService.ts ×5
264 > const magic = Buffer.from('openssh-key-v1\0', 'utf8');
265 > if (data.length < magic.length || !data.subarray(0, magic.length).equals(magic)) { sshRemoteAgentHostService.ts ×4
266 return false;
267 }
268 > const cipher = readSSHString(data, magic.length); sshRemoteAgentHostService.ts ×5
269 > return !!cipher && cipher.value !== 'none';
272 > function sshExec(client: SSHClient, command: string, opts?: { ignoreExitCode?: boolean }): Promise<{ stdout: string; stderr: string; code: number }> { sshRemoteAgentHostService.ts ×15
273 > return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {
274 > client.exec(command, (err: Error | undefined, stream: SSHChannel) => {
275 > if (err) {
276 reject(err);
277 return;
278 }
280 > let stdout = '';
281 > let stderr = '';
282 > let settled = false;
283 >
284 > const finish = (error: Error | undefined, code: number | undefined) => {
285 > if (settled) {
286 return;
287 }
288 > settled = true; sshRemoteAgentHostService.ts ×15
289 > if (error) {
290 reject(error);
291 return;
292 }
293 > if (code !== 0 && !opts?.ignoreExitCode) { sshRemoteAgentHostService.ts ×15
294 > reject(new Error(`SSH command failed (exit ${code}): ${command}\nstderr: ${stderr}`)); sshRemoteAgentHostService.ts ×5
296 > resolve({ stdout, stderr, code: code ?? 0 });
297 > }
298 > };
299 >
300 > stream.on('data', (data: Buffer) => { stdout += data.toString(); });
301 > stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
302 > stream.on('error', (streamErr: Error) => finish(streamErr, undefined));
303 > stream.on('close', (code: number) => finish(undefined, code));
304 > });
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 }> { sshRemoteAgentHostService.ts ×15
310 > return (command, opts) => sshExec(client, command, opts);
311 > }
313 function startRemoteAgentHost(
314 client: SSHClient,
315 logService: ILogService,
316 cliBin: string | undefined,
317 cliDataDir: string | undefined,
318 commandOverride?: string,
319 ): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> {
320 return new Promise((resolve, reject) => {
321 if (!commandOverride && (!cliBin || !cliDataDir)) {
322 reject(new Error(`${LOG_PREFIX} startRemoteAgentHost requires either a cliBin+cliDataDir pair or a commandOverride`));
323 return;
324 }
325 const baseCmd = commandOverride ?? buildAgentHostBaseCommand(cliBin!, cliDataDir!);
326 // Wrap in a login shell so the agent host process inherits the
327 // user's PATH and environment from ~/.bash_profile / ~/.bashrc
328 // (ssh2 exec runs a non-interactive non-login shell by default).
329 // Echo the PID so we can record it for process reuse detection.
330 const cmd = `bash -l -c ${shellEscape(`echo VSCODE_PID=$$ && exec ${baseCmd}`)}`;
331 logService.info(`${LOG_PREFIX} Starting remote agent host: ${cmd}`);
332
333 client.exec(cmd, (err: Error | undefined, stream: SSHChannel) => {
334 if (err) {
335 reject(err);
336 return;
337 }
338
339 let resolved = false;
340 let outputBuf = '';
341 let pid: number | undefined;
342
343 const timeout = setTimeout(() => {
344 if (!resolved) {
345 resolved = true;
346 reject(new Error(`${LOG_PREFIX} Timed out waiting for agent host to start.\noutput so far: ${redactToken(outputBuf)}`));
347 }
348 }, 60_000);
349
350 const checkForOutput = () => {
351 const clean = removeAnsiEscapeCodes(outputBuf);
352 if (pid === undefined) {
353 const pidMatch = clean.match(/VSCODE_PID=(\d+)/);
354 if (pidMatch) {
355 pid = parseInt(pidMatch[1], 10);
356 logService.info(`${LOG_PREFIX} Remote agent host PID: ${pid}`);
357 }
358 }
359
360 if (!resolved) {
361 const match = extractAgentHostWebSocketURL(clean);
362 if (match) {
363 resolved = true;
364 clearTimeout(timeout);
365 logService.info(`${LOG_PREFIX} Remote agent host listening on port ${match.port}`);
366 resolve({ port: match.port, connectionToken: match.token, pid, stream });
367 }
368 }
369 };
370
371 stream.stderr.on('data', (data: Buffer) => {
372 const text = data.toString();
373 outputBuf += text;
374 logService.trace(`${LOG_PREFIX} remote stderr: ${redactToken(text.trimEnd())}`);
375 checkForOutput();
376 });
377
378 stream.on('data', (data: Buffer) => {
379 const text = data.toString();
380 outputBuf += text;
381 logService.trace(`${LOG_PREFIX} remote stdout: ${redactToken(text.trimEnd())}`);
382 checkForOutput();
383 });
384
385 stream.on('error', (streamErr: Error) => {
386 if (!resolved) {
387 resolved = true;
388 clearTimeout(timeout);
389 reject(streamErr);
390 }
391 });
392
393 stream.on('close', (code: number) => {
394 if (!resolved) {
395 resolved = true;
396 clearTimeout(timeout);
397 reject(new Error(`${LOG_PREFIX} Agent host process exited with code ${code} before becoming ready.\noutput: ${redactToken(outputBuf)}`));
398 }
399 });
400 });
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,
411 client: SSHClient,
412 dstHost: string,
413 dstPort: number,
414 connectionToken: string | undefined,
415 logService: ILogService,
416 onMessage: (data: string) => void,
417 onClose: () => void,
418 ): Promise<{ send: (data: string) => void; close: () => void }> {
419 return new Promise((resolve, reject) => {
420 client.forwardOut('127.0.0.1', 0, dstHost, dstPort, (err: Error | undefined, channel: SSHChannel) => {
421 if (err) {
422 reject(err);
423 return;
424 }
425
426 const WS = nativeRequire('ws') as typeof WebSocket;
427 let url = `ws://${dstHost}:${dstPort}`;
428 if (connectionToken) {
429 url += `?tkn=${encodeURIComponent(connectionToken)}`;
430 }
431
432 // The SSH channel is a duplex stream compatible with ws's createConnection,
433 // but our minimal SSHChannel interface doesn't carry the full Node Duplex shape.
434 const ws = new WS(url, { createConnection: (() => channel) as unknown as WebSocket.ClientOptions['createConnection'] });
435
436 ws.on('open', () => {
437 logService.info(`${LOG_PREFIX} WebSocket relay connected to remote agent host`);
438 resolve({
439 send: (data: string) => {
440 if (ws.readyState === ws.OPEN) {
441 ws.send(data);
442 }
443 },
444 close: () => ws.close(),
445 });
446 });
447
448 ws.on('message', (data: WebSocket.RawData) => {
449 if (Array.isArray(data)) {
450 onMessage(Buffer.concat(data).toString());
451 } else if (data instanceof ArrayBuffer) {
452 onMessage(Buffer.from(new Uint8Array(data)).toString());
453 } else {
454 onMessage(data.toString());
455 }
456 });
457
458 ws.on('close', onClose);
459
460 ws.on('error', (wsErr: unknown) => {
461 logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`);
462 reject(wsErr);
463 });
464 });
465 });
466 }
468 > function sanitizeConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfigSanitized { sshRemoteAgentHostService.ts ×6
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`); sshRemoteAgentHostService.ts ×1
488 > this.dispose();
489 > };
490 > private readonly _sshErrorListener = (err?: Error) => { sshRemoteAgentHostService.ts ×49
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`); sshRemoteAgentHostService.ts ×1
492 > this.dispose();
493 > };
495 > constructor(
496 > fullConfig: ISSHAgentHostConfig, sshRemoteAgentHostService.ts ×6
497 > readonly connectionId: string,
498 > readonly address: string,
499 > readonly name: string,
500 > readonly connectionToken: string | undefined,
501 > readonly remotePort: number,
502 > readonly sshClient: SSHClient,
503 > private readonly _relay: { send: (data: string) => void; close: () => void },
504 > private readonly _remoteStream: SSHChannel | undefined,
505 > private readonly _logService: ILogService,
506 > ) {
507 > super();
508 >
509 > this.config = sanitizeConfig(fullConfig);
510 >
511 > // Register cleanup first so it fires _onDidClose *before* the Emitter is disposed.
512 > this._register(toDisposable(() => {
513 > if (this._closed) {
514 return;
515 }
516 > this._closed = true; sshRemoteAgentHostService.ts ×6
517 > this._relay.close();
518 > if (!this._sshClientDetached) {
519 > this._remoteStream?.close(); sshRemoteAgentHostService.ts ×1
520 > sshClient.end();
521 > }
522 > this._onDidClose.fire(); sshRemoteAgentHostService.ts ×6
523 > }));
524 >
525 > this._register(this._onDidClose);
526 >
527 > sshClient.on('close', this._sshCloseListener);
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; sshRemoteAgentHostService.ts ×4
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); sshRemoteAgentHostService.ts ×2
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, sshRemoteAgentHostService.ts ×1
592 > @IProductService private readonly _productService: IProductService,
593 > ) {
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');
608 this._nativeRequire = nodeModule.createRequire(import.meta.url);
609 }
610 return this._nativeRequire;
611 }
613 > async connect(config: ISSHAgentHostConfig, replaceRelay?: boolean): Promise<ISSHConnectResult> {
614 > const connectionKey = config.sshConfigHost sshRemoteAgentHostService.ts ×15
615 > ? `ssh:${config.sshConfigHost}` sshRemoteAgentHostService.ts ×1
616 > : `${config.username}@${config.host}:${config.port ?? 22}`; sshRemoteAgentHostService.ts ×1
618 > const existing = this._connections.get(connectionKey);
619 > if (existing) {
620 > if (replaceRelay) { sshRemoteAgentHostService.ts ×1
621 > // Tear down the old relay and create a fresh one, following sshRemoteAgentHostService.ts ×4
622 > // the same dispose-and-recreate pattern as TunnelAgentHostMainService.
623 > // The SSH client is detached so only the WebSocket relay is closed.
624 > this._logService.info(`${LOG_PREFIX} Reconnecting relay for existing SSH tunnel ${connectionKey}`);
625 > const { sshClient, remotePort, connectionToken } = existing;
626 >
627 > // Remove from map and detach SSH client before disposing so
628 > // the old relay's close handler (conn?.dispose()) is a no-op.
629 > this._connections.deleteAndLeak(connectionKey);
630 > existing.detachSshClient();
631 > existing.dispose();
632 >
633 > // Create fresh relay and connection. If relay creation fails,
634 > // clean up the detached SSH client so it doesn't leak.
635 > const connectionId = connectionKey;
636 > try {
637 > let conn: SSHConnection | undefined; // eslint-disable-line prefer-const
638 > // Bound the relay creation: a silently dead SSH client
639 > // (TCP half-open, ssh2 keepalive hasn't fired yet) can
640 > // leave forwardOut's callback unfired, hanging the whole
641 > // promise chain. raceTimeout returns undefined on timeout.
642 > const timeoutMs = this.relayCreationTimeoutMs;
643 > const relay = await raceTimeout(
644 > this._createWebSocketRelay(
645 > sshClient, '127.0.0.1', remotePort, connectionToken,
646 > (data: string) => this._onDidRelayMessage.fire({ connectionId, data }),
647 > () => { conn?.dispose(); },
648 > ),
649 > timeoutMs,
650 > );
651 > if (!relay) { sshRemoteAgentHostService.ts ×1
652 > throw new Error(`SSH relay creation timed out after ${timeoutMs}ms (SSH client appears unresponsive)`); sshRemoteAgentHostService.ts ×1
653 > }
655 > conn = new SSHConnection(
656 > config, connectionId, connectionKey, config.name,
657 > connectionToken, remotePort, sshClient, relay, undefined,
658 > this._logService,
659 > );
660 >
661 > Event.once(conn.onDidClose)(() => {
662 > if (this._connections.get(connectionKey) === conn) {
663 > this._connections.deleteAndDispose(connectionKey);
664 > this._onDidRelayClose.fire(connectionId);
665 > this._onDidCloseConnection.fire(connectionId);
666 > this._onDidChangeConnections.fire();
667 > }
668 > });
669 >
670 > this._connections.set(connectionKey, conn);
671 >
672 > return {
673 > connectionId: conn.connectionId,
674 > address: conn.address,
675 > name: conn.name,
676 > connectionToken: conn.connectionToken,
677 > config: conn.config,
678 > sshConfigHost: config.sshConfigHost,
679 > };
680 > } catch (err) { sshRemoteAgentHostService.ts ×4
681 > sshClient.end(); sshRemoteAgentHostService.ts ×1
682 > this._onDidRelayClose.fire(connectionId);
683 > this._onDidCloseConnection.fire(connectionId);
684 > this._onDidChangeConnections.fire();
685 > throw err;
686 > }
689 > return {
690 > connectionId: existing.connectionId,
691 > address: existing.address,
692 > name: existing.name,
693 > connectionToken: existing.connectionToken,
694 > config: existing.config,
695 > sshConfigHost: config.sshConfigHost,
696 > };
697 > }
699 > this._logService.info(`${LOG_PREFIX} ${replaceRelay ? 'Reconnecting' : 'Connecting'} to ${connectionKey}`);
700 > let sshClient: SSHClient | undefined;
701 >
702 > try {
703 > const reportProgress = (message: string) => {
704 > this._onDidReportConnectProgress.fire({ connectionKey, message });
705 > };
706 >
707 > // 1. Establish SSH connection
708 > reportProgress(localize('sshProgressConnecting', "Establishing SSH connection..."));
709 > sshClient = await this._connectSSH(config, connectionKey);
710 >
711 > let cliBin: string | undefined;
712 > let cliResolved = false;
713 > // Resolve the remote CLI lazily: platform detection and CLI
714 > // install/refresh only run when we're actually about to spawn
715 > // an agent host. Reconnects that reuse a live AH via the
716 > // lockfile skip this work entirely, since the running AH was
717 > // spawned from whatever CLI was current at the time.
718 > const ensureCliResolved = async (): Promise<void> => {
719 > if (cliResolved) { sshRemoteAgentHostService.ts ×3
720 return;
721 }
722 > cliResolved = true; sshRemoteAgentHostService.ts ×3
723 > if (config.remoteAgentHostCommand) {
724 > this._logService.info(`${LOG_PREFIX} Using custom agent host command: ${config.remoteAgentHostCommand}`); sshRemoteAgentHostService.ts ×1
725 > return;
726 > }
727 > const { stdout: unameS } = await sshExec(sshClient!, 'uname -s'); sshRemoteAgentHostService.ts ×5
728 > const { stdout: unameM } = await sshExec(sshClient!, 'uname -m');
729 > const platform = resolveRemotePlatform(unameS, unameM);
730 > if (!platform) {
731 throw new Error(`${LOG_PREFIX} Unsupported remote platform: ${unameS.trim()} ${unameM.trim()}`);
732 }
733 > this._logService.info(`${LOG_PREFIX} Remote platform: ${platform.os}-${platform.arch}`); sshRemoteAgentHostService.ts ×5
734 > reportProgress(localize('sshProgressInstallingCLI', "Checking remote CLI installation..."));
735 > cliBin = await this._ensureCLIInstalled(sshClient!, platform, reportProgress);
738 > // 2. Check for an already-running agent host on the remote first.
739 > // This prevents accumulating orphaned processes when the SSH
740 > // connection drops and we reconnect — and avoids paying for
741 > // platform detection + CLI install on every reconnect.
742 > let remoteHost: string = '127.0.0.1';
743 > let remotePort: number | undefined;
744 > let connectionToken: string | undefined;
745 > let agentStream: SSHChannel | undefined;
746 >
747 > reportProgress(localize('sshProgressCheckingAgent', "Checking for existing agent host..."));
748 > const exec = bindSshExec(sshClient);
749 > const existingAH = await findRunningAgentHost(exec, this._logService, this._serverDataFolderName, this._quality);
750 > if (existingAH.kind === 'compatible') {
751 > remoteHost = existingAH.host; sshRemoteAgentHostService.ts ×1
752 > remotePort = existingAH.port;
753 > connectionToken = existingAH.connectionToken;
754 > }
756 > if (remotePort === undefined) {
757 > // 3. Need to spawn fresh: resolve the CLI now. sshRemoteAgentHostService.ts ×1
758 > await ensureCliResolved();
760 > // 4. Start agent-host and capture port/token
761 > reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host..."));
762 > const result = await this._startRemoteAgentHost(sshClient, cliBin, getRemoteCLIDataDir(this._serverDataFolderName), config.remoteAgentHostCommand);
763 > remotePort = result.port;
764 > connectionToken = result.connectionToken;
765 > agentStream = result.stream;
766 >
767 > // Record state for future reuse
768 > await writeAgentHostState(exec, this._logService, this._serverDataFolderName, this._quality, result.pid, remotePort, connectionToken);
769 > }
771 > // 6. Connect to remote agent host via WebSocket relay (no local TCP port)
772 > reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host..."));
773 > const connectionId = connectionKey;
774 > let conn: SSHConnection | undefined; // eslint-disable-line prefer-const
775 > let relay: { send: (data: string) => void; close: () => void };
776 > try {
777 > relay = await this._createWebSocketRelay(
778 > sshClient, remoteHost, remotePort, connectionToken,
779 > (data: string) => this._onDidRelayMessage.fire({ connectionId, data }),
780 > () => { conn?.dispose(); },
781 > );
782 > } catch (relayErr) {
783 > if (existingAH.kind !== 'compatible') { sshRemoteAgentHostService.ts ×2
784 > throw relayErr; sshRemoteAgentHostService.ts ×1
785 > }
786 > // The reused agent host is not connectable — kill it and start fresh. sshRemoteAgentHostService.ts ×2
787 > // Resolve the CLI now (we skipped it on the reuse path).
788 > const relayErrorMessage = relayErr instanceof Error ? relayErr.message : String(relayErr); sshRemoteAgentHostService.ts ×2
789 > this._logService.warn(`${LOG_PREFIX} Failed to connect to reused agent host on ${remoteHost}:${remotePort}: ${relayErrorMessage}. Starting fresh`);
790 > await cleanupRemoteAgentHost(exec, this._logService, this._serverDataFolderName, this._quality);
791 > await ensureCliResolved(); sshRemoteAgentHostService.ts ×2
792 >
793 > reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host..."));
794 > const result = await this._startRemoteAgentHost(sshClient, cliBin, getRemoteCLIDataDir(this._serverDataFolderName), config.remoteAgentHostCommand);
795 > remoteHost = '127.0.0.1';
796 > remotePort = result.port;
797 > connectionToken = result.connectionToken;
798 > agentStream = result.stream;
799 > await writeAgentHostState(exec, this._logService, this._serverDataFolderName, this._quality, result.pid, remotePort, connectionToken);
800 >
801 > reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host..."));
802 > relay = await this._createWebSocketRelay(
803 > sshClient, remoteHost, remotePort, connectionToken,
804 > (data: string) => this._onDidRelayMessage.fire({ connectionId, data }),
805 > () => { conn?.dispose(); },
806 > );
807 > }
809 > // 7. Create connection object
810 > const address = connectionKey;
811 > conn = new SSHConnection(
812 > config,
813 > connectionId,
814 > address,
815 > config.name,
816 > connectionToken,
817 > remotePort,
818 > sshClient,
819 > relay,
820 > agentStream,
821 > this._logService,
822 > );
823 >
824 > Event.once(conn.onDidClose)(() => {
825 > if (this._connections.get(connectionKey) === conn) {
826 > this._connections.deleteAndDispose(connectionKey); sshRemoteAgentHostService.ts ×1
827 > this._onDidRelayClose.fire(connectionId);
828 > this._onDidCloseConnection.fire(connectionId);
829 > this._onDidChangeConnections.fire();
830 > }
832 >
833 > this._connections.set(connectionKey, conn);
834 > sshClient = undefined; // ownership transferred to SSHConnection
835 >
836 > this._onDidChangeConnections.fire();
837 >
838 > return {
839 > connectionId,
840 > address,
841 > name: config.name,
842 > connectionToken,
843 > config: conn.config,
844 > sshConfigHost: config.sshConfigHost,
845 > };
846 >
847 > } catch (err) { sshRemoteAgentHostService.ts ×15
848 > sshClient?.end(); sshRemoteAgentHostService.ts ×1
849 > throw err;
850 > }
853 > async disconnect(host: string): Promise<void> {
854 > for (const [key, conn] of this._connections) { sshRemoteAgentHostService.ts ×1
855 > if (key === host || conn.connectionId === host) {
856 > conn.dispose();
857 > return;
858 > }
859 > }
860 > }
862 > async relaySend(connectionId: string, message: string): Promise<void> {
863 > for (const conn of this._connections.values()) { sshRemoteAgentHostService.ts ×2
864 > if (conn.connectionId === connectionId) {
865 > conn.relaySend(message); sshRemoteAgentHostService.ts ×2
866 > return;
867 > }
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}`); sshRemoteAgentHostService.ts ×2
873 > const resolved = await this.resolveSSHConfig(sshConfigHost);
874 >
875 > // Always use Agent auth — the auth handler will walk through the SSH
876 > // agent and any default identities. If the user pinned a non-default
877 > // `IdentityFile` in their ssh config, surface it as the explicit key
878 > // so it gets tried first.
879 > let privateKeyPath: string | undefined;
880 > if (resolved.identityFile.length > 0 && !SSHRemoteAgentHostMainService._isDefaultKeyPath(resolved.identityFile[0])) {
881 privateKeyPath = resolved.identityFile[0];
882 }
883 > this._logService.info(`${LOG_PREFIX} reconnect: identityFiles=${JSON.stringify(resolved.identityFile)}, explicit key=${privateKeyPath ?? '(none)'}`); sshRemoteAgentHostService.ts ×2
884 >
885 > return this.connect({
886 > host: resolved.hostname,
887 > port: resolved.port !== 22 ? resolved.port : undefined,
888 > username: resolved.user ?? sshConfigHost,
889 > authMethod: SSHAuthMethod.Agent,
890 > privateKeyPath,
891 > identityAgent: resolved.identityAgent,
892 > name,
893 > sshConfigHost,
894 > remoteAgentHostCommand,
895 > agentForward: agentForward && resolved.forwardAgent ? true : undefined,
896 > }, /* replaceRelay */ true);
897 > }
899 > async listSSHConfigHosts(): Promise<string[]> {
900 const configPath = join(os.homedir(), '.ssh', 'config');
901 try {
902 const content = await fsp.readFile(configPath, 'utf-8');
903 return this._parseSSHConfigHosts(content, dirname(configPath));
904 } catch {
905 this._logService.info(`${LOG_PREFIX} Could not read SSH config at ${configPath}`);
906 return [];
907 }
908 }
910 > async ensureUserSSHConfig(): Promise<URI> {
911 const sshDir = join(os.homedir(), '.ssh');
912 const configPath = join(sshDir, 'config');
913 const isPosix = process.platform !== 'win32';
914 try {
915 await fsp.mkdir(sshDir, { recursive: true, mode: isPosix ? 0o700 : undefined });
916 } catch (err) {
917 this._logService.warn(`${LOG_PREFIX} Failed to ensure ~/.ssh directory: ${err}`);
918 throw err;
919 }
920 try {
921 await fsp.access(configPath);
922 } catch {
923 try {
924 const handle = await fsp.open(configPath, 'a', isPosix ? 0o600 : undefined);
925 await handle.close();
926 } catch (err) {
927 this._logService.warn(`${LOG_PREFIX} Failed to create ${configPath}: ${err}`);
928 throw err;
929 }
930 }
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');
937 const systemConfigPath = isWindows
938 ? join(process.env['ProgramData'] ?? 'C:\\ProgramData', 'ssh', 'ssh_config')
939 : '/etc/ssh/ssh_config';
940
941 const result: URI[] = [URI.file(userConfigPath)];
942 try {
943 await fsp.access(systemConfigPath);
944 result.push(URI.file(systemConfigPath));
945 } catch {
946 // system config file does not exist — skip
947 }
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) => {
954 if (err) {
955 reject(new Error(`${LOG_PREFIX} ssh -G failed for ${host}: ${err.message}`));
956 return;
957 }
958 const config = this._parseSSHGOutput(stdout);
959 resolve(config);
960 });
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[] = [];
967
968 // Extract hosts from this file directly
969 hosts.push(...parseSSHConfigHostEntries(content));
970
971 // Follow Include directives
972 for (const line of content.split('\n')) {
973 const trimmed = line.trim();
974 if (!trimmed || trimmed.startsWith('#')) {
975 continue;
976 }
977 const includeMatch = trimmed.match(/^Include\s+(.+)$/i);
978 if (!includeMatch) {
979 continue;
980 }
981
982 const rawValue = stripSSHComment(includeMatch[1]);
983 const patterns = rawValue.split(/\s+/).filter(Boolean);
984
985 for (const rawPattern of patterns) {
986 const pattern = rawPattern.replace(/^~/, os.homedir());
987 const resolvedPattern = isAbsolute(pattern) ? pattern : join(configDir, pattern);
988
989 if (seen.has(resolvedPattern)) {
990 continue;
991 }
992 seen.add(resolvedPattern);
993
994 try {
995 const stat = await fsp.stat(resolvedPattern);
996 if (stat.isDirectory()) {
997 const files = await fsp.readdir(resolvedPattern);
998 for (const file of files) {
999 try {
1000 const sub = await fsp.readFile(join(resolvedPattern, file), 'utf-8');
1001 hosts.push(...await this._parseSSHConfigHosts(sub, resolvedPattern, seen));
1002 } catch { /* skip unreadable files */ }
1003 }
1004 } else {
1005 const sub = await fsp.readFile(resolvedPattern, 'utf-8');
1006 hosts.push(...await this._parseSSHConfigHosts(sub, dirname(resolvedPattern), seen));
1007 }
1008 } catch {
1009 const dir = dirname(resolvedPattern);
1010 const base = basename(resolvedPattern);
1011 if (base.includes('*')) {
1012 try {
1013 const files = await fsp.readdir(dir);
1014 for (const file of files) {
1015 const regex = new RegExp('^' + base.replace(/\*/g, '.*') + '$');
1016 if (regex.test(file)) {
1017 try {
1018 const sub = await fsp.readFile(join(dir, file), 'utf-8');
1019 hosts.push(...await this._parseSSHConfigHosts(sub, dir, seen));
1020 } catch { /* skip */ }
1021 }
1022 }
1023 } catch { /* skip unreadable dirs */ }
1024 }
1025 }
1026 }
1027 }
1028 return hosts;
1029 }
1031 > private _parseSSHGOutput(stdout: string): ISSHResolvedConfig {
1032 return parseSSHGOutput(stdout);
1033 }
1035 > protected async _connectSSH(
1036 > config: ISSHAgentHostConfig, sshRemoteAgentHostService.ts ×8
1037 > connectionKey?: string,
1038 > ): Promise<SSHClient> {
1039 > const connectConfig: ConnectConfig = {
1040 > host: config.host,
1041 > port: config.port ?? 22,
1042 > username: config.username,
1043 > readyTimeout: 30_000,
1044 > keepaliveInterval: 15_000,
1045 > };
1046 >
1047 > const attempts = await this._buildAuthAttempts(config);
1048 > this._logService.info(`${LOG_PREFIX} Built ${attempts.length} auth attempt(s): ${attempts.map(a => describeAuthAttempt(a)).join(', ')}`);
1049 > const displayHost = config.sshConfigHost ?? `${config.username}@${config.host}`;
1050 > // Track requestIds we created during this connect so we can fire
1051 > // onDidCancelKeyboardInteractive for any still-pending prompts when
1052 > // the connect attempt fails or completes.
1053 > const liveKbiRequests = new Set<string>();
1054 > let cancelConnectFromKbi: (() => void) | undefined;
1055 > const kbiHandler: SSHKeyboardInteractivePromptHandler | undefined = attempts.some(a => a.type === 'keyboard-interactive')
1056 > ? (name, instructions, prompts, finish) => {
1057 > const requestId = this._handleKeyboardInteractive(connectionKey ?? displayHost, displayHost, config.username, name, instructions, prompts, finish, () => cancelConnectFromKbi?.());
1058 > liveKbiRequests.add(requestId);
1059 > }
1060 : undefined;
1061 > const keyPassphraseHandler: SSHKeyPassphrasePromptHandler | undefined = attempts.some(a => a.type === 'publickey' && a.encrypted) sshRemoteAgentHostService.ts ×8
1062 ? (keyPath, finish) => {
1063 const requestId = this._handleKeyboardInteractive(
1064 connectionKey ?? displayHost,
1065 displayHost,
1066 config.username,
1067 localize('sshKeyPassphraseName', "SSH Key Passphrase"),
1068 '',
1069 [{ prompt: localize('sshKeyPassphrasePrompt', "Enter passphrase for SSH key {0}.", keyPath), echo: false }],
1070 responses => finish(responses[0]),
1071 () => cancelConnectFromKbi?.(),
1072 );
1073 liveKbiRequests.add(requestId);
1074 }
1075 > : undefined; sshRemoteAgentHostService.ts ×8
1076 > // Cast: the ssh2 @types don't model `false` (give-up) for the
1077 > // callback nor `null` for the first invocation's `methodsLeft`,
1078 > // even though the runtime supports both per the ssh2 docs.
1079 > connectConfig.authHandler = makeAuthHandler(attempts, this._logService, kbiHandler, keyPassphraseHandler) as unknown as ConnectConfig['authHandler'];
1080 >
1081 > const cancelLiveKbiRequests = () => {
1082 > for (const requestId of liveKbiRequests) {
1083 > // Pull the pending finish callback (if any) and invoke it with
1084 > // empty responses so ssh2 stops waiting on this attempt — without
1085 > // this, ssh2 hangs until `readyTimeout` elapses when a connect
1086 > // attempt is aborted mid-prompt. The renderer also gets notified
1087 > // so it can dismiss any open quick-input UI.
1088 > const pending = this._pendingKbiRequests.get(requestId);
1089 > this._pendingKbiRequests.delete(requestId);
1090 > this._onDidCancelKeyboardInteractive.fire(requestId);
1091 > pending?.finish([]);
1092 > }
1093 > liveKbiRequests.clear();
1094 > };
1095 >
1096 > if (config.agentForward) {
1097 const agentSock = this._getAgentSocket(config);
1098 if (agentSock) {
1099 // ssh2 needs `connectConfig.agent` set so it knows which local
1100 // agent socket to forward to. Without it, agent forwarding is a
1101 // no-op even if `agentForward: true` is set.
1102 connectConfig.agent = agentSock;
1103 connectConfig.agentForward = true;
1104 this._logService.info(`${LOG_PREFIX} SSH agent forwarding enabled`);
1105 } else {
1106 this._logService.warn(`${LOG_PREFIX} SSH agent forwarding requested, but no SSH agent endpoint is available; agent forwarding disabled`);
1107 }
1108 }
1110 > const client = await this._createSSHClient();
1111 > return new Promise<SSHClient>((resolve, reject) => {
1112 > let settled = false;
1113 >
1114 > const resolveConnect = () => {
1115 if (settled) {
1116 return;
1117 }
1118 settled = true;
1119 this._logService.info(`${LOG_PREFIX} SSH connection established to ${config.host}`);
1120 cancelLiveKbiRequests();
1121 resolve(client);
1122 };
1124 > const rejectConnect = (err: Error, endClient: boolean) => {
1125 > if (settled) {
1126 > return;
1127 > }
1128 > settled = true;
1129 > cancelLiveKbiRequests();
1130 > if (endClient) {
1131 > client.end();
1132 > }
1133 > reject(err);
1134 > };
1135 >
1136 > cancelConnectFromKbi = () => {
1137 > this._logService.info(`${LOG_PREFIX} SSH keyboard-interactive prompt cancelled by user for ${displayHost}`);
1138 > rejectConnect(new CancellationError(), true);
1139 > };
1140 >
1141 > client.on('ready', () => {
1142 resolveConnect();
1144 >
1145 > client.on('error', (err: Error) => {
1146 > this._logService.error(`${LOG_PREFIX} SSH connection error: ${err.message}`);
1147 > rejectConnect(err, false);
1148 > });
1149 >
1150 > client.connect(connectConfig);
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[] = []; sshRemoteAgentHostService.ts ×5
1171 > const username = config.username;
1172 >
1173 > switch (config.authMethod) {
1174 > case SSHAuthMethod.Agent: {
1175 > // Try the agent first: if it has any of the configured identities sshRemoteAgentHostService.ts ×6
1176 > // loaded, auth succeeds without ever touching on-disk keys. This
1177 > // matches OpenSSH's IdentityAgent semantics and avoids an
1178 > // unnecessary passphrase prompt when an encrypted key file is
1179 > // configured but the agent already holds its unlocked copy.
1180 > const agentSock = this._getAgentSocket(config);
1181 > if (agentSock) {
1182 > attempts.push({ type: 'agent', username, agent: agentSock }); sshRemoteAgentHostService.ts ×1
1183 > }
1184 > const explicitKeyPath = config.privateKeyPath; sshRemoteAgentHostService.ts ×6
1185 > const explicitIsDefault = explicitKeyPath !== undefined && SSHRemoteAgentHostMainService._isDefaultKeyPath(explicitKeyPath);
1186 > if (explicitKeyPath && !explicitIsDefault) {
1187 > const explicit = await this._readKeyFileIfExists(explicitKeyPath); sshRemoteAgentHostService.ts ×1
1188 > if (explicit) {
1189 > attempts.push({ type: 'publickey', username, key: explicit, keyPath: explicitKeyPath, ...(isEncryptedPrivateKey(explicit) ? { encrypted: true } : undefined) });
1190 > }
1191 > }
1192 > for (const keyPath of SSHRemoteAgentHostMainService._defaultKeyPaths) { sshRemoteAgentHostService.ts ×6
1193 > const contents = await this._readKeyFileIfExists(keyPath);
1194 > if (contents) {
1195 > attempts.push({ type: 'publickey', username, key: contents, keyPath, ...(isEncryptedPrivateKey(contents) ? { encrypted: true } : undefined) }); sshRemoteAgentHostService.ts ×1
1196 > }
1198 > // Final fallback: keyboard-interactive (typically a password prompt).
1199 > // Only meaningful if the server advertises it; the auth handler
1200 > // will skip it otherwise. The prompt is forwarded to the renderer
1201 > // via {@link onDidRequestKeyboardInteractive}.
1202 > attempts.push({ type: 'keyboard-interactive', username });
1203 > break;
1204 > }
1205 > case SSHAuthMethod.KeyFile: { sshRemoteAgentHostService.ts ×5
1206 > // KeyFile mode has no fallbacks — fail fast with a clear error if sshRemoteAgentHostService.ts ×2
1207 > // the key is missing or unreadable, rather than letting it surface
1208 > // downstream as a generic auth failure.
1209 > if (!config.privateKeyPath) {
1210 > throw new Error(localize('ssh.keyFileAuthRequiresPath', "Key file authentication requires a private key path.")); sshRemoteAgentHostService.ts ×1
1211 > }
1212 > const explicit = await this._readKeyFileIfExists(config.privateKeyPath); sshRemoteAgentHostService.ts ×1
1213 > if (!explicit) {
1214 > throw new Error(localize('ssh.failedToReadPrivateKey', "Failed to read private key file: {0}", config.privateKeyPath)); sshRemoteAgentHostService.ts ×1
1215 > }
1216 > attempts.push({ type: 'publickey', username, key: explicit, keyPath: config.privateKeyPath, ...(isEncryptedPrivateKey(explicit) ? { encrypted: true } : undefined) }); sshRemoteAgentHostService.ts ×2
1217 > break;
1218 > }
1219 > case SSHAuthMethod.Password: { sshRemoteAgentHostService.ts ×5
1220 > if (config.password !== undefined) { sshRemoteAgentHostService.ts ×1
1221 > attempts.push({ type: 'password', username, password: config.password });
1222 > }
1223 > break;
1224 > }
1227 > return attempts;
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()); sshRemoteAgentHostService.ts ×2
1245 > }
1247 > private static _isDefaultKeyPath(keyPath: string): boolean {
1248 > const normalized = SSHRemoteAgentHostMainService._normalizeKeyPath(keyPath); sshRemoteAgentHostService.ts ×2
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) { sshRemoteAgentHostService.ts ×6
1259 > return this._resolveIdentityAgent(config.identityAgent); sshRemoteAgentHostService.ts ×3
1260 > }
1261 > return this._isAgentAvailable(); sshRemoteAgentHostService.ts ×1
1264 > private _resolveIdentityAgent(identityAgent: string): string | undefined {
1265 > const trimmed = identityAgent.trim(); sshRemoteAgentHostService.ts ×3
1266 > if (!trimmed || trimmed.toLowerCase() === 'none') {
1267 > return undefined; sshRemoteAgentHostService.ts ×1
1268 > }
1269 > if (trimmed === 'SSH_AUTH_SOCK') { sshRemoteAgentHostService.ts ×1
1270 > return this._isAgentAvailable(); sshRemoteAgentHostService.ts ×1
1271 > }
1272 > if (trimmed.startsWith('$')) { sshRemoteAgentHostService.ts ×2
1273 const envMatch = /^\$\{(?<braced>[A-Za-z_][A-Za-z0-9_]*)\}$|^\$(?<plain>[A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed);
1274 return envMatch?.groups ? process.env[envMatch.groups.braced ?? envMatch.groups.plain] || undefined : undefined;
1275 }
1276 > return trimmed.replace(/^~/, os.homedir()); sshRemoteAgentHostService.ts ×2
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, sshRemoteAgentHostService.ts ×5
1287 > displayHost: string,
1288 > username: string,
1289 > name: string,
1290 > instructions: string,
1291 > prompts: readonly ISSHKeyboardInteractivePrompt[],
1292 > finish: (responses: readonly string[]) => void,
1293 > cancelConnect: () => void,
1294 > ): string {
1295 > const requestId = `kbi-${++this._kbiRequestCounter}`;
1296 > // Wrap finish so it can only fire once — ssh2 ignores duplicate calls,
1297 > // but we also want to ensure we drop the pending entry exactly once.
1298 > let settled = false;
1299 > const finishOnce = (responses: readonly string[]) => {
1300 > if (settled) {
1302 > }
1303 > settled = true; sshRemoteAgentHostService.ts ×5
1304 > this._pendingKbiRequests.delete(requestId);
1305 > finish(responses);
1306 > };
1307 > this._pendingKbiRequests.set(requestId, { finish: finishOnce, cancelConnect });
1308 > this._logService.info(`${LOG_PREFIX} keyboard-interactive challenge from ${displayHost}: ${prompts.length} prompt(s)`);
1309 > this._onDidRequestKeyboardInteractive.fire({
1310 > requestId,
1311 > connectionKey,
1312 > displayHost,
1313 > username,
1314 > name,
1315 > instructions,
1316 > prompts: prompts.map(p => ({ prompt: p.prompt, echo: p.echo })),
1317 > });
1318 > return requestId;
1319 > }
1321 > async respondKeyboardInteractive(requestId: string, responses: readonly string[] | undefined): Promise<void> {
1322 > const pending = this._pendingKbiRequests.get(requestId); sshRemoteAgentHostService.ts ×5
1323 > if (!pending) {
1324 this._logService.warn(`${LOG_PREFIX} respondKeyboardInteractive: no pending request for ${requestId}`);
1325 return;
1326 }
1327 > if (responses === undefined) { sshRemoteAgentHostService.ts ×5
1328 > pending.cancelConnect(); sshRemoteAgentHostService.ts ×8
1329 > pending.finish([]);
1330 > return;
1331 > }
1332 > pending.finish(responses); sshRemoteAgentHostService.ts ×1
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 {
1343 return await fsp.readFile(resolved);
1344 } catch (error) {
1345 const errorCode = (error as NodeJS.ErrnoException).code;
1346 if (errorCode === 'ENOENT' || errorCode === 'ENOTDIR') {
1347 return undefined;
1348 }
1349 this._logService.warn(`${LOG_PREFIX} Failed to read SSH key file ${resolved}`, error);
1350 return undefined;
1351 }
1352 }
1354 > private get _quality(): string {
1355 > return this._productService.quality || 'insider'; sshRemoteAgentHostService.ts ×15
1356 > }
1358 > private get _serverDataFolderName(): string {
1359 > return this._productService.serverDataFolderName ?? '.vscode-server-oss'; sshRemoteAgentHostService.ts ×15
1360 > }
1362 > private get _commit(): string | undefined {
1363 > return this._productService.commit; sshRemoteAgentHostService.ts ×5
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,
1375 ): Promise<{ send: (data: string) => void; close: () => void }> {
1376 const nativeRequire = await this._getNativeRequire();
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; sshRemoteAgentHostService.ts ×5
1402 > if (!commit) {
1403 > return this._ensureCLIInstalledLoose(client, platform, reportProgress); sshRemoteAgentHostService.ts ×3
1404 > }
1405 > return this._ensureCLIInstalledPinned(client, platform, reportProgress, commit); sshRemoteAgentHostService.ts ×3
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); sshRemoteAgentHostService.ts ×3
1413 > const installRoot = getRemoteCLIInstallRoot(this._serverDataFolderName);
1414 >
1415 > // Primary reuse check: pure file existence on the commit-keyed path.
1416 > // No `--version` parsing — we know the file is ours and matches the
1417 > // desktop commit.
1418 > const { code: existsCode } = await sshExec(client, `test -x ${cliBin}`, { ignoreExitCode: true });
1419 > if (existsCode === 0) {
1420 > this._logService.info(`${LOG_PREFIX} Reusing remote CLI at ${cliBin}`); sshRemoteAgentHostService.ts ×2
1421 > // Bump mtime so the retention pass below doesn't prune the
1422 > // binary we just decided to reuse. Without this, a user
1423 > // rotating between several desktop builds could see their
1424 > // currently-used CLI fall out of the 5-newest window and
1425 > // get deleted just before the next reconnect.
1426 > const { code: touchCode } = await sshExec(client, `touch -- ${cliBin}`, { ignoreExitCode: true });
1427 > if (touchCode === 0) {
1428 > // Now that the in-use binary is the newest by mtime, prune
1429 > // older commit-keyed installs. Best-effort.
1430 > await sshExec(client, buildCleanupOldCLIsCommand(this._serverDataFolderName, this._quality), { ignoreExitCode: true });
1431 > } else {
1432 // If we couldn't refresh mtime, skip the retention pass —
1433 // running it now could prune the binary we just decided
1434 // to reuse. We'll retry retention on the next reconnect.
1435 this._logService.warn(`${LOG_PREFIX} Skipping CLI retention cleanup: touch exited ${touchCode}`);
1436 }
1437 > return cliBin; sshRemoteAgentHostService.ts ×2
1438 > }
1440 > reportProgress(localize('sshProgressDownloadingCLI', "Installing VS Code CLI on remote..."));
1441 > const url = buildCLIDownloadUrl(platform.os, platform.arch, this._quality, commit);
1442 >
1443 > // Extract into a temp dir inside the install root so the final `mv`
1444 > // is a same-filesystem atomic rename. Concurrent SSH sessions racing
1445 > // here both end up with a valid binary for the same commit; the
1446 > // trailing `rm -rf` of the tmp dir is idempotent.
1447 > const installCmd = [
1448 > `mkdir -p ${installRoot}`,
1449 > `tmpdir=$(mktemp -d ${installRoot}/.cli-install-XXXXXX)`,
1450 > `(cd "$tmpdir" && curl -fsSL ${shellEscape(url)} | tar xz)`,
1451 > // The archive contains exactly one file: the CLI binary, named per quality.
1452 > `mv "$tmpdir"/* ${cliBin}`,
1453 > `chmod +x ${cliBin}`,
1454 > `rm -rf "$tmpdir"`,
1455 > ].join(' && ');
1456 >
1457 > try {
1458 > await sshExec(client, installCmd);
1459 > // Validate the installed binary actually runs. If the archive was sshRemoteAgentHostService.ts ×2
1460 > // for the wrong platform / corrupted, this surfaces immediately.
1461 > const { code: versionCode } = await sshExec(client, `${cliBin} --version`, { ignoreExitCode: true });
1462 > if (versionCode !== 0) {
1463 throw new Error(`CLI at ${cliBin} failed --version check after install (exit code ${versionCode})`);
1464 }
1465 > this._logService.info(`${LOG_PREFIX} Installed remote CLI at ${cliBin}`); sshRemoteAgentHostService.ts ×2
1466 > // Prune older commit-keyed installs now that the new binary is
1467 > // in place and is the newest by mtime.
1468 > await sshExec(client, buildCleanupOldCLIsCommand(this._serverDataFolderName, this._quality), { ignoreExitCode: true });
1469 > return cliBin;
1470 > } catch (installErr) { sshRemoteAgentHostService.ts ×2
1471 > // Soft fallback (key difference from Remote-SSH): if the sshRemoteAgentHostService.ts ×5
1472 > // commit-pinned download fails (offline, 404, etc.) but another
1473 > // usable CLI is already on the box, use that instead of refusing
1474 > // to connect. The agent host has no strict commit-lock with the
1475 > // desktop — the protocol handshake will catch genuine
1476 > // incompatibilities.
1477 > const installErrorMessage = installErr instanceof Error ? installErr.message : String(installErr);
1478 > this._logService.warn(`${LOG_PREFIX} Could not install matching CLI for commit ${commit}: ${installErrorMessage}. Looking for a fallback CLI on the remote...`);
1479 > const fallback = await this._findFallbackCLI(client);
1480 > if (fallback) {
1481 > this._logService.warn(`${LOG_PREFIX} Using fallback CLI at ${fallback} (does not match desktop commit ${commit}).`); sshRemoteAgentHostService.ts ×4
1482 > return fallback;
1483 > }
1484 > throw installErr; sshRemoteAgentHostService.ts ×2
1485 > }
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); sshRemoteAgentHostService.ts ×3
1493 > const installRoot = getRemoteCLIInstallRoot(this._serverDataFolderName);
1494 > this._logService.warn(`${LOG_PREFIX} Desktop has no product commit; falling back to non-pinned CLI install at ${cliBin}.`);
1495 >
1496 > const { code } = await sshExec(client, `${cliBin} --version`, { ignoreExitCode: true });
1497 > if (code === 0) {
1498 > this._logService.info(`${LOG_PREFIX} Reusing remote CLI at ${cliBin} (dev build, --version check passed)`); sshRemoteAgentHostService.ts ×1
1499 > return cliBin;
1500 > }
1502 > reportProgress(localize('sshProgressDownloadingCLI', "Installing VS Code CLI on remote..."));
1503 > const url = buildCLIDownloadUrl(platform.os, platform.arch, this._quality);
1504 >
1505 > const installCmd = [
1506 > `mkdir -p ${installRoot}`,
1507 > `curl -fsSL ${shellEscape(url)} | tar xz -C ${installRoot}`,
1508 > `chmod +x ${cliBin}`,
1509 > ].join(' && ');
1510 >
1511 > await sshExec(client, installCmd);
1512 > this._logService.info(`${LOG_PREFIX} Installed remote CLI at ${cliBin}`);
1513 > return cliBin;
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 }); sshRemoteAgentHostService.ts ×5
1523 > const rawCandidates = stdout.split('\n').map(s => s.trim()).filter(s => s.length > 0);
1524 > // Defensive validation: the finder shell snippet emits paths we
1525 > // trust by construction, but the output is still data coming back
1526 > // over SSH that we then interpolate into a follow-up command
1527 > // (`<candidate> --version`). Filter to the exact shapes we expect
1528 > // — `<root>/<archive>-<40 hex>` or `<legacyDir>/<archive>` — so a
1529 > // malicious or junk file in the install root can never become a
1530 > // shell argument.
1531 > const candidates: string[] = [];
1532 > for (const candidate of rawCandidates) {
1533 > if (isValidFallbackCLIPath(candidate, this._serverDataFolderName, this._quality)) { sshRemoteAgentHostService.ts ×4
1534 > candidates.push(candidate);
1535 > } else {
1536 this._logService.info(`${LOG_PREFIX} Ignoring fallback CLI candidate with unexpected path shape: ${candidate}`);
1537 }
1539 > for (const candidate of candidates) { sshRemoteAgentHostService.ts ×5
1540 > const { code } = await sshExec(client, `${candidate} --version`, { ignoreExitCode: true }); sshRemoteAgentHostService.ts ×4
1541 > if (code === 0) {
1542 > return candidate;
1543 > }
1544 this._logService.info(`${LOG_PREFIX} Fallback CLI candidate ${candidate} failed --version check (exit ${code}); trying next.`);
1545 }
1546 > return undefined; sshRemoteAgentHostService.ts ×2