@johnhenry/wsh 0.20.0 → 0.21.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/README.md +82 -3
- package/package.json +1 -1
- package/src/client.mjs +76 -28
- package/src/index.d.ts +23 -3
- package/src/server/connection.mjs +216 -31
- package/src/server/index.d.ts +19 -0
- package/src/server/index.mjs +22 -2
- package/src/server/sessions.mjs +263 -0
- package/src/session.mjs +31 -0
package/README.md
CHANGED
|
@@ -186,7 +186,79 @@ paths and symlinks pointing out of it are refused -- for `write` and `rename`
|
|
|
186
186
|
(both paths) exactly as for `read`.
|
|
187
187
|
|
|
188
188
|
Not implemented (each has a tracking issue): WebTransport, relay/reverse
|
|
189
|
-
mode
|
|
189
|
+
mode.
|
|
190
|
+
|
|
191
|
+
### Sessions: attach / resume / detach
|
|
192
|
+
|
|
193
|
+
By default a pty/exec session dies with the connection that opened it. Pass
|
|
194
|
+
`sessions` and it outlives it, so a dropped socket (a flaky link, a closed
|
|
195
|
+
tab) can be picked up again:
|
|
196
|
+
|
|
197
|
+
```js
|
|
198
|
+
createWshServer({
|
|
199
|
+
auth, exec: true, pty: { spawn: nodePty.spawn },
|
|
200
|
+
sessions: {
|
|
201
|
+
detachTtlMs: 300_000, // how long a session nobody is attached to keeps running (0 = kill at once)
|
|
202
|
+
maxDetached: 16, // unattended sessions kept at once; the longest-detached is killed beyond that
|
|
203
|
+
ringBytes: 1 << 20, // output history kept per session: what a resume can replay
|
|
204
|
+
// sessionSecret: process.env.WSH_SESSION_SECRET, // fixes the token key (default: random per process)
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
// original connection
|
|
211
|
+
const s = await client.openSession({ type: 'pty' });
|
|
212
|
+
s.onData = (d) => term.write(d); // s.seq counts the output bytes received so far
|
|
213
|
+
const { sessionId, resumeToken } = s; // keep these
|
|
214
|
+
const lastSeq = s.seq; // ... the socket drops ...
|
|
215
|
+
|
|
216
|
+
// fresh connection, same key
|
|
217
|
+
const { session } = await client2.resumeSession(sessionId, resumeToken, { lastSeq });
|
|
218
|
+
session.onData = (d) => term.write(d); // only the bytes after lastSeq, then live output
|
|
219
|
+
await session.write('ls\n'); // and it is the same process
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
- **`seq` is the cumulative count of output bytes** the host has produced for
|
|
223
|
+
a session (stream data or `SessionData` alike). The client counts what it
|
|
224
|
+
received -- `WshSession.seq` -- so no per-frame field exists, and
|
|
225
|
+
`Resume.last_seq` is exactly that number. The host keeps the newest
|
|
226
|
+
`ringBytes` of output and replays `ring[last_seq - ringStart ..]`. A
|
|
227
|
+
`last_seq` older than the ring still holds is refused with an
|
|
228
|
+
`output gap` error naming where the history starts (fall back to
|
|
229
|
+
`attachSession()` for the retained tail); one newer than the session has
|
|
230
|
+
produced is refused too. While nobody is attached the process keeps running
|
|
231
|
+
and keeps filling the ring (it is never paused), so a chatty process loses its
|
|
232
|
+
oldest output rather than blocking.
|
|
233
|
+
- **Token:** `HMAC-SHA256(secret, session_id || expiry)` in the spec's
|
|
234
|
+
40-byte token format, minted at open, returned as `OpenOk.token` and checked
|
|
235
|
+
in constant time. **`Resume` needs the token AND ownership** (the same key, or
|
|
236
|
+
for a password login the same username) -- it is the credentialed connection
|
|
237
|
+
coming back. **`Attach` accepts the token OR ownership**, so the owner can
|
|
238
|
+
re-attach with nothing but the session id, and a token holder can join as a
|
|
239
|
+
guest. Refusals do not say whether the session exists.
|
|
240
|
+
- **What arrives:** the Presence reply names a new channel (`channel_id`) and the
|
|
241
|
+
`seq` of its first byte; the replay and live output follow as `SessionData` on
|
|
242
|
+
it. That channel is always message-backed, even for an `exec` session that was
|
|
243
|
+
opened on a data stream -- neither stock client opens a stream for attach/resume
|
|
244
|
+
-- so `resumeSession()`/`attachSession()` return the Presence with a
|
|
245
|
+
non-enumerable `session` (a `WshSession`) to read and write it. A client that
|
|
246
|
+
ignores that can read the same frames with `addControlListener()`.
|
|
247
|
+
- **Several connections** may attach to one session (up to 16): output fans out
|
|
248
|
+
to all of them, `attachSession(id, { readOnly: true })` attaches a viewer whose
|
|
249
|
+
input is dropped, and every change in who is attached is broadcast as `Presence`
|
|
250
|
+
(the roster, without channel ids).
|
|
251
|
+
- **Ending vs leaving:** `Close` on a channel (which `session.close()` and a
|
|
252
|
+
graceful `client.disconnect()` send) *ends* the session when its owner sends it,
|
|
253
|
+
and merely leaves it for anyone else. To walk away from a session and keep it
|
|
254
|
+
running, `client.detach(sessionId)` first; losing the connection also detaches.
|
|
255
|
+
`client.listRemoteSessions()` lists the sessions your key owns, attached or not.
|
|
256
|
+
- Exit: when the process ends, everyone attached gets `Exit` + `Close`; the
|
|
257
|
+
record then lingers for `detachTtlMs` so a late resume still collects the tail
|
|
258
|
+
and the exit code.
|
|
259
|
+
- Not built: ACL grants (`SessionGrant`/`SessionRevoke`) and `ControlChanged` --
|
|
260
|
+
only the owner re-attaches as a controller; everyone else needs the token.
|
|
261
|
+
Sessions are in-memory: a server restart ends them all.
|
|
190
262
|
|
|
191
263
|
### MCP tools
|
|
192
264
|
|
|
@@ -361,8 +433,9 @@ Opening a PTY/exec session returns a session-scoped credential alongside the cha
|
|
|
361
433
|
session.sessionId; // server-assigned session id (undefined for e.g. file channels)
|
|
362
434
|
session.resumeToken; // token minted at open time; only the opener receives it
|
|
363
435
|
|
|
364
|
-
// The original opener, reclaiming its session from a fresh connection
|
|
365
|
-
|
|
436
|
+
// The original opener, reclaiming its session from a fresh connection,
|
|
437
|
+
// replaying only the output it has not yet seen (`session.seq` counts it):
|
|
438
|
+
await client.resumeSession(session.sessionId, session.resumeToken, { lastSeq });
|
|
366
439
|
|
|
367
440
|
// Any other authorized principal attaches without a token -- ownership
|
|
368
441
|
// or an ACL grant is enough:
|
|
@@ -374,6 +447,11 @@ await client.detach(session.sessionId); // leave it running server-side
|
|
|
374
447
|
await client.listRemoteSessions(); // sessions this key can see
|
|
375
448
|
```
|
|
376
449
|
|
|
450
|
+
`@johnhenry/wsh/server` implements all of it with its [`sessions`
|
|
451
|
+
option](#sessions-attach--resume--detach). The Rust `wsh-server` replays its whole
|
|
452
|
+
ring on `Resume` regardless of `last_seq` (the position is defined in
|
|
453
|
+
[spec/wsh-v1.md](spec/wsh-v1.md)).
|
|
454
|
+
|
|
377
455
|
## Pinning a Self-Signed Certificate
|
|
378
456
|
|
|
379
457
|
Both transports normally need a certificate a public certificate authority
|
|
@@ -516,6 +594,7 @@ than left to be found.
|
|
|
516
594
|
| Install an authorized key | `WshClient.addAuthorizedKey()` | `wsh_client::WshClient::add_authorized_key()`, `wsh copy-id` | **Wire-unified** (wsh #59): `AuthorizedKeyAdd`/`AuthorizedKeyResult`, replacing a Rust-CLI-only shell command with a message every implementation can send |
|
|
517
595
|
| Host identity / TOFU | `WshKnownHosts` (localStorage-backed) | `KnownHosts`/`HostStatus` (`~/.wsh/known_hosts`-backed) | **Record unified, policy is not** (wsh #59): both pin `ServerHello.host_fingerprint`, which `@johnhenry/wsh/server` populates (with a proof of possession, [Host key](#host-key-fingerprint--tofu)) but no Rust `wsh-server` release does yet (see [Security](#security-model)). *When* to trust, prompt, or persist is deliberately left per-implementation -- a browser and a CLI have different UX for "first time seeing this host" |
|
|
518
596
|
| Interactive shell UI | none -- this SDK is a protocol client, not a terminal emulator; pair with xterm.js/ghostty-web | `wsh connect`, `wsh sftp` (line-oriented REPL) | **Deliberately not unified** -- a browser embeds a terminal widget the host page owns; a CLI process owns its own TTY |
|
|
597
|
+
| Attach / resume | `attachSession()`, `resumeSession()`, `WshSession.seq` | `attach_session()`, `resume_session()` | **Wire-unified** (`Attach`/`Resume`/`Presence`); `seq` = cumulative output bytes. `@johnhenry/wsh/server` honours `last_seq` (bounded ring, gap errors) and answers Presence with `channel_id`/`seq`; the Rust `wsh-server` replays its whole ring and leaves them unset |
|
|
519
598
|
| Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()` | `wsh reverse`, `wsh agent` (persistent, with startup-unit install) | **Wire-unified** (registration, discovery, signed peer records); **daemonization is CLI-only** -- a browser tab cannot be a background OS service |
|
|
520
599
|
| Post-quantum E2E (experimental) | `initiateE2E()` (WebCrypto ML-KEM-768 or `@noble/post-quantum` fallback) | `E2eKeyExchange` (`ml-kem` crate) | **Wire-unified** algorithm and transcript; key material backends differ by platform necessity |
|
|
521
600
|
| Session recording/replay | `SessionRecorder`/`SessionPlayer` (asciicast v2) | none | **JS-only** -- no current Rust consumer needs playback; the format itself (asciicast v2) is not proprietary if one is added later |
|
package/package.json
CHANGED
package/src/client.mjs
CHANGED
|
@@ -244,6 +244,15 @@ export class WshClient {
|
|
|
244
244
|
/** @type {Map<number, WshSession>} Active sessions keyed by channel ID. */
|
|
245
245
|
#sessions = new Map();
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* In-flight attachSession()/resumeSession() calls awaiting the Presence
|
|
249
|
+
* that answers them. Like #pendingOpens, matched synchronously in
|
|
250
|
+
* #handleControl so the WshSession exists before the replay (which the
|
|
251
|
+
* host sends right behind the Presence) is dispatched.
|
|
252
|
+
* @type {Array<{sessionId: string, token?: Uint8Array}>}
|
|
253
|
+
*/
|
|
254
|
+
#pendingAttaches = [];
|
|
255
|
+
|
|
247
256
|
/**
|
|
248
257
|
* FIFO queue of in-flight openSession() calls awaiting the next
|
|
249
258
|
* OPEN_OK/OPEN_FAIL. Handled as a dedicated synchronous case in
|
|
@@ -743,27 +752,50 @@ export class WshClient {
|
|
|
743
752
|
* @param {boolean} [opts.readOnly=false] - Attach in read-only mode
|
|
744
753
|
* @param {Uint8Array} [opts.token] - Session-scoped token (optional; see above)
|
|
745
754
|
* @param {number} [opts.timeout] - Timeout in ms
|
|
746
|
-
* @returns {Promise<object>} Server's PRESENCE response
|
|
755
|
+
* @returns {Promise<object>} Server's PRESENCE response. Against a host that
|
|
756
|
+
* assigns the attachment a channel (`@johnhenry/wsh/server` with `sessions`)
|
|
757
|
+
* the response also has a non-enumerable `session`: a message-backed
|
|
758
|
+
* `WshSession` receiving the replay and live output (`onData`, `write()`,
|
|
759
|
+
* `resize()`, `onExit`), whose `seq` is the position to resume from next time.
|
|
747
760
|
*/
|
|
748
761
|
async attachSession(targetSessionId, { readOnly = false, token, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
|
|
749
762
|
this.#assertAuthenticated('attachSession');
|
|
750
763
|
|
|
751
764
|
const attachMode = readOnly ? 'readonly' : 'control';
|
|
752
|
-
|
|
753
|
-
attachMsg({ sessionId: targetSessionId, token, mode: attachMode })
|
|
765
|
+
return this.#attachOrResume(
|
|
766
|
+
targetSessionId, token, attachMsg({ sessionId: targetSessionId, token, mode: attachMode }),
|
|
767
|
+
timeout, 'attach', 'Failed to attach',
|
|
754
768
|
);
|
|
769
|
+
}
|
|
755
770
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
771
|
+
/**
|
|
772
|
+
* Send an Attach/Resume and wait for the Presence that answers it.
|
|
773
|
+
* @private
|
|
774
|
+
*/
|
|
775
|
+
async #attachOrResume(sessionId, token, msg, timeout, what, failure) {
|
|
776
|
+
const pending = { sessionId, token };
|
|
777
|
+
this.#pendingAttaches.push(pending);
|
|
778
|
+
try {
|
|
779
|
+
await this.#transport.sendControl(msg);
|
|
761
780
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
781
|
+
const response = await this.#waitForMessage(
|
|
782
|
+
[MSG.PRESENCE, MSG.ERROR],
|
|
783
|
+
timeout,
|
|
784
|
+
`Timed out waiting for ${what} response`,
|
|
785
|
+
// A roster Presence about some other session this connection is in is not the answer.
|
|
786
|
+
(m) => m.type === MSG.ERROR || !Array.isArray(m.attachments) || m.attachments.length === 0
|
|
787
|
+
|| m.attachments.some((a) => a?.session_id === sessionId),
|
|
788
|
+
);
|
|
765
789
|
|
|
766
|
-
|
|
790
|
+
if (response.type === MSG.ERROR) {
|
|
791
|
+
throw new Error(`${failure}: ${response.message || 'rejected'}`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return response;
|
|
795
|
+
} finally {
|
|
796
|
+
const i = this.#pendingAttaches.indexOf(pending);
|
|
797
|
+
if (i !== -1) this.#pendingAttaches.splice(i, 1);
|
|
798
|
+
}
|
|
767
799
|
}
|
|
768
800
|
|
|
769
801
|
/**
|
|
@@ -779,28 +811,22 @@ export class WshClient {
|
|
|
779
811
|
* @param {string} targetSessionId - Session ID to resume
|
|
780
812
|
* @param {Uint8Array} token - Session-scoped resume token (see `WshSession.resumeToken`)
|
|
781
813
|
* @param {object} [opts]
|
|
782
|
-
* @param {number} [opts.lastSeq=0] -
|
|
814
|
+
* @param {number} [opts.lastSeq=0] - Cumulative session output bytes this
|
|
815
|
+
* client has already received (`WshSession.seq` of the session it lost).
|
|
816
|
+
* A host with a bounded history replays only what follows and refuses a
|
|
817
|
+
* position older than it still holds (use `attachSession()` then); the Rust
|
|
818
|
+
* `wsh-server` ignores it and replays its whole ring.
|
|
783
819
|
* @param {number} [opts.timeout=10000] - Timeout in ms
|
|
784
|
-
* @returns {Promise<object>} Server's PRESENCE response
|
|
820
|
+
* @returns {Promise<object>} Server's PRESENCE response, with a non-enumerable
|
|
821
|
+
* `session` exactly as for `attachSession()`.
|
|
785
822
|
*/
|
|
786
823
|
async resumeSession(targetSessionId, token, { lastSeq = 0, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
|
|
787
824
|
this.#assertAuthenticated('resumeSession');
|
|
788
825
|
|
|
789
|
-
|
|
790
|
-
resumeMsg({ sessionId: targetSessionId, token, lastSeq })
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
const response = await this.#waitForMessage(
|
|
794
|
-
[MSG.PRESENCE, MSG.ERROR],
|
|
795
|
-
timeout,
|
|
796
|
-
'Timed out waiting for resume response'
|
|
826
|
+
return this.#attachOrResume(
|
|
827
|
+
targetSessionId, token, resumeMsg({ sessionId: targetSessionId, token, lastSeq }),
|
|
828
|
+
timeout, 'resume', 'Failed to resume',
|
|
797
829
|
);
|
|
798
|
-
|
|
799
|
-
if (response.type === MSG.ERROR) {
|
|
800
|
-
throw new Error(`Failed to resume: ${response.message || 'rejected'}`);
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
return response;
|
|
804
830
|
}
|
|
805
831
|
|
|
806
832
|
/**
|
|
@@ -2390,6 +2416,28 @@ export class WshClient {
|
|
|
2390
2416
|
return;
|
|
2391
2417
|
}
|
|
2392
2418
|
|
|
2419
|
+
// The Presence answering this connection's own Attach/Resume names the
|
|
2420
|
+
// channel the host assigned (see AttachmentInfo.channel_id): build the
|
|
2421
|
+
// session now, synchronously, so the replay right behind it finds it.
|
|
2422
|
+
if (type === MSG.PRESENCE && this.#pendingAttaches.length > 0) {
|
|
2423
|
+
const own = Array.isArray(msg.attachments)
|
|
2424
|
+
? msg.attachments.find((a) => a?.channel_id !== undefined && this.#pendingAttaches.some((p) => p.sessionId === a.session_id))
|
|
2425
|
+
: undefined;
|
|
2426
|
+
if (own) {
|
|
2427
|
+
const pending = this.#pendingAttaches.find((p) => p.sessionId === own.session_id);
|
|
2428
|
+
const session = new WshSession(this.#transport, own.channel_id, {}, 'pty', {
|
|
2429
|
+
dataMode: 'virtual',
|
|
2430
|
+
capabilities: ['resize', 'signal'],
|
|
2431
|
+
sessionId: own.session_id,
|
|
2432
|
+
resumeToken: pending.token,
|
|
2433
|
+
});
|
|
2434
|
+
session._setSeq(Number.isSafeInteger(own.seq) ? own.seq : 0);
|
|
2435
|
+
this.#sessions.set(own.channel_id, session);
|
|
2436
|
+
session._activateVirtual((m) => this.sendRelayControl(m));
|
|
2437
|
+
Object.defineProperty(msg, 'session', { value: session, enumerable: false });
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2393
2441
|
// First, check if any waiters are listening for this message type.
|
|
2394
2442
|
// A waiter carrying a `match` predicate only takes messages it claims,
|
|
2395
2443
|
// so the queue is scanned rather than shifted blindly.
|
package/src/index.d.ts
CHANGED
|
@@ -1416,6 +1416,13 @@ export class WshSession {
|
|
|
1416
1416
|
*/
|
|
1417
1417
|
readonly resumeToken: Uint8Array | undefined;
|
|
1418
1418
|
|
|
1419
|
+
/**
|
|
1420
|
+
* Cumulative session output bytes received so far -- the host's `seq` for
|
|
1421
|
+
* the next byte. Pass it as `lastSeq` to `WshClient.resumeSession()`.
|
|
1422
|
+
* Absolute for a session obtained from `attachSession()`/`resumeSession()`.
|
|
1423
|
+
*/
|
|
1424
|
+
readonly seq: number;
|
|
1425
|
+
|
|
1419
1426
|
/** Called when stdout/stderr data arrives. */
|
|
1420
1427
|
onData: ((data: Uint8Array) => void) | null;
|
|
1421
1428
|
|
|
@@ -1621,6 +1628,14 @@ export interface WshOpenSessionOptions {
|
|
|
1621
1628
|
primer?: boolean;
|
|
1622
1629
|
}
|
|
1623
1630
|
|
|
1631
|
+
/**
|
|
1632
|
+
* The Presence answering `attachSession()` / `resumeSession()`. Against a host
|
|
1633
|
+
* that assigns the attachment a channel (`@johnhenry/wsh/server` with `sessions`),
|
|
1634
|
+
* `session` -- non-enumerable -- is the message-backed `WshSession` receiving
|
|
1635
|
+
* the replay and live output.
|
|
1636
|
+
*/
|
|
1637
|
+
export type WshAttachResponse = WshMessage & { readonly session?: WshSession };
|
|
1638
|
+
|
|
1624
1639
|
/** Options for WshClient.attachSession(). */
|
|
1625
1640
|
export interface WshAttachSessionOptions {
|
|
1626
1641
|
readOnly?: boolean;
|
|
@@ -1637,7 +1652,12 @@ export interface WshAttachSessionOptions {
|
|
|
1637
1652
|
|
|
1638
1653
|
/** Options for WshClient.resumeSession(). */
|
|
1639
1654
|
export interface WshResumeSessionOptions {
|
|
1640
|
-
/**
|
|
1655
|
+
/**
|
|
1656
|
+
* Cumulative session output bytes this client already has (`WshSession.seq`
|
|
1657
|
+
* of the session it lost). A host with a bounded history replays only what
|
|
1658
|
+
* follows and refuses a position older than it still holds; the Rust
|
|
1659
|
+
* `wsh-server` ignores it and replays its whole ring. Default 0.
|
|
1660
|
+
*/
|
|
1641
1661
|
lastSeq?: number;
|
|
1642
1662
|
timeout?: number;
|
|
1643
1663
|
}
|
|
@@ -1851,13 +1871,13 @@ export class WshClient {
|
|
|
1851
1871
|
* already owning/being ACL-granted access to `targetSessionId`
|
|
1852
1872
|
* server-side -- see `WshAttachSessionOptions.token`.
|
|
1853
1873
|
*/
|
|
1854
|
-
attachSession(targetSessionId: string, opts?: WshAttachSessionOptions): Promise<
|
|
1874
|
+
attachSession(targetSessionId: string, opts?: WshAttachSessionOptions): Promise<WshAttachResponse>;
|
|
1855
1875
|
|
|
1856
1876
|
/**
|
|
1857
1877
|
* Resume a previously disconnected session. Unlike `attachSession()`,
|
|
1858
1878
|
* `token` is required and verified unconditionally server-side.
|
|
1859
1879
|
*/
|
|
1860
|
-
resumeSession(targetSessionId: string, token: Uint8Array, opts?: WshResumeSessionOptions): Promise<
|
|
1880
|
+
resumeSession(targetSessionId: string, token: Uint8Array, opts?: WshResumeSessionOptions): Promise<WshAttachResponse>;
|
|
1861
1881
|
|
|
1862
1882
|
/**
|
|
1863
1883
|
* Detach from a remote session, leaving it running server-side.
|
|
@@ -19,11 +19,13 @@ import { FrameDecoder, frameEncode } from '../cbor.mjs';
|
|
|
19
19
|
import {
|
|
20
20
|
MSG, serverHello, challenge, authOk, authFail, openOk, openFail, sessionData,
|
|
21
21
|
exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk, mcpTools, mcpResult,
|
|
22
|
+
presence as presenceMsg, error as errorMsg, sessionList, detachOk, detachFail,
|
|
22
23
|
} from '../messages.gen.mjs';
|
|
23
24
|
import {
|
|
24
25
|
generateNonce, verifyChallenge, fingerprint, importPublicKeyRaw,
|
|
25
26
|
} from '../auth.mjs';
|
|
26
27
|
import { FILE_CHUNK_BYTES } from './fs.mjs';
|
|
28
|
+
import { MAX_ATTACHMENTS } from './sessions.mjs';
|
|
27
29
|
import { MCP_CALL_ID_FEATURE } from '../client.mjs';
|
|
28
30
|
import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
|
|
29
31
|
|
|
@@ -33,6 +35,7 @@ const MAX_RENAME_PATH_BYTES = 4096;
|
|
|
33
35
|
const WRITE_IDLE_MS = 30_000;
|
|
34
36
|
|
|
35
37
|
const MAX_CHANNELS = 64;
|
|
38
|
+
const REPLAY_CHUNK_BYTES = 32 * 1024;
|
|
36
39
|
const USERNAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
|
|
37
40
|
const enc = new TextEncoder();
|
|
38
41
|
|
|
@@ -49,6 +52,7 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
|
|
|
49
52
|
* @param {{ pubkey: boolean, password: Function | null }} cfg.methods - which auth methods are on
|
|
50
53
|
* @param {object | null} cfg.hostKey - from loadHostKey
|
|
51
54
|
* @param {object | null} cfg.mcp - from createMcpHost
|
|
55
|
+
* @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
|
|
52
56
|
* @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
|
|
53
57
|
* @param {number} cfg.bindTimeoutMs - how long exec output waits for the data stream
|
|
54
58
|
* @param {(line: string) => void} cfg.log
|
|
@@ -89,7 +93,9 @@ export function createConnectionFactory(cfg) {
|
|
|
89
93
|
function shutdown(why) {
|
|
90
94
|
if (state.closed) return;
|
|
91
95
|
state.closed = true;
|
|
92
|
-
|
|
96
|
+
// A hosted session survives its connection (it is detached, then expires or is resumed);
|
|
97
|
+
// everything else dies with it.
|
|
98
|
+
for (const ch of [...channels.values()]) (ch.detach ?? ch.kill)?.call(ch);
|
|
93
99
|
channels.clear();
|
|
94
100
|
for (const c of mcpCalls) c.abort(new Error('connection closed'));
|
|
95
101
|
mcpCalls.clear();
|
|
@@ -99,8 +105,13 @@ export function createConnectionFactory(cfg) {
|
|
|
99
105
|
log(`closed (${why})`);
|
|
100
106
|
}
|
|
101
107
|
|
|
108
|
+
// A transport that closes right behind a message (a client's `disconnect()` sends Close for
|
|
109
|
+
// every channel and then goes) must not make the host forget that message: it is processed
|
|
110
|
+
// first, then the connection is torn down.
|
|
111
|
+
const shutdownAfterQueue = (why) => { chain = chain.then(() => shutdown(why)); };
|
|
112
|
+
|
|
102
113
|
qmux.onError = (e) => log(`qmux error: ${e.message}`);
|
|
103
|
-
qmux.onClose = () =>
|
|
114
|
+
qmux.onClose = () => shutdownAfterQueue('peer sent CONNECTION_CLOSE');
|
|
104
115
|
qmux.onStreamOpen = (s) => {
|
|
105
116
|
if (s.id !== 0) { bindDataStream(s); return; }
|
|
106
117
|
control = s;
|
|
@@ -109,8 +120,8 @@ export function createConnectionFactory(cfg) {
|
|
|
109
120
|
try { msgs = decoder.feed(d); } catch (e) { log(`frame decode error: ${e.message}`); return; }
|
|
110
121
|
for (const m of msgs) chain = chain.then(() => handle(m)).catch((e) => log(`handler error: ${e.message}`));
|
|
111
122
|
};
|
|
112
|
-
s.onEnd = () =>
|
|
113
|
-
s.onReset = () =>
|
|
123
|
+
s.onEnd = () => shutdownAfterQueue('control stream FIN');
|
|
124
|
+
s.onReset = () => shutdownAfterQueue('control stream reset');
|
|
114
125
|
for (const b of pendingSends.splice(0)) s.write(b).catch(() => {});
|
|
115
126
|
};
|
|
116
127
|
qmux.sendHandshake();
|
|
@@ -136,6 +147,10 @@ export function createConnectionFactory(cfg) {
|
|
|
136
147
|
case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
|
|
137
148
|
case MSG.FILE_OP: return handleFileOp(m);
|
|
138
149
|
case MSG.FILE_CHUNK: return (fileWrites.has(m.channel_id) ? handleWriteChunk(m) : channels.get(m.channel_id)?.chunk?.(m));
|
|
150
|
+
case MSG.ATTACH: return handleAttach(m, false);
|
|
151
|
+
case MSG.RESUME: return handleAttach(m, true);
|
|
152
|
+
case MSG.DETACH: return handleDetach(m);
|
|
153
|
+
case MSG.SESSION_LIST_REQUEST: return handleSessionList();
|
|
139
154
|
case MSG.MCP_DISCOVER: return handleMcpDiscover();
|
|
140
155
|
// Not awaited: the handler chain is serial, and a slow tool must not hold up
|
|
141
156
|
// the next call (or a Close) behind it.
|
|
@@ -315,6 +330,118 @@ export function createConnectionFactory(cfg) {
|
|
|
315
330
|
return send(exitMsg({ channelId, code })).then(() => send(closeMsg({ channelId })));
|
|
316
331
|
}
|
|
317
332
|
|
|
333
|
+
// ── Hosted sessions (createWshServer({ sessions })) ───────────────
|
|
334
|
+
|
|
335
|
+
/** Who this connection authenticated as: its key, or its username for a password login. */
|
|
336
|
+
const principalKey = () => state.fingerprint ?? `password:${state.username}`;
|
|
337
|
+
const ownerRecord = () => ({ username: state.username, fingerprint: state.fingerprint, principal: principalKey() });
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* This connection's message-backed attachment to a hosted session: output, Presence and the
|
|
341
|
+
* final Exit/Close all go through one ordered chain, so a replay is never overtaken by live output.
|
|
342
|
+
*/
|
|
343
|
+
function virtualAttachment(hosted, channelId, mode) {
|
|
344
|
+
let out = Promise.resolve();
|
|
345
|
+
const enqueue = (fn) => { out = out.then(fn).catch(() => {}); return out; };
|
|
346
|
+
return {
|
|
347
|
+
connId: cid, channelId, mode, username: state.username, principal: principalKey(),
|
|
348
|
+
write: (bytes) => enqueue(() => send(sessionData({ channelId, data: bytes }))),
|
|
349
|
+
notify: (msg) => enqueue(() => send(presenceMsg(msg))),
|
|
350
|
+
exit: (code) => enqueue(async () => {
|
|
351
|
+
channels.delete(channelId);
|
|
352
|
+
await send(exitMsg({ channelId, code }));
|
|
353
|
+
await send(closeMsg({ channelId }));
|
|
354
|
+
}),
|
|
355
|
+
flush: () => out,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The `channels` entry for a virtual attachment (an Attach/Resume, or a pty's own channel). */
|
|
360
|
+
function attachedChannel(hosted, att) {
|
|
361
|
+
const control = () => att.mode === 'control';
|
|
362
|
+
const ch = {
|
|
363
|
+
session: hosted, attachment: att,
|
|
364
|
+
input: (d) => { if (control()) hosted.handlers.input?.(d); },
|
|
365
|
+
resize: (c, r) => { if (control()) hosted.handlers.resize?.(c, r); },
|
|
366
|
+
signal: (name) => { if (control()) hosted.handlers.signal?.(name); },
|
|
367
|
+
// The client closed the channel: the session's owner ends it, anyone else just leaves it.
|
|
368
|
+
kill: () => {
|
|
369
|
+
if (att.principal === hosted.owner.principal && control()) hosted.kill();
|
|
370
|
+
else ch.detach();
|
|
371
|
+
},
|
|
372
|
+
detach: () => { channels.delete(att.channelId); hosted.detach(att); },
|
|
373
|
+
};
|
|
374
|
+
channels.set(att.channelId, ch);
|
|
375
|
+
return ch;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function handleAttach(m, isResume) {
|
|
379
|
+
const reg = cfg.sessions;
|
|
380
|
+
const fail = (code, message) => send(errorMsg({ code, message }));
|
|
381
|
+
if (!reg) return fail(3, `${isResume ? 'resume' : 'attach'} is not enabled on this server (no "sessions" option)`);
|
|
382
|
+
const s = reg.get(m.session_id);
|
|
383
|
+
const mine = !!s && s.owner.principal === principalKey();
|
|
384
|
+
const tokenOk = !!s && m.token !== undefined && reg.checkToken(s, m.token);
|
|
385
|
+
// Attach: the token OR ownership. Resume: the token AND ownership (the credentialed connection coming back).
|
|
386
|
+
if (!s || !(isResume ? tokenOk && mine : tokenOk || mine)) {
|
|
387
|
+
log(`${isResume ? 'resume' : 'attach'} refused for ${String(m.session_id).slice(0, 8)}`);
|
|
388
|
+
return fail(2, 'unknown session or not authorized');
|
|
389
|
+
}
|
|
390
|
+
if ([...channels.values()].some((c) => c.session === s)) return fail(3, 'already attached to this session on this connection');
|
|
391
|
+
if (s.attachments.size >= MAX_ATTACHMENTS) return fail(3, 'too many attachments to this session');
|
|
392
|
+
if (channels.size >= MAX_CHANNELS) return fail(3, 'too many open channels');
|
|
393
|
+
|
|
394
|
+
let from = s.ring.start;
|
|
395
|
+
if (isResume) {
|
|
396
|
+
const last = m.last_seq;
|
|
397
|
+
if (!Number.isSafeInteger(last) || last < 0) return fail(3, 'last_seq must be a non-negative integer');
|
|
398
|
+
if (last > s.ring.end) return fail(4, `last_seq ${last} is ahead of the session (it has produced ${s.ring.end} bytes)`);
|
|
399
|
+
if (last < s.ring.start) {
|
|
400
|
+
return fail(4, `output gap: this session's retained output starts at seq ${s.ring.start} but last_seq is ${last}; attach for the retained output instead`);
|
|
401
|
+
}
|
|
402
|
+
from = last;
|
|
403
|
+
}
|
|
404
|
+
const mode = !isResume && /^(readonly|read|view|ro)$/i.test(String(m.mode ?? '')) ? 'readonly' : 'control';
|
|
405
|
+
const channelId = ++state.nextChannel;
|
|
406
|
+
const att = virtualAttachment(s, channelId, mode);
|
|
407
|
+
attachedChannel(s, att);
|
|
408
|
+
|
|
409
|
+
// Reply, replay, then (if the process is already over) its exit -- all before any live output,
|
|
410
|
+
// which only reaches `att` once `s.attach(att)` registers it, after these are queued.
|
|
411
|
+
const self = { session_id: s.id, mode, username: state.username, channel_id: channelId, seq: from };
|
|
412
|
+
att.notify({ attachments: [self, ...s.roster()] });
|
|
413
|
+
const replay = s.ring.read(from);
|
|
414
|
+
for (let off = 0; off < replay.byteLength; off += REPLAY_CHUNK_BYTES) att.write(replay.subarray(off, off + REPLAY_CHUNK_BYTES));
|
|
415
|
+
if (s.state === 'exited') att.exit(s.exitCode ?? 0);
|
|
416
|
+
else s.attach(att);
|
|
417
|
+
log(`${isResume ? 'resumed' : `attached (${mode})`} session ${s.id.slice(0, 8)} on channel ${channelId} from seq ${from}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function handleDetach(m) {
|
|
421
|
+
const s = cfg.sessions?.get(m.session_id);
|
|
422
|
+
const entry = s && [...channels.entries()].find(([, c]) => c.session === s);
|
|
423
|
+
if (!entry) return send(detachFail({ reason: 'not attached to this session on this connection' }));
|
|
424
|
+
const [channelId, ch] = entry;
|
|
425
|
+
const flushed = ch.attachment.flush();
|
|
426
|
+
ch.detach();
|
|
427
|
+
await flushed;
|
|
428
|
+
await send(closeMsg({ channelId }));
|
|
429
|
+
return send(detachOk({ sessionId: s.id }));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function handleSessionList() {
|
|
433
|
+
const now = Date.now();
|
|
434
|
+
const sessions = (cfg.sessions?.listFor(principalKey()) ?? []).map((s) => ({
|
|
435
|
+
session_id: s.id,
|
|
436
|
+
username: s.owner.username,
|
|
437
|
+
fingerprint_short: (s.owner.fingerprint ?? '').slice(0, 8),
|
|
438
|
+
created_at_secs: Math.floor(s.createdAt / 1000),
|
|
439
|
+
idle_secs: Math.max(0, Math.floor((now - s.lastActivity) / 1000)),
|
|
440
|
+
attached_count: s.attachments.size,
|
|
441
|
+
}));
|
|
442
|
+
return send(sessionList({ sessions }));
|
|
443
|
+
}
|
|
444
|
+
|
|
318
445
|
async function openExec(m) {
|
|
319
446
|
if (!cfg.execRunner) return send(openFail({ reason: 'exec is not enabled on this server' }));
|
|
320
447
|
const command = String(m.command ?? '').trim();
|
|
@@ -322,6 +449,7 @@ export function createConnectionFactory(cfg) {
|
|
|
322
449
|
|
|
323
450
|
const channelId = ++state.nextChannel;
|
|
324
451
|
const abort = new AbortController();
|
|
452
|
+
const hosted = cfg.sessions ? cfg.sessions.create({ owner: ownerRecord(), kind: 'exec', command }) : null;
|
|
325
453
|
let stream = null;
|
|
326
454
|
let primerChecked = false;
|
|
327
455
|
let wq = Promise.resolve();
|
|
@@ -331,13 +459,14 @@ export function createConnectionFactory(cfg) {
|
|
|
331
459
|
const endCbs = [];
|
|
332
460
|
const signalCbs = [];
|
|
333
461
|
|
|
462
|
+
const signalTo = (name) => {
|
|
463
|
+
const n = name.replace(/^SIG/, '').toUpperCase();
|
|
464
|
+
for (const cb of signalCbs) cb(n);
|
|
465
|
+
if (/^(INT|TERM|KILL|HUP)$/.test(n) && signalCbs.length === 0) abort.abort();
|
|
466
|
+
};
|
|
334
467
|
const ch = {
|
|
335
468
|
kill: () => abort.abort(),
|
|
336
|
-
signal:
|
|
337
|
-
const n = name.replace(/^SIG/, '').toUpperCase();
|
|
338
|
-
for (const cb of signalCbs) cb(n);
|
|
339
|
-
if (/^(INT|TERM|KILL|HUP)$/.test(n) && signalCbs.length === 0) abort.abort();
|
|
340
|
-
},
|
|
469
|
+
signal: signalTo,
|
|
341
470
|
bind(s) {
|
|
342
471
|
stream = s;
|
|
343
472
|
s.onData = (d) => {
|
|
@@ -349,7 +478,8 @@ export function createConnectionFactory(cfg) {
|
|
|
349
478
|
if (d.byteLength) for (const cb of inputCbs) cb(d);
|
|
350
479
|
};
|
|
351
480
|
s.onEnd = () => { for (const cb of endCbs) cb(); };
|
|
352
|
-
|
|
481
|
+
// A hosted session outlives its stream; an unhosted one dies with it.
|
|
482
|
+
s.onReset = () => { if (!hosted) abort.abort(); };
|
|
353
483
|
const pending = buffered.splice(0);
|
|
354
484
|
wq = wq.then(async () => { for (const b of pending) await s.write(b).catch(() => {}); });
|
|
355
485
|
for (const r of boundWaiters.splice(0)) r();
|
|
@@ -361,6 +491,49 @@ export function createConnectionFactory(cfg) {
|
|
|
361
491
|
let timer = null;
|
|
362
492
|
if (cfg.execOptions.timeoutMs > 0) timer = setTimeout(() => abort.abort(), cfg.execOptions.timeoutMs);
|
|
363
493
|
|
|
494
|
+
// Output to the opening connection's data stream (held until the client has opened it).
|
|
495
|
+
const toStream = (bytes) => {
|
|
496
|
+
if (!stream) { buffered.push(bytes); return Promise.resolve(); }
|
|
497
|
+
const s = stream;
|
|
498
|
+
wq = wq.then(() => s.write(bytes).catch(() => {}));
|
|
499
|
+
return wq;
|
|
500
|
+
};
|
|
501
|
+
// Output is ready but the client's stream may not have been bound yet (a host without
|
|
502
|
+
// announce support waits for the first byte): give it a bounded chance rather than
|
|
503
|
+
// dropping the output or hanging forever.
|
|
504
|
+
const closeStream = async (code) => {
|
|
505
|
+
if (!stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
|
|
506
|
+
if (!stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
|
|
507
|
+
await wq;
|
|
508
|
+
try { await stream?.close(); } catch { /* peer gone */ }
|
|
509
|
+
await finishChannel(channelId, Number.isInteger(code) ? code : 0);
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
if (hosted) {
|
|
513
|
+
const att = {
|
|
514
|
+
connId: cid, channelId, mode: 'control', username: state.username, principal: principalKey(),
|
|
515
|
+
write: toStream,
|
|
516
|
+
notify: (msg) => send(presenceMsg(msg)),
|
|
517
|
+
exit: (code) => closeStream(code),
|
|
518
|
+
flush: () => wq,
|
|
519
|
+
};
|
|
520
|
+
hosted.handlers = {
|
|
521
|
+
input: (d) => { for (const cb of inputCbs) cb(d); },
|
|
522
|
+
inputEnd: () => { for (const cb of endCbs) cb(); },
|
|
523
|
+
signal: signalTo,
|
|
524
|
+
resize: () => {},
|
|
525
|
+
kill: () => abort.abort(),
|
|
526
|
+
};
|
|
527
|
+
ch.session = hosted;
|
|
528
|
+
ch.attachment = att;
|
|
529
|
+
ch.detach = () => {
|
|
530
|
+
channels.delete(channelId);
|
|
531
|
+
hosted.detach(att);
|
|
532
|
+
try { stream?.close().catch(() => {}); } catch { /* gone */ }
|
|
533
|
+
};
|
|
534
|
+
hosted.attach(att);
|
|
535
|
+
}
|
|
536
|
+
|
|
364
537
|
const io = {
|
|
365
538
|
user: state.username,
|
|
366
539
|
env: m.env && typeof m.env === 'object' && cfg.execOptions.clientEnv !== false ? m.env : {},
|
|
@@ -369,9 +542,8 @@ export function createConnectionFactory(cfg) {
|
|
|
369
542
|
signal: abort.signal,
|
|
370
543
|
write(data) {
|
|
371
544
|
const bytes = toBytes(data);
|
|
372
|
-
if (!
|
|
373
|
-
|
|
374
|
-
wq = wq.then(() => s.write(bytes).catch(() => {}));
|
|
545
|
+
if (!hosted) return toStream(bytes);
|
|
546
|
+
hosted.push(bytes);
|
|
375
547
|
return wq;
|
|
376
548
|
},
|
|
377
549
|
onInput: (cb) => inputCbs.push(cb),
|
|
@@ -381,7 +553,7 @@ export function createConnectionFactory(cfg) {
|
|
|
381
553
|
|
|
382
554
|
await send(openOk({
|
|
383
555
|
channelId, dataMode: 'stream', capabilities: ['signal'],
|
|
384
|
-
sessionId: randomUUID(), token: randomBytes(16),
|
|
556
|
+
sessionId: hosted?.id ?? randomUUID(), token: hosted?.token ?? randomBytes(16),
|
|
385
557
|
}));
|
|
386
558
|
log(`exec channel ${channelId}: ${command}`);
|
|
387
559
|
|
|
@@ -390,20 +562,15 @@ export function createConnectionFactory(cfg) {
|
|
|
390
562
|
runExec().catch((e) => log(`exec channel ${channelId}: ${e.message}`));
|
|
391
563
|
|
|
392
564
|
async function runExec() {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (!stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
|
|
403
|
-
if (!stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
|
|
404
|
-
await wq;
|
|
405
|
-
try { await stream?.close(); } catch { /* peer gone */ }
|
|
406
|
-
await finishChannel(channelId, Number.isInteger(code) ? code : 0);
|
|
565
|
+
let code;
|
|
566
|
+
try { code = await cfg.execRunner(command, io); } catch (e) {
|
|
567
|
+
await io.write(`wsh: ${e.message}\n`);
|
|
568
|
+
code = 1;
|
|
569
|
+
}
|
|
570
|
+
if (timer) clearTimeout(timer);
|
|
571
|
+
// Hosted: everyone attached (this connection's stream included, if it is still there) hears the exit.
|
|
572
|
+
if (hosted) { hosted.finish(Number.isInteger(code) ? code : 0); return; }
|
|
573
|
+
await closeStream(code);
|
|
407
574
|
}
|
|
408
575
|
}
|
|
409
576
|
|
|
@@ -423,13 +590,31 @@ export function createConnectionFactory(cfg) {
|
|
|
423
590
|
} catch (e) {
|
|
424
591
|
return send(openFail({ reason: `pty spawn failed: ${e.message}` }));
|
|
425
592
|
}
|
|
426
|
-
const
|
|
593
|
+
const ctl = {
|
|
427
594
|
input: (data) => { try { proc.write(Buffer.from(data)); } catch { /* exited */ } },
|
|
428
595
|
resize: (c, r) => { try { proc.resize(c || cols, r || rows); } catch { /* exited */ } },
|
|
429
596
|
signal: (name) => { try { proc.kill(`SIG${name.replace(/^SIG/, '').toUpperCase()}`); } catch { /* exited */ } },
|
|
430
597
|
kill: () => { try { proc.kill(); } catch { /* exited */ } },
|
|
431
598
|
};
|
|
432
|
-
|
|
599
|
+
|
|
600
|
+
if (cfg.sessions) {
|
|
601
|
+
const hosted = cfg.sessions.create({ owner: ownerRecord(), kind: 'pty', command: m.command });
|
|
602
|
+
hosted.handlers = ctl;
|
|
603
|
+
const att = virtualAttachment(hosted, channelId, 'control');
|
|
604
|
+
attachedChannel(hosted, att);
|
|
605
|
+
proc.onData((d) => hosted.push(Uint8Array.from(toBytes(d))));
|
|
606
|
+
proc.onExit(({ exitCode }) => hosted.finish(exitCode ?? 0));
|
|
607
|
+
const opened = send(openOk({
|
|
608
|
+
channelId, dataMode: 'virtual', capabilities: ['resize', 'signal'],
|
|
609
|
+
sessionId: hosted.id, token: hosted.token,
|
|
610
|
+
}));
|
|
611
|
+
hosted.attach(att);
|
|
612
|
+
await opened;
|
|
613
|
+
log(`pty channel ${channelId} opened (${cols}x${rows}), session ${hosted.id.slice(0, 8)}`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
channels.set(channelId, ctl);
|
|
433
618
|
let out = Promise.resolve();
|
|
434
619
|
proc.onData((d) => { out = out.then(() => send(sessionData({ channelId, data: toBytes(d) }))); });
|
|
435
620
|
proc.onExit(({ exitCode }) => { out.then(() => finishChannel(channelId, exitCode ?? 0)); });
|
|
@@ -556,7 +741,7 @@ export function createConnectionFactory(cfg) {
|
|
|
556
741
|
|
|
557
742
|
return {
|
|
558
743
|
receive(bytes) { if (!state.closed) qmux.receiveBytes(bytes); },
|
|
559
|
-
close() {
|
|
744
|
+
close() { shutdownAfterQueue('transport closed'); },
|
|
560
745
|
};
|
|
561
746
|
};
|
|
562
747
|
}
|
package/src/server/index.d.ts
CHANGED
|
@@ -158,6 +158,17 @@ export interface WshServerMcpOptions {
|
|
|
158
158
|
timeoutMs?: number;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
export interface WshServerSessionsOptions {
|
|
162
|
+
/** How long a session with nobody attached keeps running (default 300000). `0` kills it the moment its last connection goes. */
|
|
163
|
+
detachTtlMs?: number;
|
|
164
|
+
/** Unattended sessions kept at once (default 16); beyond that the longest-detached is killed. */
|
|
165
|
+
maxDetached?: number;
|
|
166
|
+
/** Output history kept per session, in bytes (default 1 MiB) -- what a resume can replay. Older output is dropped; a Resume older than it is refused with an "output gap" error. */
|
|
167
|
+
ringBytes?: number;
|
|
168
|
+
/** Secret the session tokens are minted with (default: random per process). Fixing it makes tokens verifiable across restarts. */
|
|
169
|
+
sessionSecret?: string | Uint8Array;
|
|
170
|
+
}
|
|
171
|
+
|
|
161
172
|
export interface WshServerOptions {
|
|
162
173
|
/** Bind address (default `127.0.0.1`). */
|
|
163
174
|
host?: string;
|
|
@@ -177,6 +188,14 @@ export interface WshServerOptions {
|
|
|
177
188
|
hostKey?: true | { file: string } | CryptoKeyPair;
|
|
178
189
|
/** Enable file ops and uploads/downloads under one directory. Off by default. */
|
|
179
190
|
fs?: WshServerFsOptions;
|
|
191
|
+
/**
|
|
192
|
+
* Keep pty/exec sessions alive across disconnects so `resumeSession()` /
|
|
193
|
+
* `attachSession()` / `detach()` / `listRemoteSessions()` work against them.
|
|
194
|
+
* `true` for the defaults. Off by default: sessions then die with their connection.
|
|
195
|
+
*/
|
|
196
|
+
sessions?: true | WshServerSessionsOptions;
|
|
197
|
+
/** Alias for `sessions.sessionSecret`. */
|
|
198
|
+
sessionSecret?: string | Uint8Array;
|
|
180
199
|
/** Serve MCP tools (`McpDiscover` / `McpCall`) and advertise `mcp-call-id`. Off by default. */
|
|
181
200
|
mcp?: WshServerMcpOptions;
|
|
182
201
|
/** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
|
package/src/server/index.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { spawnRunner } from './exec.mjs';
|
|
|
14
14
|
import { normalizePty } from './pty.mjs';
|
|
15
15
|
import { createFileAccess } from './fs.mjs';
|
|
16
16
|
import { createMcpHost } from './mcp.mjs';
|
|
17
|
+
import { SessionRegistry } from './sessions.mjs';
|
|
17
18
|
import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
|
|
18
19
|
|
|
19
20
|
export { parseAuthorizedKeys, STREAM_ANNOUNCE };
|
|
@@ -30,6 +31,11 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
|
|
|
30
31
|
* @param {{ tools?: object[] | object, client?: object, authorize?: Function, maxConcurrent?: number, timeoutMs?: number }} [options.mcp]
|
|
31
32
|
* MCP tools served over `McpDiscover` / `McpCall`: `tools` (`{ name, description, inputSchema, call(args, { user, signal }) }`),
|
|
32
33
|
* and/or `client` (an `@modelcontextprotocol/sdk` Client to proxy). Off unless given.
|
|
34
|
+
* @param {true | { detachTtlMs?: number, maxDetached?: number, ringBytes?: number, sessionSecret?: string | Uint8Array }} [options.sessions]
|
|
35
|
+
* Keep pty/exec sessions alive across disconnects so a client can `resumeSession()` / `attachSession()`
|
|
36
|
+
* them: a per-server registry, a ring buffer of the newest `ringBytes` (default 1 MiB) of output, and
|
|
37
|
+
* `detachTtlMs` (default 300000; 0 = kill on disconnect) / `maxDetached` (default 16) bounds. Off unless given.
|
|
38
|
+
* @param {string | Uint8Array} [options.sessionSecret] - Alias for `sessions.sessionSecret`.
|
|
33
39
|
* @param {true | { file: string } | CryptoKeyPair} [options.hostKey] - The server's own Ed25519
|
|
34
40
|
* identity, advertised (with proof of possession) so clients can pin it. See `src/host-key.mjs`.
|
|
35
41
|
* @param {object} [options.auth.rateLimit] - Password-failure throttle: `{ maxFailures=5, windowMs=60000,
|
|
@@ -39,7 +45,7 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
|
|
|
39
45
|
* @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
|
|
40
46
|
*/
|
|
41
47
|
export function createWshServer({
|
|
42
|
-
host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, bindTimeoutMs = 3000, onLog = () => {},
|
|
48
|
+
host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, bindTimeoutMs = 3000, onLog = () => {},
|
|
43
49
|
} = {}) {
|
|
44
50
|
let wss = null;
|
|
45
51
|
let bound = null;
|
|
@@ -63,6 +69,8 @@ export function createWshServer({
|
|
|
63
69
|
failureDelayMs: rl.failureDelayMs ?? 250,
|
|
64
70
|
};
|
|
65
71
|
let host_ = null;
|
|
72
|
+
let registry = null;
|
|
73
|
+
const sessionOptions = sessions ? (sessions === true ? {} : sessions) : null;
|
|
66
74
|
|
|
67
75
|
return {
|
|
68
76
|
async listen() {
|
|
@@ -75,9 +83,19 @@ export function createWshServer({
|
|
|
75
83
|
}
|
|
76
84
|
const authorize = await buildAuthorizer(auth);
|
|
77
85
|
host_ = hostKey ? await loadHostKey(hostKey) : null;
|
|
86
|
+
if (sessionOptions) {
|
|
87
|
+
const secret = sessionOptions.sessionSecret ?? sessionSecret;
|
|
88
|
+
registry = new SessionRegistry({
|
|
89
|
+
secret: secret === undefined ? undefined : Buffer.from(secret),
|
|
90
|
+
detachTtlMs: sessionOptions.detachTtlMs,
|
|
91
|
+
maxDetached: sessionOptions.maxDetached,
|
|
92
|
+
ringBytes: sessionOptions.ringBytes,
|
|
93
|
+
log: onLog,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
78
96
|
const attach = createConnectionFactory({
|
|
79
97
|
authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
|
|
80
|
-
methods, hostKey: host_, rateLimit, mcp: mcpHost,
|
|
98
|
+
methods, hostKey: host_, rateLimit, mcp: mcpHost, sessions: registry,
|
|
81
99
|
});
|
|
82
100
|
|
|
83
101
|
wss = new WebSocketServer({ host, port });
|
|
@@ -109,6 +127,8 @@ export function createWshServer({
|
|
|
109
127
|
bound = null;
|
|
110
128
|
for (const client of server.clients) client.terminate();
|
|
111
129
|
await new Promise((resolve) => server.close(resolve));
|
|
130
|
+
registry?.closeAll();
|
|
131
|
+
registry = null;
|
|
112
132
|
},
|
|
113
133
|
|
|
114
134
|
address() {
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session registry for `@johnhenry/wsh/server`: what lets an exec/pty session
|
|
3
|
+
* outlive the connection that opened it (`createWshServer({ sessions })`).
|
|
4
|
+
*
|
|
5
|
+
* The registry is per server and holds a `HostedSession` for every pty/exec
|
|
6
|
+
* session. A connection's channel is an *attachment* to one: output is
|
|
7
|
+
* appended to the session's ring buffer and fanned out to every attachment,
|
|
8
|
+
* so when a socket drops the process keeps running and its newest output keeps
|
|
9
|
+
* accumulating until `detachTtlMs` elapses or a client resumes.
|
|
10
|
+
*
|
|
11
|
+
* `seq` -- the one design question the wire does not answer -- is defined as
|
|
12
|
+
* the cumulative count of output BYTES the host has produced for a session.
|
|
13
|
+
* A client counts what it has received (it sees every byte), passes that as
|
|
14
|
+
* `Resume.last_seq`, and the host replays `ring[last_seq - ringStart ..]`.
|
|
15
|
+
* See spec/wsh-v1.yaml (`Resume`, `AttachmentInfo`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_RING_BYTES = 1024 * 1024;
|
|
21
|
+
export const DEFAULT_DETACH_TTL_MS = 300_000;
|
|
22
|
+
export const DEFAULT_MAX_DETACHED = 16;
|
|
23
|
+
export const MAX_ATTACHMENTS = 16;
|
|
24
|
+
|
|
25
|
+
const NO_EXPIRY = Buffer.alloc(8, 0xff);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A bounded byte history addressed by absolute stream position.
|
|
29
|
+
* `start` is the seq of the oldest retained byte, `end` the seq one past the newest.
|
|
30
|
+
*/
|
|
31
|
+
export class ByteRing {
|
|
32
|
+
#chunks = [];
|
|
33
|
+
#size = 0;
|
|
34
|
+
start = 0;
|
|
35
|
+
end = 0;
|
|
36
|
+
|
|
37
|
+
constructor(maxBytes) {
|
|
38
|
+
this.max = maxBytes;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
push(bytes) {
|
|
42
|
+
if (!bytes.byteLength) return;
|
|
43
|
+
this.#chunks.push(Uint8Array.from(bytes));
|
|
44
|
+
this.#size += bytes.byteLength;
|
|
45
|
+
this.end += bytes.byteLength;
|
|
46
|
+
while (this.#size > this.max) {
|
|
47
|
+
const excess = this.#size - this.max;
|
|
48
|
+
const first = this.#chunks[0];
|
|
49
|
+
if (first.byteLength <= excess) {
|
|
50
|
+
this.#chunks.shift();
|
|
51
|
+
this.#size -= first.byteLength;
|
|
52
|
+
this.start += first.byteLength;
|
|
53
|
+
} else {
|
|
54
|
+
this.#chunks[0] = first.subarray(excess);
|
|
55
|
+
this.#size -= excess;
|
|
56
|
+
this.start += excess;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Bytes from absolute position `from` (clamped to `start`) to the end. */
|
|
62
|
+
read(from = this.start) {
|
|
63
|
+
const skip = Math.max(0, from - this.start);
|
|
64
|
+
const out = new Uint8Array(Math.max(0, this.#size - skip));
|
|
65
|
+
let at = 0;
|
|
66
|
+
let seen = 0;
|
|
67
|
+
for (const c of this.#chunks) {
|
|
68
|
+
if (seen + c.byteLength > skip) {
|
|
69
|
+
const part = c.subarray(Math.max(0, skip - seen));
|
|
70
|
+
out.set(part, at);
|
|
71
|
+
at += part.byteLength;
|
|
72
|
+
}
|
|
73
|
+
seen += c.byteLength;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mint a session token in the spec's format: `[8B expiry][32B HMAC-SHA256(secret, session_id || expiry)]`.
|
|
81
|
+
* A session's lifetime is governed by `detachTtlMs`, not by its token, so the expiry is the maximum.
|
|
82
|
+
*/
|
|
83
|
+
export function mintToken(secret, sessionId) {
|
|
84
|
+
const mac = createHmac('sha256', secret).update(sessionId).update(NO_EXPIRY).digest();
|
|
85
|
+
return new Uint8Array(Buffer.concat([NO_EXPIRY, mac]));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Constant-time verification of a token minted by `mintToken` for exactly this session id. */
|
|
89
|
+
export function verifyToken(secret, sessionId, token) {
|
|
90
|
+
if (!(token instanceof Uint8Array) || token.byteLength !== 40) return false;
|
|
91
|
+
const buf = Buffer.from(token.buffer, token.byteOffset, token.byteLength);
|
|
92
|
+
const expiry = buf.subarray(0, 8);
|
|
93
|
+
if (expiry.readBigUInt64BE() < BigInt(Math.floor(Date.now() / 1000))) return false;
|
|
94
|
+
const want = createHmac('sha256', secret).update(sessionId).update(expiry).digest();
|
|
95
|
+
return timingSafeEqual(want, buf.subarray(8));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One attached connection's view of a session; `connection.mjs` builds them.
|
|
100
|
+
* @typedef {object} Attachment
|
|
101
|
+
* @property {number} connId
|
|
102
|
+
* @property {number} channelId
|
|
103
|
+
* @property {'control' | 'readonly'} mode
|
|
104
|
+
* @property {string} username
|
|
105
|
+
* @property {string} principal - who this connection authenticated as (see `principalKey`)
|
|
106
|
+
* @property {(bytes: Uint8Array) => unknown} write - live output
|
|
107
|
+
* @property {(msg: object) => unknown} notify - an ordered control message (e.g. Presence)
|
|
108
|
+
* @property {(code: number) => unknown} exit - the session ended
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
export class HostedSession {
|
|
112
|
+
/** @type {Set<Attachment>} */
|
|
113
|
+
attachments = new Set();
|
|
114
|
+
state = 'attached';
|
|
115
|
+
exitCode = null;
|
|
116
|
+
createdAt = Date.now();
|
|
117
|
+
lastActivity = Date.now();
|
|
118
|
+
detachedAt = 0;
|
|
119
|
+
name;
|
|
120
|
+
#timer = null;
|
|
121
|
+
|
|
122
|
+
constructor(registry, { id, token, owner, kind, command, ringBytes }) {
|
|
123
|
+
this.registry = registry;
|
|
124
|
+
this.id = id;
|
|
125
|
+
this.token = token;
|
|
126
|
+
this.owner = owner;
|
|
127
|
+
this.kind = kind;
|
|
128
|
+
this.command = command;
|
|
129
|
+
this.ring = new ByteRing(ringBytes);
|
|
130
|
+
/** Set by the connection once the process exists: `{ input, inputEnd, resize, signal, kill }`. */
|
|
131
|
+
this.handlers = {};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Output from the process: retained in the ring, delivered to every attachment. */
|
|
135
|
+
push(bytes) {
|
|
136
|
+
this.lastActivity = Date.now();
|
|
137
|
+
this.ring.push(bytes);
|
|
138
|
+
for (const a of this.attachments) a.write(bytes);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
attach(att) {
|
|
142
|
+
this.attachments.add(att);
|
|
143
|
+
this.state = 'attached';
|
|
144
|
+
this.#clearTimer();
|
|
145
|
+
this.broadcastPresence(att);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
detach(att) {
|
|
149
|
+
if (!this.attachments.delete(att)) return;
|
|
150
|
+
this.broadcastPresence();
|
|
151
|
+
if (this.attachments.size === 0) this.#markDetached();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
roster() {
|
|
155
|
+
return [...this.attachments].map((a) => ({ session_id: this.id, mode: a.mode, username: a.username }));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Tell every attachment (except `except`) who is attached now. */
|
|
159
|
+
broadcastPresence(except) {
|
|
160
|
+
if (this.attachments.size === 0) return;
|
|
161
|
+
const msg = { attachments: this.roster() };
|
|
162
|
+
for (const a of this.attachments) if (a !== except) a.notify(msg);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The process ended. Everyone attached hears Exit + Close; the record lingers for late resumes. */
|
|
166
|
+
finish(code) {
|
|
167
|
+
if (this.state === 'exited') return;
|
|
168
|
+
this.exitCode = code;
|
|
169
|
+
this.state = 'exited';
|
|
170
|
+
const att = [...this.attachments];
|
|
171
|
+
this.attachments.clear();
|
|
172
|
+
for (const a of att) a.exit(code);
|
|
173
|
+
this.#markDetached();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
kill() {
|
|
177
|
+
try { this.handlers.kill?.(); } catch { /* already gone */ }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Forget the session (and stop it if it is still running). */
|
|
181
|
+
dispose() {
|
|
182
|
+
this.#clearTimer();
|
|
183
|
+
this.registry._forget(this);
|
|
184
|
+
if (this.state !== 'exited') {
|
|
185
|
+
this.kill();
|
|
186
|
+
this.state = 'exited';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#markDetached() {
|
|
191
|
+
this.detachedAt = Date.now();
|
|
192
|
+
if (this.state !== 'exited') this.state = 'detached';
|
|
193
|
+
this.#clearTimer();
|
|
194
|
+
const ttl = this.registry.detachTtlMs;
|
|
195
|
+
if (ttl <= 0) { this.dispose(); return; }
|
|
196
|
+
this.#timer = setTimeout(() => {
|
|
197
|
+
this.registry.log(`session ${this.id} expired after ${ttl}ms detached`);
|
|
198
|
+
this.dispose();
|
|
199
|
+
}, ttl);
|
|
200
|
+
this.#timer.unref?.();
|
|
201
|
+
this.registry._enforceMaxDetached(this);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#clearTimer() {
|
|
205
|
+
clearTimeout(this.#timer);
|
|
206
|
+
this.#timer = null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export class SessionRegistry {
|
|
211
|
+
/** @type {Map<string, HostedSession>} */
|
|
212
|
+
#sessions = new Map();
|
|
213
|
+
|
|
214
|
+
constructor({ secret, detachTtlMs = DEFAULT_DETACH_TTL_MS, maxDetached = DEFAULT_MAX_DETACHED, ringBytes = DEFAULT_RING_BYTES, log = () => {} } = {}) {
|
|
215
|
+
this.secret = secret ?? randomBytes(32);
|
|
216
|
+
this.detachTtlMs = detachTtlMs;
|
|
217
|
+
this.maxDetached = maxDetached;
|
|
218
|
+
this.ringBytes = ringBytes;
|
|
219
|
+
this.log = log;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @param {{ owner: { username: string, principal: string, fingerprint: string | null }, kind: 'pty' | 'exec', command?: string }} spec */
|
|
223
|
+
create({ owner, kind, command }) {
|
|
224
|
+
const id = randomUUID();
|
|
225
|
+
const s = new HostedSession(this, { id, token: mintToken(this.secret, id), owner, kind, command, ringBytes: this.ringBytes });
|
|
226
|
+
this.#sessions.set(id, s);
|
|
227
|
+
return s;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
get(id) {
|
|
231
|
+
return typeof id === 'string' ? this.#sessions.get(id) : undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
checkToken(session, token) {
|
|
235
|
+
return verifyToken(this.secret, session.id, token);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Sessions this principal owns. */
|
|
239
|
+
listFor(principal) {
|
|
240
|
+
return [...this.#sessions.values()].filter((s) => s.owner.principal === principal);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
get size() { return this.#sessions.size; }
|
|
244
|
+
|
|
245
|
+
/** Kill everything (server shutdown). */
|
|
246
|
+
closeAll() {
|
|
247
|
+
for (const s of [...this.#sessions.values()]) s.dispose();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_forget(session) {
|
|
251
|
+
this.#sessions.delete(session.id);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Keep at most `maxDetached` unattended sessions: the longest-detached are killed first. */
|
|
255
|
+
_enforceMaxDetached() {
|
|
256
|
+
const detached = [...this.#sessions.values()].filter((s) => s.attachments.size === 0).sort((a, b) => a.detachedAt - b.detachedAt);
|
|
257
|
+
while (detached.length > this.maxDetached) {
|
|
258
|
+
const victim = detached.shift();
|
|
259
|
+
this.log(`session ${victim.id} evicted (more than ${this.maxDetached} detached)`);
|
|
260
|
+
victim.dispose();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
package/src/session.mjs
CHANGED
|
@@ -76,6 +76,12 @@ export class WshSession {
|
|
|
76
76
|
*/
|
|
77
77
|
#resumeToken;
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Cumulative session OUTPUT BYTES received so far (see `seq`).
|
|
81
|
+
* @type {number}
|
|
82
|
+
*/
|
|
83
|
+
#seq = 0;
|
|
84
|
+
|
|
79
85
|
/**
|
|
80
86
|
* Stream IDs returned by the server in OPEN_OK.
|
|
81
87
|
* Typically { stdin: number, stdout: number } or a single bidirectional ID.
|
|
@@ -270,6 +276,27 @@ export class WshSession {
|
|
|
270
276
|
return this.#resumeToken;
|
|
271
277
|
}
|
|
272
278
|
|
|
279
|
+
/**
|
|
280
|
+
* How many bytes of this session's output this client has received: the
|
|
281
|
+
* host's `seq` for the next byte. Pass it as `lastSeq` to
|
|
282
|
+
* `WshClient.resumeSession()` so a resume replays only what was missed. For a
|
|
283
|
+
* session obtained from `attachSession()`/`resumeSession()` it starts at the
|
|
284
|
+
* host's position of the first replayed byte, so it is an absolute position
|
|
285
|
+
* either way. Counts bytes delivered to `onData`.
|
|
286
|
+
* @returns {number}
|
|
287
|
+
*/
|
|
288
|
+
get seq() {
|
|
289
|
+
return this.#seq;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Set the starting position of a session obtained by attach/resume.
|
|
294
|
+
* @internal
|
|
295
|
+
*/
|
|
296
|
+
_setSeq(n) {
|
|
297
|
+
this.#seq = n;
|
|
298
|
+
}
|
|
299
|
+
|
|
273
300
|
/** Current session state. */
|
|
274
301
|
get state() {
|
|
275
302
|
return this.#state;
|
|
@@ -657,6 +684,7 @@ export class WshSession {
|
|
|
657
684
|
if (msg.data && msg.data.byteLength > 0) {
|
|
658
685
|
this.#virtualBackend?.pushData(msg.data);
|
|
659
686
|
try {
|
|
687
|
+
this.#seq += msg.data.byteLength;
|
|
660
688
|
this.onData?.(msg.data);
|
|
661
689
|
} catch (err) {
|
|
662
690
|
console.error('[wsh:session] onData handler error:', err);
|
|
@@ -688,6 +716,7 @@ export class WshSession {
|
|
|
688
716
|
.then((plaintext) => {
|
|
689
717
|
this.#virtualBackend?.pushData(plaintext);
|
|
690
718
|
try {
|
|
719
|
+
this.#seq += plaintext.byteLength;
|
|
691
720
|
this.onData?.(plaintext);
|
|
692
721
|
} catch (err) {
|
|
693
722
|
console.error('[wsh:session] onData handler error:', err);
|
|
@@ -898,6 +927,7 @@ export class WshSession {
|
|
|
898
927
|
}
|
|
899
928
|
for (const plaintext of deliverable) {
|
|
900
929
|
try {
|
|
930
|
+
this.#seq += plaintext.byteLength;
|
|
901
931
|
this.onData?.(plaintext);
|
|
902
932
|
} catch (err) {
|
|
903
933
|
console.error('[wsh:session] onData handler error:', err);
|
|
@@ -905,6 +935,7 @@ export class WshSession {
|
|
|
905
935
|
}
|
|
906
936
|
} else {
|
|
907
937
|
try {
|
|
938
|
+
this.#seq += value.byteLength;
|
|
908
939
|
this.onData?.(value);
|
|
909
940
|
} catch (err) {
|
|
910
941
|
console.error('[wsh:session] onData handler error:', err);
|