From 6bab396511e8414c2bf2d7f166d5e3a254defb31 Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Tue, 21 Jul 2026 18:26:44 -0400 Subject: [PATCH 1/2] Fix interactive SSH sessions --- lib/commands/stack/ssh.js | 214 +++++++++++++++++++++----------------- package.json | 3 + test/ssh.test.js | 63 +++++++++++ 3 files changed, 184 insertions(+), 96 deletions(-) create mode 100644 test/ssh.test.js diff --git a/lib/commands/stack/ssh.js b/lib/commands/stack/ssh.js index b3b6c0f..e271166 100644 --- a/lib/commands/stack/ssh.js +++ b/lib/commands/stack/ssh.js @@ -4,28 +4,129 @@ const AWSSSMSession = pkg.default; import { getStack } from './util.js'; import Vantage from '../../vantage.js'; -// Keep the socket alive by pinging every 10s. +// Keep the socket alive by pinging every 30s. const IDLE_INTERVAL = 30_000; const INITIAL_SIZE_WAIT = 700; +export function attachSessionInput( session, input = process.stdin, errorOutput = process.stderr ) { + let lastWasNewline = true; + let startedSshControl = false; + const wasRaw = Boolean( input.isRaw ); + + const listener = data => { + let msg = ''; + for ( const byte of data.values() ) { + // ~ byte, indicates start of control code. + if ( lastWasNewline && byte === 0x7E && !startedSshControl ) { + startedSshControl = true; + continue; + } + + // Check for newlines (LF or CR). + lastWasNewline = byte === 0x0A || byte === 0x0D; + + const char = String.fromCharCode( byte ); + if ( startedSshControl ) { + startedSshControl = false; + // ~. - terminate connection (and any multiplexed sessions) + if ( byte === 0x2E ) { + session.close(); + return; + } + + // ~^Z - suspend ssh + if ( byte === 0x1A ) { + errorOutput.write( '~^Z [suspend ssh]' ); + process.kill( process.pid, 'SIGTSTP' ); + return; + } + + // ~? - show supported escape sequences + if ( byte === 0x3F ) { + let message = '~?\nSupported escape sequences:\n'; + message += '~. - terminate connection (and any multiplexed sessions)\n'; + message += '~^Z - suspend ssh\n'; + message += '~? - this message\n'; + message += '(Note that escapes are only recognized immediately after newline.)\n'; + errorOutput.write( message ); + + // Allow immediate input of another control command. + lastWasNewline = true; + break; + } + + // ~~ sends the escape character. Any other sequence sends both + // the leading ~ and the typed character to the remote session. + msg += '~'; + } + + msg += char; + } + + if ( msg ) { + session.write( msg ); + } + }; + + if ( input.isTTY && typeof input.setRawMode === 'function' ) { + input.setRawMode( true ); + } + input.on( 'data', listener ); + input.resume(); + + return () => { + input.removeListener( 'data', listener ); + if ( input.isTTY && typeof input.setRawMode === 'function' ) { + input.setRawMode( wasRaw ); + } + }; +} + +export function startKeepalive( session, schedule = setInterval ) { + return schedule( () => session.ping(), IDLE_INTERVAL ); +} + function connect( data ) { let idleTimer; + let initializationTimer; let terminalResizeListener; let terminalResizeDebounce; - let lastWasNewline = true; - let startedSshControl = false; + let detachInput; + let beforeExitListener; const session = new AWSSSMSession( data.stream_url, data.aws_ssm_session_id, data.token ); + const cleanup = () => { + if ( initializationTimer ) { + clearTimeout( initializationTimer ); + initializationTimer = null; + } + if ( terminalResizeDebounce ) { + clearTimeout( terminalResizeDebounce ); + terminalResizeDebounce = null; + } + if ( terminalResizeListener ) { + process.stdout.removeListener( 'resize', terminalResizeListener ); + terminalResizeListener = null; + } + if ( idleTimer ) { + clearInterval( idleTimer ); + idleTimer = null; + } + if ( detachInput ) { + detachInput(); + detachInput = null; + } + if ( beforeExitListener ) { + process.removeListener( 'beforeExit', beforeExitListener ); + beforeExitListener = null; + } + }; + session.on( 'connect', () => { - setTimeout( () => { - if ( ! session ) { - return; - } + initializationTimer = setTimeout( () => { + initializationTimer = null; session.setSize( process.stdout.columns, process.stdout.rows ); - - if ( process.stdin.isTTY ) { - process.stdin.setRawMode( true ); - } + detachInput = attachSessionInput( session ); terminalResizeListener = () => { if ( terminalResizeDebounce ) { @@ -35,99 +136,20 @@ function connect( data ) { terminalResizeDebounce = setTimeout( () => { session.setSize( process.stdout.columns, process.stdout.rows ); }, 500 ); - } + }; process.stdout.on( 'resize', terminalResizeListener ); - - setTimeout( () => { - process.stdin.on( 'data', data => { - let msg = ''; - for ( const byte of data.values() ) { - // ~ byte, indicates start of control code. - if ( lastWasNewline && byte === 0x7E && !startedSshControl ) { - startedSshControl = true; - continue; - } - - // Check for newlines (LF or CR). - if ( byte === 0x0A || byte === 0x0D ) { - lastWasNewline = true; - } else { - lastWasNewline = false; - } - - const char = String.fromCharCode( byte ); - if ( startedSshControl ) { - startedSshControl = false; - // ~. - terminate connection (and any multiplexed sessions) - if ( byte === 0x2E ) { - session.close(); - return; - } - - // ~B - send a BREAK to the remote system - // ~C - open a command line - // ~R - Request rekey (SSH protocol 2 only) - // ~^Z - suspend ssh - if ( byte === 0x1A ) { - process.stderr.write( '~^Z [suspend ssh]' ); - process.kill( process.pid, 'SIGTSTP' ); - return; - } - - // ~# - list forwarded connections - // ~& - background ssh (when waiting for connections to terminate) - // ~? - this message - if ( byte === 0x3F ) { - let message = '~?\nSupported escape sequences:\n'; - message += '~. - terminate connection (and any multiplexed sessions)\n'; - message += '~^Z - suspend ssh\n'; - message += '~? - this message\n'; - message += '(Note that escapes are only recognized immediately after newline.)\n'; - process.stderr.write( message ); - - // Allow immediate input of another control command. - lastWasNewline = true; - break; - } - - // ~~ - send the escape character by typing it twice - // Everything else: invalid escape, send ~ and actual char. - process.stderr.write( byte.toString( 16 ) ); - msg += '~'; - } - - msg += char; - } - - session.write( msg ); - } ); - }, 1000 ); }, INITIAL_SIZE_WAIT ); // Set up our listeners. - process.on( 'beforeExit', () => { - session.close(); - } ); - idleTimer = setInterval( () => { - if ( ! this.awsSSMSession ) { - return; - } - - this.awsSSMSession.ping(); - }, IDLE_INTERVAL ); + beforeExitListener = () => session.close(); + process.on( 'beforeExit', beforeExitListener ); + idleTimer = startKeepalive( session ); } ); session.on( 'disconnect', ( reason ) => { - if ( terminalResizeListener ) { - process.stdout.removeListener( 'resize', terminalResizeListener ); - } - if ( idleTimer ) { - clearInterval( idleTimer ); - } - + cleanup(); const message = reason ? `${ reason }. Disconnected.` : 'Disconnected.'; - process.stderr.write( message ); - session.close(); + process.stderr.write( `${ message }\n` ); process.exit(); } ); session.on( 'output', ( data ) => { diff --git a/package.json b/package.json index 5a4f4b5..a4e4d48 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,9 @@ "bin": { "altis-cli": "./bin/altis-cli.js" }, + "scripts": { + "test": "node --test" + }, "files": [ "bin", "lib" diff --git a/test/ssh.test.js b/test/ssh.test.js new file mode 100644 index 0000000..6271448 --- /dev/null +++ b/test/ssh.test.js @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { PassThrough } from 'node:stream'; +import test from 'node:test'; + +import { attachSessionInput, startKeepalive } from '../lib/commands/stack/ssh.js'; + +test('SSH input resumes a paused terminal and forwards data', async () => { + const input = new PassThrough(); + const rawModes = []; + input.isTTY = true; + input.isRaw = false; + input.setRawMode = value => { + input.isRaw = value; + rawModes.push(value); + }; + input.pause(); + + const writes = []; + const session = { + write: value => writes.push(value), + close: () => {}, + }; + const detach = attachSessionInput(session, input, new PassThrough()); + input.write(Buffer.from('pwd\r')); + await new Promise(resolve => setImmediate(resolve)); + + assert.deepEqual(writes, ['pwd\r']); + assert.deepEqual(rawModes, [true]); + detach(); + assert.deepEqual(rawModes, [true, false]); +}); + +test('SSH escape sequence closes the active session', async () => { + const input = new PassThrough(); + let closed = false; + const session = { + write: () => assert.fail('escape sequence should not be forwarded'), + close: () => { closed = true; }, + }; + const detach = attachSessionInput(session, input, new PassThrough()); + input.write(Buffer.from('~.')); + await new Promise(resolve => setImmediate(resolve)); + detach(); + + assert.equal(closed, true); +}); + +test('SSH keepalive pings the active session', () => { + let callback; + let pings = 0; + const timer = Symbol('timer'); + const returnedTimer = startKeepalive( + { ping: () => { pings++; } }, + fn => { + callback = fn; + return timer; + }, + ); + + assert.equal(returnedTimer, timer); + callback(); + assert.equal(pings, 1); +}); From bf758e8cb16f0088250bc853d21084d8168c1393 Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Tue, 21 Jul 2026 18:30:20 -0400 Subject: [PATCH 2/2] Run tests in GitHub Actions --- .github/workflows/tests.yml | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a684c43 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,42 @@ +name: Tests + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Node.js ${{ matrix.node-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: + - 20.x + - 22.x + - 24.x + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test