Guide
A full PTY session
Section titled “A full PTY session”import { WshClient, generateKeyPair } from '@johnhenry/wsh';
const keyPair = await generateKeyPair(true);
const client = new WshClient();await client.connect('wss://shell.example.com', { username: 'alice', keyPair, transport: 'ws',});
const session = await client.openSession({ type: 'pty', command: '/bin/bash', cols: 120, rows: 40,});
// Output arrives as bytes — decode it yourself. wsh does not assume UTF-8,// because a PTY carries control sequences and raw bytes, not just text.session.onData = (data) => process.stdout.write(new TextDecoder().decode(data));
await session.write('echo hello world\n');await session.resize(160, 50); // terminal size is part of the session stateawait session.close();await client.disconnect();Sessions can be opened, attached, resumed, and detached — a detached session keeps running on the host, and you (or another client) can reattach to it later. That’s the difference between wsh and a raw socket: the session is a first-class, resumable object, not just a pipe.
session.onClose now receives a closeReason (Error | null) — null for
a clean close, an Error when the session ended abnormally, so a client can
distinguish “you closed it” from “it died” without guessing from side effects.
Detach, resume, attach
Section titled “Detach, resume, attach”When a PTY or exec session is opened, the server mints a session id and a
session-scoped resume token, exposed as session.sessionId and
session.resumeToken. Those two values, plus how much output you have already
seen, are the whole story of coming back:
const { sessionId, resumeToken } = session;const lastSeq = session.seq; // cumulative output bytes received so far
await client.detach(sessionId); // release it; it keeps running host-side
// Later — possibly from a brand-new connection with the same key:const { session: resumed } = await client2.resumeSession(sessionId, resumeToken, { lastSeq });resumed.onData = (data) => process.stdout.write(new TextDecoder().decode(data));// only the bytes after lastSeq arrive, then live outputsession.seq is the running count of output bytes the client has received;
passing it as lastSeq makes the host replay only what you missed. A host
with a bounded history refuses a lastSeq older than it still holds (an
output gap error), in which case attachSession() gets you the retained
tail. resumeSession() and attachSession() return the Presence reply,
with a non-enumerable session property: the WshSession to read and write
the re-attached channel.
Note that session.close() and a graceful client.disconnect() end a session
you own. To walk away and keep it running, call detach() first (a dropped
connection also detaches). The host has to opt in to keeping sessions alive:
@johnhenry/wsh/server does so with its sessions option, see
Node server.
The distinction between the two reattachment calls is who you are:
resumeSession(sessionId, token)— the token is required. Resume is for the original opener coming back and proving it holds the exact credential minted at open time.attachSession(sessionId, { readOnly, token })— the token is optional. Attach also works for a principal who owns the session or was granted access viagrantSessionAccess(sessionId, principal, permissions)— such a principal never held the token, so the server accepts ownership or an ACL grant instead. Omit the token for that common case.
listRemoteSessions() asks the server which sessions your key owns or has
been granted (distinct from the purely local listSessions()), and
revokeSessionAccess() undoes a grant.
One-shot exec
Section titled “One-shot exec”When you don’t need an interactive terminal, WshClient.exec is a static
convenience that connects, runs one command, collects output, and disconnects:
const { stdout, exitCode } = await WshClient.exec( 'wss://shell.example.com', 'ls -la /tmp', { username: 'alice', keyPair },);
console.log(new TextDecoder().decode(stdout), 'exit', exitCode);stdout is bytes here too — same reason.
A stream-mode exec session needs the host to notice the data stream the client
opened. A host that advertises stream-announce (the Node server does)
discovers it by itself; against a host that does not, 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.
Keys and authentication
Section titled “Keys and authentication”Auth is Ed25519 challenge-response over the Web Crypto API, and the key material is the security boundary — treat it accordingly:
generateKeyPair(extractable)— passtrueonly if you need to export or back the key up;falsekeeps the private key non-extractable in the browser, which is the safer default for a long-lived key.WshKeyStorepersists keys in IndexedDB with an OPFS encrypted backup (PBKDF2 + AES-256-GCM). The backup is encrypted at rest; the passphrase is never stored.fingerprint(publicKey)gives a SHA-256 hex fingerprint — the thing to show a user or pin, not the raw key.isEd25519Supported()checks Web Crypto Ed25519 availability before you try to generate a key — useful for a clean unsupported-browser message instead of agenerateKeyPair()throw.
The handshake signs a transcript, not just a nonce — and the transcript binds the username and session id, so a signature captured on one connection cannot be replayed against another, or presented under a different identity.
WshClient.addAuthorizedKey() sends a real AuthorizedKeyAdd protocol
message (replacing the old wsh copy-id shell-script approach) and resolves
with the server’s AuthorizedKeyResult — the same authorization step, now a
first-class client call instead of a side-channel script.
Reverse mode and verified peers
Section titled “Reverse mode and verified peers”In reverse mode a peer registers with a relay and accepts incoming
connections. Registrations are self-signed peer records (the libp2p
signed-envelope pattern): connectReverse() signs your record
automatically, and listPeers() verifies each returned entry against the
peer’s own key, adding a verified: boolean to every result. A relay that
tampers with or forges a registration produces verified: false — you’re
trusting the peer’s signature, not the relay’s word.
End-to-end frame encryption
Section titled “End-to-end frame encryption”initiateE2E(sessionId) performs an ephemeral X25519 exchange and derives an
AES-256-GCM key. Pass 'X25519+ML-KEM-768' to get the hybrid post-quantum
mode — native WebCrypto ML-KEM-768 where available (Node 24.7+), the
optional @noble/post-quantum package elsewhere — which combines both
secrets via HKDF-SHA256 and falls back to classical automatically if the
peer doesn’t support it (check the returned hybrid flag).
The derived key is genuinely wired to real traffic encryption:
session.enableE2E(sharedSecret, { role }) seals every write()’s output
into an EncryptedFrame (AES-256-GCM, an 8-byte monotonic counter plus a
4-byte per-role tag as the nonce, session_id bound as AEAD associated
data) and transparently opens incoming sealed frames before they reach
onData — for both virtual-mode sessions and stream-mode (PTY/exec)
sessions, with an optional { coalesce } option tuning WriteCoalescer’s
batching profile (latency-first for a PTY, throughput-first for exec).
role matters: openFrame() checks the expected counterpart role tag, so
the two ends of a session must pass opposite roles (e.g. 'client'/
'server') or frames won’t open.
Pinning the host: expectHostKey and trust on first use
Section titled “Pinning the host: expectHostKey and trust on first use”A host that advertises a key (the Node server does, with its hostKey
option) fills in ServerHello.host_fingerprint and proves it holds the key
with a signature bound to a fresh nonce from your Hello, so the proof cannot
be replayed. The client can then pin it:
import { WshClient, WshKnownHosts, HostKeyError } from '@johnhenry/wsh';
const client = new WshClient();
// Pin a fingerprint you got out of band; refuses on mismatch.await client.connect(url, { username, keyPair, expectHostKey: fingerprint });
// Or trust on first use, remembered in a store (the ssh_known_hosts model).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 stored pin; an unseen host is
refused unless trustOnFirstUse is set or an onHostKey callback (on the
client, or per connect() call) accepts it. Either option also refuses a host
that presents no key at all. Refusals are HostKeyError, with a code of
HOST_KEY_MISSING, HOST_KEY_INVALID, HOST_KEY_MISMATCH,
HOST_KEY_UNKNOWN or HOST_KEY_REJECTED, and they are thrown before any
signature or password is sent. WshClient.exec() and connectReverse() take
the same options.
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. Pinning keeps you from talking to
the wrong host but does not encrypt anything: over plain ws:// an active
attacker can relay a genuine host proof and then read or alter the rest, so
confidentiality still needs wss:// or a link you trust. The Rust
wsh-server advertises no key, so a pin against it is refused with
HOST_KEY_MISSING, never silently skipped.
Files: list, write, rename
Section titled “Files: list, write, rename”Alongside upload() / download(), the client does directory operations
against a host that serves files (the Node server’s fs option):
await client.fileWrite('todo.txt', 'ship it\n'); // create or replaceawait client.fileWrite('todo.txt', 'X', 3); // write in place at byte 3, no truncationawait client.fileRename('todo.txt', 'done.txt'); // refuses to overwriteconst listing = await client.fileList('/'); // listing.entries: name, size, modified, typeawait client.fileRemove('done.txt');Each resolves with a FileResult-shaped object; a failure is
success: false with an error_message, not a throw. fileWrite and
fileRename send spec-conformant frames (a FileOp followed by FileChunk
frames on the same channel; for a rename, one chunk holding the UTF-8
destination), and need the host to advertise file-write / file-rename in
its ServerHello. The client throws instead of sending to a host that does
not, as the Rust wsh-server does not support those two operations.
Beyond the basics
Section titled “Beyond the basics”The same client also exposes file transfer (WshFileTransfer and
client.upload/download — FileChunk control messages in 64KB chunks, so
transfers work identically on stream-backed and virtual channels) with
structured directory listing (client.fileList(path) resolves with a
FileResult whose entries is a typed FileEntry[], and
WshFileTransfer.list() decodes it for you; both back the CLI’s sftp/ls
commands on both clients), session recording and playback (SessionRecorder
/ SessionPlayer; the recording uses wsh’s own JSON schema, not asciicast
v2), and a remote-MCP bridge (WshMcpBridge, to discover and invoke MCP
tools over the control channel — calls carry a call_id for correlating
concurrent in-flight calls, against hosts that advertise mcp-call-id). To
serve all of this yourself, see Node server. See the
API for the full surface.