npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@johnhenry/wsh

v0.34.1

Published

Web Shell — browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication

Readme

@johnhenry/wsh

Previously published as wsh-upon-star (last release: 0.1.1, now deprecated). Renamed to @johnhenry/wsh on import into the @johnhenry family. Unlike other packages in this family, wsh did not restart its version at 0.0.0 on adoption — it was already a mature, actively-depended-upon release, so versioning continued forward and now stands at 0.17.0. This is a deliberate, permanent exception to the family's usual 0.0.0-restart convention, not an oversight.

npm version CI license

Full documentation: opensource.johnhenry.me/wsh

Browser-native remote command execution over WebTransport/WebSocket with Ed25519 authentication.

wsh is a pure-JS client library (with an optional Node server at @johnhenry/wsh/server) that connects browsers to remote shells. It implements its own binary protocol — CBOR messages over QMux-multiplexed WebSocket or native WebTransport streams — with Ed25519 challenge-response auth, session management, and MCP tool bridging.

Contents

Install

npm install @johnhenry/wsh

Or via CDN:

<script type="module">
  import { WshClient, generateKeyPair } from 'https://esm.sh/@johnhenry/wsh';
</script>

Features

  • Ed25519 authentication -- challenge-response via Web Crypto API with a transcript binding username and session id, SSH key format support
  • Dual transport -- WebTransport (native streams) and WebSocket (QMux-multiplexed streams) with identical API
  • Self-signed certificate pinning -- serverCertificateHashes on the WebTransport path, so page JavaScript can reach a server whose certificate no certificate authority signed
  • CBOR encoding -- compact binary wire format with length-prefixed framing
  • Session management -- open, attach, resume, detach, rename PTY/exec sessions, with session-scoped resume tokens and per-principal access grants
  • Reverse mode -- register as a peer (via a signed peer record) and accept incoming connections through a relay
  • File transfer -- scp-like upload/download as FileChunk control messages in 64KB chunks
  • MCP bridge -- discover and invoke remote MCP tools through the control channel
  • Session recording -- own JSON schema (asciicast-v2-shaped, not a playable asciicast file -- see note below) recording and playback with seek/pause/resume
  • Key management -- IndexedDB storage with OPFS encrypted backup (PBKDF2 + AES-256-GCM)
  • 99 message types -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, authorized-key management, and more

Session recording is asciicast-v2-shaped, but not an asciicast file. The JS SessionRecorder.toJSON() export borrows asciicast v2's vocabulary -- version: 2, width, height, timestamp, env, and events as [seconds, "o" | "i" | "r", data] -- so it is easy to convert, but it is not something asciinema play can read as-is:

  • it is one JSON document with an events array; asciicast v2 is newline-delimited JSON (a header line, then one event per line);
  • the header carries wsh's own sessionId, and width/height follow the latest resize rather than the size at the start;
  • session lifecycle events (open, exit) use codes asciicast v2 does not define (the Rust recorder also has Start/Exit/Snapshot, and uses a different, incompatible schema from the JS one);
  • RecordingExport's format: "asciicast" option is accepted on the wire but not implemented server-side -- it returns the same custom JSONL as format: "jsonl".

To play a JS recording in asciinema, write the header and the o/i/r events out as lines (lossy: lifecycle events are dropped):

const { version, width, height, timestamp, env, events } = recorder.toJSON();
const cast = [JSON.stringify({ version, width, height, timestamp, env }),
  ...events.filter((e) => 'oir'.includes(e[1])).map((e) => JSON.stringify(e))].join('\n') + '\n';
// write `cast` to session.cast, then: asciinema play session.cast

A first-class .cast exporter is not provided; the conversion above is the supported path (it is covered by test/recording-format.test.mjs).

Wire Protocol: QMux

Over WebSocket, wsh multiplexes streams with QMux (draft-ietf-quic-qmux-02): QUIC-v1 frames -- RFC 9000 varints, STREAM, RESET_STREAM, STOP_SENDING, MAX_DATA/MAX_STREAM_DATA/MAX_STREAMS, CONNECTION_CLOSE, DATAGRAM, plus RESET_STREAM_AT from draft-ietf-quic-reliable-stream-reset -- carried in self-delimiting Records over the ordered, reliable WebSocket byte stream. The control channel is QMux stream 0, and every stream gets QUIC-style windowed flow control, so a slow consumer exerts real backpressure. QMuxConnection and the frame codec primitives are exported so alternate server implementations can speak the same framing.

Quick Start

import { WshClient, generateKeyPair } from '@johnhenry/wsh';

// Generate an Ed25519 key pair
const keyPair = await generateKeyPair(true);

// Connect to a wsh server
const client = new WshClient();
const sessionId = await client.connect('wss://shell.example.com', {
  username: 'alice',
  keyPair,
  transport: 'ws',
});

// Open a PTY session
const session = await client.openSession({
  type: 'pty',
  command: '/bin/bash',
  cols: 120,
  rows: 40,
});

// Handle output
session.onData = (data) => {
  const text = new TextDecoder().decode(data);
  process.stdout.write(text);
};

// Write input
await session.write('echo hello world\n');

// Resize the terminal
await session.resize(160, 50);

// Close when done
await session.close();
await client.disconnect();

One-Shot Command Execution

import { WshClient, generateKeyPair } from '@johnhenry/wsh';

const keyPair = await generateKeyPair(true);
const { stdout, exitCode } = await WshClient.exec(
  'wss://shell.example.com',
  'ls -la /tmp',
  { username: 'alice', keyPair }
);

console.log(new TextDecoder().decode(stdout));
console.log('Exit code:', exitCode);

exec and openSession({ type: 'exec' }) need no extra step to receive output. Older hosts only discover the client-opened data stream once a byte arrives on it, so against a host that does not advertise stream-announce the client writes a one-byte primer for you (a host that strips it, as those hosts do). Pass primer: false to never send it.

Stdin and EOF

stdin feeds the command and then closes its write side, so cat, wc -c and sh -s finish:

const { stdout } = await WshClient.exec(url, 'wc -c', { username, keyPair, stdin: new Uint8Array(1000) });
// stdin: a string, a Uint8Array, a ReadableStream, or an (async) iterable of strings / bytes

On an open exec session, await session.write(data); await session.closeStdin() sends EOF without closing the session: output keeps arriving and onExit / onClose fire as usual. Without stdin, exec never touches stdin.

