Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { DisposableStore } from '../../../../base/common/lifecycle.js';
8
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
9
import { NullLogService } from '../../../log/common/log.js';
10
import { IProductService } from '../../../product/common/productService.js';
11
import { SSHAuthMethod, type ISSHAgentHostConfig, type ISSHConnectProgress } from '../../common/sshRemoteAgentHost.js';
12
import { SSHRemoteAgentHostMainService, makeAuthHandler, type SSHAuthAttempt } from '../../node/sshRemoteAgentHostService.js';
13
14
/** Minimal mock SSHChannel for testing. */
15
class MockSSHChannel {
16
readonly stderr = { on: () => { } };
17
on(_event: string, _listener?: (...args: never[]) => void): this { return this; }
18
close(): void { }
19
}
20
21
/**
22
* Mock SSHClient that records exec calls and returns configured responses.
23
* Each call to `exec` shifts the next response from the queue.
24
*/
25
class MockSSHClient {
26
readonly execCalls: string[] = [];
27
ended = false;
28
29
private readonly _execResponses: Array<{ stdout: string; code: number }>;
30
private readonly _closeListeners: Array<() => void> = [];
31
private readonly _errorListeners: Array<() => void> = [];
32
33
constructor(execResponses: Array<{ stdout: string; code: number }> = []) {
34
this._execResponses = execResponses;
35
}
36
37
on(event: string, listener: (...args: never[]) => void): this {
38
if (event === 'close') {
39
this._closeListeners.push(listener as () => void);
40
} else if (event === 'error') {
41
this._errorListeners.push(listener as () => void);
42
}
43
return this;
44
}
45
46
removeListener(event: string, listener: (...args: unknown[]) => void): this {
47
const list = event === 'close' ? this._closeListeners : event === 'error' ? this._errorListeners : undefined;
48
if (list) {
49
const idx = list.indexOf(listener as () => void);
50
if (idx >= 0) {
51
list.splice(idx, 1);
52
}
53
}
54
return this;
55
}
56
57
fireClose(): void {
58
for (const listener of this._closeListeners) {
59
listener();
60
}
61
}
62
63
get closeListenerCount(): number {
64
return this._closeListeners.length;
65
}
66
67
get errorListenerCount(): number {
68
return this._errorListeners.length;
69
}
70
71
connect(): void { /* no-op */ }
72
73
exec(command: string, callback: (err: Error | undefined, stream: unknown) => void): this {
74
this.execCalls.push(command);
75
const response = this._execResponses.shift() ?? { stdout: '', code: 0 };
76
const channel = new MockSSHChannel();
77
// Simulate async SSH exec: resolve immediately via microtask
78
queueMicrotask(() => {
79
// Fire data events
80
if (response.stdout) {
81
const origOn = channel.on.bind(channel);
82
// Re-bind on to capture data handler
83
let dataHandler: ((data: Buffer) => void) | undefined;
84
let closeHandler: ((code: number) => void) | undefined;
85
channel.on = ((event: string, listener: (...args: unknown[]) => void) => {
86
if (event === 'data') {
87
dataHandler = listener as (data: Buffer) => void;
88
} else if (event === 'close') {
89
closeHandler = listener as (code: number) => void;
90
}
91
return origOn(event, listener);
92
}) as typeof channel.on;
93
callback(undefined, channel);
94
if (dataHandler) {
95
dataHandler(Buffer.from(response.stdout));
96
}
97
if (closeHandler) {
98
closeHandler(response.code);
99
}
100
} else {
101
// No stdout — just call back and fire close
102
let closeHandler: ((code: number) => void) | undefined;
103
const origOn = channel.on.bind(channel);
104
channel.on = ((event: string, listener: (...args: unknown[]) => void) => {
105
if (event === 'close') {
106
closeHandler = listener as (code: number) => void;
107
}
108
return origOn(event, listener);
109
}) as typeof channel.on;
110
callback(undefined, channel);
111
if (closeHandler) {
112
closeHandler(response.code);
113
}
114
}
115
});
116
return this;
117
}
118
119
forwardOut(
120
_srcIP: string, _srcPort: number, _dstIP: string, _dstPort: number,
121
_callback: (err: Error | undefined, channel: unknown) => void,
122
): this {
123
return this;
124
}
125
126
end(): void {
127
this.ended = true;
128
}
129
}
130
131
function makeConfig(overrides?: Partial<ISSHAgentHostConfig>): ISSHAgentHostConfig {
132
return {
133
host: '10.0.0.1',
134
username: 'testuser',
135
authMethod: SSHAuthMethod.Agent,
136
name: 'test-host',
137
...overrides,
138
};
139
}
140
141
/**
142
* Testable subclass of SSHRemoteAgentHostMainService.
143
* Overrides the SSH/WebSocket layer so the entire connect flow runs in-process
144
* without needing `ssh2` or `ws` modules.
145
*/
146
class TestableSSHRemoteAgentHostMainService extends SSHRemoteAgentHostMainService {
147
148
readonly mockClients: MockSSHClient[] = [];
149
150
/** Responses that _connectSSH will hand to MockSSHClient for its exec queue. */
151
execResponses: Array<{ stdout: string; code: number }> = [];
152
153
/** What _startRemoteAgentHost will resolve with. */
154
startResult: { port: number; connectionToken: string | undefined; pid: number | undefined } = {
155
port: 9999, connectionToken: 'tok-abc', pid: 42,
156
};
157
startCalled = 0;
158
159
/** What _createWebSocketRelay will resolve with. Set to an Error to reject. */
160
relayResult: { send: (data: string) => void; close: () => void } | Error = {
161
send: () => { },
162
close: () => { },
163
};
164
relayCalled = 0;
165
166
/** Override to intercept relay creation in specific tests. */
167
relayHook: ((call: number) => { send: (data: string) => void; close: () => void } | Error | undefined) | undefined;
168
169
/** Stored onMessage callbacks from relays, most recent last. */
170
private readonly _relayMessageCallbacks: Array<(data: string) => void> = [];
171
/** Stored onClose callbacks from relays, most recent last. */
172
private readonly _relayCloseCallbacks: Array<() => void> = [];
173
/** Stored relay result objects, most recent last (for makePreviousRelaySyncClose). */
174
private readonly _relayResults: Array<{ send: (data: string) => void; close: () => void }> = [];
175
176
protected override async _connectSSH(
177
_config: ISSHAgentHostConfig,
178
) {
179
const client = new MockSSHClient(this.execResponses);
180
this.mockClients.push(client);
181
return client as never;
182
}
183
184
protected override async _startRemoteAgentHost(
185
_client: unknown, _quality: string, _commandOverride?: string,
186
) {
187
this.startCalled++;
188
return { ...this.startResult, stream: new MockSSHChannel() as never };
189
}
190
191
protected override async _createWebSocketRelay(
192
_client: unknown, _dstHost: string, _dstPort: number, _connectionToken: string | undefined,
193
onMessage: (data: string) => void, onClose: () => void,
194
) {
195
this.relayCalled++;
196
this._relayMessageCallbacks.push(onMessage);
197
this._relayCloseCallbacks.push(onClose);
198
const hookResult = this.relayHook?.(this.relayCalled);
199
if (hookResult !== undefined) {
200
if (hookResult instanceof Error) {
201
throw hookResult;
202
}
203
this._relayResults.push(hookResult);
204
return hookResult;
205
}
206
const result = this.relayResult;
207
if (result instanceof Error) {
208
throw result;
209
}
210
// Return a distinct object per call so each SSHConnection gets its own relay
211
const relayObj = { send: result.send, close: result.close };
212
this._relayResults.push(relayObj);
213
return relayObj;
214
}
215
216
override async resolveSSHConfig(_host: string): ReturnType<SSHRemoteAgentHostMainService['resolveSSHConfig']> {
217
return {
218
hostname: '10.0.0.1',
219
port: 22,
220
user: 'testuser',
221
identityFile: [],
222
forwardAgent: false,
223
};
224
}
225
226
/**
227
* Simulate the old (superseded) relay's WebSocket close event firing.
228
* This calls the onClose callback of the second-to-last relay.
229
*/
230
simulateOldRelayClose(): void {
231
if (this._relayCloseCallbacks.length >= 2) {
232
this._relayCloseCallbacks[this._relayCloseCallbacks.length - 2]();
233
}
234
}
235
236
/**
237
* Modify the most recently created relay so that calling close()
238
* synchronously fires its onClose callback. This simulates a WebSocket
239
* implementation that fires the 'close' event inline during ws.close().
240
*/
241
makePreviousRelaySyncClose(): void {
242
const idx = this._relayResults.length - 1;
243
if (idx >= 0 && this._relayCloseCallbacks.length > idx) {
244
const onClose = this._relayCloseCallbacks[idx];
245
this._relayResults[idx].close = () => { onClose(); };
246
}
247
}
248
249
/**
250
* Simulate a message arriving on a specific relay (0-indexed).
251
* Defaults to the most recent relay.
252
*/
253
simulateRelayMessage(data: string, relayIndex?: number): void {
254
const idx = relayIndex ?? this._relayMessageCallbacks.length - 1;
255
this._relayMessageCallbacks[idx]?.(data);
256
}
257
258
/**
259
* Simulate the current (active) relay's WebSocket close event firing.
260
*/
261
simulateCurrentRelayClose(): void {
262
if (this._relayCloseCallbacks.length > 0) {
263
this._relayCloseCallbacks[this._relayCloseCallbacks.length - 1]();
264
}
265
}
266
}
267
268
suite('SSHRemoteAgentHostMainService - connect flow', () => {
269
270
const disposables = new DisposableStore();
271
let service: TestableSSHRemoteAgentHostMainService;
272
273
setup(() => {
274
const logService = new NullLogService();
275
const productService: Pick<IProductService, '_serviceBrand' | 'quality'> = {
276
_serviceBrand: undefined,
277
quality: 'insider',
278
};
279
service = new TestableSSHRemoteAgentHostMainService(
280
logService,
281
productService as IProductService,
282
);
283
disposables.add(service);
284
});
285
286
teardown(() => disposables.clear());
287
288
ensureNoDisposablesAreLeakedInTestSuite();
289
290
test('returns existing connection on duplicate connect without replacing relay', async () => {
291
// First connect: uname, CLI check, findRunningAgentHost (no state), write state
292
service.execResponses = [
293
{ stdout: 'Linux\n', code: 0 }, // uname -s
294
{ stdout: 'x86_64\n', code: 0 }, // uname -m
295
{ stdout: '1.0.0\n', code: 0 }, // CLI --version (already installed)
296
{ stdout: '', code: 1 }, // cat state file (not found)
297
{ stdout: '', code: 0 }, // echo state file (write)
298
];
299
300
const config = makeConfig({ sshConfigHost: 'myalias' });
301
const result1 = await service.connect(config);
302
assert.strictEqual(result1.connectionId, 'ssh:myalias');
303
assert.strictEqual(result1.sshConfigHost, 'myalias');
304
assert.strictEqual(service.startCalled, 1);
305
assert.strictEqual(service.relayCalled, 1);
306
307
// Second connect without replaceRelay — returns existing info
308
// without creating a new relay or restarting the agent
309
const result2 = await service.connect(config);
310
assert.strictEqual(result2.connectionId, result1.connectionId);
311
assert.strictEqual(result2.connectionToken, result1.connectionToken);
312
assert.strictEqual(result2.sshConfigHost, 'myalias');
313
assert.strictEqual(service.startCalled, 1);
314
assert.strictEqual(service.relayCalled, 1); // no new relay
315
});
316
317
test('creates fresh relay on reconnect without restarting agent', async () => {
318
// First connect: uname, CLI check, findRunningAgentHost (no state), write state
319
service.execResponses = [
320
{ stdout: 'Linux\n', code: 0 }, // uname -s
321
{ stdout: 'x86_64\n', code: 0 }, // uname -m
322
{ stdout: '1.0.0\n', code: 0 }, // CLI --version (already installed)
323
{ stdout: '', code: 1 }, // cat state file (not found)
324
{ stdout: '', code: 0 }, // echo state file (write)
325
];
326
327
const config = makeConfig({ sshConfigHost: 'myalias' });
328
const result1 = await service.connect(config);
329
assert.strictEqual(service.startCalled, 1);
330
assert.strictEqual(service.relayCalled, 1);
331
332
// Reconnect — creates fresh relay on existing SSH tunnel
333
const result2 = await service.reconnect('myalias', 'test-agent');
334
assert.strictEqual(result2.connectionId, result1.connectionId);
335
assert.strictEqual(result2.connectionToken, result1.connectionToken);
336
assert.strictEqual(service.startCalled, 1); // no restart
337
assert.strictEqual(service.relayCalled, 2); // fresh relay
338
});
339
340
test('reconnect does not fire onDidRelayClose for superseded relay', async () => {
341
service.execResponses = [
342
{ stdout: 'Linux\n', code: 0 },
343
{ stdout: 'x86_64\n', code: 0 },
344
{ stdout: '1.0.0\n', code: 0 },
345
{ stdout: '', code: 1 },
346
{ stdout: '', code: 0 },
347
];
348
349
const config = makeConfig({ sshConfigHost: 'myalias' });
350
await service.connect(config);
351
352
const closeEvents: string[] = [];
353
disposables.add(service.onDidRelayClose(id => closeEvents.push(id)));
354
355
// Reconnect replaces the relay — old relay close should be suppressed
356
await service.reconnect('myalias', 'test-agent');
357
358
// Simulate the old relay's close event firing asynchronously
359
service.simulateOldRelayClose();
360
361
assert.deepStrictEqual(closeEvents, []);
362
});
363
364
test('reconnect suppresses synchronous close from old relay during replacement', async () => {
365
service.execResponses = [
366
{ stdout: 'Linux\n', code: 0 },
367
{ stdout: 'x86_64\n', code: 0 },
368
{ stdout: '1.0.0\n', code: 0 },
369
{ stdout: '', code: 1 },
370
{ stdout: '', code: 0 },
371
];
372
373
const config = makeConfig({ sshConfigHost: 'myalias' });
374
await service.connect(config);
375
376
const closeEvents: string[] = [];
377
disposables.add(service.onDidRelayClose(id => closeEvents.push(id)));
378
379
// Make the first relay's close() synchronously fire its onClose callback,
380
// simulating a WebSocket that fires 'close' synchronously on ws.close().
381
service.makePreviousRelaySyncClose();
382
383
await service.reconnect('myalias', 'test-agent');
384
assert.deepStrictEqual(closeEvents, []);
385
});
386
387
test('uses sshConfigHost as connection key when present', async () => {
388
service.execResponses = [
389
{ stdout: 'Linux\n', code: 0 },
390
{ stdout: 'x86_64\n', code: 0 },
391
{ stdout: '1.0.0\n', code: 0 },
392
{ stdout: '', code: 1 },
393
{ stdout: '', code: 0 },
394
];
395
396
const result = await service.connect(makeConfig({ sshConfigHost: 'myhost' }));
397
assert.strictEqual(result.connectionId, 'ssh:myhost');
398
assert.strictEqual(result.sshConfigHost, 'myhost');
399
});
400
401
test('skips platform detection and CLI install with remoteAgentHostCommand', async () => {
402
// With a custom command, only state file check + write should happen
403
service.execResponses = [
404
{ stdout: '', code: 1 }, // cat state file (not found)
405
{ stdout: '', code: 0 }, // echo state file (write)
406
];
407
408
const result = await service.connect(makeConfig({
409
remoteAgentHostCommand: '/custom/agent --port 0',
410
}));
411
assert.strictEqual(result.connectionId, '[email protected]:22');
412
assert.strictEqual(service.startCalled, 1);
413
414
// Verify no uname calls were made (custom command skips platform detection)
415
const client = service.mockClients[0];
416
assert.ok(!client.execCalls.some(c => c.includes('uname')));
417
});
418
419
test('reuses existing agent host when state file has valid PID', async () => {
420
const existingState = JSON.stringify({ pid: 1234, port: 7777, connectionToken: 'existing-tok' });
421
service.execResponses = [
422
{ stdout: 'Linux\n', code: 0 }, // uname -s
423
{ stdout: 'x86_64\n', code: 0 }, // uname -m
424
{ stdout: '1.0.0\n', code: 0 }, // CLI --version
425
{ stdout: existingState, code: 0 }, // cat state file (found)
426
{ stdout: '', code: 0 }, // kill -0 (PID alive)
427
];
428
429
const result = await service.connect(makeConfig());
430
431
// Should NOT have started a new agent host
432
assert.strictEqual(service.startCalled, 0);
433
// Should have connected the WebSocket relay
434
assert.strictEqual(service.relayCalled, 1);
435
// Connection token should come from the state file
436
assert.strictEqual(result.connectionToken, 'existing-tok');
437
});
438
439
test('starts fresh when state file PID is dead', async () => {
440
const staleState = JSON.stringify({ pid: 9999, port: 7777, connectionToken: 'old-tok' });
441
service.execResponses = [
442
{ stdout: 'Linux\n', code: 0 }, // uname -s
443
{ stdout: 'x86_64\n', code: 0 }, // uname -m
444
{ stdout: '1.0.0\n', code: 0 }, // CLI --version
445
{ stdout: staleState, code: 0 }, // cat state file
446
{ stdout: '', code: 1 }, // kill -0 (PID dead)
447
{ stdout: '', code: 0 }, // rm -f state file
448
{ stdout: '', code: 0 }, // echo state file (write new)
449
];
450
451
const result = await service.connect(makeConfig());
452
453
// Should have started a new agent host since PID was dead
454
assert.strictEqual(service.startCalled, 1);
455
// Token should come from new start, not the stale state
456
assert.strictEqual(result.connectionToken, 'tok-abc');
457
});
458
459
test('falls back to fresh start when relay to reused agent fails', async () => {
460
const existingState = JSON.stringify({ pid: 1234, port: 7777, connectionToken: 'existing-tok' });
461
service.execResponses = [
462
{ stdout: 'Linux\n', code: 0 }, // uname -s
463
{ stdout: 'x86_64\n', code: 0 }, // uname -m
464
{ stdout: '1.0.0\n', code: 0 }, // CLI --version
465
{ stdout: existingState, code: 0 }, // cat state file (found)
466
{ stdout: '', code: 0 }, // kill -0 (PID alive)
467
// cleanup: cat state file, kill PID, rm state file
468
{ stdout: existingState, code: 0 },
469
{ stdout: '', code: 0 },
470
{ stdout: '', code: 0 },
471
// write new state file after fresh start
472
{ stdout: '', code: 0 },
473
];
474
475
// First relay attempt fails, second succeeds
476
let relayCallCount = 0;
477
service.relayHook = () => {
478
relayCallCount++;
479
if (relayCallCount === 1) {
480
return new Error('connection refused');
481
}
482
return { send: () => { }, close: () => { } };
483
};
484
485
const result = await service.connect(makeConfig());
486
487
// Should have started a fresh agent host after relay failure
488
assert.strictEqual(service.startCalled, 1);
489
assert.strictEqual(relayCallCount, 2);
490
assert.strictEqual(result.connectionToken, 'tok-abc');
491
});
492
493
test('does not retry when relay fails on freshly started agent', async () => {
494
service.execResponses = [
495
{ stdout: 'Linux\n', code: 0 },
496
{ stdout: 'x86_64\n', code: 0 },
497
{ stdout: '1.0.0\n', code: 0 },
498
{ stdout: '', code: 1 }, // no state file
499
{ stdout: '', code: 0 }, // write state
500
];
501
502
service.relayResult = new Error('connection refused');
503
504
await assert.rejects(
505
() => service.connect(makeConfig()),
506
/connection refused/,
507
);
508
assert.strictEqual(service.startCalled, 1);
509
});
510
511
test('cleans up SSH client on error', async () => {
512
service.execResponses = [
513
{ stdout: 'Linux\n', code: 0 },
514
{ stdout: 'x86_64\n', code: 0 },
515
{ stdout: '1.0.0\n', code: 0 },
516
{ stdout: '', code: 1 },
517
{ stdout: '', code: 0 },
518
];
519
520
service.relayResult = new Error('boom');
521
522
await assert.rejects(() => service.connect(makeConfig()));
523
524
// SSH client should have been ended in the catch block
525
assert.strictEqual(service.mockClients[0].ended, true);
526
});
527
528
test('sanitizes config in result (strips password and privateKeyPath)', async () => {
529
service.execResponses = [
530
{ stdout: '', code: 1 },
531
{ stdout: '', code: 0 },
532
];
533
534
const result = await service.connect(makeConfig({
535
remoteAgentHostCommand: '/agent',
536
authMethod: SSHAuthMethod.Password,
537
password: 'secret123',
538
privateKeyPath: '/home/user/.ssh/id_rsa',
539
}));
540
541
assert.strictEqual((result.config as Record<string, unknown>)['password'], undefined);
542
assert.strictEqual((result.config as Record<string, unknown>)['privateKeyPath'], undefined);
543
assert.strictEqual(result.config.host, '10.0.0.1');
544
});
545
546
test('disconnect removes connection and allows reconnect', async () => {
547
service.execResponses = [
548
{ stdout: '', code: 1 },
549
{ stdout: '', code: 0 },
550
];
551
552
const result = await service.connect(makeConfig({
553
remoteAgentHostCommand: '/agent',
554
}));
555
556
// Disconnect
557
await service.disconnect(result.connectionId);
558
559
// Next connect should create a new connection
560
service.execResponses = [
561
{ stdout: '', code: 1 },
562
{ stdout: '', code: 0 },
563
];
564
service.startCalled = 0;
565
566
const result2 = await service.connect(makeConfig({
567
remoteAgentHostCommand: '/agent',
568
}));
569
assert.strictEqual(service.startCalled, 1);
570
assert.strictEqual(result2.connectionId, result.connectionId);
571
});
572
573
test('fires onDidChangeConnections on connect and disconnect', async () => {
574
service.execResponses = [
575
{ stdout: '', code: 1 },
576
{ stdout: '', code: 0 },
577
];
578
579
const events: string[] = [];
580
disposables.add(service.onDidChangeConnections(() => events.push('changed')));
581
disposables.add(service.onDidCloseConnection(id => events.push(`closed:${id}`)));
582
583
const result = await service.connect(makeConfig({
584
remoteAgentHostCommand: '/agent',
585
}));
586
assert.strictEqual(events.length, 1);
587
assert.strictEqual(events[0], 'changed');
588
589
await service.disconnect(result.connectionId);
590
// disconnect fires close before change
591
assert.deepStrictEqual(events, [
592
'changed',
593
`closed:${result.connectionId}`,
594
'changed',
595
]);
596
});
597
598
// --- Relay message routing ---
599
600
test('relay messages fire onDidRelayMessage with correct connectionId', async () => {
601
service.execResponses = [
602
{ stdout: '', code: 1 },
603
{ stdout: '', code: 0 },
604
];
605
606
const result = await service.connect(makeConfig({
607
remoteAgentHostCommand: '/agent',
608
}));
609
610
const messages: Array<{ connectionId: string; data: string }> = [];
611
disposables.add(service.onDidRelayMessage(msg => messages.push(msg)));
612
613
service.simulateRelayMessage('{"jsonrpc":"2.0","id":1}');
614
service.simulateRelayMessage('{"jsonrpc":"2.0","id":2}');
615
616
assert.deepStrictEqual(messages, [
617
{ connectionId: result.connectionId, data: '{"jsonrpc":"2.0","id":1}' },
618
{ connectionId: result.connectionId, data: '{"jsonrpc":"2.0","id":2}' },
619
]);
620
});
621
622
test('relay close fires onDidRelayClose with correct connectionId', async () => {
623
service.execResponses = [
624
{ stdout: '', code: 1 },
625
{ stdout: '', code: 0 },
626
];
627
628
const result = await service.connect(makeConfig({
629
remoteAgentHostCommand: '/agent',
630
}));
631
632
const closes: string[] = [];
633
disposables.add(service.onDidRelayClose(id => closes.push(id)));
634
635
service.simulateCurrentRelayClose();
636
637
assert.deepStrictEqual(closes, [result.connectionId]);
638
});
639
640
test('relaySend delivers data to the correct connection', async () => {
641
const sentData: string[] = [];
642
service.relayResult = {
643
send: (data: string) => sentData.push(data),
644
close: () => { },
645
};
646
647
service.execResponses = [
648
{ stdout: '', code: 1 },
649
{ stdout: '', code: 0 },
650
];
651
const result = await service.connect(makeConfig({
652
remoteAgentHostCommand: '/agent',
653
}));
654
655
await service.relaySend(result.connectionId, 'hello');
656
await service.relaySend(result.connectionId, 'world');
657
658
assert.deepStrictEqual(sentData, ['hello', 'world']);
659
});
660
661
test('relaySend to unknown connectionId is a no-op', async () => {
662
service.execResponses = [
663
{ stdout: '', code: 1 },
664
{ stdout: '', code: 0 },
665
];
666
await service.connect(makeConfig({ remoteAgentHostCommand: '/agent' }));
667
668
// Should not throw
669
await service.relaySend('nonexistent', 'data');
670
});
671
672
// --- Multiple independent connections ---
673
674
test('connects to two different hosts independently', async () => {
675
// First host
676
service.execResponses = [
677
{ stdout: '', code: 1 },
678
{ stdout: '', code: 0 },
679
];
680
const r1 = await service.connect(makeConfig({
681
host: '10.0.0.1', remoteAgentHostCommand: '/agent',
682
}));
683
684
// Second host
685
service.execResponses = [
686
{ stdout: '', code: 1 },
687
{ stdout: '', code: 0 },
688
];
689
const r2 = await service.connect(makeConfig({
690
host: '10.0.0.2', remoteAgentHostCommand: '/agent',
691
}));
692
693
assert.notStrictEqual(r1.connectionId, r2.connectionId);
694
assert.strictEqual(service.startCalled, 2);
695
assert.strictEqual(service.relayCalled, 2);
696
});
697
698
test('disconnect one host does not affect the other', async () => {
699
service.execResponses = [
700
{ stdout: '', code: 1 },
701
{ stdout: '', code: 0 },
702
];
703
const r1 = await service.connect(makeConfig({
704
host: '10.0.0.1', remoteAgentHostCommand: '/agent',
705
}));
706
707
service.execResponses = [
708
{ stdout: '', code: 1 },
709
{ stdout: '', code: 0 },
710
];
711
const r2 = await service.connect(makeConfig({
712
host: '10.0.0.2', remoteAgentHostCommand: '/agent',
713
}));
714
715
await service.disconnect(r1.connectionId);
716
717
// r2 should still be live — duplicate connect returns existing info
718
const r2Again = await service.connect(makeConfig({
719
host: '10.0.0.2', remoteAgentHostCommand: '/agent',
720
}));
721
assert.strictEqual(r2Again.connectionId, r2.connectionId);
722
// No new start or relay was needed
723
assert.strictEqual(service.startCalled, 2);
724
assert.strictEqual(service.relayCalled, 2);
725
});
726
727
// --- Relay messages route to correct connection when multiple exist ---
728
729
test('relay messages from two connections are distinguished by connectionId', async () => {
730
service.execResponses = [
731
{ stdout: '', code: 1 },
732
{ stdout: '', code: 0 },
733
];
734
const r1 = await service.connect(makeConfig({
735
host: '10.0.0.1', remoteAgentHostCommand: '/agent',
736
}));
737
738
service.execResponses = [
739
{ stdout: '', code: 1 },
740
{ stdout: '', code: 0 },
741
];
742
const r2 = await service.connect(makeConfig({
743
host: '10.0.0.2', remoteAgentHostCommand: '/agent',
744
}));
745
746
const messages: Array<{ connectionId: string; data: string }> = [];
747
disposables.add(service.onDidRelayMessage(msg => messages.push(msg)));
748
749
// Message on first connection's relay (index 0)
750
service.simulateRelayMessage('msg-from-host1', 0);
751
// Message on second connection's relay (index 1)
752
service.simulateRelayMessage('msg-from-host2', 1);
753
754
assert.deepStrictEqual(messages, [
755
{ connectionId: r1.connectionId, data: 'msg-from-host1' },
756
{ connectionId: r2.connectionId, data: 'msg-from-host2' },
757
]);
758
});
759
760
// --- Reconnect creates fresh SSH connection after disconnect ---
761
762
test('reconnect after disconnect establishes a new SSH connection', async () => {
763
service.execResponses = [
764
{ stdout: 'Linux\n', code: 0 },
765
{ stdout: 'x86_64\n', code: 0 },
766
{ stdout: '1.0.0\n', code: 0 },
767
{ stdout: '', code: 1 },
768
{ stdout: '', code: 0 },
769
];
770
const r1 = await service.connect(makeConfig({ sshConfigHost: 'myhost' }));
771
assert.strictEqual(service.mockClients.length, 1);
772
773
await service.disconnect(r1.connectionId);
774
775
service.execResponses = [
776
{ stdout: 'Linux\n', code: 0 },
777
{ stdout: 'x86_64\n', code: 0 },
778
{ stdout: '1.0.0\n', code: 0 },
779
{ stdout: '', code: 1 },
780
{ stdout: '', code: 0 },
781
];
782
783
const r2 = await service.reconnect('myhost', 'test-host');
784
// Should have created a fresh SSH client (not reused the old one)
785
assert.strictEqual(service.mockClients.length, 2);
786
assert.strictEqual(r2.connectionId, r1.connectionId);
787
});
788
789
// --- Progress events ---
790
791
test('fires progress events during connect', async () => {
792
service.execResponses = [
793
{ stdout: 'Linux\n', code: 0 },
794
{ stdout: 'x86_64\n', code: 0 },
795
{ stdout: '1.0.0\n', code: 0 },
796
{ stdout: '', code: 1 },
797
{ stdout: '', code: 0 },
798
];
799
800
const progress: ISSHConnectProgress[] = [];
801
disposables.add(service.onDidReportConnectProgress(p => progress.push(p)));
802
803
await service.connect(makeConfig({ sshConfigHost: 'myhost' }));
804
805
// Expect at least: SSH connecting, platform detection, CLI check, start agent, relay
806
assert.ok(progress.length >= 3, `expected at least 3 progress events, got ${progress.length}`);
807
assert.ok(progress.every(p => p.connectionKey === 'ssh:myhost'));
808
assert.ok(progress.every(p => p.message.length > 0), 'all progress messages should be non-empty');
809
});
810
811
// --- SSH client close triggers connection disposal ---
812
813
test('SSH client close event disposes the connection', async () => {
814
service.execResponses = [
815
{ stdout: '', code: 1 },
816
{ stdout: '', code: 0 },
817
];
818
819
const result = await service.connect(makeConfig({
820
remoteAgentHostCommand: '/agent',
821
}));
822
823
const closeEvents: string[] = [];
824
disposables.add(service.onDidCloseConnection(id => closeEvents.push(id)));
825
826
// Simulate the SSH client closing (e.g. network drop)
827
service.mockClients[0].fireClose();
828
829
assert.deepStrictEqual(closeEvents, [result.connectionId]);
830
});
831
832
// --- CLI install flow ---
833
834
test('skips CLI download when CLI is already installed', async () => {
835
service.execResponses = [
836
{ stdout: 'Linux\n', code: 0 }, // uname -s
837
{ stdout: 'x86_64\n', code: 0 }, // uname -m
838
{ stdout: '1.0.0\n', code: 0 }, // CLI --version succeeds
839
{ stdout: '', code: 1 }, // cat state file (not found)
840
{ stdout: '', code: 0 }, // echo state file (write)
841
];
842
843
await service.connect(makeConfig());
844
845
// The exec calls should NOT include any curl/tar/install commands
846
const execCalls = service.mockClients[0].execCalls;
847
assert.ok(!execCalls.some(c => c.includes('curl') || c.includes('tar')),
848
'should not download CLI when already installed');
849
});
850
851
test('downloads CLI when version check fails', async () => {
852
service.execResponses = [
853
{ stdout: 'Linux\n', code: 0 }, // uname -s
854
{ stdout: 'x86_64\n', code: 0 }, // uname -m
855
{ stdout: '', code: 127 }, // CLI --version fails (not found)
856
{ stdout: '', code: 0 }, // curl | tar install
857
{ stdout: '', code: 1 }, // cat state file (not found)
858
{ stdout: '', code: 0 }, // echo state file (write)
859
];
860
861
await service.connect(makeConfig());
862
863
const execCalls = service.mockClients[0].execCalls;
864
assert.ok(execCalls.some(c => c.includes('curl')),
865
'should download CLI when not installed');
866
});
867
868
// --- Connection key formats ---
869
870
test('uses host:port as connection key without sshConfigHost', async () => {
871
service.execResponses = [
872
{ stdout: '', code: 1 },
873
{ stdout: '', code: 0 },
874
];
875
876
const result = await service.connect(makeConfig({
877
host: '192.168.1.1',
878
port: 2222,
879
remoteAgentHostCommand: '/agent',
880
}));
881
assert.strictEqual(result.connectionId, '[email protected]:2222');
882
});
883
884
test('defaults to port 22 in connection key', async () => {
885
service.execResponses = [
886
{ stdout: '', code: 1 },
887
{ stdout: '', code: 0 },
888
];
889
890
const result = await service.connect(makeConfig({
891
host: '192.168.1.1',
892
remoteAgentHostCommand: '/agent',
893
}));
894
assert.strictEqual(result.connectionId, '[email protected]:22');
895
});
896
897
// --- Reconnect preserves connection token from initial connect ---
898
899
test('reconnect preserves connection token and address', async () => {
900
service.execResponses = [
901
{ stdout: 'Linux\n', code: 0 },
902
{ stdout: 'x86_64\n', code: 0 },
903
{ stdout: '1.0.0\n', code: 0 },
904
{ stdout: '', code: 1 },
905
{ stdout: '', code: 0 },
906
];
907
908
const original = await service.connect(makeConfig({ sshConfigHost: 'myhost' }));
909
910
const reconnected = await service.reconnect('myhost', 'new-name');
911
assert.strictEqual(reconnected.connectionToken, original.connectionToken);
912
assert.strictEqual(reconnected.address, original.address);
913
assert.strictEqual(reconnected.connectionId, original.connectionId);
914
});
915
916
// --- Relay messages from superseded relay are still routed (not gated) ---
917
918
test('messages from superseded relay still arrive (only close is suppressed)', async () => {
919
service.execResponses = [
920
{ stdout: 'Linux\n', code: 0 },
921
{ stdout: 'x86_64\n', code: 0 },
922
{ stdout: '1.0.0\n', code: 0 },
923
{ stdout: '', code: 1 },
924
{ stdout: '', code: 0 },
925
];
926
927
const result = await service.connect(makeConfig({ sshConfigHost: 'myhost' }));
928
929
const messages: Array<{ connectionId: string; data: string }> = [];
930
disposables.add(service.onDidRelayMessage(msg => messages.push(msg)));
931
932
// Reconnect replaces the relay
933
await service.reconnect('myhost', 'test-host');
934
935
// Simulate a message arriving from the OLD relay (index 0)
936
service.simulateRelayMessage('stale-message', 0);
937
// And from the NEW relay (index 1)
938
service.simulateRelayMessage('fresh-message', 1);
939
940
// Both messages arrive — message suppression is deliberately NOT done
941
assert.deepStrictEqual(messages, [
942
{ connectionId: result.connectionId, data: 'stale-message' },
943
{ connectionId: result.connectionId, data: 'fresh-message' },
944
]);
945
});
946
947
// --- Reconnect failure cleans up detached SSH client ---
948
949
test('reconnect cleans up SSH client when relay recreation fails', async () => {
950
service.execResponses = [
951
{ stdout: 'Linux\n', code: 0 },
952
{ stdout: 'x86_64\n', code: 0 },
953
{ stdout: '1.0.0\n', code: 0 },
954
{ stdout: '', code: 1 },
955
{ stdout: '', code: 0 },
956
];
957
958
await service.connect(makeConfig({ sshConfigHost: 'myhost' }));
959
const originalClient = service.mockClients[0];
960
assert.strictEqual(originalClient.ended, false);
961
962
// Make relay creation fail on the next call (the reconnect attempt)
963
service.relayHook = (call) => {
964
if (call === 2) {
965
return new Error('relay failed');
966
}
967
return undefined;
968
};
969
970
const closeEvents: string[] = [];
971
disposables.add(service.onDidCloseConnection(id => closeEvents.push(id)));
972
973
await assert.rejects(
974
() => service.reconnect('myhost', 'test-host'),
975
/relay failed/,
976
);
977
978
// SSH client should have been cleaned up despite the failure
979
assert.strictEqual(originalClient.ended, true);
980
// Close event should have fired to notify the renderer
981
assert.deepStrictEqual(closeEvents, ['ssh:myhost']);
982
});
983
984
// --- Reconnect cleans up old SSH client listeners ---
985
986
test('reconnect removes old close/error listeners from shared SSH client', async () => {
987
service.execResponses = [
988
{ stdout: 'Linux\n', code: 0 },
989
{ stdout: 'x86_64\n', code: 0 },
990
{ stdout: '1.0.0\n', code: 0 },
991
{ stdout: '', code: 1 },
992
{ stdout: '', code: 0 },
993
];
994
995
await service.connect(makeConfig({ sshConfigHost: 'myhost' }));
996
const client = service.mockClients[0];
997
998
// After initial connect, the SSH client has close/error listeners from SSHConnection
999
const closeListenersBefore = client.closeListenerCount;
1000
const errorListenersBefore = client.errorListenerCount;
1001
assert.ok(closeListenersBefore > 0, 'should have close listeners after connect');
1002
assert.ok(errorListenersBefore > 0, 'should have error listeners after connect');
1003
1004
// Reconnect replaces the SSHConnection — old listeners should be removed
1005
await service.reconnect('myhost', 'test-host');
1006
1007
// Listener count should not grow — old ones removed, new ones added
1008
assert.strictEqual(client.closeListenerCount, closeListenersBefore);
1009
assert.strictEqual(client.errorListenerCount, errorListenersBefore);
1010
});
1011
});
1012
1013
/**
1014
* Subclass that exposes `_buildAuthAttempts` and stubs out the disk/env seams
1015
* so the auth-attempt building logic can be tested in isolation.
1016
*/
1017
class AuthAttemptsTestService extends SSHRemoteAgentHostMainService {
1018
1019
agentSock: string | undefined = undefined;
1020
keyFiles: Map<string, Buffer> = new Map();
1021
1022
async testBuildAuthAttempts(config: ISSHAgentHostConfig): Promise<SSHAuthAttempt[]> {
1023
return this._buildAuthAttempts(config);
1024
}
1025
1026
protected override _isAgentAvailable(): string | undefined {
1027
return this.agentSock;
1028
}
1029
1030
protected override async _readKeyFileIfExists(keyPath: string): Promise<Buffer | undefined> {
1031
return this.keyFiles.get(keyPath);
1032
}
1033
}
1034
1035
suite('SSHRemoteAgentHostMainService - _buildAuthAttempts', () => {
1036
1037
const disposables = new DisposableStore();
1038
let service: AuthAttemptsTestService;
1039
1040
setup(() => {
1041
const logService = new NullLogService();
1042
const productService: Pick<IProductService, '_serviceBrand' | 'quality'> = {
1043
_serviceBrand: undefined,
1044
quality: 'insider',
1045
};
1046
service = new AuthAttemptsTestService(
1047
logService,
1048
productService as IProductService,
1049
);
1050
disposables.add(service);
1051
});
1052
1053
teardown(() => disposables.clear());
1054
1055
ensureNoDisposablesAreLeakedInTestSuite();
1056
1057
const RSA = Buffer.from('rsa-key-bytes');
1058
const ED = Buffer.from('ed25519-key-bytes');
1059
const EXPLICIT = Buffer.from('explicit-key-bytes');
1060
1061
test('Agent + no SSH_AUTH_SOCK + only id_rsa exists → publickey id_rsa only', async () => {
1062
service.agentSock = undefined;
1063
service.keyFiles.set('~/.ssh/id_rsa', RSA);
1064
1065
const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent }));
1066
1067
assert.deepStrictEqual(attempts, [
1068
{ type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' },
1069
]);
1070
});
1071
1072
test('Agent + SSH_AUTH_SOCK + only id_rsa exists → agent then publickey id_rsa', async () => {
1073
// This is the regression-driving case: agent is set but doesn't have
1074
// the key, so we must still fall through to the on-disk default key.
1075
service.agentSock = '/tmp/ssh-agent.sock';
1076
service.keyFiles.set('~/.ssh/id_rsa', RSA);
1077
1078
const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent }));
1079
1080
assert.deepStrictEqual(attempts, [
1081
{ type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' },
1082
{ type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' },
1083
]);
1084
});
1085
1086
test('Agent + SSH_AUTH_SOCK + id_ed25519 and id_rsa exist → agent then both keys in default order', async () => {
1087
service.agentSock = '/tmp/ssh-agent.sock';
1088
service.keyFiles.set('~/.ssh/id_ed25519', ED);
1089
service.keyFiles.set('~/.ssh/id_rsa', RSA);
1090
1091
const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent }));
1092
1093
assert.deepStrictEqual(attempts, [
1094
{ type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' },
1095
{ type: 'publickey', username: 'testuser', key: ED, keyPath: '~/.ssh/id_ed25519' },
1096
{ type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' },
1097
]);
1098
});
1099
1100
test('Agent + SSH_AUTH_SOCK + no default keys → agent only', async () => {
1101
service.agentSock = '/tmp/ssh-agent.sock';
1102
1103
const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent }));
1104
1105
assert.deepStrictEqual(attempts, [
1106
{ type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' },
1107
]);
1108
});
1109
1110
test('Agent + explicit privateKeyPath + SSH_AUTH_SOCK + id_rsa → explicit, agent, id_rsa', async () => {
1111
service.agentSock = '/tmp/ssh-agent.sock';
1112
service.keyFiles.set('/some/explicit/key', EXPLICIT);
1113
service.keyFiles.set('~/.ssh/id_rsa', RSA);
1114
1115
const attempts = await service.testBuildAuthAttempts(makeConfig({
1116
authMethod: SSHAuthMethod.Agent,
1117
privateKeyPath: '/some/explicit/key',
1118
}));
1119
1120
assert.deepStrictEqual(attempts, [
1121
{ type: 'publickey', username: 'testuser', key: EXPLICIT, keyPath: '/some/explicit/key' },
1122
{ type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' },
1123
{ type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' },
1124
]);
1125
});
1126
1127
test('Agent + explicit privateKeyPath that matches a default → explicit added once', async () => {
1128
// When the user pins ~/.ssh/id_rsa explicitly, we shouldn't end up
1129
// with the same key twice in the queue.
1130
service.agentSock = undefined;
1131
service.keyFiles.set('~/.ssh/id_rsa', RSA);
1132
1133
const attempts = await service.testBuildAuthAttempts(makeConfig({
1134
authMethod: SSHAuthMethod.Agent,
1135
privateKeyPath: '~/.ssh/id_rsa',
1136
}));
1137
1138
assert.deepStrictEqual(attempts, [
1139
{ type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' },
1140
]);
1141
});
1142
1143
test('KeyFile + explicit path → publickey only', async () => {
1144
service.agentSock = '/tmp/ssh-agent.sock';
1145
service.keyFiles.set('/some/explicit/key', EXPLICIT);
1146
service.keyFiles.set('~/.ssh/id_rsa', RSA);
1147
1148
const attempts = await service.testBuildAuthAttempts(makeConfig({
1149
authMethod: SSHAuthMethod.KeyFile,
1150
privateKeyPath: '/some/explicit/key',
1151
}));
1152
1153
assert.deepStrictEqual(attempts, [
1154
{ type: 'publickey', username: 'testuser', key: EXPLICIT, keyPath: '/some/explicit/key' },
1155
]);
1156
});
1157
1158
test('KeyFile + missing privateKeyPath throws', async () => {
1159
await assert.rejects(
1160
() => service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.KeyFile })),
1161
/private key path/i,
1162
);
1163
});
1164
1165
test('KeyFile + unreadable key throws with the path in the message', async () => {
1166
await assert.rejects(
1167
() => service.testBuildAuthAttempts(makeConfig({
1168
authMethod: SSHAuthMethod.KeyFile,
1169
privateKeyPath: '/missing/key',
1170
})),
1171
/\/missing\/key/,
1172
);
1173
});
1174
1175
test('Password → password only', async () => {
1176
service.agentSock = '/tmp/ssh-agent.sock';
1177
service.keyFiles.set('~/.ssh/id_rsa', RSA);
1178
1179
const attempts = await service.testBuildAuthAttempts(makeConfig({
1180
authMethod: SSHAuthMethod.Password,
1181
password: 'pw',
1182
}));
1183
1184
assert.deepStrictEqual(attempts, [
1185
{ type: 'password', username: 'testuser', password: 'pw' },
1186
]);
1187
});
1188
});
1189
1190
suite('SSHRemoteAgentHostMainService - makeAuthHandler', () => {
1191
1192
ensureNoDisposablesAreLeakedInTestSuite();
1193
1194
const KEY = Buffer.from('k');
1195
const attempts: SSHAuthAttempt[] = [
1196
{ type: 'agent', username: 'u', agent: '/sock' },
1197
{ type: 'publickey', username: 'u', key: KEY, keyPath: '~/.ssh/id_rsa' },
1198
];
1199
1200
test('walks attempts in order, then signals exhaustion', () => {
1201
const handler = makeAuthHandler(attempts, new NullLogService());
1202
const calls: Array<object | false> = [];
1203
handler(null, false, next => calls.push(next));
1204
handler(['publickey'], false, next => calls.push(next));
1205
handler(['publickey'], false, next => calls.push(next));
1206
1207
assert.deepStrictEqual(calls, [
1208
{ type: 'agent', username: 'u', agent: '/sock' },
1209
{ type: 'publickey', username: 'u', key: KEY }, // keyPath stripped
1210
false,
1211
]);
1212
});
1213
1214
test('skips attempts whose method the server has rejected', () => {
1215
const handler = makeAuthHandler(attempts, new NullLogService());
1216
const calls: Array<object | false> = [];
1217
// Server only allows password — both attempts should be skipped and
1218
// the handler should signal exhaustion immediately.
1219
handler(['password'], false, next => calls.push(next));
1220
1221
assert.deepStrictEqual(calls, [false]);
1222
});
1223
1224
test('agent attempts are kept when server allows publickey', () => {
1225
// `agent` is a publickey-flavored method; servers advertise `publickey`,
1226
// not `agent`, so the agent attempt must not be filtered out here.
1227
const handler = makeAuthHandler(
1228
[{ type: 'agent', username: 'u', agent: '/sock' }],
1229
new NullLogService(),
1230
);
1231
const calls: Array<object | false> = [];
1232
handler(['publickey'], false, next => calls.push(next));
1233
1234
assert.deepStrictEqual(calls, [{ type: 'agent', username: 'u', agent: '/sock' }]);
1235
});
1236
});
1237
1238