1414 * limitations under the License.
1515 */
1616
17- import { describe , it , expect , vi , beforeEach , type Mock } from 'vitest' ;
18- import { LazySSHAgent , createLazyAgent } from '../../src/proxy/ssh/AgentForwarding' ;
17+ import { describe , it , expect , vi , beforeEach , afterEach , type Mock } from 'vitest' ;
18+ import {
19+ LazySSHAgent ,
20+ createLazyAgent ,
21+ openTemporaryAgentChannel ,
22+ } from '../../src/proxy/ssh/AgentForwarding' ;
1923import { SSHAgentProxy } from '../../src/proxy/ssh/AgentProxy' ;
2024import { ClientWithUser } from '../../src/proxy/ssh/types' ;
25+ import * as sshInternals from '../../src/proxy/ssh/sshInternals' ;
2126
2227describe ( 'AgentForwarding' , ( ) => {
2328 let mockClient : Partial < ClientWithUser > ;
@@ -322,8 +327,6 @@ describe('AgentForwarding', () => {
322327
323328 describe ( 'openTemporaryAgentChannel' , ( ) => {
324329 it ( 'should return null when client has no protocol' , async ( ) => {
325- const { openTemporaryAgentChannel } = await import ( '../../src/proxy/ssh/AgentForwarding' ) ;
326-
327330 const clientWithoutProtocol : any = {
328331 agentForwardingEnabled : true ,
329332 } ;
@@ -334,105 +337,267 @@ describe('AgentForwarding', () => {
334337 } ) ;
335338
336339 it ( 'should handle timeout when channel confirmation not received' , async ( ) => {
337- const { openTemporaryAgentChannel } = await import ( '../../src/proxy/ssh/AgentForwarding' ) ;
340+ vi . useFakeTimers ( ) ;
341+ try {
342+ const mockClient : any = {
343+ agentForwardingEnabled : true ,
344+ _protocol : {
345+ _handlers : { } ,
346+ openssh_authAgent : vi . fn ( ) ,
347+ channelSuccess : vi . fn ( ) ,
348+ } ,
349+ _chanMgr : {
350+ _channels : { } ,
351+ _count : 0 ,
352+ } ,
353+ } ;
354+
355+ const promise = openTemporaryAgentChannel ( mockClient ) ;
356+
357+ // Advance past the 5s confirmation window instead of waiting in real time
358+ vi . advanceTimersByTime ( 5001 ) ;
359+ const result = await promise ;
360+
361+ expect ( result ) . toBeNull ( ) ;
362+ } finally {
363+ vi . useRealTimers ( ) ;
364+ }
365+ } ) ;
366+
367+ it ( 'should find next available channel ID when channels exist' , async ( ) => {
368+ vi . useFakeTimers ( ) ;
369+ try {
370+ const mockClient : any = {
371+ agentForwardingEnabled : true ,
372+ _protocol : {
373+ _handlers : { } ,
374+ openssh_authAgent : vi . fn ( ) ,
375+ channelSuccess : vi . fn ( ) ,
376+ } ,
377+ _chanMgr : {
378+ _channels : {
379+ 1 : 'occupied' ,
380+ 2 : 'occupied' ,
381+ // Channel 3 should be used
382+ } ,
383+ _count : 2 ,
384+ } ,
385+ } ;
386+
387+ const promise = openTemporaryAgentChannel ( mockClient ) ;
388+
389+ // openssh_authAgent is called synchronously, before the promise settles
390+ expect ( mockClient . _protocol . openssh_authAgent ) . toHaveBeenCalledWith (
391+ 3 ,
392+ expect . any ( Number ) ,
393+ expect . any ( Number ) ,
394+ ) ;
395+
396+ // Advance past the confirmation timeout so the promise settles cleanly
397+ vi . advanceTimersByTime ( 5001 ) ;
398+ await promise ;
399+ } finally {
400+ vi . useRealTimers ( ) ;
401+ }
402+ } ) ;
403+
404+ it ( 'should use channel ID 1 when no channels exist' , async ( ) => {
405+ vi . useFakeTimers ( ) ;
406+ try {
407+ const mockClient : any = {
408+ agentForwardingEnabled : true ,
409+ _protocol : {
410+ _handlers : { } ,
411+ openssh_authAgent : vi . fn ( ) ,
412+ channelSuccess : vi . fn ( ) ,
413+ } ,
414+ _chanMgr : {
415+ _channels : { } ,
416+ _count : 0 ,
417+ } ,
418+ } ;
338419
420+ const promise = openTemporaryAgentChannel ( mockClient ) ;
421+
422+ expect ( mockClient . _protocol . openssh_authAgent ) . toHaveBeenCalledWith (
423+ 1 ,
424+ expect . any ( Number ) ,
425+ expect . any ( Number ) ,
426+ ) ;
427+
428+ vi . advanceTimersByTime ( 5001 ) ;
429+ await promise ;
430+ } finally {
431+ vi . useRealTimers ( ) ;
432+ }
433+ } ) ;
434+
435+ it ( 'should return null when client has no chanMgr' , async ( ) => {
339436 const mockClient : any = {
340437 agentForwardingEnabled : true ,
341438 _protocol : {
342439 _handlers : { } ,
343440 openssh_authAgent : vi . fn ( ) ,
344441 channelSuccess : vi . fn ( ) ,
345442 } ,
346- _chanMgr : {
347- _channels : { } ,
348- _count : 0 ,
349- } ,
350443 } ;
351444
352445 const result = await openTemporaryAgentChannel ( mockClient ) ;
353446
354- // Should timeout and return null after 5 seconds
355447 expect ( result ) . toBeNull ( ) ;
356- } , 6000 ) ;
448+ expect ( mockClient . _protocol . openssh_authAgent ) . not . toHaveBeenCalled ( ) ;
449+ } ) ;
357450
358- it ( 'should find next available channel ID when channels exist' , async ( ) => {
359- const { openTemporaryAgentChannel } = await import ( '../../src/proxy/ssh/AgentForwarding' ) ;
451+ describe ( 'CHANNEL_OPEN_CONFIRMATION handler' , ( ) => {
452+ let getChannelModuleSpy : ReturnType < typeof vi . spyOn > ;
360453
361- const mockClient : any = {
362- agentForwardingEnabled : true ,
363- _protocol : {
364- _handlers : { } ,
365- openssh_authAgent : vi . fn ( ) ,
366- channelSuccess : vi . fn ( ) ,
367- } ,
368- _chanMgr : {
369- _channels : {
370- 1 : 'occupied' ,
371- 2 : 'occupied' ,
372- // Channel 3 should be used
454+ afterEach ( ( ) => {
455+ getChannelModuleSpy ?. mockRestore ( ) ;
456+ } ) ;
457+
458+ function makeMockClient ( existingHandler ?: ( ...args : unknown [ ] ) => void ) {
459+ const handlers : Record < string , ( ...args : unknown [ ] ) => void > = { } ;
460+ if ( existingHandler ) {
461+ handlers . CHANNEL_OPEN_CONFIRMATION = existingHandler ;
462+ }
463+ return {
464+ _protocol : {
465+ _handlers : handlers ,
466+ openssh_authAgent : vi . fn ( ) ,
467+ channelSuccess : vi . fn ( ) ,
373468 } ,
374- _count : 2 ,
375- } ,
376- } ;
469+ _chanMgr : { _channels : { } as Record < number , any > , _count : 0 } ,
470+ } as any ;
471+ }
472+
473+ function mockChannelModule ( channelImpl ?: ( ...args : unknown [ ] ) => unknown ) {
474+ const mockChannel = { on : vi . fn ( ) , write : vi . fn ( ) , end : vi . fn ( ) } ;
475+ getChannelModuleSpy = vi . spyOn ( sshInternals , 'getChannelModule' ) . mockImplementation ( ( ) => ( {
476+ Channel : channelImpl ?? vi . fn ( ) . mockImplementation ( ( ) => mockChannel ) ,
477+ MAX_WINDOW : 2 * 1024 * 1024 ,
478+ PACKET_SIZE : 32 * 1024 ,
479+ } ) ) ;
480+ return mockChannel ;
481+ }
482+
483+ it ( 'should create AgentProxy on successful confirmation' , async ( ) => {
484+ mockChannelModule ( ) ;
485+ const client = makeMockClient ( ) ;
486+
487+ const promise = openTemporaryAgentChannel ( client ) ;
488+
489+ const handler = client . _protocol . _handlers . CHANNEL_OPEN_CONFIRMATION ;
490+ handler ( null , { recipient : 1 , sender : 42 , window : 65536 , packetSize : 32768 } ) ;
491+
492+ const result = await promise ;
493+
494+ expect ( result ) . toBeInstanceOf ( SSHAgentProxy ) ;
495+ expect ( client . _chanMgr . _channels [ 1 ] ) . toBeDefined ( ) ;
496+ expect ( client . _chanMgr . _count ) . toBe ( 1 ) ;
497+ } ) ;
377498
378- // Start the operation but don't wait for completion (will timeout)
379- const promise = openTemporaryAgentChannel ( mockClient ) ;
499+ it ( 'should call and restore original handler on confirmation' , async ( ) => {
500+ mockChannelModule ( ) ;
501+ const originalHandler = vi . fn ( ) ;
502+ const client = makeMockClient ( originalHandler ) ;
380503
381- // Verify openssh_authAgent was called with the next available channel (3)
382- expect ( mockClient . _protocol . openssh_authAgent ) . toHaveBeenCalledWith (
383- 3 ,
384- expect . any ( Number ) ,
385- expect . any ( Number ) ,
386- ) ;
504+ const promise = openTemporaryAgentChannel ( client ) ;
387505
388- // Clean up - wait for timeout
389- await promise ;
390- } , 6000 ) ;
506+ const handler = client . _protocol . _handlers . CHANNEL_OPEN_CONFIRMATION ;
507+ const confirmInfo = { recipient : 1 , sender : 42 , window : 65536 , packetSize : 32768 } ;
508+ handler ( null , confirmInfo ) ;
391509
392- it ( 'should use channel ID 1 when no channels exist' , async ( ) => {
393- const { openTemporaryAgentChannel } = await import ( '../../src/proxy/ssh/AgentForwarding' ) ;
510+ await promise ;
394511
395- const mockClient : any = {
396- agentForwardingEnabled : true ,
397- _protocol : {
398- _handlers : { } ,
399- openssh_authAgent : vi . fn ( ) ,
400- channelSuccess : vi . fn ( ) ,
401- } ,
402- _chanMgr : {
403- _channels : { } ,
404- _count : 0 ,
405- } ,
406- } ;
512+ expect ( originalHandler ) . toHaveBeenCalledWith ( null , confirmInfo ) ;
513+ expect ( client . _protocol . _handlers . CHANNEL_OPEN_CONFIRMATION ) . toBe ( originalHandler ) ;
514+ } ) ;
407515
408- const promise = openTemporaryAgentChannel ( mockClient ) ;
516+ it ( 'should delete handler when no original existed' , async ( ) => {
517+ mockChannelModule ( ) ;
518+ const client = makeMockClient ( ) ;
409519
410- expect ( mockClient . _protocol . openssh_authAgent ) . toHaveBeenCalledWith (
411- 1 ,
412- expect . any ( Number ) ,
413- expect . any ( Number ) ,
414- ) ;
520+ const promise = openTemporaryAgentChannel ( client ) ;
415521
416- await promise ;
417- } , 6000 ) ;
522+ const handler = client . _protocol . _handlers . CHANNEL_OPEN_CONFIRMATION ;
523+ handler ( null , { recipient : 1 , sender : 42 , window : 65536 , packetSize : 32768 } ) ;
418524
419- it ( 'should return null when client has no chanMgr' , async ( ) => {
420- const { openTemporaryAgentChannel } = await import ( '../../src/proxy/ssh/AgentForwarding' ) ;
525+ await promise ;
421526
422- const mockClient : any = {
423- agentForwardingEnabled : true ,
424- _protocol : {
425- _handlers : { } ,
426- openssh_authAgent : vi . fn ( ) ,
427- channelSuccess : vi . fn ( ) ,
428- } ,
429- // No _chanMgr — the internals guard should reject and return null
430- } ;
527+ expect ( client . _protocol . _handlers . CHANNEL_OPEN_CONFIRMATION ) . toBeUndefined ( ) ;
528+ } ) ;
431529
432- const result = await openTemporaryAgentChannel ( mockClient ) ;
530+ it ( 'should ignore confirmation for non-matching channel recipient' , async ( ) => {
531+ vi . useFakeTimers ( ) ;
532+ try {
533+ mockChannelModule ( ) ;
534+ const client = makeMockClient ( ) ;
433535
434- expect ( result ) . toBeNull ( ) ;
435- expect ( mockClient . _protocol . openssh_authAgent ) . not . toHaveBeenCalled ( ) ;
536+ const promise = openTemporaryAgentChannel ( client ) ;
537+
538+ const handler = client . _protocol . _handlers . CHANNEL_OPEN_CONFIRMATION ;
539+ handler ( null , { recipient : 999 , sender : 42 , window : 65536 , packetSize : 32768 } ) ;
540+
541+ vi . advanceTimersByTime ( 5001 ) ;
542+
543+ const result = await promise ;
544+ expect ( result ) . toBeNull ( ) ;
545+ } finally {
546+ vi . useRealTimers ( ) ;
547+ }
548+ } ) ;
549+
550+ it ( 'should resolve null when Channel constructor throws' , async ( ) => {
551+ mockChannelModule ( function ( ) {
552+ throw new Error ( 'Channel creation failed' ) ;
553+ } ) ;
554+ const client = makeMockClient ( ) ;
555+
556+ const promise = openTemporaryAgentChannel ( client ) ;
557+
558+ const handler = client . _protocol . _handlers . CHANNEL_OPEN_CONFIRMATION ;
559+ handler ( null , { recipient : 1 , sender : 42 , window : 65536 , packetSize : 32768 } ) ;
560+
561+ const result = await promise ;
562+ expect ( result ) . toBeNull ( ) ;
563+ } ) ;
564+ } ) ;
565+ } ) ;
566+
567+ describe ( 'LazySSHAgent - lock recovery' , ( ) => {
568+ it ( 'should continue operating after a previous operation fails' , ( ) => {
569+ return new Promise < void > ( ( resolve ) => {
570+ const openChannelFn = vi . fn ( ) ;
571+ const client = {
572+ agentForwardingEnabled : true ,
573+ clientIp : '127.0.0.1' ,
574+ authenticatedUser : { username : 'testuser' } ,
575+ } as unknown as ClientWithUser ;
576+
577+ openChannelFn . mockRejectedValueOnce ( new Error ( 'Channel open failed' ) ) ;
578+
579+ const identities = [
580+ { publicKeyBlob : Buffer . from ( 'key1' ) , comment : 'k' , algorithm : 'ssh-ed25519' } ,
581+ ] ;
582+ const mockProxy = {
583+ getIdentities : vi . fn ( ) . mockResolvedValue ( identities ) ,
584+ sign : vi . fn ( ) ,
585+ close : vi . fn ( ) ,
586+ } ;
587+ openChannelFn . mockResolvedValueOnce ( mockProxy ) ;
588+
589+ const agent = new LazySSHAgent ( openChannelFn , client ) ;
590+
591+ agent . getIdentities ( ( err ) => {
592+ expect ( err ) . toBeDefined ( ) ;
593+
594+ agent . getIdentities ( ( err2 , keys ) => {
595+ expect ( err2 ) . toBeNull ( ) ;
596+ expect ( keys ) . toHaveLength ( 1 ) ;
597+ resolve ( ) ;
598+ } ) ;
599+ } ) ;
600+ } ) ;
436601 } ) ;
437602 } ) ;
438603} ) ;
0 commit comments