From d7b2ea7d2004cd26dde0524321a98ef6c762ba8e Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 19 Aug 2026 18:16:40 +0100 Subject: [PATCH 01/11] Encode payloads as UTF-8 with matching length and digest The payload length was computed from UTF-16 code units and the bytes written via charCodeAt() truncated mod 256, while the SHA-256 digest was computed over the UTF-8 encoding. Any non-ASCII payload was corrupted on the wire and carried a digest for different bytes than were sent. Encode the payload once with TextEncoder and derive the wire bytes, PayloadLength, and PayloadDigest from that single byte sequence, matching the reference client (messageparser.go SerializeClientMessage). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018jiE4vjDs72pG61zLXP1xC --- src/agent-message.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/agent-message.ts b/src/agent-message.ts index 0c88aec..0591572 100644 --- a/src/agent-message.ts +++ b/src/agent-message.ts @@ -173,8 +173,11 @@ export type EncodeableAgentMessage = Omit; * Encode a message. */ export function encode( message: EncodeableAgentMessage ) { - const payloadLength = message.Payload.length; - const totalLength = Offset.PayloadLength + payloadLength + 4; + // Encode the payload to UTF-8 first; the length, digest, and bytes on the + // wire must all be computed from the same byte sequence. + const payload = new TextEncoder().encode( message.Payload ); + const payloadLength = payload.length; + const totalLength = Offset.Payload + payloadLength; // | HL| MessageType |Ver| CD | Seq | Flags | // | MessageId | Digest |PayType| PayLen| @@ -192,12 +195,12 @@ export function encode( message: EncodeableAgentMessage ) { uuidv4( {}, data, Offset.MessageId ); // Generate the digest. - data.set( sha256.update( message.Payload ).digest(), Offset.PayloadDigest ); + data.set( sha256.update( payload ).digest(), Offset.PayloadDigest ); // Set the payload. putInt( data, Offset.PayloadType, message.PayloadType ); putInt( data, Offset.PayloadLength, payloadLength ); - putString( data, Offset.Payload, totalLength, message.Payload ); + data.set( payload, Offset.Payload ); return data; } From 2a0462dee075584f0d7af477ec6969a3fc2e4787 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 19 Aug 2026 18:28:35 +0100 Subject: [PATCH 02/11] Fix 64-bit integer encoding and decoding putLong only wrote the low 4 bytes, so CreatedDate went out as Date.now() mod 2^32 with the top half zeroed; the reference client sends the full 64-bit epoch millis. Write both 32-bit halves instead (exact for values below 2^53). decodeInt built values with 32-bit shifts, which would wrap at 2^31 when decoding 8-byte fields such as SequenceNumber; use multiplication. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018jiE4vjDs72pG61zLXP1xC --- src/agent-message.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/agent-message.ts b/src/agent-message.ts index 0591572..17e2521 100644 --- a/src/agent-message.ts +++ b/src/agent-message.ts @@ -92,9 +92,11 @@ function putString( buffer: Uint8Array, start: number, end: number, value: strin * Decode a byte sequence to an integer. */ function decodeInt( buffer: Uint8Array, start: number, end: number ) { + // Multiply rather than shift; shifts operate on 32-bit integers, which + // would wrap when decoding 8-byte fields. Exact for values below 2^53. const bytes = buffer.slice( start, end ); return bytes.reduce( ( acc, byte ) => { - return ( acc << 8 ) + byte; + return ( acc * 0x100 ) + byte; }, 0 ) } @@ -115,11 +117,12 @@ function putInt( buffer: Uint8Array, pos: number, value: number ) { /** * Encode a (64-bit) long into a byte sequence. * - * We cheat here; JS doesn't support 64-bit integers (aka longs), so we can - * just use putInt with a 4 byte offset. + * JS numbers are doubles, so this is exact for values up to 2^53 - 1; that + * comfortably covers epoch milliseconds and sequence numbers. */ function putLong( buffer: Uint8Array, pos: number, value: number ) { - putInt( buffer, pos + 4, value ); + putInt( buffer, pos, Math.floor( value / 0x100000000 ) ); + putInt( buffer, pos + 4, value >>> 0 ); } /** From 115ecdeca24a5ae0762a1f5f8f9bc9f5282e7e50 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 19 Aug 2026 18:34:36 +0100 Subject: [PATCH 03/11] Write MessageId in the protocol's byte order The wire format stores the UUID's least-significant 8 bytes first, then the most-significant 8 (see the reference client's putUuid). decodeUuid already un-swapped on read, but encode wrote the UUID bytes in plain RFC order, so the peer decoded a different UUID than the one generated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018jiE4vjDs72pG61zLXP1xC --- src/agent-message.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/agent-message.ts b/src/agent-message.ts index 17e2521..96a5e05 100644 --- a/src/agent-message.ts +++ b/src/agent-message.ts @@ -194,8 +194,12 @@ export function encode( message: EncodeableAgentMessage ) { putLong( data, Offset.SequenceNumber, message.SequenceNumber ); putLong( data, Offset.Flags, message.SequenceNumber > 0 ? 0b00 : 0b01 ); - // Generate a UUID. - uuidv4( {}, data, Offset.MessageId ); + // Generate a UUID. The wire format stores the least-significant 8 bytes + // first, then the most-significant 8 (see decodeUuid), so flip the halves. + const messageId = new Uint8Array( 16 ); + uuidv4( {}, messageId, 0 ); + data.set( messageId.subarray( 8, 16 ), Offset.MessageId ); + data.set( messageId.subarray( 0, 8 ), Offset.MessageId + 8 ); // Generate the digest. data.set( sha256.update( payload ).digest(), Offset.PayloadDigest ); From 2232b2c8cd33a2c5446eaf2223890c54836ff95f Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 19 Aug 2026 18:35:26 +0100 Subject: [PATCH 04/11] Do not rewind the sequence number on start_publication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outgoing sequence number was reset to the last acknowledged number when publication resumed. An acknowledgement for N means the remote received N, so re-using it labels new input with an already-consumed sequence number, which the receiver discards as a duplicate — silently swallowing the first message sent after every pause/resume cycle. The reference client never rewinds: it ignores pause/start publication for sequencing and keeps the counter monotonic even across reconnects (streaming.go, sessionhandler.go). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018jiE4vjDs72pG61zLXP1xC --- src/aws-ssm-session.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/aws-ssm-session.ts b/src/aws-ssm-session.ts index 33bcc81..c9711d4 100644 --- a/src/aws-ssm-session.ts +++ b/src/aws-ssm-session.ts @@ -24,9 +24,8 @@ export default class AWSSSMSession { connectionTerminated = false; // Each message that is sent with a sequenceNumber is replied to with an // acknowledgement from the SSM remote agent. We keep track of the latest - // sequenceNumber that has been acknowledged, so we know what number to reset - // to if we fall out of sync with the remove server. Currently no buffering or - // replaying of messages exists, but it could be added in the future. + // sequenceNumber that has been acknowledged, so we know which of our sent + // messages the remote agent has received. lastAcknowledgedSequenceNumber: number | undefined; handlers: Handler[] = []; paused: boolean = false; @@ -64,11 +63,12 @@ export default class AWSSSMSession { break } case 'start_publication': { - if ( this.lastAcknowledgedSequenceNumber !== undefined ) { - this.outgoingSequenceNumber = this.lastAcknowledgedSequenceNumber; - } else { - this.outgoingSequenceNumber = 0; - } + // Note: the sequence number deliberately continues from where it + // left off. It must never rewind: an acknowledgement for sequence + // number N means the remote agent has received N, so re-using an + // acknowledged number would be discarded as a duplicate. The + // reference client keeps its counter monotonic even across full + // websocket reconnects. this.paused = false; this.emit( 'resume', '' ); break; From 961fb938205cb60c8bf630b4b65b6eca4cf388da Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 19 Aug 2026 18:42:03 +0100 Subject: [PATCH 05/11] Dispatch inbound messages by payload type and answer handshakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every output_stream_data payload was emitted as terminal output regardless of its payload type, so a session performing the handshake (newer agents on non-shell or encrypted sessions) would print the handshake JSON to the terminal, never respond, and hang. Mirror the reference client's HandleOutputMessage: emit only data payloads (Output/Error/StdErr) as output, respond to HandshakeRequest with a HandshakeResponse (SessionType accepted, anything else reported as Unsupported — we don't support KMS encryption), and surface the HandshakeComplete customer message. Also add the missing StdErr and ExitCode payload types from the protocol. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018jiE4vjDs72pG61zLXP1xC --- src/aws-ssm-session.ts | 78 ++++++++++++++++++++++++++++++++++++++--- src/channel-messages.ts | 10 ++++++ 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/aws-ssm-session.ts b/src/aws-ssm-session.ts index c9711d4..2bf205f 100644 --- a/src/aws-ssm-session.ts +++ b/src/aws-ssm-session.ts @@ -1,8 +1,11 @@ import { v4 as uuidv4 } from 'uuid'; import WebSocket from 'isomorphic-ws'; -import { decode, encode, EncodeableAgentMessage } from './agent-message'; -import { MessageType, PayloadType } from './channel-messages'; +import { AgentMessage, decode, encode, EncodeableAgentMessage } from './agent-message'; +import { ActionStatus, MessageType, PayloadType } from './channel-messages'; + +// Sent to the agent in handshake responses. Keep in sync with package.json. +const CLIENT_VERSION = '0.0.2'; interface Handler { event: 'output' | 'connect' | 'disconnect' | 'pause' | 'resume', @@ -55,11 +58,10 @@ export default class AWSSSMSession { case 'output_stream_data': { this.acknowledgeMessage( message.MessageType, message.MessageId, message.SequenceNumber ); - const text = message.Payload; if ( ! this.outputMap.has( message.MessageId ) ) { - this.emit( 'output', text ); + this.handleOutputMessage( message ); } - this.outputMap.set(message.MessageId, text); + this.outputMap.set( message.MessageId, true ); break } case 'start_publication': { @@ -161,6 +163,72 @@ export default class AWSSSMSession { this.webSocket.close(); } + // Process an output_stream_data message according to its payload type. + // Only data payloads are terminal output; handshake payloads are part of + // session setup and must not be shown to the user. + private handleOutputMessage( message: AgentMessage ) { + switch ( message.PayloadType ) { + case PayloadType.Output: + case PayloadType.Error: + case PayloadType.StdErr: { + this.emit( 'output', message.Payload ); + break; + } + case PayloadType.HandshakeRequest: { + this.handleHandshakeRequest( message ); + break; + } + case PayloadType.HandshakeComplete: { + const payloadData = JSON.parse( message.Payload ); + if ( payloadData.CustomerMessage ) { + this.emit( 'output', payloadData.CustomerMessage + '\r\n' ); + } + break; + } + default: { + console.error( `Unhandled payload type ${ message.PayloadType }.` ); + } + } + } + + // Respond to a handshake request from the agent. + // + // We can satisfy the SessionType action; anything else (notably + // KMSEncryption) is unsupported, which the agent treats as a failed + // handshake, and it will cancel the session. + private handleHandshakeRequest( message: AgentMessage ) { + const request = JSON.parse( message.Payload ); + const errors: string[] = []; + const actions = ( request.RequestedClientActions || [] ).map( ( action: { ActionType: string } ) => { + if ( action.ActionType === 'SessionType' ) { + return { + ActionType: action.ActionType, + ActionStatus: ActionStatus.Success, + }; + } + + const error = `Unsupported action ${ action.ActionType }`; + errors.push( error ); + return { + ActionType: action.ActionType, + ActionStatus: ActionStatus.Unsupported, + Error: error, + }; + } ); + + const response = { + ClientVersion: CLIENT_VERSION, + ProcessedClientActions: actions, + Errors: errors, + }; + this.send( encode( { + MessageType: MessageType.InputStreamDataMessage, + SequenceNumber: this.outgoingSequenceNumber, + Payload: JSON.stringify( response ), + PayloadType: PayloadType.HandshakeResponse, + } ) ); + } + private acknowledgeMessage( messageType: MessageType, messageId: string, messageSequenceNumber: number ) { const message: EncodeableAgentMessage = { MessageType: MessageType.AcknowledgeMessage, diff --git a/src/channel-messages.ts b/src/channel-messages.ts index c22b963..ec87d44 100644 --- a/src/channel-messages.ts +++ b/src/channel-messages.ts @@ -34,4 +34,14 @@ export enum PayloadType { EncChallengeRequest = 8, EncChallengeResponse = 9, Flag = 10, + StdErr = 11, + ExitCode = 12, +} + +// ActionStatus is the result of processing a client action requested by the +// agent during the handshake. +export enum ActionStatus { + Success = 1, + Failed = 2, + Unsupported = 3, } From f649c05178f86f61272dab237e1e60113d0876b2 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 19 Aug 2026 18:46:44 +0100 Subject: [PATCH 06/11] Order inbound messages by sequence number Inbound stream messages were emitted in arrival order and deduplicated by MessageId in an unbounded map, which leaked every payload for the session's lifetime and relied on the service preserving MessageIds across retransmits. Follow the reference client (HandleOutputMessage): track the expected sequence number, buffer out-of-order messages until the gap is filled (capped at the reference's capacity of 10000), and re-acknowledge but drop duplicates below the expected number. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018jiE4vjDs72pG61zLXP1xC --- src/aws-ssm-session.ts | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/aws-ssm-session.ts b/src/aws-ssm-session.ts index 2bf205f..5aefdf4 100644 --- a/src/aws-ssm-session.ts +++ b/src/aws-ssm-session.ts @@ -7,6 +7,11 @@ import { ActionStatus, MessageType, PayloadType } from './channel-messages'; // Sent to the agent in handshake responses. Keep in sync with package.json. const CLIENT_VERSION = '0.0.2'; +// Maximum number of out-of-order inbound messages held while waiting for the +// gap before them to be filled. Matches the reference client's +// IncomingMessageBufferCapacity. +const INCOMING_MESSAGE_BUFFER_CAPACITY = 10000; + interface Handler { event: 'output' | 'connect' | 'disconnect' | 'pause' | 'resume', callback: Function @@ -22,7 +27,12 @@ export default class AWSSSMSession { // an incrementing integer). Also, messages can be ordered back into sequence if // anything were to get ahead of line. outgoingSequenceNumber = 0; - outputMap = new Map(); + // The sequence number we expect from the next inbound stream message. + // Messages arriving ahead of it are held in incomingMessageBuffer until + // the gap before them is filled; messages behind it are duplicates and + // are re-acknowledged but not re-processed. + expectedSequenceNumber = 0; + incomingMessageBuffer: Map = new Map(); connectionClosed = false; connectionTerminated = false; // Each message that is sent with a sequenceNumber is replied to with an @@ -56,12 +66,30 @@ export default class AWSSSMSession { switch( message.MessageType ) { case 'output_stream_data': { - this.acknowledgeMessage( message.MessageType, message.MessageId, message.SequenceNumber ); - - if ( ! this.outputMap.has( message.MessageId ) ) { + if ( message.SequenceNumber === this.expectedSequenceNumber ) { + this.acknowledgeMessage( message.MessageType, message.MessageId, message.SequenceNumber ); this.handleOutputMessage( message ); + this.expectedSequenceNumber++; + + // Process any buffered messages which are now in sequence. + let next; + while ( ( next = this.incomingMessageBuffer.get( this.expectedSequenceNumber ) ) !== undefined ) { + this.incomingMessageBuffer.delete( this.expectedSequenceNumber ); + this.handleOutputMessage( next ); + this.expectedSequenceNumber++; + } + } else if ( message.SequenceNumber > this.expectedSequenceNumber ) { + // Out-of-order message: acknowledge it and hold it until the + // gap before it is filled. + if ( this.incomingMessageBuffer.size < INCOMING_MESSAGE_BUFFER_CAPACITY ) { + this.acknowledgeMessage( message.MessageType, message.MessageId, message.SequenceNumber ); + this.incomingMessageBuffer.set( message.SequenceNumber, message ); + } + } else { + // Already processed (e.g. a resend after our acknowledgement + // was lost): re-acknowledge, but don't re-emit. + this.acknowledgeMessage( message.MessageType, message.MessageId, message.SequenceNumber ); } - this.outputMap.set( message.MessageId, true ); break } case 'start_publication': { From 765b553f165f7d9bb807f77326f35cc991167170 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 19 Aug 2026 18:50:54 +0100 Subject: [PATCH 07/11] Retransmit unacknowledged outgoing messages Sent messages were fire-and-forget: a single lost input message would permanently stall agent-side input, since the agent waits on the missing sequence number while buffering everything after it. Buffer sent messages until their acknowledgement arrives and resend the oldest one when its ack is overdue, mirroring the reference client's ResendStreamDataMessageScheduler (100ms check interval, capacity 10000; a fixed 1s retransmission timeout in place of the reference's RTT-tuned value). Duplicate resends are safe: the receiver re-acknowledges and drops messages below its expected sequence number. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018jiE4vjDs72pG61zLXP1xC --- src/aws-ssm-session.ts | 71 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/aws-ssm-session.ts b/src/aws-ssm-session.ts index 5aefdf4..0e1e15a 100644 --- a/src/aws-ssm-session.ts +++ b/src/aws-ssm-session.ts @@ -12,11 +12,31 @@ const CLIENT_VERSION = '0.0.2'; // IncomingMessageBufferCapacity. const INCOMING_MESSAGE_BUFFER_CAPACITY = 10000; +// Maximum number of sent-but-unacknowledged messages kept for +// retransmission. Matches the reference client's +// OutgoingMessageBufferCapacity. +const OUTGOING_MESSAGE_BUFFER_CAPACITY = 10000; + +// How often to check whether the oldest unacknowledged message needs +// resending. Matches the reference client's ResendSleepInterval. +const RESEND_CHECK_INTERVAL = 100; + +// How long to wait for an acknowledgement before resending. The reference +// client starts at 200ms and tunes this from measured round-trip times; we +// don't measure RTT, so use a conservative fixed value. +const RETRANSMISSION_TIMEOUT = 1000; + interface Handler { event: 'output' | 'connect' | 'disconnect' | 'pause' | 'resume', callback: Function } +interface OutgoingMessage { + sequenceNumber: number, + data: Uint8Array, + lastSentTime: number, +} + export default class AWSSSMSession { webSocket: WebSocket; sessionId: string; @@ -40,6 +60,10 @@ export default class AWSSSMSession { // sequenceNumber that has been acknowledged, so we know which of our sent // messages the remote agent has received. lastAcknowledgedSequenceNumber: number | undefined; + // Sent messages we haven't received an acknowledgement for yet, oldest + // first, kept so they can be retransmitted. + outgoingMessageBuffer: OutgoingMessage[] = []; + resendTimer?: ReturnType; handlers: Handler[] = []; paused: boolean = false; @@ -111,9 +135,14 @@ export default class AWSSSMSession { case 'acknowledge': { if ( message.Payload ) { const payloadData = JSON.parse( message.Payload ); - if ( this.lastAcknowledgedSequenceNumber === undefined || this.lastAcknowledgedSequenceNumber < payloadData.AcknowledgedMessageSequenceNumber ) { - this.lastAcknowledgedSequenceNumber = payloadData.AcknowledgedMessageSequenceNumber; + const acknowledged = payloadData.AcknowledgedMessageSequenceNumber; + if ( this.lastAcknowledgedSequenceNumber === undefined || this.lastAcknowledgedSequenceNumber < acknowledged ) { + this.lastAcknowledgedSequenceNumber = acknowledged; } + + // The message has been received; no need to keep it around + // for retransmission. + this.outgoingMessageBuffer = this.outgoingMessageBuffer.filter( m => m.sequenceNumber !== acknowledged ); } break; } @@ -138,9 +167,33 @@ export default class AWSSSMSession { this.webSocket.onclose = (ev: any) => { this.connectionClosed = true; + if ( this.resendTimer !== undefined ) { + clearInterval( this.resendTimer ); + this.resendTimer = undefined; + } this.emit( 'disconnect', ev.reason ); }; + + // Retransmit unacknowledged messages, like the reference client's + // ResendStreamDataMessageScheduler. + this.resendTimer = setInterval( () => this.resendUnacknowledgedMessage(), RESEND_CHECK_INTERVAL ); + } + + // Resend the oldest unacknowledged message if its acknowledgement is + // overdue. Duplicates are safe: the receiver re-acknowledges and drops + // messages below its expected sequence number. + private resendUnacknowledgedMessage() { + const head = this.outgoingMessageBuffer[ 0 ]; + if ( ! head || this.webSocket.readyState !== 1 ) { + return; + } + if ( Date.now() - head.lastSentTime < RETRANSMISSION_TIMEOUT ) { + return; + } + + head.lastSentTime = Date.now(); + this.webSocket.send( head.data ); } send( message: string | ArrayBuffer | SharedArrayBuffer | ArrayBufferView | Blob ) { @@ -148,6 +201,20 @@ export default class AWSSSMSession { console.warn( 'WebSocket is not yet ready.' ); return; } + + // Keep sequential messages around until they're acknowledged, so they + // can be retransmitted if the acknowledgement doesn't arrive. + if ( message instanceof Uint8Array ) { + this.outgoingMessageBuffer.push( { + sequenceNumber: this.outgoingSequenceNumber, + data: message, + lastSentTime: Date.now(), + } ); + if ( this.outgoingMessageBuffer.length > OUTGOING_MESSAGE_BUFFER_CAPACITY ) { + this.outgoingMessageBuffer.shift(); + } + } + this.outgoingSequenceNumber++; this.webSocket.send( message ) } From 1989f388959357fe1444e87ad19a1817a0bd1cc4 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 19 Aug 2026 18:55:03 +0100 Subject: [PATCH 08/11] Match reference header fields for acknowledgements and data messages The reference client sends acknowledgements with sequence number 0 and flags 3, and data messages always with flags 0; we sent acks carrying the current outgoing counter and derived a SYN flag from the sequence number. Make Flags an explicit optional encode field defaulting to 0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018jiE4vjDs72pG61zLXP1xC --- src/agent-message.ts | 9 +++++++-- src/aws-ssm-session.ts | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/agent-message.ts b/src/agent-message.ts index 96a5e05..de8d1c7 100644 --- a/src/agent-message.ts +++ b/src/agent-message.ts @@ -170,7 +170,12 @@ export function decode( buffer: Uint8Array ): AgentMessage { }; } -export type EncodeableAgentMessage = Omit; +export type EncodeableAgentMessage = Omit & { + // Packed control flags (see header notes above). Defaults to 0, which is + // what the reference client sends for all data messages; it uses 3 for + // acknowledgements. + Flags?: number; +}; /** * Encode a message. @@ -192,7 +197,7 @@ export function encode( message: EncodeableAgentMessage ) { putInt( data, Offset.SchemaVersion, 1 ); putLong( data, Offset.CreatedDate, Date.now() ); putLong( data, Offset.SequenceNumber, message.SequenceNumber ); - putLong( data, Offset.Flags, message.SequenceNumber > 0 ? 0b00 : 0b01 ); + putLong( data, Offset.Flags, message.Flags ?? 0 ); // Generate a UUID. The wire format stores the least-significant 8 bytes // first, then the most-significant 8 (see decodeUuid), so flip the halves. diff --git a/src/aws-ssm-session.ts b/src/aws-ssm-session.ts index 0e1e15a..4265fa4 100644 --- a/src/aws-ssm-session.ts +++ b/src/aws-ssm-session.ts @@ -327,7 +327,10 @@ export default class AWSSSMSession { private acknowledgeMessage( messageType: MessageType, messageId: string, messageSequenceNumber: number ) { const message: EncodeableAgentMessage = { MessageType: MessageType.AcknowledgeMessage, - SequenceNumber: this.outgoingSequenceNumber, + // Acknowledgements aren't part of the sequential stream; the + // reference client sends them with sequence number 0 and flags 3. + SequenceNumber: 0, + Flags: 3, PayloadType: PayloadType.Output, Payload: JSON.stringify( { AcknowledgedMessageType: messageType, From 537931b82f65224b94154cb583d4f6ff0e2b615e Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 19 Aug 2026 18:57:55 +0100 Subject: [PATCH 09/11] Decode payloads without NUL-stripping, stream UTF-8 across messages NUL-stripping is an accommodation for the padded fixed-width header fields (the service pads differently than the reference client, which tolerates both NUL and space padding); applying it to the payload silently altered legitimate output. Expose the raw payload bytes from decode() and decode the payload without stripping. Each payload was also decoded with a fresh TextDecoder, so a multi-byte UTF-8 sequence split across two output messages decoded to U+FFFD garbage at the boundary; terminal output now goes through a single streaming decoder. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018jiE4vjDs72pG61zLXP1xC --- src/agent-message.ts | 16 +++++++++++++--- src/aws-ssm-session.ts | 6 +++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/agent-message.ts b/src/agent-message.ts index de8d1c7..afd24a8 100644 --- a/src/agent-message.ts +++ b/src/agent-message.ts @@ -19,7 +19,11 @@ export type AgentMessage = { MessageId: string; // PayloadDigest: string; PayloadType: PayloadType; + // The payload decoded as UTF-8 text; JSON payloads are parsed from this. Payload: string; + // The raw payload bytes. Use these for binary-safe handling, or for + // streaming text decoding across message boundaries. + PayloadBytes: Uint8Array; } // HL - HeaderLength is a 4 byte integer that represents the header length. @@ -66,7 +70,11 @@ enum Offset { } /** - * Decode a UTF-8 byte sequence to a string. + * Decode a padded fixed-width header field to a string. + * + * Header fields may be padded with NULs or spaces depending on the sender, + * so strip NULs here (and trim whitespace at the call site). Not for use on + * the payload, where every byte is meaningful. */ function decodeText( buffer: Uint8Array, start: number, end?: number ) { const decoder = new TextDecoder( 'utf8' ); @@ -161,16 +169,18 @@ function longToHex( buffer: Uint8Array ) { */ export function decode( buffer: Uint8Array ): AgentMessage { const headerLength = decodeInt( buffer, Offset.HL, Offset.HL + Length.HL ); + const payloadBytes = buffer.slice( headerLength + Length.PayloadLength ); return { MessageType: decodeText( buffer, Offset.MessageType, Offset.MessageType + Length.MessageType ).trimEnd() as MessageType, SequenceNumber: decodeInt( buffer, Offset.SequenceNumber, Offset.SequenceNumber + Length.SequenceNumber ), MessageId: decodeUuid( buffer, Offset.MessageId, Offset.MessageId + Length.MessageId ), PayloadType: decodeInt( buffer, Offset.PayloadType, Offset.PayloadType + Length.PayloadType ), - Payload: decodeText( buffer, headerLength + Length.PayloadLength ), + Payload: new TextDecoder( 'utf8' ).decode( payloadBytes ), + PayloadBytes: payloadBytes, }; } -export type EncodeableAgentMessage = Omit & { +export type EncodeableAgentMessage = Omit & { // Packed control flags (see header notes above). Defaults to 0, which is // what the reference client sends for all data messages; it uses 3 for // acknowledgements. diff --git a/src/aws-ssm-session.ts b/src/aws-ssm-session.ts index 4265fa4..df02d90 100644 --- a/src/aws-ssm-session.ts +++ b/src/aws-ssm-session.ts @@ -66,6 +66,10 @@ export default class AWSSSMSession { resendTimer?: ReturnType; handlers: Handler[] = []; paused: boolean = false; + // Terminal output is decoded with a single streaming decoder so that + // multi-byte UTF-8 sequences split across two messages survive the + // boundary intact. + private outputDecoder = new TextDecoder( 'utf8' ); constructor( streamUrl: string, sessionId: string, tokenValue: string ) { this.streamUrl = streamUrl; @@ -266,7 +270,7 @@ export default class AWSSSMSession { case PayloadType.Output: case PayloadType.Error: case PayloadType.StdErr: { - this.emit( 'output', message.Payload ); + this.emit( 'output', this.outputDecoder.decode( message.PayloadBytes, { stream: true } ) ); break; } case PayloadType.HandshakeRequest: { From 948b8df87bd8a875c5d4266a6c6a493aa2c8853c Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 19 Aug 2026 19:01:58 +0100 Subject: [PATCH 10/11] Send real WebSocket ping frames where available ping() sent the literal text "ping" as a data frame; the reference client's keepalive is a WebSocket ping control frame with payload "keepalive". Use the underlying socket's ping() when it exists (Node `ws`), keeping the text frame only as a browser fallback since browsers cannot send control frames. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018jiE4vjDs72pG61zLXP1xC --- src/aws-ssm-session.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/aws-ssm-session.ts b/src/aws-ssm-session.ts index df02d90..c3c372a 100644 --- a/src/aws-ssm-session.ts +++ b/src/aws-ssm-session.ts @@ -241,7 +241,16 @@ export default class AWSSSMSession { } ping() { - this.webSocket.send( 'ping' ); + // The reference client's keepalive is a WebSocket ping control frame + // with payload "keepalive", sent every 5 minutes. Node's `ws` exposes + // ping(); browsers can't send control frames, so fall back to a bare + // text frame, which the service tolerates. + const webSocket = this.webSocket as WebSocket & { ping?: ( data: string ) => void }; + if ( typeof webSocket.ping === 'function' ) { + webSocket.ping( 'keepalive' ); + } else { + this.webSocket.send( 'ping' ); + } } emit( eventName: 'output' | 'connect' | 'disconnect' | 'pause' | 'resume', data: string | ArrayBuffer | SharedArrayBuffer | ArrayBufferView | Blob ) { From f21a3d245ae55ef7397a2fdd3f56a69785a3b79c Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 19 Aug 2026 19:08:27 +0100 Subject: [PATCH 11/11] Guard channel_closed Output access payloadData.Output.length threw an uncaught TypeError when the Output key was absent. The client-side contract includes Output, so this is defensive, but Go zero-values a missing key where JS throws. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018jiE4vjDs72pG61zLXP1xC --- src/aws-ssm-session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aws-ssm-session.ts b/src/aws-ssm-session.ts index c3c372a..bbc2bf5 100644 --- a/src/aws-ssm-session.ts +++ b/src/aws-ssm-session.ts @@ -156,7 +156,7 @@ export default class AWSSSMSession { case 'channel_closed': { if ( message.Payload ) { const payloadData = JSON.parse( message.Payload ); - if ( payloadData.Output.length > 0 ) { + if ( payloadData.Output ) { this.emit( 'output', payloadData.Output + '\r\n' ); } }