@johnhenry/wsh 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Web Shell — browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/e2e-frame.mjs CHANGED
@@ -4,11 +4,15 @@
4
4
  * `CryptoKey` this module seals/opens with, and `spec/wsh-v1.yaml`'s
5
5
  * `EncryptedFrame` message).
6
6
  *
7
- * This is PR 1 of a multi-PR E2E rollout: it implements the AEAD
8
- * primitives and wires them into virtual-mode sessions only (see
9
- * `session.mjs`'s `enableE2E`). Stream-mode sessions (raw WebTransport
10
- * byte streams with no message boundaries) are explicitly out of scope
11
- * here -- they need separate ad hoc chunk-framing design.
7
+ * This module's AEAD primitives were introduced in wsh #19 (virtual-mode
8
+ * only) and, as of wsh #22 (PR 1 of 3), are reused unchanged by
9
+ * stream-mode sessions too: `stream-frame.mjs` wraps `sealFrame`/
10
+ * `openFrame` in inline length-prefixed chunk framing for raw
11
+ * WebTransport byte streams (no message boundaries), while
12
+ * `session.mjs`'s `enableE2E` now dispatches to either transport based
13
+ * on the session's `dataMode`. This module itself needed no changes for
14
+ * that -- see `stream-frame.mjs`'s doc comment for the chunk-framing
15
+ * design.
12
16
  *
13
17
  * ── Nonce construction ─────────────────────────────────────────────
14
18
  * Nonces are the AES-GCM-mandated 96 bits (12 bytes), built as:
package/src/session.mjs CHANGED
@@ -10,6 +10,9 @@ import {
10
10
  } from './messages.mjs';
11
11
  import { WshVirtualSessionBackend } from './virtual-session.mjs';
12
12
  import { sealFrame, openFrame, ROLE_TAGS } from './e2e-frame.mjs';
13
+ import {
14
+ ChunkAccumulator, encodeChunk, resolveCoalesceOptions, WriteCoalescer, StreamAuthenticationError,
15
+ } from './stream-frame.mjs';
13
16
 
14
17
  // ── Session states ────────────────────────────────────────────────────
15
18
 