EOF is either the end of the session's stdin data stream (a stream-mode host: @johnhenry/wsh/server, any version) or, on a virtual-mode host, a zero-length SessionData. The Rust wsh-server from rust-v0.5.0 supports the second form: it advertises the exec-pipes feature, and a client that sees it opens the exec channel with stdio: 'pipes' (exec({ stdin }) does this by itself), so the command runs on plain pipes -- byte-exact input, no echo, stderr kept apart (session.onStderr, and stderr in the exec() result) -- and closeStdin() works. Against an older Rust server (no exec-pipes; its exec is a PTY) nothing is sent that it would reject, and closeStdin() / exec({ stdin }) fail with a clear error instead of hanging. exec without stdin, pty sessions and shells keep the PTY.

Node Server

@johnhenry/wsh/server is a Node host for the same protocol, over WebSocket (QMux) and, optionally, WebTransport. It is a separate subpath -- the package root stays browser-safe and never imports it -- and needs the optional peer dependency ws (npm install ws).

import { createWshServer } from '@johnhenry/wsh/server';
import { readFileSync } from 'node:fs';

const server = createWshServer({
  host: '127.0.0.1',
  port: 4422,                                          // 0 = pick a free port
  auth: { authorizedKeys: readFileSync('authorized_keys', 'utf8') },  // ssh-ed25519 lines
  hostKey: { file: '/etc/wsh/host_key' },              // advertise a pinnable host identity (created 0600 on first start)
  exec: true,                                          // opt in: run commands via /bin/sh
  fs: { root: '/srv/share', readOnly: false },         // opt in: list/stat/read/write/rename/mkdir/remove/upload/download
  // pty: { spawn: nodePty.spawn },                    // opt in: bring your own node-pty
});
const { port } = await server.listen();                // later: server.address(), await server.close()

Everything that touches the machine -- exec, pty, fs -- is off unless you pass it, and with no auth every connection is refused. auth is { authorizedKeys, authorize, password, rateLimit } (a key must be on the list and pass authorize({ username, fingerprint, publicKey }) when both are given) or just an authorize function. exec can also be { cwd, env, shell, timeoutMs, clientEnv } or a custom run(command, io) for a restricted host with no shell. fs.root confines every path: .., absolute paths and symlinks pointing out of it are refused -- for write and rename (both paths) exactly as for read.

Gateway (TCP and DNS egress from the host)

