@johnhenry/wsh 0.24.0 → 0.25.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
@@ -26,6 +26,7 @@ wsh is a pure-JS client library (with an optional Node server at `@johnhenry/wsh
26
26
  - [Quick Start](#quick-start)
27
27
  - [One-Shot Command Execution](#one-shot-command-execution)
28
28
  - [Node Server](#node-server)
29
+ - [RPC channels (object mode)](#rpc-channels-object-mode)
29
30
  - [Attach and Resume](#attach-and-resume)
30
31
  - [Pinning a Self-Signed Certificate](#pinning-a-self-signed-certificate)
31
32
  - [API Overview](#api-overview)
@@ -545,6 +546,109 @@ the host to find it:
545
546
  | A client that primes anyway (older stock client, `primer` forced, other implementations) | `@johnhenry/wsh/server` | **The leading `0x00` is dropped:** if the first data on an exec stream is exactly one `0x00` byte it is discarded, never forwarded to the process's stdin. Only that first chunk is inspected; a later `0x00`, or a first chunk that is longer than one byte, is delivered untouched. The cost: a client whose genuine first stdin chunk is a lone NUL loses it. |
546
547
  | A client that neither announces nor primes (a third-party QMux client; the stock client always does one or the other) | `@johnhenry/wsh/server` | The host waits `bindTimeoutMs` (default 3000) for the stream, then drops the output and logs it. This cannot be fixed host-side: nothing about the stream has been sent, so there is nothing to bind. Such a client must send `QMuxStream.announce()` (an empty STREAM frame) or any first write -- a zero-length `write()` now does the same. |
547
548
 
549
+ ## RPC channels (object mode)
550
+
551
+ An `rpc` channel is an ordinary QMux stream whose payload is a sequence of
552
+ **objects**, not bytes -- wsh's counterpart of an SSH *subsystem*: SSH channels
553
+ are byte streams, SFTP is a typed, id-correlated protocol layered inside one.
554
+ Each message is one CBOR data item (a [CBOR sequence](https://www.rfc-editor.org/rfc/rfc8742),
555
+ self-delimiting, no length prefix) shaped as JSON-RPC 2.0, so MCP maps 1:1 and
556
+ binary values are native byte strings (`Uint8Array`). Stream-announce,
557
+ backpressure and close/reset apply unchanged; the control plane, the Rust
558
+ codegen and exec/pty byte streams do not change.
559
+
560
+ ```js
561
+ // client
562
+ const rpc = await client.openRpc('mcp', { timeoutMs: 30_000 }); // sugar over openSession({ type: 'rpc', protocol })
563
+ const { tools } = await rpc.request('tools/list', {}); // rejects with RpcError
564
+ rpc.notify('notifications/initialized');
565
+ rpc.onRequest('sampling/createMessage', async (params, ctx) => ({ /* server -> client request */ }));
566
+ const call = rpc.request('read', { path: 'big.bin' }, { onProgress: (chunk) => {} }); // $/progress chunks
567
+ call.cancel(); // $/cancel; rejects -32001 now, late answer dropped
568
+ rpc.onProgress(call.id, (chunk) => {}); // or attach to a request already in flight
569
+ await rpc.close();
570
+
571
+ // server
572
+ import { createWshServer, mcpServerAdapter } from '@johnhenry/wsh/server';
573
+ createWshServer({
574
+ auth, fs: { root: '/srv/files' },
575
+ rpc: {
576
+ 'wsh-host': true, // host.info, host.ping
577
+ 'wsh-fs': true, // the server's `fs`; or { root, readOnly, maxFileBytes }
578
+ mcp: mcpServerAdapter(mcpServer), // any @modelcontextprotocol/sdk Server (or a factory for one per channel)
579
+ custom: (channel, ctx) => { // anything else: a function run per opened channel
580
+ channel.onRequest('echo', (params, { signal, progress }) => params);
581
+ },
582
+ },
583
+ });
584
+ ```
585
+
586
+ **Negotiation.** The host advertises `rpc`, one `rpc-protocol:<name>` per
587
+ protocol and `rpc-max-message:<bytes>` (default 1 MiB) in `ServerHello.features`.
588
+ `openRpc()` / `openSession({ type: 'rpc', protocol })` refuse an unadvertised
589
+ `rpc` or protocol with an `RpcError` (`code` -32000, `reason: 'UNSUPPORTED_PROTOCOL'`)
590
+ **before sending any bytes**. `Open.command` carries the protocol name (the
591
+ `Open` message has no other free field; `kind` is `'rpc'`).
592
+
593
+ **Semantics.** `id` is unique per direction per channel and both sides may
594
+ request. Responses are matched by `id` only (the class of bug behind #72).
595
+ `$/cancel { id }` asks the callee to stop: it answers `-32001` at once, its
596
+ `ctx.signal` aborts, and the caller drops any late result. `$/progress
597
+ { id, chunk }` notifications may precede the final response (file reads, logs).
598
+ Closing the stream rejects every pending request with `-32001` / `reason:
599
+ 'channel-closed'`. Error codes: JSON-RPC's `-32700..-32603`, plus `-32000`
600
+ unsupported protocol, `-32001` cancelled / timed out / closed, `-32002` too many
601
+ requests in flight (`rpcMaxInflight`, default 64), `-32003` unauthorized.
602
+ A message over the negotiated maximum is refused locally when sending and
603
+ closes the channel when received.
604
+
605
+ **Built-in protocols**
606
+
607
+ | Protocol | Methods |
608
+ |---|---|
609
+ | `wsh-host` | `host.info` -> `{ version, protocol, features (this connection's ServerHello), hostFingerprint, user, rpc }`; `host.ping` -> `{ time }` |
610
+ | `wsh-fs` | `stat`, `list`, `mkdir`, `remove`, `rename { path, newPath }`, `write { path, data, offset? }`, `upload { path, data, offset? }` (offset omitted/0 creates or truncates, later offsets continue in place: chunk big files), `read { path, offset?, length? }` and `download { path }` (the bytes arrive as `$/progress` chunks of at most 64 KiB; the result is `{ size, offset, length, eof }`). Same confinement as `fs`: no traversal or symlink escapes, `readOnly`, `maxFileBytes`; policy refusals are `-32003`. |
611
+ | `mcp` | The MCP JSON-RPC surface verbatim; see below |
612
+
613
+ When a host advertises `rpc-protocol:wsh-fs`, `fileStat` / `fileList` / `fileRead` /
614
+ `fileWrite` / `fileRename` / `fileMkdir` / `fileRemove` use it (results keep the
615
+ `FileResult` shape; `client.preferRpcFiles = false` opts out). The control-plane
616
+ `FileOp`/`FileResult` path stays for hosts that do not advertise it.
617
+
618
+ **MCP.** `mcpServerAdapter(server)` implements the official SDK's `Transport`
619
+ interface over the channel, so any `Server` can be exposed (pass a factory
620
+ `(ctx) => new Server(...)` for one per channel; an instance serves one channel
621
+ at a time). On the other end, `mcpClientTransport(channel)` is a `Transport` for
622
+ the SDK's `Client`:
623
+
624
+ ```js
625
+ const channel = await client.openRpc('mcp');
626
+ const mcp = new Client({ name: 'me', version: '1' }, { capabilities: {} });
627
+ await mcp.connect(mcpClientTransport(channel));
628
+ await mcp.callTool({ name: 'echo', arguments: { text: 'hi' } });
629
+ ```
630
+
631
+ MCP is already JSON-RPC, so the adapter is a pass-through: request ids,
632
+ results, errors and notifications (including `notifications/progress`) are
633
+ forwarded verbatim. The one translation is cancellation: an MCP client abort
634
+ (`notifications/cancelled`) becomes `$/cancel` on the wire, and an incoming
635
+ `$/cancel` is delivered to the SDK as `notifications/cancelled`, so the tool
636
+ handler's `signal` fires. The SDK is **not** imported by this package (the
637
+ transport is structural); `@modelcontextprotocol/sdk` is an optional peer for
638
+ whoever supplies the `Server`/`Client`, and the package root stays
639
+ dependency-free and browser-safe.
640
+
641
+ **Compatibility.** Purely additive. Old clients never open `rpc` sessions; an old
642
+ host (or the Rust `wsh-server`, which has no `rpc` kind yet) does not advertise
643
+ `rpc`, so a new client fails fast with `UNSUPPORTED_PROTOCOL` instead of waiting
644
+ on an `OpenFail`. Only `@johnhenry/wsh/server` serves `rpc` today. Not goals: a
645
+ replacement for byte streams (exec/pty are unchanged), a new transport (`rpc`
646
+ rides WebSocket/QMux and WebTransport streams), or an auth change -- an `rpc`
647
+ session is opened on an authenticated connection and per-method authorization is
648
+ the handler's job (`-32003`; `ctx.user` / `ctx.fingerprint` identify the caller).
649
+ Handlers run per channel, so register methods synchronously (or before the
650
+ returned promise settles: inbound messages are held until it does).
651
+
548
652
  ## Attach and Resume
549
653
 
550
654
  Opening a PTY/exec session returns a session-scoped credential alongside the channel:
@@ -652,6 +756,8 @@ so options the platform gains later need no change here.
652
756
  | `WshKeyStore` | Ed25519 key management via IndexedDB + OPFS encrypted backup |
653
757
  | `WshFileTransfer` | File upload/download over dedicated streams |
654
758
  | `WshMcpBridge` | Remote MCP tool discovery and invocation |
759
+ | `RpcChannel` / `RpcError` | Typed RPC channel (JSON-RPC 2.0 over a CBOR sequence): `client.openRpc(protocol)`; `RPC_FEATURE`, `rpcProtocolFeature(name)`, `RPC_ERROR`, `CborSequenceDecoder` |
760
+ | `mcpClientTransport()` / `mcpServerAdapter()` (`/server`) | MCP SDK `Transport` over an `rpc` channel |
655
761
  | `SessionRecorder` | Record PTY I/O with timestamps (own schema, not asciicast v2) |
656
762
  | `SessionPlayer` | Replay recordings with original timing |
657
763
  | `generateKeyPair()` | Create Ed25519 key pair via Web Crypto |
@@ -714,6 +820,7 @@ than left to be found.
714
820
  | Remove a remote file | `WshClient.fileRemove()` | `file_transfer::remove()`, `wsh sftp`'s `rm` | **Wire-unified**: `FileOp`/`FileResult` (`op: "remove"`) -- refused today by every `wsh-server` release ("not yet implemented"), the same refusal on both sides |
715
821
  | 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 |
716
822
  | 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" |
823
+ | Typed RPC channels (`openRpc`, `rpc` sessions; `wsh-host`, `wsh-fs`, `mcp`) | `WshClient.openRpc()`, `RpcChannel` | none yet (a Rust `wsh-server` does not advertise `rpc`; a JS client falls back to `FileOp` for files) | **JS server only, no wire change**: `Open { kind: 'rpc', command: <protocol> }` plus `rpc*` `ServerHello` features, negotiated so it fails fast elsewhere |
717
824
  | 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 |
718
825
  | 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 |
719
826
  | 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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.24.0",
3
+ "version": "0.25.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",
@@ -26,7 +26,8 @@
26
26
  "peerDependencies": {
27
27
  "@fails-components/webtransport": "^1.6.4",
28
28
  "@fails-components/webtransport-transport-http3-quiche": "^1.6.4",
29
- "ws": "^8.0.0"
29
+ "ws": "^8.0.0",
30
+ "@modelcontextprotocol/sdk": "^1.17.0"
30
31
  },
31
32
  "peerDependenciesMeta": {
32
33
  "@fails-components/webtransport": {
@@ -37,6 +38,9 @@
37
38
  },
38
39
  "ws": {
39
40
  "optional": true
41
+ },
42
+ "@modelcontextprotocol/sdk": {
43
+ "optional": true
40
44
  }
41
45
  },
42
46
  "devDependencies": {
@@ -44,7 +48,8 @@
44
48
  "@fails-components/webtransport-transport-http3-quiche": "^1.6.4",
45
49
  "@johnhenry/browsermesh-primitives": "^0.2.0",
46
50
  "typescript": "^7.0.0",
47
- "ws": "^8.21.0"
51
+ "ws": "^8.21.0",
52
+ "@modelcontextprotocol/sdk": "^1.17.0"
48
53
  },
49
54
  "scripts": {
50
55
  "test": "node --test test/*.test.mjs",
@@ -57,7 +62,8 @@
57
62
  "example:02": "node examples/02-tampered-challenge-fails-authentication.mjs",
58
63
  "example:03": "node examples/03-handshake-to-exec-over-in-process-pipe.mjs",
59
64
  "example:04": "node examples/04-recording-replays-with-original-timing.mjs",
60
- "example:05": "node examples/05-remote-mcp-tools-called-through-bridge.mjs"
65
+ "example:05": "node examples/05-remote-mcp-tools-called-through-bridge.mjs",
66
+ "example:06": "node examples/06-rpc-requests-answered-out-of-order.mjs"
61
67
  },
62
68
  "keywords": [
63
69
  "wsh",
package/src/client.mjs CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  keyExchange as keyExchangeMsg,
30
30
  fileOp as fileOpMsg,
31
31
  fileChunk as fileChunkMsg,
32
+ fileResult as fileResultMsg,
32
33
  authorizedKeyAdd as authorizedKeyAddMsg,
33
34
  policyEval as policyEvalMsg, policyUpdate as policyUpdateMsg,
34
35
  detach as detachMsg,
@@ -40,6 +41,10 @@ import { signChallenge, exportPublicKeyRaw, signPeerRecord, verifyPeerRecord, im
40
41
  import { generateMlKemKeyPair, mlKemEncapsulate, mlKemDecapsulate } from './mlkem.mjs';
41
42
  import { WshSession } from './session.mjs';
42
43
  import { cborDecode } from './cbor.mjs';
44
+ import {
45
+ RpcChannel, RpcError, RPC_ERROR, RPC_FEATURE, RPC_PROTOCOL_NAME_RE, RPC_DEFAULT_MAX_MESSAGE,
46
+ rpcProtocolFeature, parseRpcFeatures,
47
+ } from './rpc.mjs';
43
48
  import {
44
49
  HostKeyError, newHostKeyNonce, hostKeyProofMessage, findHostKeyAdvert,
45
50
  HOST_KEY_NONCE_PREFIX,
@@ -58,6 +63,7 @@ const STATE_CLOSED = 'closed';
58
63
 
59
64
  const DEFAULT_AUTH_TIMEOUT = 10_000; // ms
60
65
  const DEFAULT_OPEN_TIMEOUT = 10_000; // ms
66
+ const FS_RPC_OPS = new Set(['stat', 'list', 'read', 'write', 'mkdir', 'remove', 'rename']);
61
67
 
62
68
  /**
63
69
  * ServerHello feature advertising that McpResult echoes McpCall's call_id.
@@ -677,10 +683,20 @@ export class WshClient {
677
683
  * stream (without it they never bind the stream and drop all output).
678
684
  * Skipped automatically against a host advertising `stream-announce`; pass
679
685
  * `false` to never send it (e.g. a host that forwards stdin verbatim).
686
+ * @param {string} [opts.protocol] - For `type: 'rpc'` (wsh #85): the protocol to speak (`mcp`, `wsh-fs`,
687
+ * `wsh-host`, or a host-defined name). Carried in `Open.command`. The session's data stream is a CBOR sequence
688
+ * of JSON-RPC 2.0 messages -- use {@link WshClient#openRpc} to get an `RpcChannel` over it.
689
+ * Throws `RpcError` (`UNSUPPORTED_PROTOCOL`) before sending anything if the host does not advertise it.
690
+ * @param {(session: WshSession) => void} [opts.attach] - Advanced: called synchronously the moment the data
691
+ * stream is bound, before any received byte can be delivered (`openRpc` uses it to set `onData`).
680
692
  * @returns {Promise<WshSession>}
681
693
  */
682
- async openSession({ type = 'pty', command, cols = 80, rows = 24, env, timeout = DEFAULT_OPEN_TIMEOUT, primer = true } = {}) {
694
+ async openSession({ type = 'pty', command, protocol, cols = 80, rows = 24, env, timeout = DEFAULT_OPEN_TIMEOUT, primer = true, attach } = {}) {
683
695
  this.#assertAuthenticated('openSession');
696
+ if (type === 'rpc') {
697
+ this.#assertRpcProtocol(protocol);
698
+ command = protocol;
699
+ }
684
700
  const requestedChannelId = this._nextChannelId();
685
701
 
686
702
  await this.#transport.sendControl(
@@ -696,6 +712,7 @@ export class WshClient {
696
712
  mode: 'session',
697
713
  kind: type,
698
714
  primer,
715
+ attach,
699
716
  requestedChannelId,
700
717
  resolve,
701
718
  reject,
@@ -709,6 +726,50 @@ export class WshClient {
709
726
  });
710
727
  }
711
728
 
729
+ /** Refuse an `rpc` open the host did not advertise, before any bytes are sent (wsh #85). */
730
+ #assertRpcProtocol(protocol) {
731
+ if (typeof protocol !== 'string' || !RPC_PROTOCOL_NAME_RE.test(protocol)) {
732
+ throw new RpcError(RPC_ERROR.INVALID_PARAMS, 'openSession({ type: "rpc" }) needs a protocol name', undefined, 'INVALID_PROTOCOL');
733
+ }
734
+ if (!this.hasFeature(RPC_FEATURE)) {
735
+ throw new RpcError(RPC_ERROR.UNSUPPORTED_PROTOCOL, `UNSUPPORTED_PROTOCOL: this host does not advertise "${RPC_FEATURE}" channels (cannot open "${protocol}")`, undefined, 'UNSUPPORTED_PROTOCOL');
736
+ }
737
+ if (!this.hasFeature(rpcProtocolFeature(protocol))) {
738
+ throw new RpcError(RPC_ERROR.UNSUPPORTED_PROTOCOL, `UNSUPPORTED_PROTOCOL: this host does not advertise the "${protocol}" rpc protocol`, undefined, 'UNSUPPORTED_PROTOCOL');
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Open a typed (object-mode) RPC channel (wsh #85): sugar over `openSession({ type: 'rpc', protocol })` that
744
+ * returns an {@link RpcChannel} -- JSON-RPC 2.0 over a CBOR sequence on one QMux stream.
745
+ *
746
+ * @param {string} protocol - e.g. `'mcp'`, `'wsh-fs'`, `'wsh-host'`
747
+ * @param {object} [opts]
748
+ * @param {number} [opts.timeoutMs] - default timeout for every `request()` on the channel
749
+ * @param {number} [opts.openTimeout] - how long to wait for the host to open the channel
750
+ * @returns {Promise<RpcChannel>}
751
+ */
752
+ async openRpc(protocol, { timeoutMs, openTimeout = DEFAULT_OPEN_TIMEOUT } = {}) {
753
+ this.#assertAuthenticated('openRpc');
754
+ const { maxMessageBytes } = parseRpcFeatures(this.#serverFeatures);
755
+ let channel = null;
756
+ const session = await this.openSession({
757
+ type: 'rpc', protocol, timeout: openTimeout,
758
+ attach: (s) => {
759
+ channel = new RpcChannel({
760
+ maxMessageBytes: maxMessageBytes ?? RPC_DEFAULT_MAX_MESSAGE,
761
+ timeoutMs,
762
+ write: (bytes) => s.write(bytes),
763
+ close: () => s.close(),
764
+ });
765
+ s.onData = (bytes) => channel.feed(bytes);
766
+ s.onClose = (err) => channel.handleClose(err ? 'stream-error' : 'channel-closed');
767
+ },
768
+ });
769
+ channel.session = session;
770
+ return channel;
771
+ }
772
+
712
773
  /**
713
774
  * List locally tracked sessions with their current state.
714
775
  * @returns {Array<{channelId: number, kind: string, state: string}>}
@@ -1835,6 +1896,12 @@ export class WshClient {
1835
1896
  */
1836
1897
  async fileOperation(op, path, opts = {}, timeout = DEFAULT_OPEN_TIMEOUT) {
1837
1898
  this.#assertAuthenticated('fileOperation');
1899
+ // wsh #85: a host that serves the `wsh-fs` rpc protocol is preferred over the FileOp/FileResult control path.
1900
+ if (this.preferRpcFiles && FS_RPC_OPS.has(op) && this.hasFeature(rpcProtocolFeature('wsh-fs'))) {
1901
+ let rpc = null;
1902
+ try { rpc = await this.#fsChannel(); } catch { /* fall back to FileOp */ }
1903
+ if (rpc) return this.#fileOperationRpc(rpc, op, path, opts, timeout);
1904
+ }
1838
1905
  const channelId = this._nextChannelId();
1839
1906
  // FileOp and FileResult both carry channel_id; each call claims only the
1840
1907
  // result for its own id, so overlapping operations cannot swap replies (#72).
@@ -1880,6 +1947,77 @@ export class WshClient {
1880
1947
  );
1881
1948
  }
1882
1949
 
1950
+ /**
1951
+ * Use the `wsh-fs` rpc protocol for `fileStat`/`fileList`/`fileRead`/`fileWrite`/... when the host advertises it
1952
+ * (default `true`); set `false` to always use the FileOp control path. Results keep the FileResult shape either way.
1953
+ */
1954
+ preferRpcFiles = true;
1955
+ #fsRpc = null;
1956
+
1957
+ async #fsChannel() {
1958
+ if (this.#fsRpc) {
1959
+ const existing = await this.#fsRpc.catch(() => null);
1960
+ if (existing && !existing.closed) return existing;
1961
+ this.#fsRpc = null;
1962
+ }
1963
+ const opening = this.openRpc('wsh-fs');
1964
+ this.#fsRpc = opening;
1965
+ try { return await opening; } catch (err) { if (this.#fsRpc === opening) this.#fsRpc = null; throw err; }
1966
+ }
1967
+
1968
+ /** `fileOperation` over `wsh-fs`, mapped back onto the FileResult shape the FileOp path returns. */
1969
+ async #fileOperationRpc(rpc, op, path, opts, timeout) {
1970
+ const channelId = this._nextChannelId();
1971
+ const ok = (metadata = {}, entries = []) => fileResultMsg({ channelId, success: true, metadata, entries });
1972
+ const timeoutMs = timeout;
1973
+ try {
1974
+ switch (op) {
1975
+ case 'stat': return ok(await rpc.request('stat', { path }, { timeoutMs }));
1976
+ case 'list': {
1977
+ const r = await rpc.request('list', { path }, { timeoutMs });
1978
+ return ok({ path: r.path }, r.entries);
1979
+ }
1980
+ case 'read': {
1981
+ const parts = [];
1982
+ const r = await rpc.request('read', { path, offset: opts.offset, length: opts.length ?? FILE_CHUNK_SIZE }, { timeoutMs, onProgress: (c) => { if (c instanceof Uint8Array) parts.push(c); } });
1983
+ const data = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0));
1984
+ let at = 0;
1985
+ for (const p of parts) { data.set(p, at); at += p.byteLength; }
1986
+ return ok({ data, size: r.size });
1987
+ }
1988
+ case 'mkdir': case 'remove':
1989
+ return ok(await rpc.request(op, { path }, { timeoutMs }));
1990
+ case 'rename':
1991
+ if (typeof opts.newPath !== 'string' || !opts.newPath) throw new TypeError('fileRename: newPath is required');
1992
+ return ok(await rpc.request('rename', { path, newPath: opts.newPath }, { timeoutMs }));
1993
+ case 'write': {
1994
+ const data = typeof opts.data === 'string' ? new TextEncoder().encode(opts.data) : opts.data;
1995
+ if (!(data instanceof Uint8Array)) throw new TypeError('fileWrite: data must be a string or Uint8Array');
1996
+ const step = Math.max(256, Math.min(256 * 1024, rpc.maxMessageBytes - 1024));
1997
+ const base = opts.offset === undefined || opts.offset === null ? undefined : Number(opts.offset);
1998
+ let off = 0;
1999
+ do {
2000
+ const end = Math.min(off + step, data.byteLength);
2001
+ const chunk = data.subarray(off, end);
2002
+ // Whole-file replace streams as `upload` (offset 0 truncates, later offsets continue); an explicit
2003
+ // offset writes in place chunk by chunk, never truncating.
2004
+ if (base === undefined) await rpc.request('upload', { path, data: chunk, offset: off || undefined }, { timeoutMs });
2005
+ else await rpc.request('write', { path, data: chunk, offset: base + off }, { timeoutMs });
2006
+ off = end;
2007
+ } while (off < data.byteLength);
2008
+ return ok({ written: data.byteLength });
2009
+ }
2010
+ default: throw new Error(`unsupported file op ${op}`);
2011
+ }
2012
+ } catch (err) {
2013
+ // Host-reported failures keep the FileResult contract (success: false); transport trouble still throws.
2014
+ if (err instanceof RpcError && err.code !== RPC_ERROR.CANCELLED) {
2015
+ return fileResultMsg({ channelId, success: false, errorMessage: err.message });
2016
+ }
2017
+ throw err;
2018
+ }
2019
+ }
2020
+
1883
2021
  /** Stat a remote file. */
1884
2022
  async fileStat(path, timeout) { return this.fileOperation('stat', path, {}, timeout); }
1885
2023
  /** List a remote directory. */
@@ -2386,6 +2524,7 @@ export class WshClient {
2386
2524
 
2387
2525
  if (dataMode === 'virtual') {
2388
2526
  session._activateVirtual((m) => this.sendRelayControl(m));
2527
+ pending.attach?.(session);
2389
2528
  pending.resolve(session);
2390
2529
  return;
2391
2530
  }
@@ -2397,6 +2536,7 @@ export class WshClient {
2397
2536
  this.#transport.openStream().then(
2398
2537
  async (stream) => {
2399
2538
  session._bind(stream.readable, stream.writable);
2539
+ pending.attach?.(session);
2400
2540
  // The transport has already announced the stream, which is all a
2401
2541
  // current host needs. Older hosts only bind a stream on its first
2402
2542
  // byte, so exec (which has no stdin to send) writes a one-byte
package/src/index.d.ts CHANGED
@@ -245,6 +245,146 @@ export const MCP_CALL_ID_FEATURE: 'mcp-call-id';
245
245
  */
246
246
  export const STREAM_ANNOUNCE_FEATURE: 'stream-announce';
247
247
 
248
+ // ── Typed (object-mode) RPC channels -- wsh #85 ────────────────────────
249
+
250
+ /** ServerHello feature: the host serves `rpc` sessions (`openSession({ type: 'rpc', protocol })`). */
251
+ export const RPC_FEATURE: 'rpc';
252
+ /** ServerHello feature prefix: one `rpc-protocol:<name>` per protocol the host serves. */
253
+ export const RPC_PROTOCOL_PREFIX: 'rpc-protocol:';
254
+ /** ServerHello feature prefix: `rpc-max-message:<bytes>`. */
255
+ export const RPC_MAX_MESSAGE_PREFIX: 'rpc-max-message:';
256
+ /** Largest single rpc message when the host does not say (1 MiB). */
257
+ export const RPC_DEFAULT_MAX_MESSAGE: 1048576;
258
+ /** Allowed protocol names. */
259
+ export const RPC_PROTOCOL_NAME_RE: RegExp;
260
+ /** `'rpc-protocol:' + name`. */
261
+ export function rpcProtocolFeature(name: string): string;
262
+ /** Read the rpc advertisement out of `ServerHello.features` (`client.features`). */
263
+ export function parseRpcFeatures(features?: string[]): { enabled: boolean; protocols: string[]; maxMessageBytes: number };
264
+
265
+ /** JSON-RPC's reserved error codes plus wsh's. */
266
+ export const RPC_ERROR: Readonly<{
267
+ PARSE: -32700;
268
+ INVALID_REQUEST: -32600;
269
+ METHOD_NOT_FOUND: -32601;
270
+ INVALID_PARAMS: -32602;
271
+ INTERNAL: -32603;
272
+ /** The host does not serve the requested protocol. */
273
+ UNSUPPORTED_PROTOCOL: -32000;
274
+ /** Cancelled (`$/cancel`, a timeout, or the channel closing: see `RpcError.reason`). */
275
+ CANCELLED: -32001;
276
+ /** Too many requests in flight on the channel. */
277
+ STREAM_LIMIT: -32002;
278
+ /** Refused on policy grounds (confinement, read-only, a handler's own authorization). */
279
+ UNAUTHORIZED: -32003;
280
+ }>;
281
+
282
+ /** A JSON-RPC error: what `request()` rejects with, and what a handler throws to answer with one. */
283
+ export class RpcError extends Error {
284
+ constructor(code: number, message: string, data?: unknown, reason?: string);
285
+ readonly code: number;
286
+ readonly data?: unknown;
287
+ /** A short machine-readable cause: `channel-closed`, `timeout`, `cancelled`, `aborted`, `message-too-large`, `UNSUPPORTED_PROTOCOL`. */
288
+ readonly reason?: string;
289
+ toJSON(): { code: number; message: string; data?: unknown };
290
+ }
291
+
292
+ /** Incremental decoder for a CBOR sequence (RFC 8742); feed bytes, get completed items. */
293
+ export class CborSequenceDecoder {
294
+ constructor(opts?: { maxItemBytes?: number });
295
+ feed(chunk: Uint8Array): unknown[];
296
+ /** Bytes buffered towards an incomplete item. */
297
+ readonly pending: number;
298
+ }
299
+
300
+ /** What a request handler receives beside the params. */
301
+ export interface RpcContext {
302
+ readonly id: string | number;
303
+ readonly method: string;
304
+ /** Aborts on `$/cancel` or channel close. */
305
+ readonly signal: AbortSignal;
306
+ readonly channel: RpcChannel;
307
+ /** Send a `$/progress` chunk for this request (resolves once written; no-op after cancel). */
308
+ progress(chunk: unknown): Promise<void>;
309
+ }
310
+
311
+ export interface RpcRequestOptions {
312
+ onProgress?: (chunk: any) => void;
313
+ signal?: AbortSignal;
314
+ /** Overrides the channel's default timeout; on expiry the request rejects with `-32001` / `timeout` and the callee is told. */
315
+ timeoutMs?: number;
316
+ /** Use this JSON-RPC id instead of the channel's counter (it must not be in flight). */
317
+ id?: string | number;
318
+ }
319
+
320
+ /** A pending request: a promise that also knows its id and can be cancelled. */
321
+ export interface RpcRequestPromise<T = any> extends Promise<T> {
322
+ readonly id: string | number;
323
+ cancel(reason?: string): boolean;
324
+ }
325
+
326
+ export interface RpcChannelOptions {
327
+ write(bytes: Uint8Array): Promise<void> | void;
328
+ close?(): Promise<void> | void;
329
+ maxMessageBytes?: number;
330
+ timeoutMs?: number;
331
+ maxInflight?: number;
332
+ }
333
+
334
+ /**
335
+ * One end of a typed RPC channel: JSON-RPC 2.0 over a CBOR sequence on one QMux stream. Symmetric -- either side may
336
+ * request, notify and answer. Binary values are `Uint8Array`.
337
+ */
338
+ export class RpcChannel {
339
+ constructor(opts: RpcChannelOptions);
340
+ /** The `WshSession` carrying this channel (client side, from `openRpc`). */
341
+ session?: WshSession;
342
+ readonly closed: boolean;
343
+ readonly closeReason: string | null;
344
+ readonly maxMessageBytes: number;
345
+ readonly pendingCount: number;
346
+ readonly inflightCount: number;
347
+ /** Called once when the channel ends. */
348
+ onClose: ((reason: string) => void) | null;
349
+
350
+ request<T = any>(method: string, params?: unknown, opts?: RpcRequestOptions): RpcRequestPromise<T>;
351
+ notify(method: string, params?: unknown): Promise<void>;
352
+ /** Cancel an outbound request: `$/cancel` to the callee, `-32001` locally, late responses dropped. */
353
+ cancel(id: string | number, reason?: string): boolean;
354
+ onRequest(method: string, handler: (params: any, ctx: RpcContext) => unknown): () => void;
355
+ onNotification(method: string, handler: (params: any) => void): () => void;
356
+ onProgress(id: string | number, fn: (chunk: any) => void): void;
357
+ setFallbackRequestHandler(fn: ((method: string, params: any, ctx: RpcContext) => unknown) | null): void;
358
+ setFallbackNotificationHandler(fn: ((method: string, params: any) => void) | null): void;
359
+ /** Pending requests reject with `-32001` / `channel-closed`; the stream is closed. */
360
+ close(reason?: string): Promise<void>;
361
+ /** Transport hooks. */
362
+ feed(bytes: Uint8Array): void;
363
+ handleClose(reason?: string): void;
364
+ hold(): void;
365
+ release(): void;
366
+ }
367
+
368
+ /**
369
+ * An MCP SDK `Transport` (structural -- the SDK is not imported) over an {@link RpcChannel}.
370
+ * `await mcpClient.connect(mcpClientTransport(await client.openRpc('mcp')))`.
371
+ */
372
+ export class RpcMcpTransport {
373
+ constructor(channel: RpcChannel);
374
+ readonly channel: RpcChannel;
375
+ sessionId?: string;
376
+ onclose?: () => void;
377
+ onerror?: (error: Error) => void;
378
+ onmessage?: (message: any, extra?: any) => void;
379
+ start(): Promise<void>;
380
+ send(message: any, options?: any): Promise<void>;
381
+ close(): Promise<void>;
382
+ }
383
+ /** A transport for an MCP `Client` over a channel from `openRpc('mcp')`. */
384
+ export function mcpClientTransport(channel: RpcChannel): RpcMcpTransport;
385
+ /** A transport for an MCP `Server` over a server-side channel (`mcpServerAdapter` wraps this). */
386
+ export function mcpServerTransport(channel: RpcChannel): RpcMcpTransport;
387
+
248
388
  /** A wsh protocol control message (all messages have a numeric `type`). */
249
389
  export interface WshMessage {
250
390
  type: number;
@@ -1633,8 +1773,15 @@ export interface WshConnectOptions extends WshHostKeyOptions {
1633
1773
 
1634
1774
  /** Options for WshClient.openSession(). */
1635
1775
  export interface WshOpenSessionOptions {
1636
- type?: 'pty' | 'exec';
1776
+ type?: 'pty' | 'exec' | 'file' | 'rpc';
1637
1777
  command?: string;
1778
+ /**
1779
+ * `type: 'rpc'` only: the protocol to speak (`mcp`, `wsh-fs`, `wsh-host`, or a host-defined name). Throws
1780
+ * `RpcError` (`UNSUPPORTED_PROTOCOL`) before sending anything if the host does not advertise it.
1781
+ */
1782
+ protocol?: string;
1783
+ /** Advanced: called synchronously when the data stream binds, before any received byte is delivered. */
1784
+ attach?: (session: WshSession) => void;
1638
1785
  cols?: number;
1639
1786
  rows?: number;
1640
1787
  env?: Record<string, string>;
@@ -1880,6 +2027,19 @@ export class WshClient {
1880
2027
  */
1881
2028
  openSession(opts?: WshOpenSessionOptions): Promise<WshSession>;
1882
2029
 
2030
+ /**
2031
+ * Open a typed (object-mode) RPC channel (wsh #85): JSON-RPC 2.0 over a CBOR sequence on one stream. Sugar over
2032
+ * `openSession({ type: 'rpc', protocol })`. Throws `RpcError` (`UNSUPPORTED_PROTOCOL`) before sending any bytes
2033
+ * if the host does not advertise `rpc` and `rpc-protocol:<protocol>`.
2034
+ */
2035
+ openRpc(protocol: string, opts?: { timeoutMs?: number; openTimeout?: number }): Promise<RpcChannel>;
2036
+
2037
+ /**
2038
+ * Use the `wsh-fs` rpc protocol for `fileStat`/`fileList`/`fileRead`/`fileWrite`/... when the host advertises it
2039
+ * (default `true`); `false` always uses the FileOp control path. Results keep the `FileResult` shape.
2040
+ */
2041
+ preferRpcFiles: boolean;
2042
+
1883
2043
  /**
1884
2044
  * List locally tracked sessions with their current state.
1885
2045
  */
package/src/index.mjs CHANGED
@@ -91,3 +91,10 @@ export { SessionRecorder, SessionPlayer } from './recording.mjs';
91
91
 
92
92
  // MCP bridge
93
93
  export { WshMcpBridge } from './mcp-bridge.mjs';
94
+
95
+ // Typed (object-mode) RPC channels -- wsh #85
96
+ export {
97
+ RpcChannel, RpcError, CborSequenceDecoder, RPC_ERROR, RPC_FEATURE, RPC_PROTOCOL_PREFIX, RPC_MAX_MESSAGE_PREFIX,
98
+ RPC_DEFAULT_MAX_MESSAGE, RPC_PROTOCOL_NAME_RE, rpcProtocolFeature, parseRpcFeatures,
99
+ } from './rpc.mjs';
100
+ export { RpcMcpTransport, mcpClientTransport, mcpServerTransport } from './rpc-mcp.mjs';