@johnhenry/wsh 0.20.0 → 0.22.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 +161 -5
- package/package.json +1 -1
- package/src/client.mjs +76 -28
- package/src/index.d.ts +23 -3
- package/src/server/backends.mjs +32 -0
- package/src/server/connection.mjs +302 -39
- package/src/server/index.d.ts +95 -0
- package/src/server/index.mjs +40 -17
- package/src/server/relay.mjs +281 -0
- package/src/server/reverse-host.mjs +147 -0
- package/src/server/sessions.mjs +263 -0
- package/src/session.mjs +31 -0
package/README.md
CHANGED
|
@@ -185,8 +185,157 @@ restricted host with no shell. `fs.root` confines every path: `..`, absolute
|
|
|
185
185
|
paths and symlinks pointing out of it are refused -- for `write` and `rename`
|
|
186
186
|
(both paths) exactly as for `read`.
|
|
187
187
|
|
|
188
|
-
Not implemented (
|
|
189
|
-
|
|
188
|
+
Not implemented (tracking issue): WebTransport.
|
|
189
|
+
|
|
190
|
+
### Relay / reverse mode
|
|
191
|
+
|
|
192
|
+
A host that cannot accept connections (behind NAT, or a browser tab) dials *out*
|
|
193
|
+
and registers; an operator reaches it through a relay. `@johnhenry/wsh/server`
|
|
194
|
+
is both ends:
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
// The relay -- anyone who can reach it and authenticate; nothing is permitted by default.
|
|
198
|
+
createWshServer({
|
|
199
|
+
auth: { authorizedKeys },
|
|
200
|
+
relay: {
|
|
201
|
+
canRegister: (who, record) => who.username === 'build-box', // who may be a peer
|
|
202
|
+
canConnect: (from, to) => from.username === 'alice', // who may list and reach which peer
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// The peer, on the machine behind the NAT: same backends as createWshServer.
|
|
207
|
+
const host = createReverseHost({
|
|
208
|
+
url: 'wss://relay.example/', username: 'build-box', keyPair,
|
|
209
|
+
exec: true, fs: { root: '/srv/share' }, mcp: { tools },
|
|
210
|
+
accept: ({ fingerprint, username }) => fingerprint === aliceFingerprint, // default: nobody
|
|
211
|
+
});
|
|
212
|
+
await host.start(); // host.fingerprint is what operators connect to
|
|
213
|
+
|
|
214
|
+
// The operator: stock client.
|
|
215
|
+
const peers = await client.listPeers(); // each entry's signed record verified client-side
|
|
216
|
+
await client.reverseConnect(peers[0].fingerprint); // ReverseAccept trusts the peer for RelayForward
|
|
217
|
+
const s = await client.openSession({ type: 'exec', command: 'echo hello' });
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
- **Default deny, twice.** The relay admits no peer (`canRegister`) and lets no
|
|
221
|
+
one connect (`canConnect`) unless you say so, and the peer refuses every
|
|
222
|
+
operator unless `accept` says otherwise -- the client's `trustRelayPeer()` is
|
|
223
|
+
the third gate. A peer an operator may not connect to is not listed and is
|
|
224
|
+
indistinguishable from an absent one (`ReverseReject: no such peer`).
|
|
225
|
+
Relay roles need a **key** login (`from_fingerprint` is a key fingerprint);
|
|
226
|
+
password logins can still use the relay server as an ordinary host.
|
|
227
|
+
- **Peer table.** Keyed by fingerprint. `ReverseRegister` is checked against the
|
|
228
|
+
connection's own authenticated key (`public_key` must hash to it) and its
|
|
229
|
+
self-signed record (`buildPeerRecordTranscript` / `signPeerRecord`) is verified
|
|
230
|
+
here, not trusted; `ReversePeers` forwards the signed fields so operators verify
|
|
231
|
+
them again themselves. The record `seq` must increase per fingerprint -- and is
|
|
232
|
+
remembered across reconnects (the last 4096 fingerprints), so an old record
|
|
233
|
+
cannot regress a peer. Failures answer an `Error` (the stock client surfaces it
|
|
234
|
+
through `onError`); there is no acknowledgement of success on the wire, so poll
|
|
235
|
+
`listPeers()` (or `server.peerFingerprints()`) if you need to know it landed.
|
|
236
|
+
- **Bridge.** `ReverseConnect` is forwarded to the peer with `from_fingerprint`
|
|
237
|
+
(and `username`) set from the operator's authenticated login, whatever the
|
|
238
|
+
client wrote; the peer's `ReverseAccept`/`Reject` goes back, attributed to the
|
|
239
|
+
peer that sent it, and an accept pairs the two connections. From then on every
|
|
240
|
+
spec-`forwardable` message from either end is wrapped as `RelayForward {
|
|
241
|
+
from_fingerprint, inner }` with `from_fingerprint` the sender's authenticated
|
|
242
|
+
key. A `RelayForward` a client writes itself is unwrapped, its inner type
|
|
243
|
+
checked against the allowlist (a non-forwardable or undecodable inner is
|
|
244
|
+
dropped, logged), and re-wrapped with the real sender; traffic from a connection
|
|
245
|
+
that is not bridged is never forwarded.
|
|
246
|
+
- **One operator per peer at a time**; a second `ReverseConnect` is rejected as
|
|
247
|
+
`busy`, and one the peer does not answer within `connectTimeoutMs` (default
|
|
248
|
+
8 s) as `peer did not respond`. A bridge lasts as long as both connections:
|
|
249
|
+
when either ends, the relay closes the other, so nothing an operator started
|
|
250
|
+
outlives it (the reverse host kills its processes). `createReverseHost`
|
|
251
|
+
redials with backoff by default (`reconnect: false` to turn it off), so it is
|
|
252
|
+
registered again for the next operator.
|
|
253
|
+
- **On the peer**, the bridged operator is served by the same connection code as
|
|
254
|
+
a direct client, over an in-memory message pipe instead of a socket. Nothing a
|
|
255
|
+
client opens can cross the relay but control messages, so exec sessions are
|
|
256
|
+
`data_mode: 'virtual'` there (stdin and output ride `SessionData`; there is no
|
|
257
|
+
stdin EOF).
|
|
258
|
+
- **Not provided:** end-to-end encryption between operator and peer -- `KeyExchange` /
|
|
259
|
+
`EncryptedFrame` are not on the spec's `forwardable` list, so the relay sees the
|
|
260
|
+
traffic in clear (serve it over `wss://`, and treat the relay operator as trusted).
|
|
261
|
+
Client-side feature gates (`fileWrite`/`fileRename` need `file-write`/`file-rename`,
|
|
262
|
+
`callTool` correlates on `mcp-call-id`) read the **relay's** `ServerHello`, which
|
|
263
|
+
says nothing about the peer behind it: against a relay without `fs`, `fileWrite()`
|
|
264
|
+
refuses locally (reads, listing, stat and exec are unaffected). Multiplexing
|
|
265
|
+
several operators onto one peer needs a way to address replies, which `RelayForward`
|
|
266
|
+
does not have.
|
|
267
|
+
|
|
268
|
+
### Sessions: attach / resume / detach
|
|
269
|
+
|
|
270
|
+
By default a pty/exec session dies with the connection that opened it. Pass
|
|
271
|
+
`sessions` and it outlives it, so a dropped socket (a flaky link, a closed
|
|
272
|
+
tab) can be picked up again:
|
|
273
|
+
|
|
274
|
+
```js
|
|
275
|
+
createWshServer({
|
|
276
|
+
auth, exec: true, pty: { spawn: nodePty.spawn },
|
|
277
|
+
sessions: {
|
|
278
|
+
detachTtlMs: 300_000, // how long a session nobody is attached to keeps running (0 = kill at once)
|
|
279
|
+
maxDetached: 16, // unattended sessions kept at once; the longest-detached is killed beyond that
|
|
280
|
+
ringBytes: 1 << 20, // output history kept per session: what a resume can replay
|
|
281
|
+
// sessionSecret: process.env.WSH_SESSION_SECRET, // fixes the token key (default: random per process)
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
// original connection
|
|
288
|
+
const s = await client.openSession({ type: 'pty' });
|
|
289
|
+
s.onData = (d) => term.write(d); // s.seq counts the output bytes received so far
|
|
290
|
+
const { sessionId, resumeToken } = s; // keep these
|
|
291
|
+
const lastSeq = s.seq; // ... the socket drops ...
|
|
292
|
+
|
|
293
|
+
// fresh connection, same key
|
|
294
|
+
const { session } = await client2.resumeSession(sessionId, resumeToken, { lastSeq });
|
|
295
|
+
session.onData = (d) => term.write(d); // only the bytes after lastSeq, then live output
|
|
296
|
+
await session.write('ls\n'); // and it is the same process
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
- **`seq` is the cumulative count of output bytes** the host has produced for
|
|
300
|
+
a session (stream data or `SessionData` alike). The client counts what it
|
|
301
|
+
received -- `WshSession.seq` -- so no per-frame field exists, and
|
|
302
|
+
`Resume.last_seq` is exactly that number. The host keeps the newest
|
|
303
|
+
`ringBytes` of output and replays `ring[last_seq - ringStart ..]`. A
|
|
304
|
+
`last_seq` older than the ring still holds is refused with an
|
|
305
|
+
`output gap` error naming where the history starts (fall back to
|
|
306
|
+
`attachSession()` for the retained tail); one newer than the session has
|
|
307
|
+
produced is refused too. While nobody is attached the process keeps running
|
|
308
|
+
and keeps filling the ring (it is never paused), so a chatty process loses its
|
|
309
|
+
oldest output rather than blocking.
|
|
310
|
+
- **Token:** `HMAC-SHA256(secret, session_id || expiry)` in the spec's
|
|
311
|
+
40-byte token format, minted at open, returned as `OpenOk.token` and checked
|
|
312
|
+
in constant time. **`Resume` needs the token AND ownership** (the same key, or
|
|
313
|
+
for a password login the same username) -- it is the credentialed connection
|
|
314
|
+
coming back. **`Attach` accepts the token OR ownership**, so the owner can
|
|
315
|
+
re-attach with nothing but the session id, and a token holder can join as a
|
|
316
|
+
guest. Refusals do not say whether the session exists.
|
|
317
|
+
- **What arrives:** the Presence reply names a new channel (`channel_id`) and the
|
|
318
|
+
`seq` of its first byte; the replay and live output follow as `SessionData` on
|
|
319
|
+
it. That channel is always message-backed, even for an `exec` session that was
|
|
320
|
+
opened on a data stream -- neither stock client opens a stream for attach/resume
|
|
321
|
+
-- so `resumeSession()`/`attachSession()` return the Presence with a
|
|
322
|
+
non-enumerable `session` (a `WshSession`) to read and write it. A client that
|
|
323
|
+
ignores that can read the same frames with `addControlListener()`.
|
|
324
|
+
- **Several connections** may attach to one session (up to 16): output fans out
|
|
325
|
+
to all of them, `attachSession(id, { readOnly: true })` attaches a viewer whose
|
|
326
|
+
input is dropped, and every change in who is attached is broadcast as `Presence`
|
|
327
|
+
(the roster, without channel ids).
|
|
328
|
+
- **Ending vs leaving:** `Close` on a channel (which `session.close()` and a
|
|
329
|
+
graceful `client.disconnect()` send) *ends* the session when its owner sends it,
|
|
330
|
+
and merely leaves it for anyone else. To walk away from a session and keep it
|
|
331
|
+
running, `client.detach(sessionId)` first; losing the connection also detaches.
|
|
332
|
+
`client.listRemoteSessions()` lists the sessions your key owns, attached or not.
|
|
333
|
+
- Exit: when the process ends, everyone attached gets `Exit` + `Close`; the
|
|
334
|
+
record then lingers for `detachTtlMs` so a late resume still collects the tail
|
|
335
|
+
and the exit code.
|
|
336
|
+
- Not built: ACL grants (`SessionGrant`/`SessionRevoke`) and `ControlChanged` --
|
|
337
|
+
only the owner re-attaches as a controller; everyone else needs the token.
|
|
338
|
+
Sessions are in-memory: a server restart ends them all.
|
|
190
339
|
|
|
191
340
|
### MCP tools
|
|
192
341
|
|
|
@@ -361,8 +510,9 @@ Opening a PTY/exec session returns a session-scoped credential alongside the cha
|
|
|
361
510
|
session.sessionId; // server-assigned session id (undefined for e.g. file channels)
|
|
362
511
|
session.resumeToken; // token minted at open time; only the opener receives it
|
|
363
512
|
|
|
364
|
-
// The original opener, reclaiming its session from a fresh connection
|
|
365
|
-
|
|
513
|
+
// The original opener, reclaiming its session from a fresh connection,
|
|
514
|
+
// replaying only the output it has not yet seen (`session.seq` counts it):
|
|
515
|
+
await client.resumeSession(session.sessionId, session.resumeToken, { lastSeq });
|
|
366
516
|
|
|
367
517
|
// Any other authorized principal attaches without a token -- ownership
|
|
368
518
|
// or an ACL grant is enough:
|
|
@@ -374,6 +524,11 @@ await client.detach(session.sessionId); // leave it running server-side
|
|
|
374
524
|
await client.listRemoteSessions(); // sessions this key can see
|
|
375
525
|
```
|
|
376
526
|
|
|
527
|
+
`@johnhenry/wsh/server` implements all of it with its [`sessions`
|
|
528
|
+
option](#sessions-attach--resume--detach). The Rust `wsh-server` replays its whole
|
|
529
|
+
ring on `Resume` regardless of `last_seq` (the position is defined in
|
|
530
|
+
[spec/wsh-v1.md](spec/wsh-v1.md)).
|
|
531
|
+
|
|
377
532
|
## Pinning a Self-Signed Certificate
|
|
378
533
|
|
|
379
534
|
Both transports normally need a certificate a public certificate authority
|
|
@@ -516,7 +671,8 @@ than left to be found.
|
|
|
516
671
|
| 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
672
|
| 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
673
|
| 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 |
|
|
519
|
-
|
|
|
674
|
+
| 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 |
|
|
675
|
+
| Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()`; `@johnhenry/wsh/server`: `relay` option and `createReverseHost()` | `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. The Node relay is default-deny and bridges one operator per peer |
|
|
520
676
|
| 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
677
|
| 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 |
|
|
522
678
|
| Self-signed cert pinning | `serverCertificateHashes` (WebTransport option) | N/A -- Rust dials a cert its own TLS stack already trusts, or `--generate-cert`'s self-signed cert out of band | **Deliberately not unified** -- this is a browser-specific WebTransport API shape, not a wire-protocol concept |
|
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.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a host can do for the client on the other end: shared by
|
|
3
|
+
* `createWshServer` (clients that connect to it) and `createReverseHost`
|
|
4
|
+
* (operators bridged to it by a relay), so the two cannot drift.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawnRunner } from './exec.mjs';
|
|
8
|
+
import { normalizePty } from './pty.mjs';
|
|
9
|
+
import { createFileAccess } from './fs.mjs';
|
|
10
|
+
import { createMcpHost } from './mcp.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{ exec?: true | object | Function, pty?: object, fs?: object, mcp?: object }} options
|
|
14
|
+
* @returns {{ execRunner: Function | null, execOptions: object, pty: object | null, files: object | null, mcp: object | null }}
|
|
15
|
+
*/
|
|
16
|
+
export function buildBackends({ exec, pty, fs, mcp } = {}) {
|
|
17
|
+
let execRunner = null;
|
|
18
|
+
let execOptions = {};
|
|
19
|
+
if (typeof exec === 'function') {
|
|
20
|
+
execRunner = exec;
|
|
21
|
+
} else if (exec) {
|
|
22
|
+
execOptions = exec === true ? {} : exec;
|
|
23
|
+
execRunner = typeof execOptions.run === 'function' ? execOptions.run : spawnRunner(execOptions);
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
execRunner,
|
|
27
|
+
execOptions,
|
|
28
|
+
pty: pty ? normalizePty(pty) : null,
|
|
29
|
+
files: fs ? createFileAccess(fs) : null,
|
|
30
|
+
mcp: mcp ? createMcpHost(mcp) : null,
|
|
31
|
+
};
|
|
32
|
+
}
|