gateway: { allow: ['broker.lan:1883'], socks: '127.0.0.1:9050' } makes the Node server answer the gateway opcodes, so a client (a browser tab via netway's GatewayBackend) can open TCP connections and resolve names from the host. Default deny; allow takes "*", "host" or "host:port". socks routes every dial through a SOCKS5 proxy (Tor) with the name unresolved. UDP and reverse tunnels are refused.

TLS (wss://) and host extensions

tls: { cert, key } (PEM, the options of https.createServer) serves the WebSocket listener over TLS on the same port. extensions: { [type]: (msg, { username, fingerprint, send }) => void } handles application-defined control messages whose type is a string, from authenticated connections only. On a relay, relay: { onUnreachable(from, targetFingerprint, request) } is told when an operator asked for a peer that is not registered (the operator still gets no such peer), so the host can reach that peer another way, such as a Web Push.

Pre-upgraded transports: accept()

createWshServer() normally opens its own listener. If your application already owns the HTTP server (or Worker, or test harness) and has upgraded the connection itself, hand the socket over instead of bridging it to a per-session loopback listener (which any local process could also connect to):

const wsh = createWshServer({ auth: { authorizedKeys }, exec: true });   // no listen()
wss.on('connection', (ws, req) => wsh.accept(ws, { remote: { address: req.socket.remoteAddress } }));

accept(socket, { remote }) takes any WebSocket-like duplex (send, close, readyState, and on or addEventListener): a ws socket, a browser/Worker WebSocket, a test double. It applies the same auth, features, rate limit and session registry as listen()'s connections, may be mixed with listen(), and resolves to { close() }. remote.address keys the password-failure throttle (sockets without one share a key). server.close() drops every accepted socket.

In-memory host (@johnhenry/wsh/memory)

A browser-safe host for demos and tests: no sockets, no ports, no Node (nothing in its import graph is node:* or ws).

import { createMemoryHost } from '@johnhenry/wsh/memory';

const host = createMemoryHost({
  auth: { authorizedKeys: [rawPublicKey] },                       // same shapes as createWshServer
  exec: async (command, io) => { await io.write(`ran ${command}\n`); return 0; },
});
const client = await host.connect({ username: 'demo', keyPair });   // or { signer } / { password }
const session = await client.openSession({ type: 'exec', command: 'hi' });
session.onData = (d) => console.log(new TextDecoder().decode(d));   // "ran hi"

It is the real connection handler speaking QMux over an in-memory socket pair, so the client code you test is the code you ship. host.createClient() / host.WebSocket / host.accept(socket) / createSocketPair() are the lower-level pieces. Supported: auth, exec (a function; there is no shell), mcp, extensions. Not available in memory: pty, fs, gateway, relay, sessions, rpc, hostKey and WebTransport (use @johnhenry/wsh/server).

Signing with a page-held signer

connect(), WshClient.exec() and connectReverse() take signer: { publicKey, sign(data) } instead of keyPair (not both): publicKey is the raw 32-byte Ed25519 key or a public CryptoKey, sign returns the 64-byte signature (sync or async). For keys that live in a wallet, a hardware token or another realm and are not a CryptoKeyPair.

WebTransport listener

The client's ladder prefers WebTransport (https://) and falls back to wss://; webTransport gives the Node host the first rung, with real independent QUIC streams (no head-of-line blocking across channels):

const server = createWshServer({
  auth, exec: true, fs: { root: '/srv/share' },
  webTransport: { port: 4433, selfSigned: true },        // or { cert, privKey } PEM
});
await server.listen();                                    // the WebSocket listener still runs
const { url, certificateHash } = server.webTransport();  // https://127.0.0.1:4433/wsh, sha-256 of the cert

// client (a browser, or Node with a WebTransport implementation)
await client.connect(url, {
  username, keyPair, transport: 'wt',
  webTransport: { serverCertificateHashes: [{ algorithm: 'sha-256', value: certificateHash }] },
});
  • Optional peers, imported only when webTransport is set: npm install @fails-components/webtransport @fails-components/webtransport-transport-http3-quiche (a native HTTP/3 build, prebuilt for Linux/macOS/Windows on x64 and arm). Without them listen() rejects with that instruction, and a server with no webTransport never loads them.
  • Same host, other wire. Auth (keys, password, host key), exec, pty, fs, mcp, sessions and relay behave identically; only the transport differs. The control stream is the session's first bidirectional stream (length-prefixed CBOR frames); every bidirectional stream the client opens afterwards is a data stream, bound to the next exec channel in OpenOk order. A client-created stream is visible to the host as soon as it exists, so the host advertises stream-announce and the client writes no primer.
  • selfSigned: true generates (no dependency) an ECDSA P-256 certificate valid for 13 days -- a browser refuses a pinned certificate valid for more than 14 -- and exposes the SHA-256 for serverCertificateHashes ({ selfSigned: { hosts, validityDays } } to change the SANs or the lifetime). A certificate you pass as cert/privKey is used as is (certificateHash is then null, and nothing rotates). generateSelfSignedCertificate() is exported for the same certificate without a server. The web platform's other pinning rules apply (https: URL, HTTP/3 only; see Pinning a Self-Signed Certificate). It is rotated, see below.
  • Node has no WebTransport global: a Node client needs globalThis.WebTransport = (await import( '@fails-components/webtransport')).WebTransport first. UDP, so a firewall must allow port, and there is no wss://-style TLS terminator in front: the certificate is the one this process serves.
  • Tests (test/server-webtransport.test.mjs) run the stock client against it over a real HTTP/3 connection and are skipped, with the reason, where the native binary cannot load.

Certificate rotation

A pinned certificate cannot just be renewed in place: clients hold its hash, and the platform takes none valid for more than 14 days. So a selfSigned certificate is rotated ahead of its expiry, with an overlap in which both hashes are published:

server.certificateHashes();       // [{ algorithm: 'sha-256', value }, ...]: what a client should pin right now
await server.rotateCertificate(); // make the next certificate now and publish its hash too -> [current, next]
await server.rotateCertificate({ activate: true });   // ... and switch the listener to it immediately
  • Automatically: prepareBeforeMs before notAfter (default 3 days, at most a third of the validity) the next certificate is generated and certificateHashes() becomes [current, next]; activateBeforeMs before notAfter (default 1 hour, at most a sixth) the listener switches to it and the list drops the old hash. Then the next rotation is scheduled from the new certificate. selfSigned: { rotate: false } turns this off (restart before notAfter, as before); { prepareBeforeMs, activateBeforeMs, validityDays } tune it (tests use a seconds-long validity). The same list is on server.webTransport().certificateHashes(), with validity and which one is active in .certificates(); certificateHash/notAfter there always describe the certificate being presented.

  • The switch restarts the HTTP/3 listener on the same port. The native transport has no way to swap a certificate in place and presents one certificate at a time, so live WebTransport sessions end at the switch (the WebSocket listener is untouched) and clients reconnect. A client that pinned the overlap list [current, next] before the switch connects after it; one that only has the old hash does not, so it must re-pin.

  • Client re-pin flow. serverCertificateHashes takes several values, and connect() / WebTransportTransport pass the whole array. Publish server.certificateHashes() over something the client already trusts (your page's HTTPS, an API call, a config endpoint) and have the client fetch it (a) before connecting and (b) again whenever the WebTransport handshake fails, then retry once:

    // server: GET /wsh-pins -> hex strings (parseCertificateHash() on the client accepts hex)
    app.get('/wsh-pins', (req, res) => res.json(server.certificateHashes().map((h) => Buffer.from(h.value).toString('hex'))));
    
    // client
    async function connectPinned(url, opts) {
      for (let attempt = 0; ; attempt++) {
        const pins = (await (await fetch('/wsh-pins')).json()).map((hex) => ({ algorithm: 'sha-256', value: hexToBytes(hex) }));
        try { return await client.connect(url, { ...opts, transport: 'wt', webTransport: { serverCertificateHashes: pins } }); }
        catch (err) { if (attempt === 1) throw err; }   // stale pins: fetch the list again and retry once
      }
    }

    Because the next hash is published prepareBeforeMs (3 days) before the switch, a client that refreshes at least that often (or on any failure) never sees a certificate it has not pinned. A client that stays up for longer than a certificate lives and never refetches needs this retry; transport: 'wt' makes the failure explicit instead of falling back to wss://.

  • Not covered: a certificate you supply (cert/privKey) is yours to renew; restart the server (or run it behind a CA-issued certificate, which needs no pinning).

  • Node has no WebTransport global: a Node client needs globalThis.WebTransport = (await import( '@fails-components/webtransport')).WebTransport first. UDP, so a firewall must allow port, and there is no wss://-style TLS terminator in front: the certificate is the one this process serves.

  • Tests (test/server-webtransport.test.mjs) run the stock client against it over a real HTTP/3 connection and are skipped, with the reason, where the native binary cannot load.

Relay / reverse mode

A host that cannot accept connections (behind NAT, or a browser tab) dials out and registers; an operator reaches it through a relay. @johnhenry/wsh/server is both ends:

// The relay -- anyone who can reach it and authenticate; nothing is permitted by default.
createWshServer({
  auth: { authorizedKeys },
  relay: {
    canRegister: (who, record) => who.username === 'build-box',          // who may be a peer
    canConnect: (from, to) => from.username === 'alice',                  // who may list and reach which peer
  },
});

// The peer, on the machine behind the NAT: same backends as createWshServer.
const host = createReverseHost({
  url: 'wss://relay.example/', username: 'build-box', keyPair,
  exec: true, fs: { root: '/srv/share' }, mcp: { tools },
  accept: ({ fingerprint, username }) => fingerprint === aliceFingerprint, // default: nobody
});
await host.start();                    // host.fingerprint is what operators connect to

// The operator: stock client.
const peers = await client.listPeers();                       // each entry's signed record verified client-side
await client.reverseConnect(peers[0].fingerprint);            // ReverseAccept trusts the peer for RelayForward
const s = await client.openSession({ type: 'exec', command: 'echo hello' });
  • Default deny, twice. The relay admits no peer (canRegister) and lets no one connect (canConnect) unless you say so, and the peer refuses every operator unless accept says otherwise -- the client's trustRelayPeer() is the third gate. A peer an operator may not connect to is not listed and is indistinguishable from an absent one (ReverseReject: no such peer). Relay roles need a key login (from_fingerprint is a key fingerprint); password logins can still use the relay server as an ordinary host.
  • Peer table. Keyed by fingerprint. ReverseRegister is checked against the connection's own authenticated key (public_key must hash to it) and its self-signed record (buildPeerRecordTranscript / signPeerRecord) is verified here, not trusted; ReversePeers forwards the signed fields so operators verify them again themselves. The record seq must increase per fingerprint -- and is remembered across reconnects (the last 4096 fingerprints), so an old record cannot regress a peer. Failures answer an Error (the stock client surfaces it through onError); there is no acknowledgement of success on the wire, so poll listPeers() (or server.peerFingerprints()) if you need to know it landed.
  • Bridge. ReverseConnect is forwarded to the peer with from_fingerprint (and username) set from the operator's authenticated login, whatever the client wrote; the peer's ReverseAccept/Reject goes back, attributed to the peer that sent it, and an accept pairs the two connections. From then on every spec-forwardable message from either end is wrapped as RelayForward { from_fingerprint, inner } with from_fingerprint the sender's authenticated key. A RelayForward a client writes itself is unwrapped, its inner type checked against the allowlist (a non-forwardable or undecodable inner is dropped, logged), and re-wrapped with the real sender; traffic from a connection that is not bridged is never forwarded.
  • One operator per peer at a time, unless you raise it. The default: a second ReverseConnect to a peer that already has an operator (or a request awaiting its answer) is rejected with ReverseReject.reason busy: this peer already has an operator ... (exported as BUSY_PEER from src/server/relay.mjs), and an operator that already has a bridge or a request in flight is rejected with busy: you already have a bridge ... (BUSY_OPERATOR); a request the peer does not answer within connectTimeoutMs (default 8 s) is peer did not respond. A bridge lasts as long as both connections: when either ends, the relay closes the other, so nothing an operator started outlives it (the reverse host kills its processes). createReverseHost redials with backoff by default (reconnect: false to turn it off), so it is registered again for the next operator.
  • Several operators per peer (opt-in on both ends). Set relay: { maxOperatorsPerPeer: n } on the relay and createReverseHost({ maxOperators: n }) on the peer. Three things make it work, all optional on the wire so nothing older breaks: a peer states relay-multi-operator in ReverseAccept.features; it addresses each message to one operator with RelayForward.to_fingerprint (the relay routes it there and re-wraps it with the peer as sender -- the field is never delivered, and a forward addressed to nobody it serves is dropped, as is an unaddressed one while two or more are bridged; with exactly one operator an unaddressed reply still goes to it); and when one operator leaves the relay sends the peer a ReverseClose { target_fingerprint } and keeps the peer registered, instead of closing it. Each operator is accepted on its own identity (accept runs per operator) and gets its own connection state on the host -- channels, processes, files -- so when one leaves only its work is cleaned up. The relay advertises relay-multi-operator in its ServerHello when it is configured for several, and a host only claims it then, so a host with maxOperators on an older or default relay serves one operator exactly as before. A peer that never stated the feature stays held to one operator (and is closed when its operator leaves) even on a multi-operator relay; a peer answers one ReverseConnect at a time (a concurrent one is told busy: ... retry in a moment), since a ReverseAccept does not name its operator; and at the cap the reason is busy: this peer already has its maximum of n operators (busyPeerFull(n)). The Rust relay is still one operator per peer: it never sends ReverseClose and rejects the second operator; the new spec fields are regenerated into the Rust types and ignored there. Tracked for the Rust relay in #93.
  • On the peer, the bridged operator is served by the same connection code as a direct client, over an in-memory message pipe instead of a socket. Nothing a client opens can cross the relay but control messages, so exec sessions are data_mode: 'virtual' there (stdin and output ride SessionData; there is no stdin EOF).
  • End-to-end encryption through the bridge. KeyExchange and EncryptedFrame are on the spec's forwardable list, so an operator and a peer can run initiateE2E() / enableE2E() with each other through the relay: it carries the CBOR of each as opaque bytes and holds no key, so it sees only public values and ciphertext (test/server-relay-bridge.test.mjs and test/reverse-host-e2e.test.mjs check the relay never holds the plaintext). createReverseHost answers it too (e2e option, on by default; false turns it off): for a session the host has opened (the id is the one in OpenOk), an operator's KeyExchange is answered -- X25519, or hybrid X25519+ML-KEM-768 when asked and available -- and from then on that session's output is sealed into EncryptedFrames (AAD = the session id, strict counters, the responder role tag) and its input is opened from them. Plaintext SessionData input for an encrypted session is ignored, so a relay cannot inject stdin around the encryption, and a replayed, tampered or spliced frame is dropped without consuming a counter. The host advertises e2e (and e2e-sign) in ReverseAccept.features. Start E2E before the session produces output you need confidential: output sent before the key exists is plaintext, and a frame that reaches the operator before it has called enableE2E() is dropped (open the session with a command that waits for input, or use a shell and let the first prompt precede the exchange). This is virtual-mode E2E (EncryptedFrame), so it is offered over a relay bridge, not on direct connections (createWshServer). KeyExchange is unauthenticated by itself, which protects against a relay that only observes; to defeat one that substitutes keys the host signs its reply with its identity key (Ed25519 over the label, session id, algorithm and both ephemeral keys; the optional KeyExchange.signature), and the operator checks it: client.initiateE2E(sessionId, algorithm, timeout, { verifyPeer: hostPublicKey }) -- the host's raw key, e.g. the public_key of the record listPeers() verified -- rejects with code: 'E2E_PEER_UNAUTHENTICATED' on a missing or wrong signature, and resolves peerAuthenticated: true otherwise. Without verifyPeer nothing is checked, as before. Signing is e2e: { sign: 'auto' } by default: the host signs when the relay's ServerHello carries e2e-sign (this package's relay does) because a relay built before the field existed -- notably Rust before the release that regenerates it -- drops a connection that sends one; sign: true forces it, false never. Key exchange from a connection that is not bridged is never carried.
  • Feature gates follow the host behind the bridge. The host's own features (file-write, file-rename, mcp-call-id) are stated in the optional ReverseAccept.features; the relay forwards the list (bounded: at most 64 strings of 128 characters, otherwise it is dropped) and, once reverseConnect() resolves, client.hasFeature() / client.features answer from it instead of the relay's ServerHello. client.bridgedFeatures is that list (null before a bridge, or when the peer did not say, in which case the gates fall back to client.serverFeatures, the relay's, as before). createReverseHost always states its features (reportFeatures: false leaves them out). A Rust wsh-server / wsh older than rust-v0.3.0 rejects a ReverseAccept that carries features (the message is deny_unknown_fields there): against such a relay or operator, set reportFeatures: false on the host (and use ReverseAccept without the field in your own peers).

Sessions: attach / resume / detach

By default a pty/exec session dies with the connection that opened it. Pass sessions and it outlives it, so a dropped socket (a flaky link, a closed tab) can be picked up again:

createWshServer({
  auth, exec: true, pty: { spawn: nodePty.spawn },
  sessions: {
    detachTtlMs: 300_000,   // how long a session nobody is attached to keeps running (0 = kill at once)
    maxDetached: 16,        // unattended sessions kept at once; the longest-detached is killed beyond that
    ringBytes: 1 << 20,     // output history kept per session: what a resume can replay
    // sessionSecret: process.env.WSH_SESSION_SECRET,  // fixes the token key (default: random per process)
  },
});
// original connection
const s = await client.openSession({ type: 'pty' });
s.onData = (d) => term.write(d);            // s.seq counts the output bytes received so far
const { sessionId, resumeToken } = s;       // keep these
const lastSeq = s.seq;                      // ... the socket drops ...

// fresh connection, same key
const { session } = await client2.resumeSession(sessionId, resumeToken, { lastSeq });
session.onData = (d) => term.write(d);      // only the bytes after lastSeq, then live output
await session.write('ls\n');                // and it is the same process
  • seq is the cumulative count of output bytes the host has produced for a session (stream data or SessionData alike). The client counts what it received -- WshSession.seq -- so no per-frame field exists, and Resume.last_seq is exactly that number. The host keeps the newest ringBytes of output and replays ring[last_seq - ringStart ..]. A last_seq older than the ring still holds is refused with an output gap error naming where the history starts (fall back to attachSession() for the retained tail); one newer than the session has produced is refused too. While nobody is attached the process keeps running and keeps filling the ring (it is never paused), so a chatty process loses its oldest output rather than blocking.
  • Token: HMAC-SHA256(secret, session_id || expiry) in the spec's 40-byte token format, minted at open, returned as OpenOk.token and checked in constant time. Resume needs the token AND ownership (the same key, or for a password login the same username) -- it is the credentialed connection coming back. Attach accepts the token OR ownership, so the owner can re-attach with nothing but the session id, and a token holder can join as a guest. Refusals do not say whether the session exists.
  • What arrives: the Presence reply names a new channel (channel_id) and the seq of its first byte; the replay and live output follow as SessionData on it. That channel is always message-backed, even for an exec session that was opened on a data stream -- neither stock client opens a stream for attach/resume -- so resumeSession()/attachSession() return the Presence with a non-enumerable session (a WshSession) to read and write it. A client that ignores that can read the same frames with addControlListener().
  • Several connections may attach to one session (up to 16): output fans out to all of them, attachSession(id, { readOnly: true }) attaches a viewer whose input is dropped, and every change in who is attached is broadcast as Presence (the roster, without channel ids).
  • Ending vs leaving: Close on a channel (which session.close() and a graceful client.disconnect() send) ends the session when its owner sends it, and merely leaves it for anyone else. To walk away from a session and keep it running, client.detach(sessionId) first; losing the connection also detaches. client.listRemoteSessions() lists the sessions your key owns, attached or not.
  • Exit: when the process ends, everyone attached gets Exit + Close; the record then lingers for detachTtlMs so a late resume still collects the tail and the exit code.
  • Not built: ACL grants (SessionGrant/SessionRevoke) and ControlChanged -- only the owner re-attaches as a controller; everyone else needs the token. Sessions are in-memory: a server restart ends them all.

Cancelling transfers and tool calls

const ac = new AbortController();
const p = client.upload(bytes, 'big.bin', { signal: ac.signal });
ac.abort();                       // p rejects with an AbortError and the file channel is closed
await client.download('big.bin', { signal });
await client.callTool('search', { q: 'x' }, { timeout: 30_000, signal });
const pty = client.getSession(client.listSessions()[0].channelId); // live session: write() / close()

An aborted upload writes nothing (the server writes the file only when the last chunk arrives). An aborted callTool also sends McpCancel to a host advertising mcp-cancel, so the tool handler's ctx.signal fires.

MCP tools

createWshServer({ mcp }) answers McpDiscover and McpCall, so the stock client.discoverTools() / client.callTool() and WshMcpBridge work against it. Off unless you pass it.

const server = createWshServer({
  auth,
  mcp: {
    tools: [{
      name: 'read_note',
      description: 'Read a note by id',
      inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false },
      call: async ({ id }, { user, signal }) => ({ success: true, output: await readNote(id, { signal }) }),
    }],
    authorize: (user, tool) => tool.name !== 'admin_only' || user === 'root',   // optional
    // client: mcpSdkClient,   // optional: proxy an @modelcontextprotocol/sdk Client's tools too
    // maxConcurrent: 8, timeoutMs: 30_000,
  },
});
  • Tool shape: tools is an array of { name, description, inputSchema, call(args, { user, fingerprint, signal }) } or a { name: { ... } } map. inputSchema is advertised to clients as parameters (the McpToolSpec field).
  • Arguments are untrusted. They are validated against inputSchema before call() runs; invalid arguments never reach the tool. The package has no runtime dependencies, so the validator covers the keywords tool schemas use (type, enum, const, properties, required, additionalProperties, items, length/range/pattern/multipleOf bounds, allOf/anyOf/oneOf/ not, local $ref); a schema using any other keyword (if, patternProperties, ...) is refused when the server is created rather than silently enforced less strictly. Only tools you list are reachable.
  • Results: whatever call() returns is McpResult.result verbatim (the stock WshMcpBridge normalizes the { success, output, error } shape, and wraps anything else as { success: true, output }). A tool that throws, an unknown tool, invalid arguments, a timeout or the concurrency cap all answer { success: false, error } -- the error text is the thrown message, so throw messages you are happy for the caller to read.
  • Correlation: every McpResult echoes the call's call_id, and the server advertises mcp-call-id (when mcp is configured), so concurrent calls from one client get their own results, and a slow tool does not hold up the calls behind it.
  • Limits: maxConcurrent in-flight calls per connection (default 8) and a timeoutMs per call (default 30000). On timeout or disconnect the tool's signal aborts; a tool that ignores it keeps running but its reply is dropped.
  • Access: discovery and calls are answered after authentication only. authorize(user, tool, { username, fingerprint }) filters both, and a tool hidden from a caller is indistinguishable from one that does not exist.
  • Proxying: client takes anything with listTools() / callTool() (an @modelcontextprotocol/sdk Client; this package does not import the SDK). Its tools are re-listed on every discovery, validated against their own inputSchemas here, and isError results become { success: false, error }, else the result is structuredContent or the content array. A proxied tool whose schema uses an unsupported keyword is not exposed.
  • Without mcp, McpDiscover answers an empty list and McpCall an { success: false, error: 'mcp is not enabled on this server' } result.

Host key (fingerprint / TOFU)

Give the server an identity and the stock client can pin it:

// server
const server = createWshServer({ auth, hostKey: { file: '/etc/wsh/host_key' } });
await server.listen();
server.hostKey();   // { fingerprint, publicKey, openssh } -- publish this out of band

// client
import { WshClient, WshKnownHosts, HostKeyError } from '@johnhenry/wsh';
const client = new WshClient();
await client.connect(url, { username, keyPair, expectHostKey: fingerprint });   // pin; refuses on mismatch
// or trust-on-first-use, remembered in a store:
await client.connect(url, { username, keyPair, knownHosts: new WshKnownHosts(), trustOnFirstUse: true });
client.hostKey;  // { fingerprint, publicKey, openssh, status: 'pinned' | 'known' | 'unknown' | 'unpinned' }
client.onHostKey = (hk) => confirm(`Trust ${hk.fingerprint}?`);   // or per call: connect(..., { onHostKey })
  • hostKey is { file } (a PKCS#8 PEM, created mode 0600 on first start -- use this), a CryptoKeyPair, or true (a fresh key every start: every TOFU client will see "changed" after a restart; tests and demos only).
  • expectHostKey takes a hex fingerprint (sha256: prefix fine), raw 32-byte key, or an ssh-ed25519 AAAA... line. With knownHosts, a changed key is always refused (and never overwrites the pin); an unseen host is refused unless trustOnFirstUse is set or onHostKey accepts it. Either option also refuses a host that presents no key at all. Refusals are HostKeyError with a code (HOST_KEY_MISSING | _INVALID | _MISMATCH | _UNKNOWN | _REJECTED), thrown before any credential -- signature or password -- is sent.
  • WshClient.exec() and connectReverse() accept the same options.
  • WshKnownHosts defaults to localStorage, which in Node is not a persistent file store: for a real TOFU file pass new WshKnownHosts({ storage }) with a getItem/setItem/removeItem object you back with a file.
  • Wire shape: the spec's ServerHello.host_fingerprint is populated, and because a bare fingerprint proves nothing (anyone can repeat it) the key and a signature ride in features strings: the client sends host-key-nonce:<hex> in Hello; the server answers host-key:<hex> and host-key-sig:<hex> (Ed25519 over a tag, the session id, the client's nonce and the username). The fresh nonce makes the proof unreplayable. Hosts that don't know these strings ignore them (the Rust wsh-server advertises no key, so a pin against it is refused with HOST_KEY_MISSING, never silently skipped).
  • What it does not give you: the proof authenticates the ServerHello, not the byte stream. Over plain ws:// an active attacker can relay a genuine ServerHello and then read or alter the rest. Pinning keeps you from talking to the wrong host; confidentiality still needs wss:// or a link you trust.

Password auth

createWshServer({
  auth: {
    password: async (username, password) => verifyAgainstYourHashes(username, password),
    // authorizedKeys / authorize may be given too: both methods are then served.
    rateLimit: { maxFailures: 5, windowMs: 60_000, lockoutMs: 60_000, failureDelayMs: 250 },
  },
});
await client.connect(url, { username, password });

The wire already has it (Hello.auth_method: 'password' then Auth{ method: 'password', password }, no challenge), so no extension is involved -- and that is also its limit: the password travels as plain text inside the connection, so serve password logins over wss:// (TLS-terminating proxy) or a link you trust, and pin the host key so it is only ever sent to the right host. Use a constant-time comparison against stored hashes in the callback, never === on plaintext. A host with only password refuses key logins and vice versa; with neither configured every connection is refused.

Failures are throttled per peer address (override with rateLimit.key, e.g. read x-forwarded-for from headers behind a proxy): each connection gets one guess, a failed one is answered after failureDelayMs, and after maxFailures in windowMs the caller is locked out for lockoutMs -- the callback is not even consulted during lockout, so the right password does not get through either. The counter is in-process memory and per server.

Files: write and rename

client.fileWrite(path, data, offset?) and client.fileRename(from, to) work against the server's fs (earlier versions of the client sent neither the bytes nor the new path). Without offset, fileWrite replaces the file (creating it); with one, it writes in place at that byte offset without truncating. fileRename refuses to overwrite an existing destination, to move a directory into itself, or to touch the root. Both honour readOnly and maxFileBytes, and a failure comes back as success: false with an error_message.

FileOp has no field for the bytes or the destination, so both use spec-conformant frames only: the FileOp is followed by FileChunk frame(s) on the same channel id (for rename, one chunk holding the UTF-8 destination). The host advertises file-write / file-rename in ServerHello; the client throws instead of sending to a host without them (the Rust wsh-server refuses these ops).

Compatibility: announce vs. primer

An exec session's output flows on a second, client-opened stream, and a QMux stream is invisible on the wire until its first byte. Two mechanisms exist for the host to find it:

| Client | Host | What happens | |---|---|---| | Current stock client | @johnhenry/wsh/server (advertises stream-announce) | The client's transport announces the stream with an empty STREAM frame. No primer is written. | | Current stock client | Host without stream-announce (e.g. the Rust wsh-server, older vendored hosts) | The client writes a one-byte primer (0x00) on the new stream so the host sees it; that host strips it. primer: false disables this (then such a host never binds the stream and the output is lost). | | 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. | | 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. |

RPC channels (object mode)

An rpc channel is an ordinary QMux stream whose payload is a sequence of objects, not bytes -- wsh's counterpart of an SSH subsystem: SSH channels are byte streams, SFTP is a typed, id-correlated protocol layered inside one. Each message is one CBOR data item (a CBOR sequence, self-delimiting, no length prefix) shaped as JSON-RPC 2.0, so MCP maps 1:1 and binary values are native byte strings (Uint8Array). Stream-announce, backpressure and close/reset apply unchanged; the control plane, the Rust codegen and exec/pty byte streams do not change.

// client
const rpc = await client.openRpc('mcp', { timeoutMs: 30_000 });   // sugar over openSession({ type: 'rpc', protocol })
const { tools } = await rpc.request('tools/list', {});            // rejects with RpcError
rpc.notify('notifications/initialized');
rpc.onRequest('sampling/createMessage', async (params, ctx) => ({ /* server -> client request */ }));
const call = rpc.request('read', { path: 'big.bin' }, { onProgress: (chunk) => {} });   // $/progress chunks
call.cancel();                                                    // $/cancel; rejects -32001 now, late answer dropped
rpc.onProgress(call.id, (chunk) => {});                           // or attach to a request already in flight
await rpc.close();

// server
import { createWshServer, mcpServerAdapter } from '@johnhenry/wsh/server';
createWshServer({
  auth, fs: { root: '/srv/files' },
  rpc: {
    'wsh-host': true,                       // host.info, host.ping
    'wsh-fs': true,                         // the server's `fs`; or { root, readOnly, maxFileBytes }
    mcp: mcpServerAdapter(mcpServer),       // any @modelcontextprotocol/sdk Server (or a factory for one per channel)
    custom: (channel, ctx) => {             // anything else: a function run per opened channel
      channel.onRequest('echo', (params, { signal, progress }) => params);
    },
  },
});

Negotiation. The host advertises rpc, one rpc-protocol:<name> per protocol and rpc-max-message:<bytes> (default 1 MiB) in ServerHello.features. openRpc() / openSession({ type: 'rpc', protocol }) refuse an unadvertised rpc or protocol with an RpcError (code -32000, reason: 'UNSUPPORTED_PROTOCOL') before sending any bytes. Open.command carries the protocol name (the Open message has no other free field; kind is 'rpc').

Semantics. id is unique per direction per channel and both sides may request. Responses are matched by id only (the class of bug behind #72). $/cancel { id } asks the callee to stop: it answers -32001 at once, its ctx.signal aborts, and the caller drops any late result. $/progress { id, chunk } notifications may precede the final response (file reads, logs). Closing the stream rejects every pending request with -32001 / reason: 'channel-closed'. Error codes: JSON-RPC's -32700..-32603, plus -32000 unsupported protocol, -32001 cancelled / timed out / closed, -32002 too many requests in flight (rpcMaxInflight, default 64), -32003 unauthorized. A message over the negotiated maximum is refused locally when sending and closes the channel when received.

Built-in protocols

| Protocol | Methods | |---|---| | wsh-host | host.info -> { version, protocol, features (this connection's ServerHello), hostFingerprint, user, rpc }; host.ping -> { time } | | 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. | | mcp | The MCP JSON-RPC surface verbatim; see below |

When a host advertises rpc-protocol:wsh-fs, fileStat / fileList / fileRead / fileWrite / fileRename / fileMkdir / fileRemove use it (results keep the FileResult shape; client.preferRpcFiles = false opts out). The control-plane FileOp/FileResult path stays for hosts that do not advertise it.

MCP. mcpServerAdapter(server) implements the official SDK's Transport interface over the channel, so any Server can be exposed (pass a factory (ctx) => new Server(...) for one per channel; an instance serves one channel at a time). On the other end, mcpClientTransport(channel) is a Transport for the SDK's Client:

const channel = await client.openRpc('mcp');
const mcp = new Client({ name: 'me', version: '1' }, { capabilities: {} });
await mcp.connect(mcpClientTransport(channel));
await mcp.callTool({ name: 'echo', arguments: { text: 'hi' } });

MCP is already JSON-RPC, so the adapter is a pass-through: request ids, results, errors and notifications (including notifications/progress) are forwarded verbatim. The one translation is cancellation: an MCP client abort (notifications/cancelled) becomes $/cancel on the wire, and an incoming $/cancel is delivered to the SDK as notifications/cancelled, so the tool handler's signal fires. The SDK is not imported by this package (the transport is structural); @modelcontextprotocol/sdk is an optional peer for whoever supplies the Server/Client, and the package root stays dependency-free and browser-safe.

Compatibility. Purely additive. Old clients never open rpc sessions; an old host does not advertise rpc, so a new client fails fast with UNSUPPORTED_PROTOCOL instead of waiting on an OpenFail. @johnhenry/wsh/server and the Rust wsh-server (see below) serve rpc today. Not goals: a replacement for byte streams (exec/pty are unchanged), a new transport (rpc rides WebSocket/QMux and WebTransport streams), or an auth change -- an rpc session is opened on an authenticated connection and per-method authorization is the handler's job (-32003; ctx.user / ctx.fingerprint identify the caller). Handlers run per channel, so register methods synchronously (or before the returned promise settles: inbound messages are held until it does).

The Rust wsh-server (since rust-v0.4.0) serves wsh-host and wsh-fs exactly as the JS server does -- same features, methods, parameters, results, error codes and messages, same CBOR-sequence decoder and size bound, same $/cancel / $/progress -- over a WebSocket (QMux) connection. It does not serve mcp (hosting an MCP server is an embedding concern) and does not offer rpc on the native WebTransport listener, whose single control stream has no client-opened data streams to carry a channel. wsh-host is on by default; wsh-fs is off until you give it a root (file access is opt-in), and is confined to it the way the JS fs option is. In ~/.wsh/config.toml:

[rpc]
enabled = true            # default; false turns rpc off entirely
max_message_bytes = 1048576   # advertised as rpc-max-message:<n>; at least 256
max_inflight = 64         # concurrent requests per channel before -32002
fs_root = "/srv/files"    # unset (the default): wsh-fs is not offered
fs_read_only = false
fs_max_file_bytes = 67108864

Key options apply as they do to other channels: a key restricted to a forced command opens no rpc channel, and wsh-fs needs the file-transfer scope (permit-file-transfer under restrict). host.info reports the crate version and hostFingerprint: null (the Rust server has no host key). The JS client needs nothing new: it reads rpc from ServerHello, and its file helpers prefer wsh-fs automatically once the server offers it.

Attach and Resume

Opening a PTY/exec session returns a session-scoped credential alongside the channel:

session.sessionId;   // server-assigned session id (undefined for e.g. file channels)
session.resumeToken; // token minted at open time; only the opener receives it

// The original opener, reclaiming its session from a fresh connection,
// replaying only the output it has not yet seen (`session.seq` counts it):
await client.resumeSession(session.sessionId, session.resumeToken, { lastSeq });

// Any other authorized principal attaches without a token -- ownership
// or an ACL grant is enough:
await client.grantSessionAccess(session.sessionId, 'bob');  // by the owner
await otherClient.attachSession(session.sessionId);         // by 'bob'

// Other session-management round trips:
await client.detach(session.sessionId);   // leave it running server-side
await client.listRemoteSessions();        // sessions this key can see

@johnhenry/wsh/server implements all of it with its sessions option, and so does the Rust wsh-server (since rust-v0.2.0): Resume replays only the output after last_seq (the position is defined in spec/wsh-v1.md), and Attach/Resume answer with a Presence carrying the new channel_id and seq, replay and live output arriving as SessionData on it. The Rust host's ring is a fixed 256 KiB per session and its sessions end with the server process; a session that exits while no one is attached is kept (for the replay and the exit) until it is resumed or expires.

Pinning a Self-Signed Certificate

Both transports normally need a certificate a public certificate authority signed. On a LAN -- a phone talking to a desktop on 192.168.x.x, a browser talking to a device on the same Wi-Fi -- there is no such certificate to be had, and a plaintext ws:// from an https:// page is blocked as mixed content.

WebTransport is the one place in the web platform with an answer: serverCertificateHashes lets the page pin a specific certificate by SHA-256 digest, no certificate authority involved.

// The digest can be raw bytes, base64, plain hex, or the colon-separated
// hex `openssl x509 -fingerprint -sha256 -noout -in cert.pem` prints --
// the whole `SHA256 Fingerprint=AB:CD:...` line is accepted as-is.
await client.connect('https://192.168.1.20:4433/wsh', {
  username: 'alice',
  keyPair,
  transport: 'wt',
  webTransport: {
    serverCertificateHashes: [
      'SHA256 Fingerprint=A1:B2:C3:...',
    ],
  },
});

WebTransportTransport also takes the same options directly, for use with connectWithTransport():

const transport = new WebTransportTransport({
  serverCertificateHashes: [certDigestBytes],
});

A malformed digest throws locally (RangeError for the wrong length, TypeError for an unrecognised shape) before any connection is attempted -- a wrong hash otherwise surfaces only as an opaque WebTransportError from wt.ready.

The constraints are the platform's, not wsh's:

  • The URL must be https:; pinning is HTTP/3 only, with no HTTP/2 fallback.
  • The certificate must use an ECDSA P-256 key and be valid for at most 14 days, so it has to be reissued on a schedule. serverCertificateHashes takes several digests: pin the current and the next one across a rotation (the Node host's certificateHashes() publishes both; see Certificate rotation).
  • Connection pooling is disabled for a pinned connection.
  • Only sha-256 is accepted as the algorithm.

The option applies to the WebTransport rung of the transport ladder only. If the WebTransport attempt fails and the client falls back to WebSocket, that wss: connection is subject to the ordinary certificate-authority check again -- there is no WebSocket equivalent of certificate pinning. Pass transport: 'wt' if you would rather fail than fall back.

Any other key in webTransport is forwarded to the WebTransport constructor verbatim (congestionControl, allowPooling, requireUnreliable, the anticipatedConcurrentIncoming*Streams hints), so options the platform gains later need no change here.

API Overview

Core Classes

| Class | Description | |-------|-------------| | WshClient | Full lifecycle client: connect, auth, sessions, reverse mode, MCP | | WshSession | Single PTY or exec channel with read/write/resize/signal | | WshTransport | Abstract transport base class | | WebTransportTransport | WebTransport implementation (native streams); takes serverCertificateHashes and other WebTransport options | | WebSocketTransport | WebSocket implementation (multiplexed virtual streams) |

Utilities

| Class / Function | Description | |------------------|-------------| | WshKeyStore | Ed25519 key management via IndexedDB + OPFS encrypted backup | | WshFileTransfer | File upload/download over dedicated streams | | WshMcpBridge | Remote MCP tool discovery and invocation | | RpcChannel / RpcError | Typed RPC channel (JSON-RPC 2.0 over a CBOR sequence): client.openRpc(protocol); RPC_FEATURE, rpcProtocolFeature(name), RPC_ERROR, CborSequenceDecoder | | mcpClientTransport() / mcpServerAdapter() (/server) | MCP SDK Transport over an rpc channel | | SessionRecorder | Record PTY I/O with timestamps (asciicast-v2-shaped JSON; convert to play in asciinema, see the note under Session recording) | | SessionPlayer | Replay recordings with original timing | | generateKeyPair() | Create Ed25519 key pair via Web Crypto | | sign(privateKey, message) / verify(publicKey, signature, message) | Raw Ed25519 sign / verify. This positional order (WebCrypto's) is the one canonical form across wsh, raijin and browsermesh | | signChallenge() | Build transcript + sign for auth handshake | | signPeerRecord() / verifyPeerRecord() | Sign / verify reverse-mode peer records | | fingerprint() | SHA-256 hex fingerprint of a public key | | podId() / fingerprintToPodId() / podIdToFingerprint() | The same SHA-256 as base64url, the BrowserMesh pod ID: await podId(raw) === await derivePodId(cryptoKey) (@johnhenry/browsermesh-primitives) for the same Ed25519 key, so one key carries both the wsh fingerprint and the mesh identity. The two converters are pure re-encodings between the two forms | | parseCertificateHash() | Decode a certificate digest from hex / base64 / bytes | | normalizeWebTransportOptions() | Build a WebTransportOptions dictionary from loose input |

Protocol

| Export | Description | |--------|-------------| | MSG | 99 message type constants (hex opcodes) | | CHANNEL_KIND | Channel types: pty, exec, meta, file, tcp, udp, job | | AUTH_METHOD | Auth methods: pubkey, password | | cborEncode / cborDecode | CBOR codec (maps, arrays, strings, ints, bytes, bools, null, floats) | | frameEncode / FrameDecoder | 4-byte big-endian length-prefixed framing | | QMuxConnection + QMux primitives | QMux stream state machine, error codes, and stream-id helpers for building alternate servers |

Protocol Specification

The spec/ directory contains the protocol definition:

  • wsh-v1.yaml -- machine-readable protocol schema
  • wsh-v1.md -- human-readable protocol specification
  • codegen.mjs -- generates messages.gen.mjs from the YAML spec

Rust implementation

crates/ is a native Rust implementation of the wsh protocol, moved into this repo from erisera-code/clawser on 2026-09-06 (with history, via git subtree) so it lives next to the JS client whose wire spec it implements instead of dragging its own toolchain and CI job into an unrelated browser-app repo. It was previously deleted from clawser by accident (2026-03-14) and restored (2026-07-05) before this move.

Four crates, workspace-versioned (currently 0.5.0):

| Crate | Kind | Description | |-------|------|--------------| | wsh-core | library | Shared protocol types -- CBOR messages (generated, see below), codec, identity, auth, QMux | | wsh-client | library | Native Rust client -- WebTransport/WebSocket transports, sessions, file transfer, E2E | | wsh-cli | binary (wsh) | SSH-like CLI: connect, exec, scp-style copy, reverse tunnels, key management | | wsh-server | binary (wsh-server) | Server: WebTransport/WebSocket listener, real PTY sessions (portable-pty), relay/reverse-connect, WISP bridging |

Capability matrix: JS SDK vs Rust CLI/client

Two implementations of one protocol will always have some asymmetry -- the question wsh #59 asks is whether it is visible and deliberate, or discovered by accident. This table is that answer, made explicit rather than left to be found.

| Capability | JS SDK (browser) | Rust CLI/client | Unified via | |---|---|---|---| | Directory listing (list) | WshFileTransfer.list() | wsh_client::file_transfer::list(), wsh ls, wsh sftp | Wire-unified: FileOp/FileResult with the generated FileEntry type (wsh #59) -- both languages decode the same shape, including symlink targets | | Upload/download | WshClient.upload()/.download() | file_transfer::upload()/download(), wsh scp | Wire-unified: FileChunk over a 'file'-kind channel (wsh #13) | | 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 | | 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 | | 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) but no Rust wsh-server release does yet (see Security). 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" | | Typed RPC channels (openRpc, rpc sessions; wsh-host, wsh-fs, mcp) | WshClient.openRpc(), RpcChannel | Rust wsh-server (rust-v0.4.0+): wsh-host, and wsh-fs when [rpc] fs_root is set; no client yet in wsh-client | No wire change: Open { kind: 'rpc', command: <protocol> } plus rpc* ServerHello features, negotiated so it fails fast elsewhere; the Rust server serves it on WebSocket/QMux only | | Exec stdin + EOF, separate stderr | exec({ stdin }), session.closeStdin(), session.onStderr | none yet in wsh-client | Wire-unified, negotiated (#105): Open.stdio = "pipes" + OpenOk.stderr_channel_id + zero-length SessionData