Path: blob/main/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { DisposableStore } from '../../../../base/common/lifecycle.js';7import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';8import { NullLogService } from '../../../log/common/log.js';9import { IProductService } from '../../../product/common/productService.js';10import { SSHAuthMethod, type ISSHAgentHostConfig, type ISSHConnectProgress } from '../../common/sshRemoteAgentHost.js';11import { SSHRemoteAgentHostMainService, makeAuthHandler, type SSHAuthAttempt } from '../../node/sshRemoteAgentHostService.js';1213/** Minimal mock SSHChannel for testing. */14class MockSSHChannel {15readonly stderr = { on: () => { } };16on(_event: string, _listener?: (...args: never[]) => void): this { return this; }17close(): void { }18}1920/**21* Mock SSHClient that records exec calls and returns configured responses.22* Each call to `exec` shifts the next response from the queue.23*/24class MockSSHClient {25readonly execCalls: string[] = [];26ended = false;2728private readonly _execResponses: Array<{ stdout: string; code: number }>;29private readonly _closeListeners: Array<() => void> = [];30private readonly _errorListeners: Array<() => void> = [];3132constructor(execResponses: Array<{ stdout: string; code: number }> = []) {33this._execResponses = execResponses;34}3536on(event: string, listener: (...args: never[]) => void): this {37if (event === 'close') {38this._closeListeners.push(listener as () => void);39} else if (event === 'error') {40this._errorListeners.push(listener as () => void);41}42return this;43}4445removeListener(event: string, listener: (...args: unknown[]) => void): this {46const list = event === 'close' ? this._closeListeners : event === 'error' ? this._errorListeners : undefined;47if (list) {48const idx = list.indexOf(listener as () => void);49if (idx >= 0) {50list.splice(idx, 1);51}52}53return this;54}5556fireClose(): void {57for (const listener of this._closeListeners) {58listener();59}60}6162get closeListenerCount(): number {63return this._closeListeners.length;64}6566get errorListenerCount(): number {67return this._errorListeners.length;68}6970connect(): void { /* no-op */ }7172exec(command: string, callback: (err: Error | undefined, stream: unknown) => void): this {73this.execCalls.push(command);74const response = this._execResponses.shift() ?? { stdout: '', code: 0 };75const channel = new MockSSHChannel();76// Simulate async SSH exec: resolve immediately via microtask77queueMicrotask(() => {78// Fire data events79if (response.stdout) {80const origOn = channel.on.bind(channel);81// Re-bind on to capture data handler82let dataHandler: ((data: Buffer) => void) | undefined;83let closeHandler: ((code: number) => void) | undefined;84channel.on = ((event: string, listener: (...args: unknown[]) => void) => {85if (event === 'data') {86dataHandler = listener as (data: Buffer) => void;87} else if (event === 'close') {88closeHandler = listener as (code: number) => void;89}90return origOn(event, listener);91}) as typeof channel.on;92callback(undefined, channel);93if (dataHandler) {94dataHandler(Buffer.from(response.stdout));95}96if (closeHandler) {97closeHandler(response.code);98}99} else {100// No stdout — just call back and fire close101let closeHandler: ((code: number) => void) | undefined;102const origOn = channel.on.bind(channel);103channel.on = ((event: string, listener: (...args: unknown[]) => void) => {104if (event === 'close') {105closeHandler = listener as (code: number) => void;106}107return origOn(event, listener);108}) as typeof channel.on;109callback(undefined, channel);110if (closeHandler) {111closeHandler(response.code);112}113}114});115return this;116}117118forwardOut(119_srcIP: string, _srcPort: number, _dstIP: string, _dstPort: number,120_callback: (err: Error | undefined, channel: unknown) => void,121): this {122return this;123}124125end(): void {126this.ended = true;127}128}129130function makeConfig(overrides?: Partial<ISSHAgentHostConfig>): ISSHAgentHostConfig {131return {132host: '10.0.0.1',133username: 'testuser',134authMethod: SSHAuthMethod.Agent,135name: 'test-host',136...overrides,137};138}139140/**141* Testable subclass of SSHRemoteAgentHostMainService.142* Overrides the SSH/WebSocket layer so the entire connect flow runs in-process143* without needing `ssh2` or `ws` modules.144*/145class TestableSSHRemoteAgentHostMainService extends SSHRemoteAgentHostMainService {146147readonly mockClients: MockSSHClient[] = [];148149/** Responses that _connectSSH will hand to MockSSHClient for its exec queue. */150execResponses: Array<{ stdout: string; code: number }> = [];151152/** What _startRemoteAgentHost will resolve with. */153startResult: { port: number; connectionToken: string | undefined; pid: number | undefined } = {154port: 9999, connectionToken: 'tok-abc', pid: 42,155};156startCalled = 0;157158/** What _createWebSocketRelay will resolve with. Set to an Error to reject. */159relayResult: { send: (data: string) => void; close: () => void } | Error = {160send: () => { },161close: () => { },162};163relayCalled = 0;164165/** Override to intercept relay creation in specific tests. */166relayHook: ((call: number) => { send: (data: string) => void; close: () => void } | Error | undefined) | undefined;167168/** Stored onMessage callbacks from relays, most recent last. */169private readonly _relayMessageCallbacks: Array<(data: string) => void> = [];170/** Stored onClose callbacks from relays, most recent last. */171private readonly _relayCloseCallbacks: Array<() => void> = [];172/** Stored relay result objects, most recent last (for makePreviousRelaySyncClose). */173private readonly _relayResults: Array<{ send: (data: string) => void; close: () => void }> = [];174175protected override async _connectSSH(176_config: ISSHAgentHostConfig,177) {178const client = new MockSSHClient(this.execResponses);179this.mockClients.push(client);180return client as never;181}182183protected override async _startRemoteAgentHost(184_client: unknown, _quality: string, _commandOverride?: string,185) {186this.startCalled++;187return { ...this.startResult, stream: new MockSSHChannel() as never };188}189190protected override async _createWebSocketRelay(191_client: unknown, _dstHost: string, _dstPort: number, _connectionToken: string | undefined,192onMessage: (data: string) => void, onClose: () => void,193) {194this.relayCalled++;195this._relayMessageCallbacks.push(onMessage);196this._relayCloseCallbacks.push(onClose);197const hookResult = this.relayHook?.(this.relayCalled);198if (hookResult !== undefined) {199if (hookResult instanceof Error) {200throw hookResult;201}202this._relayResults.push(hookResult);203return hookResult;204}205const result = this.relayResult;206if (result instanceof Error) {207throw result;208}209// Return a distinct object per call so each SSHConnection gets its own relay210const relayObj = { send: result.send, close: result.close };211this._relayResults.push(relayObj);212return relayObj;213}214215override async resolveSSHConfig(_host: string): ReturnType<SSHRemoteAgentHostMainService['resolveSSHConfig']> {216return {217hostname: '10.0.0.1',218port: 22,219user: 'testuser',220identityFile: [],221forwardAgent: false,222};223}224225/**226* Simulate the old (superseded) relay's WebSocket close event firing.227* This calls the onClose callback of the second-to-last relay.228*/229simulateOldRelayClose(): void {230if (this._relayCloseCallbacks.length >= 2) {231this._relayCloseCallbacks[this._relayCloseCallbacks.length - 2]();232}233}234235/**236* Modify the most recently created relay so that calling close()237* synchronously fires its onClose callback. This simulates a WebSocket238* implementation that fires the 'close' event inline during ws.close().239*/240makePreviousRelaySyncClose(): void {241const idx = this._relayResults.length - 1;242if (idx >= 0 && this._relayCloseCallbacks.length > idx) {243const onClose = this._relayCloseCallbacks[idx];244this._relayResults[idx].close = () => { onClose(); };245}246}247248/**249* Simulate a message arriving on a specific relay (0-indexed).250* Defaults to the most recent relay.251*/252simulateRelayMessage(data: string, relayIndex?: number): void {253const idx = relayIndex ?? this._relayMessageCallbacks.length - 1;254this._relayMessageCallbacks[idx]?.(data);255}256257/**258* Simulate the current (active) relay's WebSocket close event firing.259*/260simulateCurrentRelayClose(): void {261if (this._relayCloseCallbacks.length > 0) {262this._relayCloseCallbacks[this._relayCloseCallbacks.length - 1]();263}264}265}266267suite('SSHRemoteAgentHostMainService - connect flow', () => {268269const disposables = new DisposableStore();270let service: TestableSSHRemoteAgentHostMainService;271272setup(() => {273const logService = new NullLogService();274const productService: Pick<IProductService, '_serviceBrand' | 'quality'> = {275_serviceBrand: undefined,276quality: 'insider',277};278service = new TestableSSHRemoteAgentHostMainService(279logService,280productService as IProductService,281);282disposables.add(service);283});284285teardown(() => disposables.clear());286287ensureNoDisposablesAreLeakedInTestSuite();288289test('returns existing connection on duplicate connect without replacing relay', async () => {290// First connect: uname, CLI check, findRunningAgentHost (no state), write state291service.execResponses = [292{ stdout: 'Linux\n', code: 0 }, // uname -s293{ stdout: 'x86_64\n', code: 0 }, // uname -m294{ stdout: '1.0.0\n', code: 0 }, // CLI --version (already installed)295{ stdout: '', code: 1 }, // cat state file (not found)296{ stdout: '', code: 0 }, // echo state file (write)297];298299const config = makeConfig({ sshConfigHost: 'myalias' });300const result1 = await service.connect(config);301assert.strictEqual(result1.connectionId, 'ssh:myalias');302assert.strictEqual(result1.sshConfigHost, 'myalias');303assert.strictEqual(service.startCalled, 1);304assert.strictEqual(service.relayCalled, 1);305306// Second connect without replaceRelay — returns existing info307// without creating a new relay or restarting the agent308const result2 = await service.connect(config);309assert.strictEqual(result2.connectionId, result1.connectionId);310assert.strictEqual(result2.connectionToken, result1.connectionToken);311assert.strictEqual(result2.sshConfigHost, 'myalias');312assert.strictEqual(service.startCalled, 1);313assert.strictEqual(service.relayCalled, 1); // no new relay314});315316test('creates fresh relay on reconnect without restarting agent', async () => {317// First connect: uname, CLI check, findRunningAgentHost (no state), write state318service.execResponses = [319{ stdout: 'Linux\n', code: 0 }, // uname -s320{ stdout: 'x86_64\n', code: 0 }, // uname -m321{ stdout: '1.0.0\n', code: 0 }, // CLI --version (already installed)322{ stdout: '', code: 1 }, // cat state file (not found)323{ stdout: '', code: 0 }, // echo state file (write)324];325326const config = makeConfig({ sshConfigHost: 'myalias' });327const result1 = await service.connect(config);328assert.strictEqual(service.startCalled, 1);329assert.strictEqual(service.relayCalled, 1);330331// Reconnect — creates fresh relay on existing SSH tunnel332const result2 = await service.reconnect('myalias', 'test-agent');333assert.strictEqual(result2.connectionId, result1.connectionId);334assert.strictEqual(result2.connectionToken, result1.connectionToken);335assert.strictEqual(service.startCalled, 1); // no restart336assert.strictEqual(service.relayCalled, 2); // fresh relay337});338339test('reconnect does not fire onDidRelayClose for superseded relay', async () => {340service.execResponses = [341{ stdout: 'Linux\n', code: 0 },342{ stdout: 'x86_64\n', code: 0 },343{ stdout: '1.0.0\n', code: 0 },344{ stdout: '', code: 1 },345{ stdout: '', code: 0 },346];347348const config = makeConfig({ sshConfigHost: 'myalias' });349await service.connect(config);350351const closeEvents: string[] = [];352disposables.add(service.onDidRelayClose(id => closeEvents.push(id)));353354// Reconnect replaces the relay — old relay close should be suppressed355await service.reconnect('myalias', 'test-agent');356357// Simulate the old relay's close event firing asynchronously358service.simulateOldRelayClose();359360assert.deepStrictEqual(closeEvents, []);361});362363test('reconnect suppresses synchronous close from old relay during replacement', async () => {364service.execResponses = [365{ stdout: 'Linux\n', code: 0 },366{ stdout: 'x86_64\n', code: 0 },367{ stdout: '1.0.0\n', code: 0 },368{ stdout: '', code: 1 },369{ stdout: '', code: 0 },370];371372const config = makeConfig({ sshConfigHost: 'myalias' });373await service.connect(config);374375const closeEvents: string[] = [];376disposables.add(service.onDidRelayClose(id => closeEvents.push(id)));377378// Make the first relay's close() synchronously fire its onClose callback,379// simulating a WebSocket that fires 'close' synchronously on ws.close().380service.makePreviousRelaySyncClose();381382await service.reconnect('myalias', 'test-agent');383assert.deepStrictEqual(closeEvents, []);384});385386test('uses sshConfigHost as connection key when present', async () => {387service.execResponses = [388{ stdout: 'Linux\n', code: 0 },389{ stdout: 'x86_64\n', code: 0 },390{ stdout: '1.0.0\n', code: 0 },391{ stdout: '', code: 1 },392{ stdout: '', code: 0 },393];394395const result = await service.connect(makeConfig({ sshConfigHost: 'myhost' }));396assert.strictEqual(result.connectionId, 'ssh:myhost');397assert.strictEqual(result.sshConfigHost, 'myhost');398});399400test('skips platform detection and CLI install with remoteAgentHostCommand', async () => {401// With a custom command, only state file check + write should happen402service.execResponses = [403{ stdout: '', code: 1 }, // cat state file (not found)404{ stdout: '', code: 0 }, // echo state file (write)405];406407const result = await service.connect(makeConfig({408remoteAgentHostCommand: '/custom/agent --port 0',409}));410assert.strictEqual(result.connectionId, '[email protected]:22');411assert.strictEqual(service.startCalled, 1);412413// Verify no uname calls were made (custom command skips platform detection)414const client = service.mockClients[0];415assert.ok(!client.execCalls.some(c => c.includes('uname')));416});417418test('reuses existing agent host when state file has valid PID', async () => {419const existingState = JSON.stringify({ pid: 1234, port: 7777, connectionToken: 'existing-tok' });420service.execResponses = [421{ stdout: 'Linux\n', code: 0 }, // uname -s422{ stdout: 'x86_64\n', code: 0 }, // uname -m423{ stdout: '1.0.0\n', code: 0 }, // CLI --version424{ stdout: existingState, code: 0 }, // cat state file (found)425{ stdout: '', code: 0 }, // kill -0 (PID alive)426];427428const result = await service.connect(makeConfig());429430// Should NOT have started a new agent host431assert.strictEqual(service.startCalled, 0);432// Should have connected the WebSocket relay433assert.strictEqual(service.relayCalled, 1);434// Connection token should come from the state file435assert.strictEqual(result.connectionToken, 'existing-tok');436});437438test('starts fresh when state file PID is dead', async () => {439const staleState = JSON.stringify({ pid: 9999, port: 7777, connectionToken: 'old-tok' });440service.execResponses = [441{ stdout: 'Linux\n', code: 0 }, // uname -s442{ stdout: 'x86_64\n', code: 0 }, // uname -m443{ stdout: '1.0.0\n', code: 0 }, // CLI --version444{ stdout: staleState, code: 0 }, // cat state file445{ stdout: '', code: 1 }, // kill -0 (PID dead)446{ stdout: '', code: 0 }, // rm -f state file447{ stdout: '', code: 0 }, // echo state file (write new)448];449450const result = await service.connect(makeConfig());451452// Should have started a new agent host since PID was dead453assert.strictEqual(service.startCalled, 1);454// Token should come from new start, not the stale state455assert.strictEqual(result.connectionToken, 'tok-abc');456});457458test('falls back to fresh start when relay to reused agent fails', async () => {459const existingState = JSON.stringify({ pid: 1234, port: 7777, connectionToken: 'existing-tok' });460service.execResponses = [461{ stdout: 'Linux\n', code: 0 }, // uname -s462{ stdout: 'x86_64\n', code: 0 }, // uname -m463{ stdout: '1.0.0\n', code: 0 }, // CLI --version464{ stdout: existingState, code: 0 }, // cat state file (found)465{ stdout: '', code: 0 }, // kill -0 (PID alive)466// cleanup: cat state file, kill PID, rm state file467{ stdout: existingState, code: 0 },468{ stdout: '', code: 0 },469{ stdout: '', code: 0 },470// write new state file after fresh start471{ stdout: '', code: 0 },472];473474// First relay attempt fails, second succeeds475let relayCallCount = 0;476service.relayHook = () => {477relayCallCount++;478if (relayCallCount === 1) {479return new Error('connection refused');480}481return { send: () => { }, close: () => { } };482};483484const result = await service.connect(makeConfig());485486// Should have started a fresh agent host after relay failure487assert.strictEqual(service.startCalled, 1);488assert.strictEqual(relayCallCount, 2);489assert.strictEqual(result.connectionToken, 'tok-abc');490});491492test('does not retry when relay fails on freshly started agent', async () => {493service.execResponses = [494{ stdout: 'Linux\n', code: 0 },495{ stdout: 'x86_64\n', code: 0 },496{ stdout: '1.0.0\n', code: 0 },497{ stdout: '', code: 1 }, // no state file498{ stdout: '', code: 0 }, // write state499];500501service.relayResult = new Error('connection refused');502503await assert.rejects(504() => service.connect(makeConfig()),505/connection refused/,506);507assert.strictEqual(service.startCalled, 1);508});509510test('cleans up SSH client on error', async () => {511service.execResponses = [512{ stdout: 'Linux\n', code: 0 },513{ stdout: 'x86_64\n', code: 0 },514{ stdout: '1.0.0\n', code: 0 },515{ stdout: '', code: 1 },516{ stdout: '', code: 0 },517];518519service.relayResult = new Error('boom');520521await assert.rejects(() => service.connect(makeConfig()));522523// SSH client should have been ended in the catch block524assert.strictEqual(service.mockClients[0].ended, true);525});526527test('sanitizes config in result (strips password and privateKeyPath)', async () => {528service.execResponses = [529{ stdout: '', code: 1 },530{ stdout: '', code: 0 },531];532533const result = await service.connect(makeConfig({534remoteAgentHostCommand: '/agent',535authMethod: SSHAuthMethod.Password,536password: 'secret123',537privateKeyPath: '/home/user/.ssh/id_rsa',538}));539540assert.strictEqual((result.config as Record<string, unknown>)['password'], undefined);541assert.strictEqual((result.config as Record<string, unknown>)['privateKeyPath'], undefined);542assert.strictEqual(result.config.host, '10.0.0.1');543});544545test('disconnect removes connection and allows reconnect', async () => {546service.execResponses = [547{ stdout: '', code: 1 },548{ stdout: '', code: 0 },549];550551const result = await service.connect(makeConfig({552remoteAgentHostCommand: '/agent',553}));554555// Disconnect556await service.disconnect(result.connectionId);557558// Next connect should create a new connection559service.execResponses = [560{ stdout: '', code: 1 },561{ stdout: '', code: 0 },562];563service.startCalled = 0;564565const result2 = await service.connect(makeConfig({566remoteAgentHostCommand: '/agent',567}));568assert.strictEqual(service.startCalled, 1);569assert.strictEqual(result2.connectionId, result.connectionId);570});571572test('fires onDidChangeConnections on connect and disconnect', async () => {573service.execResponses = [574{ stdout: '', code: 1 },575{ stdout: '', code: 0 },576];577578const events: string[] = [];579disposables.add(service.onDidChangeConnections(() => events.push('changed')));580disposables.add(service.onDidCloseConnection(id => events.push(`closed:${id}`)));581582const result = await service.connect(makeConfig({583remoteAgentHostCommand: '/agent',584}));585assert.strictEqual(events.length, 1);586assert.strictEqual(events[0], 'changed');587588await service.disconnect(result.connectionId);589// disconnect fires close before change590assert.deepStrictEqual(events, [591'changed',592`closed:${result.connectionId}`,593'changed',594]);595});596597// --- Relay message routing ---598599test('relay messages fire onDidRelayMessage with correct connectionId', async () => {600service.execResponses = [601{ stdout: '', code: 1 },602{ stdout: '', code: 0 },603];604605const result = await service.connect(makeConfig({606remoteAgentHostCommand: '/agent',607}));608609const messages: Array<{ connectionId: string; data: string }> = [];610disposables.add(service.onDidRelayMessage(msg => messages.push(msg)));611612service.simulateRelayMessage('{"jsonrpc":"2.0","id":1}');613service.simulateRelayMessage('{"jsonrpc":"2.0","id":2}');614615assert.deepStrictEqual(messages, [616{ connectionId: result.connectionId, data: '{"jsonrpc":"2.0","id":1}' },617{ connectionId: result.connectionId, data: '{"jsonrpc":"2.0","id":2}' },618]);619});620621test('relay close fires onDidRelayClose with correct connectionId', async () => {622service.execResponses = [623{ stdout: '', code: 1 },624{ stdout: '', code: 0 },625];626627const result = await service.connect(makeConfig({628remoteAgentHostCommand: '/agent',629}));630631const closes: string[] = [];632disposables.add(service.onDidRelayClose(id => closes.push(id)));633634service.simulateCurrentRelayClose();635636assert.deepStrictEqual(closes, [result.connectionId]);637});638639test('relaySend delivers data to the correct connection', async () => {640const sentData: string[] = [];641service.relayResult = {642send: (data: string) => sentData.push(data),643close: () => { },644};645646service.execResponses = [647{ stdout: '', code: 1 },648{ stdout: '', code: 0 },649];650const result = await service.connect(makeConfig({651remoteAgentHostCommand: '/agent',652}));653654await service.relaySend(result.connectionId, 'hello');655await service.relaySend(result.connectionId, 'world');656657assert.deepStrictEqual(sentData, ['hello', 'world']);658});659660test('relaySend to unknown connectionId is a no-op', async () => {661service.execResponses = [662{ stdout: '', code: 1 },663{ stdout: '', code: 0 },664];665await service.connect(makeConfig({ remoteAgentHostCommand: '/agent' }));666667// Should not throw668await service.relaySend('nonexistent', 'data');669});670671// --- Multiple independent connections ---672673test('connects to two different hosts independently', async () => {674// First host675service.execResponses = [676{ stdout: '', code: 1 },677{ stdout: '', code: 0 },678];679const r1 = await service.connect(makeConfig({680host: '10.0.0.1', remoteAgentHostCommand: '/agent',681}));682683// Second host684service.execResponses = [685{ stdout: '', code: 1 },686{ stdout: '', code: 0 },687];688const r2 = await service.connect(makeConfig({689host: '10.0.0.2', remoteAgentHostCommand: '/agent',690}));691692assert.notStrictEqual(r1.connectionId, r2.connectionId);693assert.strictEqual(service.startCalled, 2);694assert.strictEqual(service.relayCalled, 2);695});696697test('disconnect one host does not affect the other', async () => {698service.execResponses = [699{ stdout: '', code: 1 },700{ stdout: '', code: 0 },701];702const r1 = await service.connect(makeConfig({703host: '10.0.0.1', remoteAgentHostCommand: '/agent',704}));705706service.execResponses = [707{ stdout: '', code: 1 },708{ stdout: '', code: 0 },709];710const r2 = await service.connect(makeConfig({711host: '10.0.0.2', remoteAgentHostCommand: '/agent',712}));713714await service.disconnect(r1.connectionId);715716// r2 should still be live — duplicate connect returns existing info717const r2Again = await service.connect(makeConfig({718host: '10.0.0.2', remoteAgentHostCommand: '/agent',719}));720assert.strictEqual(r2Again.connectionId, r2.connectionId);721// No new start or relay was needed722assert.strictEqual(service.startCalled, 2);723assert.strictEqual(service.relayCalled, 2);724});725726// --- Relay messages route to correct connection when multiple exist ---727728test('relay messages from two connections are distinguished by connectionId', async () => {729service.execResponses = [730{ stdout: '', code: 1 },731{ stdout: '', code: 0 },732];733const r1 = await service.connect(makeConfig({734host: '10.0.0.1', remoteAgentHostCommand: '/agent',735}));736737service.execResponses = [738{ stdout: '', code: 1 },739{ stdout: '', code: 0 },740];741const r2 = await service.connect(makeConfig({742host: '10.0.0.2', remoteAgentHostCommand: '/agent',743}));744745const messages: Array<{ connectionId: string; data: string }> = [];746disposables.add(service.onDidRelayMessage(msg => messages.push(msg)));747748// Message on first connection's relay (index 0)749service.simulateRelayMessage('msg-from-host1', 0);750// Message on second connection's relay (index 1)751service.simulateRelayMessage('msg-from-host2', 1);752753assert.deepStrictEqual(messages, [754{ connectionId: r1.connectionId, data: 'msg-from-host1' },755{ connectionId: r2.connectionId, data: 'msg-from-host2' },756]);757});758759// --- Reconnect creates fresh SSH connection after disconnect ---760761test('reconnect after disconnect establishes a new SSH connection', async () => {762service.execResponses = [763{ stdout: 'Linux\n', code: 0 },764{ stdout: 'x86_64\n', code: 0 },765{ stdout: '1.0.0\n', code: 0 },766{ stdout: '', code: 1 },767{ stdout: '', code: 0 },768];769const r1 = await service.connect(makeConfig({ sshConfigHost: 'myhost' }));770assert.strictEqual(service.mockClients.length, 1);771772await service.disconnect(r1.connectionId);773774service.execResponses = [775{ stdout: 'Linux\n', code: 0 },776{ stdout: 'x86_64\n', code: 0 },777{ stdout: '1.0.0\n', code: 0 },778{ stdout: '', code: 1 },779{ stdout: '', code: 0 },780];781782const r2 = await service.reconnect('myhost', 'test-host');783// Should have created a fresh SSH client (not reused the old one)784assert.strictEqual(service.mockClients.length, 2);785assert.strictEqual(r2.connectionId, r1.connectionId);786});787788// --- Progress events ---789790test('fires progress events during connect', async () => {791service.execResponses = [792{ stdout: 'Linux\n', code: 0 },793{ stdout: 'x86_64\n', code: 0 },794{ stdout: '1.0.0\n', code: 0 },795{ stdout: '', code: 1 },796{ stdout: '', code: 0 },797];798799const progress: ISSHConnectProgress[] = [];800disposables.add(service.onDidReportConnectProgress(p => progress.push(p)));801802await service.connect(makeConfig({ sshConfigHost: 'myhost' }));803804// Expect at least: SSH connecting, platform detection, CLI check, start agent, relay805assert.ok(progress.length >= 3, `expected at least 3 progress events, got ${progress.length}`);806assert.ok(progress.every(p => p.connectionKey === 'ssh:myhost'));807assert.ok(progress.every(p => p.message.length > 0), 'all progress messages should be non-empty');808});809810// --- SSH client close triggers connection disposal ---811812test('SSH client close event disposes the connection', async () => {813service.execResponses = [814{ stdout: '', code: 1 },815{ stdout: '', code: 0 },816];817818const result = await service.connect(makeConfig({819remoteAgentHostCommand: '/agent',820}));821822const closeEvents: string[] = [];823disposables.add(service.onDidCloseConnection(id => closeEvents.push(id)));824825// Simulate the SSH client closing (e.g. network drop)826service.mockClients[0].fireClose();827828assert.deepStrictEqual(closeEvents, [result.connectionId]);829});830831// --- CLI install flow ---832833test('skips CLI download when CLI is already installed', async () => {834service.execResponses = [835{ stdout: 'Linux\n', code: 0 }, // uname -s836{ stdout: 'x86_64\n', code: 0 }, // uname -m837{ stdout: '1.0.0\n', code: 0 }, // CLI --version succeeds838{ stdout: '', code: 1 }, // cat state file (not found)839{ stdout: '', code: 0 }, // echo state file (write)840];841842await service.connect(makeConfig());843844// The exec calls should NOT include any curl/tar/install commands845const execCalls = service.mockClients[0].execCalls;846assert.ok(!execCalls.some(c => c.includes('curl') || c.includes('tar')),847'should not download CLI when already installed');848});849850test('downloads CLI when version check fails', async () => {851service.execResponses = [852{ stdout: 'Linux\n', code: 0 }, // uname -s853{ stdout: 'x86_64\n', code: 0 }, // uname -m854{ stdout: '', code: 127 }, // CLI --version fails (not found)855{ stdout: '', code: 0 }, // curl | tar install856{ stdout: '', code: 1 }, // cat state file (not found)857{ stdout: '', code: 0 }, // echo state file (write)858];859860await service.connect(makeConfig());861862const execCalls = service.mockClients[0].execCalls;863assert.ok(execCalls.some(c => c.includes('curl')),864'should download CLI when not installed');865});866867// --- Connection key formats ---868869test('uses host:port as connection key without sshConfigHost', async () => {870service.execResponses = [871{ stdout: '', code: 1 },872{ stdout: '', code: 0 },873];874875const result = await service.connect(makeConfig({876host: '192.168.1.1',877port: 2222,878remoteAgentHostCommand: '/agent',879}));880assert.strictEqual(result.connectionId, '[email protected]:2222');881});882883test('defaults to port 22 in connection key', async () => {884service.execResponses = [885{ stdout: '', code: 1 },886{ stdout: '', code: 0 },887];888889const result = await service.connect(makeConfig({890host: '192.168.1.1',891remoteAgentHostCommand: '/agent',892}));893assert.strictEqual(result.connectionId, '[email protected]:22');894});895896// --- Reconnect preserves connection token from initial connect ---897898test('reconnect preserves connection token and address', async () => {899service.execResponses = [900{ stdout: 'Linux\n', code: 0 },901{ stdout: 'x86_64\n', code: 0 },902{ stdout: '1.0.0\n', code: 0 },903{ stdout: '', code: 1 },904{ stdout: '', code: 0 },905];906907const original = await service.connect(makeConfig({ sshConfigHost: 'myhost' }));908909const reconnected = await service.reconnect('myhost', 'new-name');910assert.strictEqual(reconnected.connectionToken, original.connectionToken);911assert.strictEqual(reconnected.address, original.address);912assert.strictEqual(reconnected.connectionId, original.connectionId);913});914915// --- Relay messages from superseded relay are still routed (not gated) ---916917test('messages from superseded relay still arrive (only close is suppressed)', async () => {918service.execResponses = [919{ stdout: 'Linux\n', code: 0 },920{ stdout: 'x86_64\n', code: 0 },921{ stdout: '1.0.0\n', code: 0 },922{ stdout: '', code: 1 },923{ stdout: '', code: 0 },924];925926const result = await service.connect(makeConfig({ sshConfigHost: 'myhost' }));927928const messages: Array<{ connectionId: string; data: string }> = [];929disposables.add(service.onDidRelayMessage(msg => messages.push(msg)));930931// Reconnect replaces the relay932await service.reconnect('myhost', 'test-host');933934// Simulate a message arriving from the OLD relay (index 0)935service.simulateRelayMessage('stale-message', 0);936// And from the NEW relay (index 1)937service.simulateRelayMessage('fresh-message', 1);938939// Both messages arrive — message suppression is deliberately NOT done940assert.deepStrictEqual(messages, [941{ connectionId: result.connectionId, data: 'stale-message' },942{ connectionId: result.connectionId, data: 'fresh-message' },943]);944});945946// --- Reconnect failure cleans up detached SSH client ---947948test('reconnect cleans up SSH client when relay recreation fails', async () => {949service.execResponses = [950{ stdout: 'Linux\n', code: 0 },951{ stdout: 'x86_64\n', code: 0 },952{ stdout: '1.0.0\n', code: 0 },953{ stdout: '', code: 1 },954{ stdout: '', code: 0 },955];956957await service.connect(makeConfig({ sshConfigHost: 'myhost' }));958const originalClient = service.mockClients[0];959assert.strictEqual(originalClient.ended, false);960961// Make relay creation fail on the next call (the reconnect attempt)962service.relayHook = (call) => {963if (call === 2) {964return new Error('relay failed');965}966return undefined;967};968969const closeEvents: string[] = [];970disposables.add(service.onDidCloseConnection(id => closeEvents.push(id)));971972await assert.rejects(973() => service.reconnect('myhost', 'test-host'),974/relay failed/,975);976977// SSH client should have been cleaned up despite the failure978assert.strictEqual(originalClient.ended, true);979// Close event should have fired to notify the renderer980assert.deepStrictEqual(closeEvents, ['ssh:myhost']);981});982983// --- Reconnect cleans up old SSH client listeners ---984985test('reconnect removes old close/error listeners from shared SSH client', async () => {986service.execResponses = [987{ stdout: 'Linux\n', code: 0 },988{ stdout: 'x86_64\n', code: 0 },989{ stdout: '1.0.0\n', code: 0 },990{ stdout: '', code: 1 },991{ stdout: '', code: 0 },992];993994await service.connect(makeConfig({ sshConfigHost: 'myhost' }));995const client = service.mockClients[0];996997// After initial connect, the SSH client has close/error listeners from SSHConnection998const closeListenersBefore = client.closeListenerCount;999const errorListenersBefore = client.errorListenerCount;1000assert.ok(closeListenersBefore > 0, 'should have close listeners after connect');1001assert.ok(errorListenersBefore > 0, 'should have error listeners after connect');10021003// Reconnect replaces the SSHConnection — old listeners should be removed1004await service.reconnect('myhost', 'test-host');10051006// Listener count should not grow — old ones removed, new ones added1007assert.strictEqual(client.closeListenerCount, closeListenersBefore);1008assert.strictEqual(client.errorListenerCount, errorListenersBefore);1009});1010});10111012/**1013* Subclass that exposes `_buildAuthAttempts` and stubs out the disk/env seams1014* so the auth-attempt building logic can be tested in isolation.1015*/1016class AuthAttemptsTestService extends SSHRemoteAgentHostMainService {10171018agentSock: string | undefined = undefined;1019keyFiles: Map<string, Buffer> = new Map();10201021async testBuildAuthAttempts(config: ISSHAgentHostConfig): Promise<SSHAuthAttempt[]> {1022return this._buildAuthAttempts(config);1023}10241025protected override _isAgentAvailable(): string | undefined {1026return this.agentSock;1027}10281029protected override async _readKeyFileIfExists(keyPath: string): Promise<Buffer | undefined> {1030return this.keyFiles.get(keyPath);1031}1032}10331034suite('SSHRemoteAgentHostMainService - _buildAuthAttempts', () => {10351036const disposables = new DisposableStore();1037let service: AuthAttemptsTestService;10381039setup(() => {1040const logService = new NullLogService();1041const productService: Pick<IProductService, '_serviceBrand' | 'quality'> = {1042_serviceBrand: undefined,1043quality: 'insider',1044};1045service = new AuthAttemptsTestService(1046logService,1047productService as IProductService,1048);1049disposables.add(service);1050});10511052teardown(() => disposables.clear());10531054ensureNoDisposablesAreLeakedInTestSuite();10551056const RSA = Buffer.from('rsa-key-bytes');1057const ED = Buffer.from('ed25519-key-bytes');1058const EXPLICIT = Buffer.from('explicit-key-bytes');10591060test('Agent + no SSH_AUTH_SOCK + only id_rsa exists → publickey id_rsa only', async () => {1061service.agentSock = undefined;1062service.keyFiles.set('~/.ssh/id_rsa', RSA);10631064const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent }));10651066assert.deepStrictEqual(attempts, [1067{ type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' },1068]);1069});10701071test('Agent + SSH_AUTH_SOCK + only id_rsa exists → agent then publickey id_rsa', async () => {1072// This is the regression-driving case: agent is set but doesn't have1073// the key, so we must still fall through to the on-disk default key.1074service.agentSock = '/tmp/ssh-agent.sock';1075service.keyFiles.set('~/.ssh/id_rsa', RSA);10761077const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent }));10781079assert.deepStrictEqual(attempts, [1080{ type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' },1081{ type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' },1082]);1083});10841085test('Agent + SSH_AUTH_SOCK + id_ed25519 and id_rsa exist → agent then both keys in default order', async () => {1086service.agentSock = '/tmp/ssh-agent.sock';1087service.keyFiles.set('~/.ssh/id_ed25519', ED);1088service.keyFiles.set('~/.ssh/id_rsa', RSA);10891090const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent }));10911092assert.deepStrictEqual(attempts, [1093{ type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' },1094{ type: 'publickey', username: 'testuser', key: ED, keyPath: '~/.ssh/id_ed25519' },1095{ type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' },1096]);1097});10981099test('Agent + SSH_AUTH_SOCK + no default keys → agent only', async () => {1100service.agentSock = '/tmp/ssh-agent.sock';11011102const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent }));11031104assert.deepStrictEqual(attempts, [1105{ type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' },1106]);1107});11081109test('Agent + explicit privateKeyPath + SSH_AUTH_SOCK + id_rsa → explicit, agent, id_rsa', async () => {1110service.agentSock = '/tmp/ssh-agent.sock';1111service.keyFiles.set('/some/explicit/key', EXPLICIT);1112service.keyFiles.set('~/.ssh/id_rsa', RSA);11131114const attempts = await service.testBuildAuthAttempts(makeConfig({1115authMethod: SSHAuthMethod.Agent,1116privateKeyPath: '/some/explicit/key',1117}));11181119assert.deepStrictEqual(attempts, [1120{ type: 'publickey', username: 'testuser', key: EXPLICIT, keyPath: '/some/explicit/key' },1121{ type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' },1122{ type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' },1123]);1124});11251126test('Agent + explicit privateKeyPath that matches a default → explicit added once', async () => {1127// When the user pins ~/.ssh/id_rsa explicitly, we shouldn't end up1128// with the same key twice in the queue.1129service.agentSock = undefined;1130service.keyFiles.set('~/.ssh/id_rsa', RSA);11311132const attempts = await service.testBuildAuthAttempts(makeConfig({1133authMethod: SSHAuthMethod.Agent,1134privateKeyPath: '~/.ssh/id_rsa',1135}));11361137assert.deepStrictEqual(attempts, [1138{ type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' },1139]);1140});11411142test('KeyFile + explicit path → publickey only', async () => {1143service.agentSock = '/tmp/ssh-agent.sock';1144service.keyFiles.set('/some/explicit/key', EXPLICIT);1145service.keyFiles.set('~/.ssh/id_rsa', RSA);11461147const attempts = await service.testBuildAuthAttempts(makeConfig({1148authMethod: SSHAuthMethod.KeyFile,1149privateKeyPath: '/some/explicit/key',1150}));11511152assert.deepStrictEqual(attempts, [1153{ type: 'publickey', username: 'testuser', key: EXPLICIT, keyPath: '/some/explicit/key' },1154]);1155});11561157test('KeyFile + missing privateKeyPath throws', async () => {1158await assert.rejects(1159() => service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.KeyFile })),1160/private key path/i,1161);1162});11631164test('KeyFile + unreadable key throws with the path in the message', async () => {1165await assert.rejects(1166() => service.testBuildAuthAttempts(makeConfig({1167authMethod: SSHAuthMethod.KeyFile,1168privateKeyPath: '/missing/key',1169})),1170/\/missing\/key/,1171);1172});11731174test('Password → password only', async () => {1175service.agentSock = '/tmp/ssh-agent.sock';1176service.keyFiles.set('~/.ssh/id_rsa', RSA);11771178const attempts = await service.testBuildAuthAttempts(makeConfig({1179authMethod: SSHAuthMethod.Password,1180password: 'pw',1181}));11821183assert.deepStrictEqual(attempts, [1184{ type: 'password', username: 'testuser', password: 'pw' },1185]);1186});1187});11881189suite('SSHRemoteAgentHostMainService - makeAuthHandler', () => {11901191ensureNoDisposablesAreLeakedInTestSuite();11921193const KEY = Buffer.from('k');1194const attempts: SSHAuthAttempt[] = [1195{ type: 'agent', username: 'u', agent: '/sock' },1196{ type: 'publickey', username: 'u', key: KEY, keyPath: '~/.ssh/id_rsa' },1197];11981199test('walks attempts in order, then signals exhaustion', () => {1200const handler = makeAuthHandler(attempts, new NullLogService());1201const calls: Array<object | false> = [];1202handler(null, false, next => calls.push(next));1203handler(['publickey'], false, next => calls.push(next));1204handler(['publickey'], false, next => calls.push(next));12051206assert.deepStrictEqual(calls, [1207{ type: 'agent', username: 'u', agent: '/sock' },1208{ type: 'publickey', username: 'u', key: KEY }, // keyPath stripped1209false,1210]);1211});12121213test('skips attempts whose method the server has rejected', () => {1214const handler = makeAuthHandler(attempts, new NullLogService());1215const calls: Array<object | false> = [];1216// Server only allows password — both attempts should be skipped and1217// the handler should signal exhaustion immediately.1218handler(['password'], false, next => calls.push(next));12191220assert.deepStrictEqual(calls, [false]);1221});12221223test('agent attempts are kept when server allows publickey', () => {1224// `agent` is a publickey-flavored method; servers advertise `publickey`,1225// not `agent`, so the agent attempt must not be filtered out here.1226const handler = makeAuthHandler(1227[{ type: 'agent', username: 'u', agent: '/sock' }],1228new NullLogService(),1229);1230const calls: Array<object | false> = [];1231handler(['publickey'], false, next => calls.push(next));12321233assert.deepStrictEqual(calls, [{ type: 'agent', username: 'u', agent: '/sock' }]);1234});1235});123612371238