Node server
The package root is a client and stays browser-safe. @johnhenry/wsh/server is
a separate subpath: a Node host for the same protocol, over WebSocket (QMux)
and, optionally, WebTransport. The root never imports it. It needs the
optional peer dependency ws:
npm install @johnhenry/wsh wsNode.js 26 or newer is required, like the rest of the package.
A minimal host
Section titled “A minimal host”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 identity exec: true, // opt in: run commands via /bin/sh fs: { root: '/srv/share', readOnly: false }, // opt in: file operations under one directory});
const { port } = await server.listen(); // later: server.address(), await server.close()The design rule is everything that touches the machine is off unless you
pass it, and with no auth every connection is refused. exec, pty,
fs, mcp, sessions, relay and webTransport are each opt-in options;
leaving one out means that capability does not exist on the host, not that it
is merely unconfigured.
auth is { authorizedKeys, authorize, password, rateLimit }, or just an
authorize function. When both authorizedKeys and authorize are given, a
key has to be on the list and pass authorize({ username, fingerprint, publicKey }). parseAuthorizedKeys(text) is exported if you want the raw
32-byte keys.
The stock client connects with no changes:
import { WshClient, generateKeyPair } from '@johnhenry/wsh';
const keyPair = await generateKeyPair(true);const { stdout, exitCode } = await WshClient.exec( 'ws://127.0.0.1:4422', 'uname -a', { username: 'alice', keyPair },);exec and pty
Section titled “exec and pty”exec: true runs each command with /bin/sh. Pass an options object for
{ cwd, env, shell, timeoutMs, clientEnv }, or a custom run(command, io)
function for a restricted host with no shell at all. A runner gets the user,
the requested environment, an abort signal, write(), and onInput /
onInputEnd / onSignal hooks, and resolves with the exit code:
createWshServer({ auth, exec: async (command, io) => { if (command !== 'date') { await io.write('only `date` is allowed\n'); return 1; } await io.write(new Date().toISOString() + '\n'); return 0; },});pty is bring-your-own: the package has no native dependency, so you hand it
node-pty’s spawn (or anything shaped like it):
import * as nodePty from 'node-pty';
createWshServer({ auth, pty: { spawn: nodePty.spawn, shell: '/bin/bash' } });fs: { root, readOnly, maxFileBytes } serves list, stat, read, write,
rename, mkdir and remove, plus upload and download, from one directory.
root confines every path: .., absolute paths and symlinks that point out
of it are refused, for write and rename (both paths) exactly as for
read.
On the client, fileWrite(path, data, offset?) and fileRename(from, to) are
real: the bytes and the destination ride spec-conformant FileOp plus
FileChunk frames. Without offset, fileWrite replaces the file; with one
it writes in place at that byte offset without truncating. fileRename
refuses to overwrite an existing destination. A failure comes back as
success: false with an error_message. The host advertises file-write /
file-rename in ServerHello, and the client throws instead of sending to a
host that does not.
await client.fileWrite('todo.txt', 'ship it\n'); // creates or replacesawait client.fileRename('todo.txt', 'done.txt');const listing = await client.fileList('/');Host key and trust on first use
Section titled “Host key and trust on first use”With hostKey, the server has an Ed25519 identity and signs a proof of
possession into its ServerHello, so clients can pin it. hostKey is { file } (a PKCS#8 PEM, created mode 0600 on first start; use this), a
CryptoKeyPair, or true (a fresh key every start, for tests and demos).
// serverawait server.listen();server.hostKey(); // { fingerprint, publicKey, openssh } -- publish this out of band
// clientimport { 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' }expectHostKey takes a hex fingerprint (a sha256: prefix is fine), a 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. Refusals are
HostKeyError with a code of HOST_KEY_MISSING, HOST_KEY_INVALID,
HOST_KEY_MISMATCH, HOST_KEY_UNKNOWN or HOST_KEY_REJECTED, thrown before
any signature or password is sent. WshClient.exec() and connectReverse()
take the same options.
Two limits worth knowing. WshKnownHosts defaults to localStorage, which
in Node is not a persistent file store; pass new WshKnownHosts({ storage })
with a getItem / setItem / removeItem object you back with a file for a
real TOFU file. And the proof authenticates the ServerHello, not the byte
stream: over plain ws:// an active attacker can relay a genuine hello 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
Section titled “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 password travels as plain text inside the connection, so serve password
logins over wss:// (a TLS-terminating proxy) or a link you trust, and pin
the host key so the password is only ever sent to the right host. Compare
against stored hashes in constant time, never with === on plaintext.
Failures are throttled per peer address (override with rateLimit.key, for
example to read x-forwarded-for behind a proxy). After maxFailures inside
windowMs the caller is locked out for lockoutMs, and the callback is not
even consulted during lockout, so the right password does not get through
either. The counter lives in process memory, per server. A host configured
with only password refuses key logins, and vice versa.
Resumable sessions
Section titled “Resumable sessions”By default a pty or exec session dies with the connection that opened it.
Pass sessions and it outlives it:
createWshServer({ auth, exec: true, sessions: { detachTtlMs: 300_000, // how long a session nobody is attached to keeps running maxDetached: 16, // unattended sessions kept at once ringBytes: 1 << 20, // output history kept per session: what a resume can replay // sessionSecret: process.env.WSH_SESSION_SECRET, // fix the token key across restarts },});sessions: true takes the defaults. On the client:
const s = await client.openSession({ type: 'exec', command: 'make watch' });s.onData = (d) => process.stdout.write(d); // s.seq counts output bytes receivedconst { sessionId, resumeToken } = s;const lastSeq = s.seq;
await client.detach(sessionId); // leave it running; then the socket can goawait client.disconnect();
// fresh connection, same keyconst { session } = await client2.resumeSession(sessionId, resumeToken, { lastSeq });session.onData = (d) => process.stdout.write(d); // only the bytes after lastSeq, then live outputseqis the cumulative count of output bytes the host has produced for a session. The client counts what it received (WshSession.seq), so there is no per-frame field andlastSeqis exactly that number.- The host keeps the newest
ringBytesof output. AlastSeqolder than the ring still holds is refused with anoutput gaperror naming where the history starts (fall back toattachSession()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, so a chatty process loses its oldest output rather than blocking. resumeSession()needs the token and ownership (the same key, or for a password login the same username).attachSession()accepts the token or ownership, so the owner can re-attach with just the session id and a token holder can join as a guest. Both return thePresencereply with a non-enumerablesessionproperty: aWshSessionto read and write the re-attached channel.- Up to 16 connections can attach to one session. Output fans out to all of
them, and
attachSession(id, { readOnly: true })attaches a viewer whose input is dropped. - Ending versus leaving:
Closefrom the owner ends the session, which is whatsession.close()and a gracefulclient.disconnect()send. To walk away and keep it running, callclient.detach(sessionId)first; simply losing the connection also detaches.client.listRemoteSessions()lists the sessions your key owns, attached or not. - Sessions are in memory: a server restart ends them all. ACL grants
(
SessionGrant/SessionRevoke) andControlChangedare not built on this host.
MCP tools
Section titled “MCP tools”mcp makes the host answer McpDiscover and McpCall, so client.discoverTools(),
client.callTool() and WshMcpBridge work against it:
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', // client: mcpSdkClient, // proxy an @modelcontextprotocol/sdk Client's tools too // maxConcurrent: 8, timeoutMs: 30_000, },});
const result = await client.callTool('read_note', { id: '7' });Arguments are untrusted. They are validated against inputSchema before
call() runs, and invalid arguments never reach the tool. The package has no
runtime dependencies, so the validator covers the keywords tool schemas
actually use (type, enum, const, properties, required,
additionalProperties, items, length and range bounds, pattern,
multipleOf, allOf / anyOf / oneOf / not, local $ref). A schema
using any other keyword is refused when the server is created rather than
silently enforced less strictly. Only tools you list are reachable.
A tool that throws, an unknown tool, invalid arguments, a timeout, or the
concurrency cap all answer { success: false, error }, so throw messages you
are happy for the caller to read. authorize(user, tool, who) filters
discovery and calls alike, and a hidden tool looks the same as one that does
not exist. client takes anything with listTools() / callTool(); this
package does not import the MCP SDK itself. Without mcp, discovery answers
an empty list.
WebTransport listener
Section titled “WebTransport listener”The client prefers WebTransport (https://) and falls back to wss://.
webTransport gives the Node host the first rung, with independent QUIC
streams and 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 } as PEM});await server.listen(); // the WebSocket listener still runsconst { url, certificateHash } = server.webTransport(); // https://127.0.0.1:4433/wsh, sha-256 of the cert
// a browser, or Node with a WebTransport implementationawait client.connect(url, { username, keyPair, transport: 'wt', webTransport: { serverCertificateHashes: [{ algorithm: 'sha-256', value: certificateHash }] },});- The listener needs two optional peers, imported only when
webTransportis set:npm install @fails-components/webtransport @fails-components/webtransport-transport-http3-quiche(a native HTTP/3 build). Without themlisten()rejects with that instruction, and a server with nowebTransportnever loads them. - Auth,
exec,pty,fs,mcp,sessionsandrelaybehave identically on both wires; only the transport differs. selfSigned: truegenerates an ECDSA P-256 certificate valid for 13 days, because browsers refuse a pinned certificate valid for more than 14, and exposes its SHA-256 forserverCertificateHashes. Pass{ selfSigned: { hosts, validityDays } }to change the names or the lifetime. It is not renewed: restart beforenotAfterand re-pin. A certificate you pass ascert/privKeyis used as is, andcertificateHashis thennull.generateSelfSignedCertificate()is exported for the same certificate without a server. It returns{ cert, privKey, hash, hashHex, notBefore, notAfter }.- Node has no
WebTransportglobal, so a Node client needsglobalThis.WebTransport = (await import('@fails-components/webtransport')).WebTransportfirst. The transport is UDP, so a firewall must allow the port, and there is no TLS terminator in front: the certificate is the one this process serves. - The platform’s pinning rules apply: an
https:URL and HTTP/3 only. If the WebTransport attempt fails and the client falls back to WebSocket, that connection is subject to the ordinary certificate check again; passtransport: 'wt'to fail instead.
Relay and reverse mode
Section titled “Relay and 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. The server subpath provides both ends:
import { createWshServer, createReverseHost } from '@johnhenry/wsh/server';
// The relay: 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' }, accept: ({ fingerprint, username }) => fingerprint === aliceFingerprint, // default: nobody});await host.start(); // host.fingerprint is what operators connect to
// The operator: the stock client.const peers = await client.listPeers(); // each entry's signed record is verified client-sideawait client.reverseConnect(peers[0].fingerprint); // resolves with ReverseAccept or ReverseRejectconst s = await client.openSession({ type: 'exec', command: 'echo hello' });- Default deny, three times. The relay admits no peer (
canRegister) and lets no one connect (canConnect) unless you say so; the peer refuses every operator unlessacceptsays otherwise; and the client’strustRelayPeer()is the third gate. A peer an operator may not connect to is not listed and looks the same as an absent one. Relay roles need a key login; password logins can still use the relay server as an ordinary host. - Signed records. A registration is checked against the connection’s own
authenticated key and its self-signed record is verified at the relay, then
verified again by each operator. The record’s
seqmust increase per fingerprint, so an old record cannot regress a peer. - One operator per peer at a time. A second
reverseConnectis rejected asbusy. A bridge lasts as long as both connections; when either ends the relay closes the other, so nothing an operator started outlives it.createReverseHostredials with backoff unless you passreconnect: false. - Across the relay, exec sessions are
data_mode: 'virtual': stdin and output rideSessionData, and there is no stdin EOF. - Not provided: end-to-end encryption between operator and peer.
KeyExchangeandEncryptedFrameare not on the spec’s forwardable list, so the relay sees the traffic in clear. Serve the relay overwss://and treat its operator as trusted. Client-side feature gates such asfileWrite’sfile-writecheck read the relay’sServerHello, which says nothing about the peer behind it, so against a relay withoutfs,fileWrite()refuses locally.
Compatibility with other hosts
Section titled “Compatibility with other hosts”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. This host advertises
stream-announce, so the stock client announces the stream itself and writes
no primer byte. Against a host that does not advertise it (for example the
Rust wsh-server), the client writes a one-byte primer for you, and that
host strips it. Pass primer: false to WshClient.exec() or openSession() to never send it.
If a client primes anyway, this server drops a leading lone 0x00 from the
first chunk of an exec stream rather than forwarding it to the process’s
stdin.
See the Guide for the client side and the API for every export.