@johnhenry/wsh 0.19.1 → 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 CHANGED
@@ -186,7 +186,139 @@ 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, attach/resume of server-side sessions, MCP.
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.
262
+
263
+ ### MCP tools
264
+
265
+ `createWshServer({ mcp })` answers `McpDiscover` and `McpCall`, so the stock
266
+ `client.discoverTools()` / `client.callTool()` and `WshMcpBridge` work against
267
+ it. Off unless you pass it.
268
+
269
+ ```js
270
+ const server = createWshServer({
271
+ auth,
272
+ mcp: {
273
+ tools: [{
274
+ name: 'read_note',
275
+ description: 'Read a note by id',
276
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false },
277
+ call: async ({ id }, { user, signal }) => ({ success: true, output: await readNote(id, { signal }) }),
278
+ }],
279
+ authorize: (user, tool) => tool.name !== 'admin_only' || user === 'root', // optional
280
+ // client: mcpSdkClient, // optional: proxy an @modelcontextprotocol/sdk Client's tools too
281
+ // maxConcurrent: 8, timeoutMs: 30_000,
282
+ },
283
+ });
284
+ ```
285
+
286
+ - **Tool shape:** `tools` is an array of `{ name, description, inputSchema,
287
+ call(args, { user, fingerprint, signal }) }` or a `{ name: { ... } }` map.
288
+ `inputSchema` is advertised to clients as `parameters` (the `McpToolSpec`
289
+ field).
290
+ - **Arguments are untrusted.** They are validated against `inputSchema` before
291
+ `call()` runs; invalid arguments never reach the tool. The package has no
292
+ runtime dependencies, so the validator covers the keywords tool schemas use
293
+ (`type`, `enum`, `const`, `properties`, `required`, `additionalProperties`,
294
+ `items`, length/range/`pattern`/`multipleOf` bounds, `allOf`/`anyOf`/`oneOf`/
295
+ `not`, local `$ref`); a schema using any other keyword (`if`,
296
+ `patternProperties`, ...) is **refused when the server is created** rather than
297
+ silently enforced less strictly. Only tools you list are reachable.
298
+ - **Results:** whatever `call()` returns is `McpResult.result` verbatim (the
299
+ stock `WshMcpBridge` normalizes the `{ success, output, error }` shape, and
300
+ wraps anything else as `{ success: true, output }`). A tool that throws, an
301
+ unknown tool, invalid arguments, a timeout or the concurrency cap all answer
302
+ `{ success: false, error }` -- the error text is the thrown `message`, so
303
+ throw messages you are happy for the caller to read.
304
+ - **Correlation:** every `McpResult` echoes the call's `call_id`, and the
305
+ server advertises `mcp-call-id` (when `mcp` is configured), so concurrent
306
+ calls from one client get their own results, and a slow tool does not hold up
307
+ the calls behind it.
308
+ - **Limits:** `maxConcurrent` in-flight calls per connection (default 8) and a
309
+ `timeoutMs` per call (default 30000). On timeout or disconnect the tool's
310
+ `signal` aborts; a tool that ignores it keeps running but its reply is dropped.
311
+ - **Access:** discovery and calls are answered after authentication only.
312
+ `authorize(user, tool, { username, fingerprint })` filters both, and a tool
313
+ hidden from a caller is indistinguishable from one that does not exist.
314
+ - **Proxying:** `client` takes anything with `listTools()` / `callTool()` (an
315
+ `@modelcontextprotocol/sdk` `Client`; this package does not import the SDK).
316
+ Its tools are re-listed on every discovery, validated against their own
317
+ `inputSchema`s here, and `isError` results become `{ success: false, error }`,
318
+ else the result is `structuredContent` or the `content` array. A proxied tool
319
+ whose schema uses an unsupported keyword is not exposed.
320
+ - Without `mcp`, `McpDiscover` answers an empty list and `McpCall` an
321
+ `{ success: false, error: 'mcp is not enabled on this server' }` result.
190
322
 
191
323
  ### Host key (fingerprint / TOFU)
192
324
 
@@ -301,8 +433,9 @@ Opening a PTY/exec session returns a session-scoped credential alongside the cha
301
433
  session.sessionId; // server-assigned session id (undefined for e.g. file channels)
302
434
  session.resumeToken; // token minted at open time; only the opener receives it
303
435
 
304
- // The original opener, reclaiming its session from a fresh connection:
305
- await client.resumeSession(session.sessionId, session.resumeToken);
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 });
306
439
 
307
440
  // Any other authorized principal attaches without a token -- ownership
308
441
  // or an ACL grant is enough:
@@ -314,6 +447,11 @@ await client.detach(session.sessionId); // leave it running server-side
314
447
  await client.listRemoteSessions(); // sessions this key can see
315
448
  ```
316
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
+
317
455
  ## Pinning a Self-Signed Certificate
318
456
 
319
457
  Both transports normally need a certificate a public certificate authority
@@ -456,6 +594,7 @@ than left to be found.
456
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 |
457
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" |
458
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 |
459
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 |
460
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 |
461
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.19.1",
3
+ "version": "0.21.0",
4
4
  "description": "Web Shell \u2014 browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
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
- await this.#transport.sendControl(
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
- const response = await this.#waitForMessage(
757
- [MSG.PRESENCE, MSG.ERROR],
758
- timeout,
759
- 'Timed out waiting for attach response'
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
- if (response.type === MSG.ERROR) {
763
- throw new Error(`Failed to attach: ${response.message || 'rejected'}`);
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
- return response;
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] - Last sequence number this client already has (currently advisory -- the ring buffer replays its full contents regardless)
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
- await this.#transport.sendControl(
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
- /** Last sequence number this client already has (currently advisory). */
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<WshMessage>;
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<WshMessage>;
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.