@@ -99,6 +102,22 @@ export class WshSession {
99
102
  /** @type {number} Next expected incoming frame counter from the peer. */
100
103
  #e2eRecvCounter = 0;
101
104
 
105
+ /**
106
+ * Reassembles the raw stdout byte stream into complete sealed chunks
107
+ * when E2E is enabled on a stream-mode session. `null` for virtual-mode
108
+ * sessions or when E2E is not enabled.
109
+ * @type {import('./stream-frame.mjs').ChunkAccumulator|null}
110
+ */
111
+ #streamAccumulator = null;
112
+
113
+ /**
114
+ * Batches outgoing stream-mode writes before sealing, per the
115
+ * write-coalescing design (wsh #22). `null` means coalescing is
116
+ * disabled (`coalesce: false`) -- every write() seals immediately.
117
+ * @type {WriteCoalescer|null}
118
+ */
119
+ #streamCoalescer = null;
120
+
102
121
  /**
103
122
  * The readable side of the stdout data stream.
104
123
  * @type {ReadableStream<Uint8Array>|null}
@@ -320,16 +339,26 @@ export class WshSession {
320
339
  }
321
340
 
322
341
  /**
323
- * Opt in to end-to-end encryption for this session's data plane
324
- * (virtual-mode sessions only in this PR; stream-mode is out of
325
- * scope -- see `e2e-frame.mjs`'s doc comment). After this call,
326
- * `write()` seals outgoing data into `EncryptedFrame` messages instead
327
- * of plaintext `SessionData`, and incoming `EncryptedFrame` messages
328
- * are opened and delivered via `onData` exactly like `SessionData` is
329
- * today; plaintext `SessionData` from a peer is no longer expected
330
- * once this side has opted in (it's simply passed through unchanged,
331
- * as before -- this method doesn't add any negotiation-failure
332
- * handling; that's a later PR).
342
+ * Opt in to end-to-end encryption for this session's data plane.
343
+ * Works for both virtual-mode and stream-mode sessions (wsh #22
344
+ * generalized this from virtual-mode-only, #19).
345
+ *
346
+ * Virtual-mode: after this call, `write()` seals outgoing data into
347
+ * `EncryptedFrame` control messages instead of plaintext
348
+ * `SessionData`, and incoming `EncryptedFrame` messages are opened and
349
+ * delivered via `onData` exactly like `SessionData` is today;
350
+ * plaintext `SessionData` from a peer is no longer expected once this
351
+ * side has opted in (it's simply passed through unchanged, as before
352
+ * -- this method doesn't add any negotiation-failure handling; that's
353
+ * a later PR).
354
+ *
355
+ * Stream-mode: outgoing bytes are sealed and framed inline in the raw
356
+ * byte stream (`stream-frame.mjs`'s `[len][nonce][ciphertext]` chunk
357
+ * format, reusing the same `sealFrame`/`openFrame` primitives) --
358
+ * invisible to the control-message spec, no new CBOR message type.
359
+ * Small writes are batched before sealing per `opts.coalesce` (see
360
+ * below); incoming bytes are reassembled via a `ChunkAccumulator` and
361
+ * opened per-chunk before reaching `onData`.
333
362
  *
334
363
  * IMPORTANT -- key lifetime: `sharedSecret` must come from a *fresh*
335
364
  * `WshClient.initiateE2E()` call on the *current* connection. E2E
@@ -347,17 +376,18 @@ export class WshSession {
347
376
  * @param {'initiator'|'responder'} opts.role - which side of the KeyExchange this session was;
348
377
  * determines this side's nonce role tag (see `e2e-frame.mjs`'s `ROLE_TAGS`). The two peers of
349
378
  * one session MUST pick opposite roles, or their nonces can collide.
379
+ * @param {false|{maxBytes?: number, maxDelayMs?: number}} [opts.coalesce] - stream-mode only:
380
+ * override the default write-coalescing profile derived from this session's `kind`
381
+ * ('pty'|'exec'), or pass `false` to seal every write() immediately with no batching.
382
+ * Ignored for virtual-mode sessions (each write() is already one EncryptedFrame).
350
383
  */
351
- enableE2E(sharedSecret, { role } = {}) {
384
+ enableE2E(sharedSecret, { role, coalesce } = {}) {
352
385
  if (!sharedSecret || typeof sharedSecret !== 'object') {
353
386
  throw new Error('enableE2E: sharedSecret must be a CryptoKey (see WshClient.initiateE2E)');
354
387
  }
355
388
  if (role !== 'initiator' && role !== 'responder') {
356
389
  throw new Error("enableE2E: opts.role must be 'initiator' or 'responder'");
357
390
  }
358
- if (this.#dataMode !== 'virtual') {
359
- throw new Error('enableE2E: only virtual-mode sessions are supported in this version (stream-mode is out of scope)');
360
- }
361
391
  if (!this.#sessionId) {
362
392
  throw new Error('enableE2E: session has no server-assigned session_id to bind as AAD -- was OPEN_OK missing session_id?');
363
393
  }
@@ -366,6 +396,75 @@ export class WshSession {
366
396
  this.#e2eRecvRoleTag = role === 'initiator' ? ROLE_TAGS.responder : ROLE_TAGS.initiator;
367
397
  this.#e2eSendCounter = 0;
368
398
  this.#e2eRecvCounter = 0;
399
+
400
+ if (this.#dataMode === 'stream') {
401
+ // Two-data-plane counter isolation note: send/recv counters above
402
+ // are per-session (this instance), not shared with any other data
403
+ // plane -- a session is always exactly one of stream/virtual today,
404
+ // so there's nothing to cross-wire, but a future refactor that let
405
+ // one session span both planes simultaneously would need separate
406
+ // counters per plane.
407
+ this.#streamAccumulator = new ChunkAccumulator();
408
+ const profile = resolveCoalesceOptions(this.kind, coalesce);
409
+ this.#streamCoalescer = profile
410
+ ? new WriteCoalescer(profile, (bytes) => this.#sealAndWriteStreamChunk(bytes))
411
+ : null;
412
+ } else {
413
+ this.#streamAccumulator = null;
414
+ this.#streamCoalescer = null;
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Seal one stream-mode chunk and write it to the stdin stream. Used
420
+ * directly by write() when coalescing is disabled, and as the flush
421
+ * callback for `#streamCoalescer` otherwise.
422
+ * @private
423
+ */
424
+ async #sealAndWriteStreamChunk(bytes) {
425
+ if (this.#stdinWriter === null) {
426
+ throw new Error('Session not yet bound — stdin writer unavailable');
427
+ }
428
+ const counter = this.#e2eSendCounter++;
429
+ const { nonce, ciphertext } = await sealFrame(
430
+ this.#e2eKey, this.#sessionId, this.#e2eSendRoleTag, counter, bytes
431
+ );
432
+ await this.#stdinWriter.write(encodeChunk(nonce, ciphertext));
433
+ }
434
+
435
+ /**
436
+ * Feed newly-read bytes through the stream E2E accumulator and open
437
+ * every complete chunk it yields.
438
+ *
439
+ * @param {Uint8Array} bytes
440
+ * @returns {Promise<Uint8Array[]|null>} the opened plaintext chunks, in order,
441
+ * or `null` if framing was corrupt or a chunk failed authentication
442
+ * (already logged; caller should tear the pump down).
443
+ * @private
444
+ */
445
+ async #openStreamChunks(bytes) {
446
+ let wireChunks;
447
+ try {
448
+ wireChunks = this.#streamAccumulator.feed(bytes);
449
+ } catch (err) {
450
+ console.error('[wsh:session] stream E2E framing error:', err);
451
+ return null;
452
+ }
453
+ const plaintexts = [];
454
+ for (const { nonce, ciphertext } of wireChunks) {
455
+ const counter = this.#e2eRecvCounter++;
456
+ try {
457
+ const plaintext = await openFrame(this.#e2eKey, this.#sessionId, counter, { nonce, ciphertext });
458
+ plaintexts.push(plaintext);
459
+ } catch (err) {
460
+ console.error(
461
+ '[wsh:session] stream E2E chunk failed authentication:',
462
+ new StreamAuthenticationError(err.message)
463
+ );
464
+ return null;
465
+ }
466
+ }
467
+ return plaintexts;
369
468
  }
370
469
 
371
470
  // ── Public API ──────────────────────────────────────────────────────
@@ -401,6 +500,16 @@ export class WshSession {
401
500
  }
402
501
 
403
502
  const bytes = typeof data === 'string' ? textEncoder.encode(data) : data;
503
+
504
+ if (this.#e2eKey !== null) {
505
+ if (this.#streamCoalescer) {
506
+ await this.#streamCoalescer.write(bytes);
507
+ } else {
508
+ await this.#sealAndWriteStreamChunk(bytes);
509
+ }
510
+ return;
511
+ }
512
+
404
513
  await this.#stdinWriter.write(bytes);
405
514
  }
406
515
 
@@ -482,6 +591,16 @@ export class WshSession {
482
591
  }
483
592
 
484
593
  if (this.#dataMode === 'stream') {
594
+ // Flush any bytes still batched by write-coalescing before closing
595
+ // the writer, so a pending buffer isn't silently dropped.
596
+ if (this.#streamCoalescer) {
597
+ try {
598
+ await this.#streamCoalescer.flush();
599
+ } catch (err) {
600
+ console.error('[wsh:session] failed to flush coalesced stream E2E writes on close:', err);
601
+ }
602
+ }
603
+
485
604
  // Release the stdin writer.
486
605
  try {
487
606
  await this.#stdinWriter?.close();
@@ -710,13 +829,44 @@ export class WshSession {
710
829
  if (this.#abort.signal.aborted) break;
711
830
 
712
831
  const { done, value } = await reader.read();
713
- if (done) break;
832
+ if (done) {
833
+ // Clean EOF: catch a torn (truncated) chunk left in the E2E
834
+ // accumulator, if any -- per the design, no partial-chunk
835
+ // plaintext is ever released, so this is purely a diagnostic
836
+ // (the stream is ending either way).
837
+ if (this.#e2eKey !== null && this.#streamAccumulator) {
838
+ try {
839
+ this.#streamAccumulator.finish();
840
+ } catch (err) {
841
+ console.error('[wsh:session] stream E2E torn chunk at stream end:', err);
842
+ }
843
+ }
844
+ break;
845
+ }
714
846
 
715
847
  if (value && value.byteLength > 0) {
716
- try {
717
- this.onData?.(value);
718
- } catch (err) {
719
- console.error('[wsh:session] onData handler error:', err);
848
+ if (this.#e2eKey !== null && this.#streamAccumulator) {
849
+ const deliverable = await this.#openStreamChunks(value);
850
+ if (deliverable === null) {
851
+ // Authentication failure (or corrupt framing) -- matches
852
+ // the strict "no skip-and-continue" philosophy of
853
+ // virtual-mode's counter check: tear the session down
854
+ // rather than deliver anything from this point on.
855
+ break;
856
+ }
857
+ for (const plaintext of deliverable) {
858
+ try {
859
+ this.onData?.(plaintext);
860
+ } catch (err) {
861
+ console.error('[wsh:session] onData handler error:', err);
862
+ }
863
+ }
864
+ } else {
865
+ try {
866
+ this.onData?.(value);
867
+ } catch (err) {
868
+ console.error('[wsh:session] onData handler error:', err);
869
+ }
720
870
  }
721
871
  }
722
872
  }
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Stream-mode chunk framing for wsh's opt-in end-to-end encryption layer
3
+ * (see `session.mjs`'s `enableE2E`, which now accepts stream-backed
4
+ * sessions -- this module supplies the framing wrapper that makes that
5
+ * possible; the AEAD primitives themselves live in `e2e-frame.mjs` and
6
+ * are reused unchanged).
7
+ *
8
+ * This is PR 1 of 3 in the stream-mode E2E rollout (wsh #22, follow-up
9
+ * to the virtual-mode rollout in #19). See that issue for the full
10
+ * design doc; the short version below is enough to work on this module.
11
+ *
12
+ * ── Why framing is needed at all ──────────────────────────────────────
13
+ * Stream-mode sessions are raw WebTransport bidirectional byte streams
14
+ * with no message boundaries (`transport.mjs`'s `openStream()`: "Data
15
+ * streams carry raw bytes with no framing overhead"). Virtual-mode reuses
16
+ * the control-message envelope (`EncryptedFrame`) to carry one sealed
17
+ * frame per `SessionData` 1:1, but a byte stream has no such envelope --
18
+ * a single `read()` may return a partial chunk, multiple chunks, or a
19
+ * chunk split across two reads. This module's `ChunkAccumulator` restores
20
+ * chunk boundaries on the read side; `encodeChunk` builds them on the
21
+ * write side.
22
+ *
23
+ * ── Wire format ────────────────────────────────────────────────────────
24
+ *
25
+ * [ 4-byte big-endian length prefix N ][ 12-byte nonce ][ N bytes: ciphertext + 16-byte GCM tag ]
26
+ *
27
+ * - N counts only the ciphertext+tag bytes that follow the nonce (the
28
+ * nonce itself is a fixed 12-byte wire constant, not counted -- avoids
29
+ * an off-by-12 footgun).
30
+ * - The nonce is 12 bytes, clear (not secret -- same convention as
31
+ * virtual-mode's `EncryptedFrame.nonce`); it encodes the same
32
+ * 8-byte-counter + 4-byte-role-tag scheme from `e2e-frame.mjs`, reused
33
+ * verbatim: a stream "chunk index" is exactly the same thing as
34
+ * virtual-mode's "message counter" (see `buildNonce`/`ROLE_TAGS`).
35
+ * - The length field is NOT bound as AEAD additional authenticated data:
36
+ * there's no tampering scenario it prevents that the GCM tag doesn't
37
+ * already catch -- a corrupted length either fails decryption outright
38
+ * or misaligns the *next* chunk's boundary, but plaintext is never
39
+ * released without passing authentication either way.
40
+ * - Chunk size: soft target ~16 KiB plaintext, hard cap 64 KiB (a `u32`
41
+ * length prefix has ample headroom above that). See `resolveCoalesceOptions`
42
+ * for how writes are batched up to (well below) that cap before sealing.
43
+ *
44
+ * ── Failure modes at stream end ───────────────────────────────────────
45
+ * `ChunkAccumulator` distinguishes:
46
+ * 1. Clean EOF -- buffer empty when `finish()` is called.
47
+ * 2. Torn chunk -- partial bytes buffered (not enough for a complete
48
+ * chunk) when `finish()` is called -- throws `StreamTornChunkError`.
49
+ * 3. Failed AEAD authentication on an otherwise-complete chunk -- this
50
+ * is *not* raised by this module (it has no key); callers use
51
+ * `StreamAuthenticationError` to report it after calling `openFrame`
52
+ * from `e2e-frame.mjs` (see `session.mjs`'s stream read path).
53
+ * No partial-chunk plaintext is ever released in any case.
54
+ *
55
+ * ── Efficiency note ────────────────────────────────────────────────────
56
+ * `ChunkAccumulator` uses a cursor-based internal buffer (append via an
57
+ * amortized-doubling growable `Uint8Array`, consume via advancing a read
58
+ * offset) rather than repeatedly `Uint8Array.slice()`-ing the whole
59
+ * buffer on every `feed()` call, which would go quadratic under many
60
+ * small `read()`s. The only per-chunk `slice()` calls are for the two
61
+ * pieces (`nonce`, `ciphertext`) actually handed back to the caller.
62
+ */
63
+
64
+ import { sealFrame } from './e2e-frame.mjs';
65
+
66
+ const LENGTH_PREFIX_BYTES = 4;
67
+ const NONCE_BYTES = 12;
68
+ const GCM_TAG_BYTES = 16;
69
+
70
+ /** Soft target plaintext chunk size (see module doc comment). */
71
+ export const CHUNK_SOFT_TARGET_BYTES = 16 * 1024;
72
+
73
+ /** Hard cap on plaintext chunk size; ciphertext+tag never exceeds this + 16. */
74
+ export const CHUNK_HARD_CAP_BYTES = 64 * 1024;
75
+
76
+ /**
77
+ * Sanity bound on the wire length prefix, used only to fail fast on an
78
+ * obviously-corrupt/malicious length field (e.g. a torn/garbled header)
79
+ * rather than buffering unboundedly while waiting for a chunk that will
80
+ * never complete. Generous headroom above `CHUNK_HARD_CAP_BYTES` so a
81
+ * legitimate sender is never rejected.
82
+ */
83
+ const MAX_WIRE_CIPHERTEXT_BYTES = CHUNK_HARD_CAP_BYTES + GCM_TAG_BYTES;
84
+
85
+ /**
86
+ * Thrown by `ChunkAccumulator.finish()` when the internal buffer still
87
+ * holds partial (incomplete) chunk bytes at stream EOF -- a truncated
88
+ * stream, not a clean close. Distinct from `StreamAuthenticationError`
89
+ * so callers can tell "the stream ended mid-chunk" apart from "a
90
+ * complete chunk arrived but failed to authenticate".
91
+ */
92
+ export class StreamTornChunkError extends Error {
93
+ constructor(message) {
94
+ super(message);
95
+ this.name = 'StreamTornChunkError';
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Thrown by callers (see `session.mjs`'s stream read path) when a
101
+ * complete, well-framed chunk fails AEAD authentication in
102
+ * `openFrame()` (tampered ciphertext, wrong key, or session_id
103
+ * mismatch). Distinct from `StreamTornChunkError` -- this module itself
104
+ * never throws it, since `ChunkAccumulator` has no key and cannot
105
+ * authenticate anything.
106
+ */
107
+ export class StreamAuthenticationError extends Error {
108
+ constructor(message) {
109
+ super(message);
110
+ this.name = 'StreamAuthenticationError';
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Encode one sealed chunk for the wire: `[len][nonce][ciphertext]`.
116
+ *
117
+ * @param {Uint8Array} nonce - exactly 12 bytes
118
+ * @param {Uint8Array} ciphertext - ciphertext + GCM tag, as returned by `sealFrame`
119
+ * @returns {Uint8Array}
120
+ */
121
+ export function encodeChunk(nonce, ciphertext) {
122
+ if (!(nonce instanceof Uint8Array) || nonce.length !== NONCE_BYTES) {
123
+ throw new Error(`stream-frame: nonce must be a ${NONCE_BYTES}-byte Uint8Array`);
124
+ }
125
+ if (!(ciphertext instanceof Uint8Array)) {
126
+ throw new Error('stream-frame: ciphertext must be a Uint8Array');
127
+ }
128
+ const out = new Uint8Array(LENGTH_PREFIX_BYTES + NONCE_BYTES + ciphertext.length);
129
+ new DataView(out.buffer).setUint32(0, ciphertext.length, false);
130
+ out.set(nonce, LENGTH_PREFIX_BYTES);
131
+ out.set(ciphertext, LENGTH_PREFIX_BYTES + NONCE_BYTES);
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Reassembles a raw byte stream into complete `{nonce, ciphertext}`
137
+ * chunks. See the module doc comment for the wire format and the
138
+ * efficiency note on the internal buffer strategy.
139
+ */
140
+ export class ChunkAccumulator {
141
+ /** @type {Uint8Array} */
142
+ #buf = new Uint8Array(0);
143
+ /** @type {number} Read cursor -- bytes before this offset are already consumed. */
144
+ #start = 0;
145
+ /** @type {number} Write cursor -- bytes before this offset are valid buffered data. */
146
+ #end = 0;
147
+
148
+ /**
149
+ * Feed newly-read bytes in and pull out every complete chunk now
150
+ * available. Any trailing partial chunk stays buffered for the next
151
+ * `feed()` call.
152
+ *
153
+ * @param {Uint8Array} bytes
154
+ * @returns {Array<{nonce: Uint8Array, ciphertext: Uint8Array}>}
155
+ */
156
+ feed(bytes) {
157
+ if (bytes && bytes.length > 0) {
158
+ this.#append(bytes);
159
+ }
160
+ const chunks = [];
161
+ for (;;) {
162
+ const chunk = this.#tryParseOne();
163
+ if (chunk === null) break;
164
+ chunks.push(chunk);
165
+ }
166
+ // Once fully drained, reset cursors so the buffer doesn't retain a
167
+ // large-but-empty backing array indefinitely.
168
+ if (this.#start === this.#end) {
169
+ this.#start = 0;
170
+ this.#end = 0;
171
+ }
172
+ return chunks;
173
+ }
174
+
175
+ /**
176
+ * Call at clean stream EOF. Throws `StreamTornChunkError` if partial
177
+ * chunk bytes are still buffered (a truncated stream); otherwise a
178
+ * no-op.
179
+ */
180
+ finish() {
181
+ const remaining = this.#end - this.#start;
182
+ if (remaining > 0) {
183
+ throw new StreamTornChunkError(
184
+ `stream-frame: stream ended with ${remaining} torn (incomplete) chunk bytes buffered`
185
+ );
186
+ }
187
+ }
188
+
189
+ /** @private */
190
+ #append(bytes) {
191
+ const liveLength = this.#end - this.#start;
192
+ const needed = liveLength + bytes.length;
193
+ if (needed > this.#buf.length - this.#start) {
194
+ // Grow (and compact away already-consumed prefix bytes) with
195
+ // amortized doubling, so repeated small feed() calls stay linear
196
+ // overall rather than re-copying the whole live region every time.
197
+ const newCapacity = Math.max(needed, this.#buf.length * 2, 4096);
198
+ const newBuf = new Uint8Array(newCapacity);
199
+ newBuf.set(this.#buf.subarray(this.#start, this.#end), 0);
200
+ this.#buf = newBuf;
201
+ this.#end = liveLength;
202
+ this.#start = 0;
203
+ }
204
+ this.#buf.set(bytes, this.#end);
205
+ this.#end += bytes.length;
206
+ }
207
+
208
+ /** @private */
209
+ #tryParseOne() {
210
+ const available = this.#end - this.#start;
211
+ if (available < LENGTH_PREFIX_BYTES + NONCE_BYTES) return null;
212
+ const headerView = new DataView(this.#buf.buffer, this.#buf.byteOffset + this.#start, LENGTH_PREFIX_BYTES);
213
+ const n = headerView.getUint32(0, false);
214
+ if (n > MAX_WIRE_CIPHERTEXT_BYTES) {
215
+ throw new StreamTornChunkError(
216
+ `stream-frame: chunk length ${n} exceeds max ${MAX_WIRE_CIPHERTEXT_BYTES} -- corrupt or malicious framing`
217
+ );
218
+ }
219
+ const total = LENGTH_PREFIX_BYTES + NONCE_BYTES + n;
220
+ if (available < total) return null; // wait for more bytes
221
+ const nonceStart = this.#start + LENGTH_PREFIX_BYTES;
222
+ const ciphertextStart = nonceStart + NONCE_BYTES;
223
+ const nonce = this.#buf.slice(nonceStart, ciphertextStart);
224
+ const ciphertext = this.#buf.slice(ciphertextStart, this.#start + total);
225
+ this.#start += total;
226
+ return { nonce, ciphertext };
227
+ }
228
+ }
229
+
230
+ // ── Write coalescing ─────────────────────────────────────────────────
231
+ //
232
+ // Purely local, sender-side batching of small consecutive write() calls
233
+ // into one sealed chunk -- NOT a wire protocol change (the receiver has
234
+ // no idea how many writes got merged, see wsh #22's design-doc update).
235
+ // Smart defaults derive from session `type` (already known at
236
+ // `openSession()` time); callers can override via `enableE2E(key, {
237
+ // coalesce })` or fully disable via `coalesce: false`.
238
+
239
+ /**
240
+ * Default coalescing profiles by session kind.
241
+ * - `pty` (interactive): latency-first -- flush on a short timer or a
242
+ * small byte threshold, whichever fires first. This isn't "batching
243
+ * keystrokes"; it mainly catches writes issued in the same event-loop
244
+ * tick (multi-byte UTF-8, escape sequences, paste bursts) -- a single
245
+ * keystroke still goes out almost immediately.
246
+ * - `exec` (bulk): throughput-first -- longer timer and the full 16 KiB
247
+ * soft target from the chunk-size design above.
248
+ * @type {Record<'pty'|'exec', {maxBytes: number, maxDelayMs: number}>}
249
+ */
250
+ export const DEFAULT_COALESCE_PROFILES = Object.freeze({
251
+ pty: Object.freeze({ maxBytes: 4 * 1024, maxDelayMs: 6 }),
252
+ exec: Object.freeze({ maxBytes: CHUNK_SOFT_TARGET_BYTES, maxDelayMs: 30 }),
253
+ });
254
+
255
+ /**
256
+ * Resolve the effective coalescing config for a session.
257
+ *
258
+ * @param {'pty'|'exec'} kind
259
+ * @param {false|{maxBytes?: number, maxDelayMs?: number}|undefined} override
260
+ * @returns {{maxBytes: number, maxDelayMs: number}|null} `null` means "coalescing disabled -- seal every write immediately"
261
+ */
262
+ export function resolveCoalesceOptions(kind, override) {
263
+ if (override === false) return null;
264
+ const base = DEFAULT_COALESCE_PROFILES[kind] || DEFAULT_COALESCE_PROFILES.exec;
265
+ if (override && typeof override === 'object') {
266
+ const maxBytes = Number.isFinite(override.maxBytes) && override.maxBytes > 0 ? override.maxBytes : base.maxBytes;
267
+ const maxDelayMs = Number.isFinite(override.maxDelayMs) && override.maxDelayMs >= 0 ? override.maxDelayMs : base.maxDelayMs;
268
+ return { maxBytes, maxDelayMs };
269
+ }
270
+ return { ...base };
271
+ }
272
+
273
+ /**
274
+ * Batches consecutive `write()` calls and flushes them as one chunk once
275
+ * `maxBytes` is buffered or `maxDelayMs` has elapsed since the first
276
+ * buffered byte of the current batch, whichever comes first.
277
+ *
278
+ * `write()` resolves once bytes are queued, not once they're actually
279
+ * flushed -- coalescing is local batching, so "queued" is the caller-
280
+ * visible unit of success (matching wsh #22's resolved design). Flush
281
+ * errors surface on the promise returned by the *next* `write()`/
282
+ * `flush()` call that observes the same underlying flush chain.
283
+ */
284
+ export class WriteCoalescer {
285
+ #maxBytes;
286
+ #maxDelayMs;
287
+ #onFlush;
288
+ #pending = [];
289
+ #pendingLength = 0;
290
+ #timer = null;
291
+ #chain = Promise.resolve();
292
+
293
+ /**
294
+ * @param {{maxBytes: number, maxDelayMs: number}} options
295
+ * @param {function(Uint8Array): Promise<void>} onFlush - called with the merged buffered bytes
296
+ */
297
+ constructor({ maxBytes, maxDelayMs }, onFlush) {
298
+ if (!(maxBytes > 0)) {
299
+ throw new Error('stream-frame: WriteCoalescer requires a positive maxBytes');
300
+ }
301
+ if (!(maxDelayMs >= 0)) {
302
+ throw new Error('stream-frame: WriteCoalescer requires a non-negative maxDelayMs');
303
+ }
304
+ if (typeof onFlush !== 'function') {
305
+ throw new Error('stream-frame: WriteCoalescer requires an onFlush callback');
306
+ }
307
+ this.#maxBytes = maxBytes;
308
+ this.#maxDelayMs = maxDelayMs;
309
+ this.#onFlush = onFlush;
310
+ }
311
+
312
+ /**
313
+ * Buffer bytes for later coalesced sealing.
314
+ * @param {Uint8Array} bytes
315
+ * @returns {Promise<void>}
316
+ */
317
+ write(bytes) {
318
+ this.#pending.push(bytes);
319
+ this.#pendingLength += bytes.length;
320
+ if (this.#pendingLength >= this.#maxBytes) {
321
+ return this.flush();
322
+ }
323
+ if (this.#timer === null) {
324
+ this.#timer = setTimeout(() => {
325
+ this.#timer = null;
326
+ this.flush().catch(() => {
327
+ // Errors are observed via #chain by the next write()/flush()
328
+ // caller; swallow here so an unobserved timer-driven flush
329
+ // doesn't produce an unhandled rejection.
330
+ });
331
+ }, this.#maxDelayMs);
332
+ if (typeof this.#timer.unref === 'function') this.#timer.unref();
333
+ }
334
+ return this.#chain;
335
+ }
336
+
337
+ /**
338
+ * Force-flush any currently-buffered bytes immediately (used on
339
+ * session close and by the byte/timer thresholds above).
340
+ * @returns {Promise<void>}
341
+ */
342
+ flush() {
343
+ if (this.#timer !== null) {
344
+ clearTimeout(this.#timer);
345
+ this.#timer = null;
346
+ }
347
+ if (this.#pending.length === 0) {
348
+ return this.#chain;
349
+ }
350
+ const merged = concatBytes(this.#pending, this.#pendingLength);
351
+ this.#pending = [];
352
+ this.#pendingLength = 0;
353
+ this.#chain = this.#chain.then(() => this.#onFlush(merged));
354
+ return this.#chain;
355
+ }
356
+ }
357
+
358
+ /** @private */
359
+ function concatBytes(parts, totalLength) {
360
+ if (parts.length === 1) return parts[0];
361
+ const out = new Uint8Array(totalLength);
362
+ let offset = 0;
363
+ for (const part of parts) {
364
+ out.set(part, offset);
365
+ offset += part.length;
366
+ }
367
+ return out;
368
+ }
369
+
370
+ // Re-exported for convenience so callers that only need stream-mode
371
+ // sealing don't have to import from two modules.
372
+ export { sealFrame };