/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; import * as os from 'os'; import { DeferredPromise } from '../../../../base/common/async.js'; import { isCancellationError } from '../../../../base/common/errors.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { createRemoteAgentHostState } from '../../common/remoteAgentHostMetadata.js'; import { SSHAuthMethod, type ISSHAgentHostConfig, type ISSHConnectProgress, type ISSHKeyboardInteractivePrompt, type ISSHKeyboardInteractiveRequest } from '../../common/sshRemoteAgentHost.js'; import { SSHRemoteAgentHostMainService, makeAuthHandler, type SSHAuthAttempt } from '../../node/sshRemoteAgentHostService.js'; import type { AnyAuthMethod, AuthenticationType, ConnectConfig } from 'ssh2'; const dataFolderName = '.vscode-insiders'; const quality = 'insider'; function stateJson(pid: number, port: number, connectionToken: string | undefined | null): string { return JSON.stringify(createRemoteAgentHostState({ pid, port, connectionToken: connectionToken ?? undefined, quality, })); } /** Minimal mock SSHChannel for testing. */ class MockSSHChannel { readonly stderr = { on: () => { } }; on(_event: string, _listener?: (...args: never[]) => void): this { return this; } close(): void { } } /** * Mock SSHClient that records exec calls and returns configured responses. * Each call to `exec` shifts the next response from the queue. */ class MockSSHClient { readonly execCalls: string[] = []; ended = false; private readonly _execResponses: Array<{ stdout: string; code: number }>; private readonly _closeListeners: Array<() => void> = []; private readonly _errorListeners: Array<() => void> = []; constructor(execResponses: Array<{ stdout: string; code: number }> = []) { this._execResponses = execResponses; } on(event: string, listener: (...args: never[]) => void): this { if (event === 'close') { this._closeListeners.push(listener as () => void); } else if (event === 'error') { this._errorListeners.push(listener as () => void); } return this; } removeListener(event: string, listener: (...args: unknown[]) => void): this { const list = event === 'close' ? this._closeListeners : event === 'error' ? this._errorListeners : undefined; if (list) { const idx = list.indexOf(listener as () => void); if (idx >= 0) { list.splice(idx, 1); } } return this; } fireClose(): void { for (const listener of this._closeListeners) { listener(); } } get closeListenerCount(): number { return this._closeListeners.length; } get errorListenerCount(): number { return this._errorListeners.length; } connect(): void { /* no-op */ } exec(command: string, callback: (err: Error | undefined, stream: unknown) => void): this { this.execCalls.push(command); const response = this._execResponses.shift() ?? { stdout: '', code: 0 }; const channel = new MockSSHChannel(); // Simulate async SSH exec: resolve immediately via microtask queueMicrotask(() => { // Fire data events if (response.stdout) { const origOn = channel.on.bind(channel); // Re-bind on to capture data handler let dataHandler: ((data: Buffer) => void) | undefined; let closeHandler: ((code: number) => void) | undefined; channel.on = ((event: string, listener: (...args: unknown[]) => void) => { if (event === 'data') { dataHandler = listener as (data: Buffer) => void; } else if (event === 'close') { closeHandler = listener as (code: number) => void; } return origOn(event, listener); }) as typeof channel.on; callback(undefined, channel); if (dataHandler) { dataHandler(Buffer.from(response.stdout)); } if (closeHandler) { closeHandler(response.code); } } else { // No stdout — just call back and fire close let closeHandler: ((code: number) => void) | undefined; const origOn = channel.on.bind(channel); channel.on = ((event: string, listener: (...args: unknown[]) => void) => { if (event === 'close') { closeHandler = listener as (code: number) => void; } return origOn(event, listener); }) as typeof channel.on; callback(undefined, channel); if (closeHandler) { closeHandler(response.code); } } }); return this; } forwardOut( _srcIP: string, _srcPort: number, _dstIP: string, _dstPort: number, _callback: (err: Error | undefined, channel: unknown) => void, ): this { return this; } end(): void { this.ended = true; } } class KeyboardInteractiveMockSSHClient { ended = false; finishResponses: readonly string[] | undefined; private readonly _errorListeners: Array<(err: Error) => void> = []; on(event: 'ready', listener: () => void): this; on(event: 'error', listener: (err: Error) => void): this; on(event: 'close', listener: () => void): this; on(event: string, listener: ((err: Error) => void) | (() => void)): this { if (event === 'error') { this._errorListeners.push(listener as (err: Error) => void); } return this; } removeListener(_event: string, _listener: (...args: never[]) => void): this { return this; } connect(config: ConnectConfig): void { const authHandler = config.authHandler as ((methodsLeft: AuthenticationType[] | null, partialSuccess: boolean, callback: (next: AnyAuthMethod | false) => void) => void) | undefined; authHandler?.(null, false, method => { if (method && method.type === 'keyboard-interactive') { method.prompt('Keyboard', '', 'en-US', [{ prompt: 'Password: ', echo: false }], responses => { this.finishResponses = responses; this.fireError(new Error('All configured authentication methods failed')); }); } }); } end(): void { this.ended = true; } private fireError(err: Error): void { for (const listener of this._errorListeners) { listener(err); } } } function makeConfig(overrides?: Partial): ISSHAgentHostConfig { return { host: '10.0.0.1', username: 'testuser', authMethod: SSHAuthMethod.Agent, name: 'test-host', ...overrides, }; } /** * Testable subclass of SSHRemoteAgentHostMainService. * Overrides the SSH/WebSocket layer so the entire connect flow runs in-process * without needing `ssh2` or `ws` modules. */ class TestableSSHRemoteAgentHostMainService extends SSHRemoteAgentHostMainService { readonly mockClients: MockSSHClient[] = []; /** Responses that _connectSSH will hand to MockSSHClient for its exec queue. */ execResponses: Array<{ stdout: string; code: number }> = []; /** What _startRemoteAgentHost will resolve with. */ startResult: { port: number; connectionToken: string | undefined; pid: number | undefined } = { port: 9999, connectionToken: 'tok-abc', pid: 42, }; startCalled = 0; /** What _createWebSocketRelay will resolve with. Set to an Error to reject. */ relayResult: { send: (data: string) => void; close: () => void } | Error = { send: () => { }, close: () => { }, }; relayCalled = 0; /** Override to intercept relay creation in specific tests. */ relayHook: ((call: number) => { send: (data: string) => void; close: () => void } | Error | undefined) | undefined; /** * If set to a positive number, the Nth `_createWebSocketRelay` call will * return a promise that never resolves nor rejects. This simulates a * silently dead SSH client where `forwardOut`'s callback never fires. */ hangRelayCreationOnCall: number | undefined; /** Public override so tests can shorten the relay creation timeout. */ protected override relayCreationTimeoutMs: number = 30_000; /** Stored onMessage callbacks from relays, most recent last. */ private readonly _relayMessageCallbacks: Array<(data: string) => void> = []; /** Stored onClose callbacks from relays, most recent last. */ private readonly _relayCloseCallbacks: Array<() => void> = []; /** Stored relay result objects, most recent last (for makePreviousRelaySyncClose). */ private readonly _relayResults: Array<{ send: (data: string) => void; close: () => void }> = []; protected override async _connectSSH( _config: ISSHAgentHostConfig, ) { const client = new MockSSHClient(this.execResponses); this.mockClients.push(client); return client as never; } protected override async _startRemoteAgentHost( _client: unknown, _cliBin: string | undefined, _cliDataDir: string | undefined, _commandOverride?: string, ) { this.startCalled++; return { ...this.startResult, stream: new MockSSHChannel() as never }; } protected override async _createWebSocketRelay( _client: unknown, _dstHost: string, _dstPort: number, _connectionToken: string | undefined, onMessage: (data: string) => void, onClose: () => void, ) { this.relayCalled++; this._relayMessageCallbacks.push(onMessage); this._relayCloseCallbacks.push(onClose); if (this.hangRelayCreationOnCall === this.relayCalled) { // Simulate forwardOut hanging — never resolve. The wrapper in // `connect()` should still surface a timeout error instead of // hanging the whole connect() call. return new Promise<{ send: (data: string) => void; close: () => void }>(() => { /* never */ }); } const hookResult = this.relayHook?.(this.relayCalled); if (hookResult !== undefined) { if (hookResult instanceof Error) { throw hookResult; } this._relayResults.push(hookResult); return hookResult; } const result = this.relayResult; if (result instanceof Error) { throw result; } // Return a distinct object per call so each SSHConnection gets its own relay const relayObj = { send: result.send, close: result.close }; this._relayResults.push(relayObj); return relayObj; } override async resolveSSHConfig(_host: string): ReturnType { return { hostname: '10.0.0.1', port: 22, user: 'testuser', identityFile: [], identityAgent: undefined, forwardAgent: false, }; } /** * Simulate the old (superseded) relay's WebSocket close event firing. * This calls the onClose callback of the second-to-last relay. */ simulateOldRelayClose(): void { if (this._relayCloseCallbacks.length >= 2) { this._relayCloseCallbacks[this._relayCloseCallbacks.length - 2](); } } /** * Modify the most recently created relay so that calling close() * synchronously fires its onClose callback. This simulates a WebSocket * implementation that fires the 'close' event inline during ws.close(). */ makePreviousRelaySyncClose(): void { const idx = this._relayResults.length - 1; if (idx >= 0 && this._relayCloseCallbacks.length > idx) { const onClose = this._relayCloseCallbacks[idx]; this._relayResults[idx].close = () => { onClose(); }; } } /** * Simulate a message arriving on a specific relay (0-indexed). * Defaults to the most recent relay. */ simulateRelayMessage(data: string, relayIndex?: number): void { const idx = relayIndex ?? this._relayMessageCallbacks.length - 1; this._relayMessageCallbacks[idx]?.(data); } /** * Simulate the current (active) relay's WebSocket close event firing. */ simulateCurrentRelayClose(): void { if (this._relayCloseCallbacks.length > 0) { this._relayCloseCallbacks[this._relayCloseCallbacks.length - 1](); } } /** Sets the relay creation timeout; exposed for tests only. */ setRelayCreationTimeoutForTest(ms: number): void { this.relayCreationTimeoutMs = ms; } startKeyboardInteractiveForTest( prompts: readonly ISSHKeyboardInteractivePrompt[], finish: (responses: readonly string[]) => void, cancelConnect: () => void, ): string { return this._handleKeyboardInteractive('ssh:test-host', 'test-host', 'testuser', '', '', prompts, finish, cancelConnect); } } class KeyboardInteractiveConnectTestService extends SSHRemoteAgentHostMainService { readonly client = new KeyboardInteractiveMockSSHClient(); protected override async _createSSHClient() { return this.client as never; } protected override async _buildAuthAttempts(config: ISSHAgentHostConfig): Promise { return [{ type: 'keyboard-interactive', username: config.username }]; } connectSSHForTest(config: ISSHAgentHostConfig) { return this._connectSSH(config, 'ssh:test-host'); } } suite('SSHRemoteAgentHostMainService - connect flow', () => { const disposables = new DisposableStore(); let service: TestableSSHRemoteAgentHostMainService; setup(() => { const logService = new NullLogService(); const productService: Pick = { _serviceBrand: undefined, quality, dataFolderName, }; service = new TestableSSHRemoteAgentHostMainService( logService, productService as IProductService, ); disposables.add(service); }); teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); test('returns existing connection on duplicate connect without replacing relay', async () => { // First connect: uname, CLI check, findRunningAgentHost (no state), write state service.execResponses = [ { stdout: '', code: 1 }, // cat state file (not found) { stdout: 'Linux\n', code: 0 }, // uname -s { stdout: 'x86_64\n', code: 0 }, // uname -m { stdout: '1.0.0\n', code: 0 }, // CLI --version (already installed) { stdout: '', code: 0 }, // echo state file (write) ]; const config = makeConfig({ sshConfigHost: 'myalias' }); const result1 = await service.connect(config); assert.strictEqual(result1.connectionId, 'ssh:myalias'); assert.strictEqual(result1.sshConfigHost, 'myalias'); assert.strictEqual(service.startCalled, 1); assert.strictEqual(service.relayCalled, 1); // Second connect without replaceRelay — returns existing info // without creating a new relay or restarting the agent const result2 = await service.connect(config); assert.strictEqual(result2.connectionId, result1.connectionId); assert.strictEqual(result2.connectionToken, result1.connectionToken); assert.strictEqual(result2.sshConfigHost, 'myalias'); assert.strictEqual(service.startCalled, 1); assert.strictEqual(service.relayCalled, 1); // no new relay }); test('creates fresh relay on reconnect without restarting agent', async () => { // First connect: uname, CLI check, findRunningAgentHost (no state), write state service.execResponses = [ { stdout: '', code: 1 }, // cat state file (not found) { stdout: 'Linux\n', code: 0 }, // uname -s { stdout: 'x86_64\n', code: 0 }, // uname -m { stdout: '1.0.0\n', code: 0 }, // CLI --version (already installed) { stdout: '', code: 0 }, // echo state file (write) ]; const config = makeConfig({ sshConfigHost: 'myalias' }); const result1 = await service.connect(config); assert.strictEqual(service.startCalled, 1); assert.strictEqual(service.relayCalled, 1); // Reconnect — creates fresh relay on existing SSH tunnel const result2 = await service.reconnect('myalias', 'test-agent'); assert.strictEqual(result2.connectionId, result1.connectionId); assert.strictEqual(result2.connectionToken, result1.connectionToken); assert.strictEqual(service.startCalled, 1); // no restart assert.strictEqual(service.relayCalled, 2); // fresh relay }); test('reconnect does not fire onDidRelayClose for superseded relay', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, ]; const config = makeConfig({ sshConfigHost: 'myalias' }); await service.connect(config); const closeEvents: string[] = []; disposables.add(service.onDidRelayClose(id => closeEvents.push(id))); // Reconnect replaces the relay — old relay close should be suppressed await service.reconnect('myalias', 'test-agent'); // Simulate the old relay's close event firing asynchronously service.simulateOldRelayClose(); assert.deepStrictEqual(closeEvents, []); }); test('reconnect suppresses synchronous close from old relay during replacement', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, ]; const config = makeConfig({ sshConfigHost: 'myalias' }); await service.connect(config); const closeEvents: string[] = []; disposables.add(service.onDidRelayClose(id => closeEvents.push(id))); // Make the first relay's close() synchronously fire its onClose callback, // simulating a WebSocket that fires 'close' synchronously on ws.close(). service.makePreviousRelaySyncClose(); await service.reconnect('myalias', 'test-agent'); assert.deepStrictEqual(closeEvents, []); }); test('uses sshConfigHost as connection key when present', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, ]; const result = await service.connect(makeConfig({ sshConfigHost: 'myhost' })); assert.strictEqual(result.connectionId, 'ssh:myhost'); assert.strictEqual(result.sshConfigHost, 'myhost'); }); test('skips platform detection and CLI install with remoteAgentHostCommand', async () => { // With a custom command, only state file check + write should happen service.execResponses = [ { stdout: '', code: 1 }, // cat state file (not found) { stdout: '', code: 0 }, // echo state file (write) ]; const result = await service.connect(makeConfig({ remoteAgentHostCommand: '/custom/agent --port 0', })); assert.strictEqual(result.connectionId, 'testuser@10.0.0.1:22'); assert.strictEqual(service.startCalled, 1); // Verify no uname calls were made (custom command skips platform detection) const client = service.mockClients[0]; assert.ok(!client.execCalls.some(c => c.includes('uname'))); }); test('reuses existing agent host when state file has valid PID', async () => { const existingState = stateJson(1234, 7777, 'existing-tok'); service.execResponses = [ { stdout: existingState, code: 0 }, // cat state file (found) { stdout: '', code: 0 }, // kill -0 (PID alive) ]; const result = await service.connect(makeConfig()); // Should NOT have started a new agent host assert.strictEqual(service.startCalled, 0); // Should have connected the WebSocket relay assert.strictEqual(service.relayCalled, 1); // Connection token should come from the state file assert.strictEqual(result.connectionToken, 'existing-tok'); }); test('agent-host reuse skips platform detection and CLI install', async () => { // Regression: on the AH-reuse path we must not pay for `uname -s`, // `uname -m`, `--version`, install, or cleanup — those are only // needed when we're actually about to spawn a fresh agent host. const existingState = stateJson(1234, 7777, 'existing-tok'); service.execResponses = [ { stdout: existingState, code: 0 }, // cat state file (found) { stdout: '', code: 0 }, // kill -0 (PID alive) ]; await service.connect(makeConfig()); const execCalls = service.mockClients[0].execCalls; assert.ok(!execCalls.some(c => c.includes('uname')), `uname should not run on reuse; saw: ${JSON.stringify(execCalls)}`); assert.ok(!execCalls.some(c => c.includes('--version')), `--version should not run on reuse; saw: ${JSON.stringify(execCalls)}`); assert.ok(!execCalls.some(c => c.includes('test -x')), `test -x should not run on reuse; saw: ${JSON.stringify(execCalls)}`); assert.ok(!execCalls.some(c => c.includes('curl')), `curl should not run on reuse; saw: ${JSON.stringify(execCalls)}`); }); test('starts fresh when state file PID is dead', async () => { const staleState = stateJson(9999, 7777, 'old-tok'); service.execResponses = [ { stdout: staleState, code: 0 }, // cat state file { stdout: '', code: 1 }, // kill -0 (PID dead) { stdout: '', code: 0 }, // rm -f state file { stdout: 'Linux\n', code: 0 }, // uname -s { stdout: 'x86_64\n', code: 0 }, // uname -m { stdout: '1.0.0\n', code: 0 }, // CLI --version { stdout: '', code: 0 }, // echo state file (write new) ]; const result = await service.connect(makeConfig()); // Should have started a new agent host since PID was dead assert.strictEqual(service.startCalled, 1); // Token should come from new start, not the stale state assert.strictEqual(result.connectionToken, 'tok-abc'); }); test('falls back to fresh start when relay to reused agent fails', async () => { const existingState = stateJson(1234, 7777, 'existing-tok'); service.execResponses = [ { stdout: existingState, code: 0 }, // cat state file (found) { stdout: '', code: 0 }, // kill -0 (PID alive) // cleanup: cat state file, kill PID, rm state file { stdout: existingState, code: 0 }, { stdout: '', code: 0 }, { stdout: '', code: 0 }, { stdout: 'Linux\n', code: 0 }, // uname -s { stdout: 'x86_64\n', code: 0 }, // uname -m { stdout: '1.0.0\n', code: 0 }, // CLI --version // write new state file after fresh start { stdout: '', code: 0 }, ]; // First relay attempt fails, second succeeds let relayCallCount = 0; service.relayHook = () => { relayCallCount++; if (relayCallCount === 1) { return new Error('connection refused'); } return { send: () => { }, close: () => { } }; }; const result = await service.connect(makeConfig()); // Should have started a fresh agent host after relay failure assert.strictEqual(service.startCalled, 1); assert.strictEqual(relayCallCount, 2); assert.strictEqual(result.connectionToken, 'tok-abc'); }); test('treats malformed legacy state as missing and starts fresh', async () => { const legacyState = JSON.stringify({ pid: 1234, port: 7777, connectionToken: 'existing-tok' }); service.execResponses = [ { stdout: legacyState, code: 0 }, // cat lockfile (no schemaVersion) { stdout: '', code: 0 }, // rm -f corrupt lockfile { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, // write new lockfile ]; const result = await service.connect(makeConfig()); assert.strictEqual(service.startCalled, 1); assert.strictEqual(service.relayCalled, 1); assert.strictEqual(result.connectionToken, 'tok-abc'); }); test('does not retry when relay fails on freshly started agent', async () => { service.execResponses = [ { stdout: '', code: 1 }, // no state file { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, // write state ]; service.relayResult = new Error('connection refused'); await assert.rejects( () => service.connect(makeConfig()), /connection refused/, ); assert.strictEqual(service.startCalled, 1); }); test('cleans up SSH client on error', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, ]; service.relayResult = new Error('boom'); await assert.rejects(() => service.connect(makeConfig())); // SSH client should have been ended in the catch block assert.strictEqual(service.mockClients[0].ended, true); }); test('sanitizes config in result (strips password and privateKeyPath)', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const result = await service.connect(makeConfig({ remoteAgentHostCommand: '/agent', authMethod: SSHAuthMethod.Password, password: 'secret123', privateKeyPath: '/home/user/.ssh/id_rsa', })); assert.strictEqual((result.config as Record)['password'], undefined); assert.strictEqual((result.config as Record)['privateKeyPath'], undefined); assert.strictEqual(result.config.host, '10.0.0.1'); }); test('disconnect removes connection and allows reconnect', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const result = await service.connect(makeConfig({ remoteAgentHostCommand: '/agent', })); // Disconnect await service.disconnect(result.connectionId); // Next connect should create a new connection service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; service.startCalled = 0; const result2 = await service.connect(makeConfig({ remoteAgentHostCommand: '/agent', })); assert.strictEqual(service.startCalled, 1); assert.strictEqual(result2.connectionId, result.connectionId); }); test('fires onDidChangeConnections on connect and disconnect', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const events: string[] = []; disposables.add(service.onDidChangeConnections(() => events.push('changed'))); disposables.add(service.onDidCloseConnection(id => events.push(`closed:${id}`))); const result = await service.connect(makeConfig({ remoteAgentHostCommand: '/agent', })); assert.strictEqual(events.length, 1); assert.strictEqual(events[0], 'changed'); await service.disconnect(result.connectionId); // disconnect fires close before change assert.deepStrictEqual(events, [ 'changed', `closed:${result.connectionId}`, 'changed', ]); }); // --- Relay message routing --- test('relay messages fire onDidRelayMessage with correct connectionId', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const result = await service.connect(makeConfig({ remoteAgentHostCommand: '/agent', })); const messages: Array<{ connectionId: string; data: string }> = []; disposables.add(service.onDidRelayMessage(msg => messages.push(msg))); service.simulateRelayMessage('{"jsonrpc":"2.0","id":1}'); service.simulateRelayMessage('{"jsonrpc":"2.0","id":2}'); assert.deepStrictEqual(messages, [ { connectionId: result.connectionId, data: '{"jsonrpc":"2.0","id":1}' }, { connectionId: result.connectionId, data: '{"jsonrpc":"2.0","id":2}' }, ]); }); test('relay close fires onDidRelayClose with correct connectionId', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const result = await service.connect(makeConfig({ remoteAgentHostCommand: '/agent', })); const closes: string[] = []; disposables.add(service.onDidRelayClose(id => closes.push(id))); service.simulateCurrentRelayClose(); assert.deepStrictEqual(closes, [result.connectionId]); }); test('relaySend delivers data to the correct connection', async () => { const sentData: string[] = []; service.relayResult = { send: (data: string) => sentData.push(data), close: () => { }, }; service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const result = await service.connect(makeConfig({ remoteAgentHostCommand: '/agent', })); await service.relaySend(result.connectionId, 'hello'); await service.relaySend(result.connectionId, 'world'); assert.deepStrictEqual(sentData, ['hello', 'world']); }); test('relaySend to unknown connectionId is a no-op', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; await service.connect(makeConfig({ remoteAgentHostCommand: '/agent' })); // Should not throw await service.relaySend('nonexistent', 'data'); }); // --- Multiple independent connections --- test('connects to two different hosts independently', async () => { // First host service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const r1 = await service.connect(makeConfig({ host: '10.0.0.1', remoteAgentHostCommand: '/agent', })); // Second host service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const r2 = await service.connect(makeConfig({ host: '10.0.0.2', remoteAgentHostCommand: '/agent', })); assert.notStrictEqual(r1.connectionId, r2.connectionId); assert.strictEqual(service.startCalled, 2); assert.strictEqual(service.relayCalled, 2); }); test('disconnect one host does not affect the other', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const r1 = await service.connect(makeConfig({ host: '10.0.0.1', remoteAgentHostCommand: '/agent', })); service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const r2 = await service.connect(makeConfig({ host: '10.0.0.2', remoteAgentHostCommand: '/agent', })); await service.disconnect(r1.connectionId); // r2 should still be live — duplicate connect returns existing info const r2Again = await service.connect(makeConfig({ host: '10.0.0.2', remoteAgentHostCommand: '/agent', })); assert.strictEqual(r2Again.connectionId, r2.connectionId); // No new start or relay was needed assert.strictEqual(service.startCalled, 2); assert.strictEqual(service.relayCalled, 2); }); // --- Relay messages route to correct connection when multiple exist --- test('relay messages from two connections are distinguished by connectionId', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const r1 = await service.connect(makeConfig({ host: '10.0.0.1', remoteAgentHostCommand: '/agent', })); service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const r2 = await service.connect(makeConfig({ host: '10.0.0.2', remoteAgentHostCommand: '/agent', })); const messages: Array<{ connectionId: string; data: string }> = []; disposables.add(service.onDidRelayMessage(msg => messages.push(msg))); // Message on first connection's relay (index 0) service.simulateRelayMessage('msg-from-host1', 0); // Message on second connection's relay (index 1) service.simulateRelayMessage('msg-from-host2', 1); assert.deepStrictEqual(messages, [ { connectionId: r1.connectionId, data: 'msg-from-host1' }, { connectionId: r2.connectionId, data: 'msg-from-host2' }, ]); }); // --- Reconnect creates fresh SSH connection after disconnect --- test('reconnect after disconnect establishes a new SSH connection', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, ]; const r1 = await service.connect(makeConfig({ sshConfigHost: 'myhost' })); assert.strictEqual(service.mockClients.length, 1); await service.disconnect(r1.connectionId); service.execResponses = [ { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, ]; const r2 = await service.reconnect('myhost', 'test-host'); // Should have created a fresh SSH client (not reused the old one) assert.strictEqual(service.mockClients.length, 2); assert.strictEqual(r2.connectionId, r1.connectionId); }); // --- Progress events --- test('fires progress events during connect', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, ]; const progress: ISSHConnectProgress[] = []; disposables.add(service.onDidReportConnectProgress(p => progress.push(p))); await service.connect(makeConfig({ sshConfigHost: 'myhost' })); // Expect at least: SSH connecting, platform detection, CLI check, start agent, relay assert.ok(progress.length >= 3, `expected at least 3 progress events, got ${progress.length}`); assert.ok(progress.every(p => p.connectionKey === 'ssh:myhost')); assert.ok(progress.every(p => p.message.length > 0), 'all progress messages should be non-empty'); }); test('cancelling keyboard-interactive prompt rejects connect with cancellation', async () => { const kbiService = disposables.add(new KeyboardInteractiveConnectTestService( new NullLogService(), { _serviceBrand: undefined, quality, dataFolderName, } as IProductService, )); const request = new DeferredPromise(); disposables.add(kbiService.onDidRequestKeyboardInteractive(kbiRequest => request.complete(kbiRequest))); const connectPromise = kbiService.connectSSHForTest(makeConfig({ sshConfigHost: 'test-host' })); const kbiRequest = await request.p; await kbiService.respondKeyboardInteractive(kbiRequest.requestId, undefined); await assert.rejects(connectPromise, error => isCancellationError(error)); assert.deepStrictEqual({ ended: kbiService.client.ended, finishResponses: kbiService.client.finishResponses, }, { ended: true, finishResponses: [], }); }); test('responding to keyboard-interactive prompt does not cancel connection attempt', async () => { let finished: readonly string[] | undefined; let cancelled = false; const requestId = service.startKeyboardInteractiveForTest([ { prompt: 'Password: ', echo: false }, ], responses => { finished = responses; }, () => { cancelled = true; }); await service.respondKeyboardInteractive(requestId, ['secret']); assert.deepStrictEqual({ finished, cancelled }, { finished: ['secret'], cancelled: false, }); }); // --- SSH client close triggers connection disposal --- test('SSH client close event disposes the connection', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const result = await service.connect(makeConfig({ remoteAgentHostCommand: '/agent', })); const closeEvents: string[] = []; disposables.add(service.onDidCloseConnection(id => closeEvents.push(id))); // Simulate the SSH client closing (e.g. network drop) service.mockClients[0].fireClose(); assert.deepStrictEqual(closeEvents, [result.connectionId]); }); // --- CLI install flow --- test('skips CLI download when CLI is already installed', async () => { service.execResponses = [ { stdout: '', code: 1 }, // cat state file (not found) { stdout: 'Linux\n', code: 0 }, // uname -s { stdout: 'x86_64\n', code: 0 }, // uname -m { stdout: '1.0.0\n', code: 0 }, // CLI --version succeeds { stdout: '', code: 0 }, // echo state file (write) ]; await service.connect(makeConfig()); // The exec calls should NOT include any curl/tar/install commands const execCalls = service.mockClients[0].execCalls; assert.ok(!execCalls.some(c => c.includes('curl') || c.includes('tar')), 'should not download CLI when already installed'); }); test('downloads CLI when version check fails', async () => { service.execResponses = [ { stdout: '', code: 1 }, // cat state file (not found) { stdout: 'Linux\n', code: 0 }, // uname -s { stdout: 'x86_64\n', code: 0 }, // uname -m { stdout: '', code: 127 }, // CLI --version fails (not found) { stdout: '', code: 0 }, // curl | tar install { stdout: '', code: 0 }, // echo state file (write) ]; await service.connect(makeConfig()); const execCalls = service.mockClients[0].execCalls; assert.ok(execCalls.some(c => c.includes('curl')), 'should download CLI when not installed'); }); // --- Commit-pinned install flow (release builds with productService.commit) --- suite('commit-pinned install', () => { const commit = 'abcdef0123456789abcdef0123456789abcdef01'; const cliBin = `~/.vscode-insiders/code-insiders-${commit}`; let pinnedService: TestableSSHRemoteAgentHostMainService; setup(() => { const logService = new NullLogService(); const productService: Pick = { _serviceBrand: undefined, quality, dataFolderName, serverDataFolderName: '.vscode-insiders', commit, }; pinnedService = new TestableSSHRemoteAgentHostMainService( logService, productService as IProductService, ); disposables.add(pinnedService); }); test('always invokes cleanup of old commit-keyed CLIs', async () => { pinnedService.execResponses = [ { stdout: '', code: 1 }, // cat state (none) { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '', code: 0 }, // test -x cliBin → present { stdout: '', code: 0 }, // touch cliBin (refresh mtime on reuse) { stdout: '', code: 0 }, // cleanup (runs after reuse decision) { stdout: '', code: 0 }, // write state ]; await pinnedService.connect(makeConfig()); const execCalls = pinnedService.mockClients[0].execCalls; // Retention snippet: `ls -1t ... | awk 'NR>5' | xargs rm` assert.ok(execCalls.some(c => /ls -1t .*code-insiders-/.test(c) && /awk\s+'NR>5'/.test(c)), `cleanup command should have run; saw: ${JSON.stringify(execCalls)}`); }); test('reuses existing commit-keyed CLI without re-downloading', async () => { pinnedService.execResponses = [ { stdout: '', code: 1 }, // cat state (none) { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '', code: 0 }, // test -x cliBin → 0 (present) { stdout: '', code: 0 }, // touch cliBin { stdout: '', code: 0 }, // cleanup { stdout: '', code: 0 }, // write state ]; await pinnedService.connect(makeConfig()); const execCalls = pinnedService.mockClients[0].execCalls; assert.ok(execCalls.some(c => c.includes(`test -x ${cliBin}`)), `should test for commit-keyed CLI; saw: ${JSON.stringify(execCalls)}`); assert.ok(!execCalls.some(c => c.includes('curl')), `should not download when commit-keyed CLI present; saw: ${JSON.stringify(execCalls)}`); }); test('downloads from commit-pinned URL when CLI is missing', async () => { pinnedService.execResponses = [ { stdout: '', code: 1 }, // cat state (none) { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '', code: 1 }, // test -x → missing { stdout: '', code: 0 }, // mkdir+mktemp+curl|tar+mv+chmod+rm { stdout: '1.0.0\n', code: 0 }, // --version validation { stdout: '', code: 0 }, // cleanup (after successful install) { stdout: '', code: 0 }, // write state ]; await pinnedService.connect(makeConfig()); const execCalls = pinnedService.mockClients[0].execCalls; const installCall = execCalls.find(c => c.includes('curl')); assert.ok(installCall, `should have run curl install; saw: ${JSON.stringify(execCalls)}`); assert.ok(installCall!.includes(`commit:${commit}`), `install URL should be commit-pinned; got: ${installCall}`); assert.ok(installCall!.includes(`mv `) && installCall!.includes(cliBin), `install should atomic-mv into commit-keyed path; got: ${installCall}`); }); test('falls back to any usable CLI when commit-pinned download fails', async () => { const fallbackBin = `~/.vscode-insiders/code-insiders-0000000000000000000000000000000000000000`; pinnedService.execResponses = [ { stdout: '', code: 1 }, // cat state (none) { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '', code: 1 }, // test -x → missing { stdout: '', code: 7 }, // install fails (curl exit 7) { stdout: `${fallbackBin}\n`, code: 0 }, // fallback finder lists old commit-keyed { stdout: '1.0.0\n', code: 0 }, // fallback --version succeeds { stdout: '', code: 0 }, // write state ]; await pinnedService.connect(makeConfig()); const execCalls = pinnedService.mockClients[0].execCalls; // Fallback finder snippet enumerates commit-keyed candidates by mtime. assert.ok(execCalls.some(c => /ls -1t .*code-insiders-/.test(c) && c.includes('.vscode-cli-insider/code-insiders')), `should have run fallback finder; saw: ${JSON.stringify(execCalls)}`); // Should have --version-validated the fallback candidate. assert.ok(execCalls.some(c => c.includes(`${fallbackBin} --version`)), `should --version-validate fallback; saw: ${JSON.stringify(execCalls)}`); }); test('propagates install error when no fallback CLI exists', async () => { pinnedService.execResponses = [ { stdout: '', code: 1 }, // cat state (none) { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '', code: 1 }, // test -x → missing { stdout: '', code: 7 }, // install fails { stdout: '', code: 0 }, // fallback finder returns nothing ]; await assert.rejects(pinnedService.connect(makeConfig())); }); }); // --- Connection key formats --- test('uses host:port as connection key without sshConfigHost', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const result = await service.connect(makeConfig({ host: '192.168.1.1', port: 2222, remoteAgentHostCommand: '/agent', })); assert.strictEqual(result.connectionId, 'testuser@192.168.1.1:2222'); }); test('defaults to port 22 in connection key', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const result = await service.connect(makeConfig({ host: '192.168.1.1', remoteAgentHostCommand: '/agent', })); assert.strictEqual(result.connectionId, 'testuser@192.168.1.1:22'); }); // --- Reconnect preserves connection token from initial connect --- test('reconnect preserves connection token and address', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, ]; const original = await service.connect(makeConfig({ sshConfigHost: 'myhost' })); const reconnected = await service.reconnect('myhost', 'new-name'); assert.strictEqual(reconnected.connectionToken, original.connectionToken); assert.strictEqual(reconnected.address, original.address); assert.strictEqual(reconnected.connectionId, original.connectionId); }); // --- Relay messages from superseded relay are still routed (not gated) --- test('messages from superseded relay still arrive (only close is suppressed)', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, ]; const result = await service.connect(makeConfig({ sshConfigHost: 'myhost' })); const messages: Array<{ connectionId: string; data: string }> = []; disposables.add(service.onDidRelayMessage(msg => messages.push(msg))); // Reconnect replaces the relay await service.reconnect('myhost', 'test-host'); // Simulate a message arriving from the OLD relay (index 0) service.simulateRelayMessage('stale-message', 0); // And from the NEW relay (index 1) service.simulateRelayMessage('fresh-message', 1); // Both messages arrive — message suppression is deliberately NOT done assert.deepStrictEqual(messages, [ { connectionId: result.connectionId, data: 'stale-message' }, { connectionId: result.connectionId, data: 'fresh-message' }, ]); }); // --- Reconnect failure cleans up detached SSH client --- test('reconnect cleans up SSH client when relay recreation fails', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, ]; await service.connect(makeConfig({ sshConfigHost: 'myhost' })); const originalClient = service.mockClients[0]; assert.strictEqual(originalClient.ended, false); // Make relay creation fail on the next call (the reconnect attempt) service.relayHook = (call) => { if (call === 2) { return new Error('relay failed'); } return undefined; }; const closeEvents: string[] = []; disposables.add(service.onDidCloseConnection(id => closeEvents.push(id))); await assert.rejects( () => service.reconnect('myhost', 'test-host'), /relay failed/, ); // SSH client should have been cleaned up despite the failure assert.strictEqual(originalClient.ended, true); // Close event should have fired to notify the renderer assert.deepStrictEqual(closeEvents, ['ssh:myhost']); }); test('reconnect rejects with timeout when relay creation hangs (silently dead SSH client)', async () => { // Repro for: after a silent network drop, the SSH client's TCP is // half-open but ssh2 hasn't seen 'close' yet. Reusing it for a fresh // relay calls forwardOut, whose callback never fires. Without a // timeout the whole connect() call hangs forever, so the renderer // never sees a rejection and never retries — even after a window // reload, since the shared-process state survives. service.execResponses = [ { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, ]; await service.connect(makeConfig({ sshConfigHost: 'myhost' })); const originalClient = service.mockClients[0]; assert.strictEqual(originalClient.ended, false); // Use a short timeout so the test completes quickly. service.setRelayCreationTimeoutForTest(50); // Make the *reconnect* call's relay creation hang (the second relay). service.hangRelayCreationOnCall = 2; const closeEvents: string[] = []; disposables.add(service.onDidCloseConnection(id => closeEvents.push(id))); await assert.rejects( () => service.reconnect('myhost', 'test-host'), /timed out|timeout/i, 'reconnect should reject (with a timeout error) instead of hanging when relay creation never settles' ); // SSH client should have been ended so subsequent reconnect attempts // don't keep reusing the dead client. After this, the entry is also // removed from `_connections` so a fresh reconnect path runs. assert.strictEqual(originalClient.ended, true, 'dead SSH client should be ended'); // Close event should have fired so the renderer's contribution sees // the reconnect attempt resolved (even as a failure) and can retry. assert.deepStrictEqual(closeEvents, ['ssh:myhost']); }); // --- Reconnect cleans up old SSH client listeners --- test('reconnect removes old close/error listeners from shared SSH client', async () => { service.execResponses = [ { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, { stdout: '', code: 0 }, ]; await service.connect(makeConfig({ sshConfigHost: 'myhost' })); const client = service.mockClients[0]; // After initial connect, the SSH client has close/error listeners from SSHConnection const closeListenersBefore = client.closeListenerCount; const errorListenersBefore = client.errorListenerCount; assert.ok(closeListenersBefore > 0, 'should have close listeners after connect'); assert.ok(errorListenersBefore > 0, 'should have error listeners after connect'); // Reconnect replaces the SSHConnection — old listeners should be removed await service.reconnect('myhost', 'test-host'); // Listener count should not grow — old ones removed, new ones added assert.strictEqual(client.closeListenerCount, closeListenersBefore); assert.strictEqual(client.errorListenerCount, errorListenersBefore); }); }); /** * Subclass that exposes `_buildAuthAttempts` and stubs out the disk/env seams * so the auth-attempt building logic can be tested in isolation. */ class AuthAttemptsTestService extends SSHRemoteAgentHostMainService { agentSock: string | undefined = undefined; keyFiles: Map = new Map(); async testBuildAuthAttempts(config: ISSHAgentHostConfig): Promise { return this._buildAuthAttempts(config); } protected override _isAgentAvailable(): string | undefined { return this.agentSock; } protected override async _readKeyFileIfExists(keyPath: string): Promise { return this.keyFiles.get(keyPath); } } suite('SSHRemoteAgentHostMainService - _buildAuthAttempts', () => { const disposables = new DisposableStore(); let service: AuthAttemptsTestService; setup(() => { const logService = new NullLogService(); const productService: Pick = { _serviceBrand: undefined, quality, dataFolderName, }; service = new AuthAttemptsTestService( logService, productService as IProductService, ); disposables.add(service); }); teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); const RSA = Buffer.from('rsa-key-bytes'); const ED = Buffer.from('ed25519-key-bytes'); const EXPLICIT = Buffer.from('explicit-key-bytes'); function sshString(value: string): Buffer { const valueBuffer = Buffer.from(value, 'utf8'); const lengthBuffer = Buffer.alloc(4); lengthBuffer.writeUInt32BE(valueBuffer.length, 0); return Buffer.concat([lengthBuffer, valueBuffer]); } function openSSHPrivateKeyWithCipher(cipher: string): Buffer { const data = Buffer.concat([ Buffer.from('openssh-key-v1\0', 'utf8'), sshString(cipher), ]); return Buffer.from([ '-----BEGIN OPENSSH PRIVATE KEY-----', data.toString('base64'), '-----END OPENSSH PRIVATE KEY-----', ].join('\n')); } test('Agent + no SSH_AUTH_SOCK + only id_rsa exists → publickey id_rsa, then keyboard-interactive', async () => { service.agentSock = undefined; service.keyFiles.set('~/.ssh/id_rsa', RSA); const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent })); assert.deepStrictEqual(attempts, [ { type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' }, { type: 'keyboard-interactive', username: 'testuser' }, ]); }); test('Agent + SSH_AUTH_SOCK + only id_rsa exists → agent then publickey id_rsa, then keyboard-interactive', async () => { // This is the regression-driving case: agent is set but doesn't have // the key, so we must still fall through to the on-disk default key. service.agentSock = '/tmp/ssh-agent.sock'; service.keyFiles.set('~/.ssh/id_rsa', RSA); const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent })); assert.deepStrictEqual(attempts, [ { type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' }, { type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' }, { type: 'keyboard-interactive', username: 'testuser' }, ]); }); test('Agent + SSH_AUTH_SOCK + id_ed25519 and id_rsa exist → agent then both keys in default order, then keyboard-interactive', async () => { service.agentSock = '/tmp/ssh-agent.sock'; service.keyFiles.set('~/.ssh/id_ed25519', ED); service.keyFiles.set('~/.ssh/id_rsa', RSA); const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent })); assert.deepStrictEqual(attempts, [ { type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' }, { type: 'publickey', username: 'testuser', key: ED, keyPath: '~/.ssh/id_ed25519' }, { type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' }, { type: 'keyboard-interactive', username: 'testuser' }, ]); }); test('Agent + SSH_AUTH_SOCK + no default keys → agent then keyboard-interactive', async () => { service.agentSock = '/tmp/ssh-agent.sock'; const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent })); assert.deepStrictEqual(attempts, [ { type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' }, { type: 'keyboard-interactive', username: 'testuser' }, ]); }); test('Agent + IdentityAgent uses configured agent endpoint before default keys', async () => { service.agentSock = '/tmp/ssh-agent.sock'; service.keyFiles.set('~/.ssh/id_rsa', RSA); const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent, identityAgent: '//./pipe/pageant.user.1234', })); assert.deepStrictEqual(attempts, [ { type: 'agent', username: 'testuser', agent: '//./pipe/pageant.user.1234' }, { type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' }, { type: 'keyboard-interactive', username: 'testuser' }, ]); }); test('Agent + IdentityAgent SSH_AUTH_SOCK uses the default agent endpoint', async () => { service.agentSock = '/tmp/ssh-agent.sock'; const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent, identityAgent: 'SSH_AUTH_SOCK', })); assert.deepStrictEqual(attempts, [ { type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' }, { type: 'keyboard-interactive', username: 'testuser' }, ]); }); test('Agent + IdentityAgent none disables the default SSH_AUTH_SOCK fallback', async () => { service.agentSock = '/tmp/ssh-agent.sock'; const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent, identityAgent: 'none', })); assert.deepStrictEqual(attempts, [ { type: 'keyboard-interactive', username: 'testuser' }, ]); }); test('Agent + explicit privateKeyPath + SSH_AUTH_SOCK + id_rsa → agent first, then explicit, id_rsa, keyboard-interactive', async () => { service.agentSock = '/tmp/ssh-agent.sock'; service.keyFiles.set('/some/explicit/key', EXPLICIT); service.keyFiles.set('~/.ssh/id_rsa', RSA); const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent, privateKeyPath: '/some/explicit/key', })); assert.deepStrictEqual(attempts, [ { type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' }, { type: 'publickey', username: 'testuser', key: EXPLICIT, keyPath: '/some/explicit/key' }, { type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' }, { type: 'keyboard-interactive', username: 'testuser' }, ]); }); test('Agent + explicit privateKeyPath that matches a default → explicit added once, then keyboard-interactive', async () => { // When the user pins ~/.ssh/id_rsa explicitly, we shouldn't end up // with the same key twice in the queue. service.agentSock = undefined; service.keyFiles.set('~/.ssh/id_rsa', RSA); const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent, privateKeyPath: '~/.ssh/id_rsa', })); assert.deepStrictEqual(attempts, [ { type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' }, { type: 'keyboard-interactive', username: 'testuser' }, ]); }); test('Agent + explicit privateKeyPath as absolute default path → agent first, key added once', async () => { // Regression: `ssh -G` always returns absolute identity-file paths, so // /Users//.ssh/id_ed25519 must be recognized as a default and not // promoted to an explicit (encrypted) attempt that would fire a // passphrase prompt before the agent ever gets a chance. service.agentSock = '/tmp/ssh-agent.sock'; service.keyFiles.set('~/.ssh/id_ed25519', ED); const absoluteDefault = `${os.homedir()}/.ssh/id_ed25519`; const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent, privateKeyPath: absoluteDefault, })); assert.deepStrictEqual(attempts, [ { type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' }, { type: 'publickey', username: 'testuser', key: ED, keyPath: '~/.ssh/id_ed25519' }, { type: 'keyboard-interactive', username: 'testuser' }, ]); }); test('KeyFile + explicit path → publickey only', async () => { service.agentSock = '/tmp/ssh-agent.sock'; service.keyFiles.set('/some/explicit/key', EXPLICIT); service.keyFiles.set('~/.ssh/id_rsa', RSA); const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.KeyFile, privateKeyPath: '/some/explicit/key', })); assert.deepStrictEqual(attempts, [ { type: 'publickey', username: 'testuser', key: EXPLICIT, keyPath: '/some/explicit/key' }, ]); }); test('KeyFile + encrypted OpenSSH key marks attempt as encrypted', async () => { const encryptedKey = openSSHPrivateKeyWithCipher('aes256-ctr'); service.keyFiles.set('/some/encrypted/key', encryptedKey); const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.KeyFile, privateKeyPath: '/some/encrypted/key', })); assert.deepStrictEqual(attempts, [ { type: 'publickey', username: 'testuser', key: encryptedKey, keyPath: '/some/encrypted/key', encrypted: true }, ]); }); test('KeyFile + missing privateKeyPath throws', async () => { await assert.rejects( () => service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.KeyFile })), /private key path/i, ); }); test('KeyFile + unreadable key throws with the path in the message', async () => { await assert.rejects( () => service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.KeyFile, privateKeyPath: '/missing/key', })), /\/missing\/key/, ); }); test('Password → password only', async () => { service.agentSock = '/tmp/ssh-agent.sock'; service.keyFiles.set('~/.ssh/id_rsa', RSA); const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Password, password: 'pw', })); assert.deepStrictEqual(attempts, [ { type: 'password', username: 'testuser', password: 'pw' }, ]); }); }); suite('SSHRemoteAgentHostMainService - makeAuthHandler', () => { ensureNoDisposablesAreLeakedInTestSuite(); const KEY = Buffer.from('k'); const attempts: SSHAuthAttempt[] = [ { type: 'agent', username: 'u', agent: '/sock' }, { type: 'publickey', username: 'u', key: KEY, keyPath: '~/.ssh/id_rsa' }, ]; test('walks attempts in order, then signals exhaustion', () => { const handler = makeAuthHandler(attempts, new NullLogService()); const calls: Array = []; handler(null, false, next => calls.push(next)); handler(['publickey'], false, next => calls.push(next)); handler(['publickey'], false, next => calls.push(next)); assert.deepStrictEqual(calls, [ { type: 'agent', username: 'u', agent: '/sock' }, { type: 'publickey', username: 'u', key: KEY }, // keyPath stripped false, ]); }); test('skips attempts whose method the server has rejected', () => { const handler = makeAuthHandler(attempts, new NullLogService()); const calls: Array = []; // Server only allows password — both attempts should be skipped and // the handler should signal exhaustion immediately. handler(['password'], false, next => calls.push(next)); assert.deepStrictEqual(calls, [false]); }); test('agent attempts are kept when server allows publickey', () => { // `agent` is a publickey-flavored method; servers advertise `publickey`, // not `agent`, so the agent attempt must not be filtered out here. const handler = makeAuthHandler( [{ type: 'agent', username: 'u', agent: '/sock' }], new NullLogService(), ); const calls: Array = []; handler(['publickey'], false, next => calls.push(next)); assert.deepStrictEqual(calls, [{ type: 'agent', username: 'u', agent: '/sock' }]); }); test('keyboard-interactive routes prompts to the kbi handler and is skipped without one', () => { const kbiAttempts: SSHAuthAttempt[] = [ { type: 'keyboard-interactive', username: 'u' }, { type: 'publickey', username: 'u', key: KEY, keyPath: '~/.ssh/id_rsa' }, ]; // Without a kbi handler the kbi attempt is skipped entirely. const handlerNoKbi = makeAuthHandler(kbiAttempts, new NullLogService()); const callsNoKbi: Array = []; handlerNoKbi(null, false, next => callsNoKbi.push(next)); assert.deepStrictEqual(callsNoKbi, [{ type: 'publickey', username: 'u', key: KEY }]); // With a kbi handler we get an auth method whose `prompt` callback // forwards into the handler. let promptArgs: { name: string; instructions: string; prompts: ReadonlyArray<{ prompt: string; echo: boolean }> } | undefined; const handlerWithKbi = makeAuthHandler(kbiAttempts, new NullLogService(), (name, instructions, prompts, finish) => { promptArgs = { name, instructions, prompts }; finish(['secret']); }); const callsWithKbi: Array<{ type: string; username: string; prompt?: Function } | false> = []; handlerWithKbi(null, false, next => callsWithKbi.push(next as { type: string; username: string; prompt?: Function })); assert.strictEqual(callsWithKbi.length, 1); assert.strictEqual((callsWithKbi[0] as { type: string }).type, 'keyboard-interactive'); const finishCalls: ReadonlyArray[] = []; (callsWithKbi[0] as { prompt: Function }).prompt('n', 'i', 'lang', [{ prompt: 'Password:', echo: false }], (responses: ReadonlyArray) => finishCalls.push(responses)); assert.deepStrictEqual(promptArgs, { name: 'n', instructions: 'i', prompts: [{ prompt: 'Password:', echo: false }] }); assert.deepStrictEqual(finishCalls, [['secret']]); }); test('encrypted publickey requests passphrase and passes it to ssh2', () => { const encryptedAttempts: SSHAuthAttempt[] = [ { type: 'publickey', username: 'u', key: KEY, keyPath: '~/.ssh/id_rsa', encrypted: true }, ]; const calls: Array = []; const handler = makeAuthHandler(encryptedAttempts, new NullLogService(), undefined, (keyPath, finish) => { assert.strictEqual(keyPath, '~/.ssh/id_rsa'); finish('passphrase'); }); handler(null, false, next => calls.push(next)); assert.deepStrictEqual(calls, [ { type: 'publickey', username: 'u', key: KEY, passphrase: 'passphrase' }, ]); }); });