Skip to content

Guide

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 state
await 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.

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 output

session.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 via grantSessionAccess(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.

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.

Auth is Ed25519 challenge-response over the Web Crypto API, and the key material is the security boundary — treat it accordingly:

  • generateKeyPair(extractable) — pass true only if you need to export or back the key up; false keeps the private key non-extractable in the browser, which is the safer default for a long-lived key.
  • WshKeyStore persists 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 a generateKeyPair() 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.

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.

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.

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 replace
await client.fileWrite('todo.txt', 'X', 3); // write in place at byte 3, no truncation
await client.fileRename('todo.txt', 'done.txt'); // refuses to overwrite
const listing = await client.fileList('/'); // listing.entries: name, size, modified, type
await 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.

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.