@johnhenry/wsh 0.17.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,40 @@
1
1
  # @johnhenry/wsh
2
2
 
3
- Full documentation: [opensource.johnhenry.me/wsh](https://opensource.johnhenry.me/wsh/)
4
-
5
3
  > Previously published as `wsh-upon-star` (last release: 0.1.1, now deprecated).
6
- > Renamed to `@johnhenry/wsh` and restarted at 0.0.0 on import into the
7
- > @johnhenry family — a new name and era, not a maturity signal.
4
+ > Renamed to `@johnhenry/wsh` on import into the @johnhenry family. Unlike
5
+ > other packages in this family, wsh did **not** restart its version at
6
+ > `0.0.0` on adoption — it was already a mature, actively-depended-upon
7
+ > release, so versioning continued forward and now stands at `0.17.0`. This
8
+ > is a deliberate, permanent exception to the family's usual 0.0.0-restart
9
+ > convention, not an oversight.
10
+
11
+ [![npm version](https://img.shields.io/npm/v/%40johnhenry%2Fwsh.svg)](https://www.npmjs.com/package/@johnhenry/wsh)
12
+ [![CI](https://github.com/johnhenry/wsh/actions/workflows/ci.yml/badge.svg)](https://github.com/johnhenry/wsh/actions/workflows/ci.yml)
13
+ [![license](https://img.shields.io/npm/l/%40johnhenry%2Fwsh.svg)](LICENSE)
14
+
15
+ Full documentation: [opensource.johnhenry.me/wsh](https://opensource.johnhenry.me/wsh/)
8
16
 
9
17
  Browser-native remote command execution over WebTransport/WebSocket with Ed25519 authentication.
10
18
 
11
19
  wsh is a pure-JS client library 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.
12
20
 
21
+ ## Contents
22
+
23
+ - [Install](#install)
24
+ - [Features](#features)
25
+ - [Wire Protocol: QMux](#wire-protocol-qmux)
26
+ - [Quick Start](#quick-start)
27
+ - [One-Shot Command Execution](#one-shot-command-execution)
28
+ - [Attach and Resume](#attach-and-resume)
29
+ - [Pinning a Self-Signed Certificate](#pinning-a-self-signed-certificate)
30
+ - [API Overview](#api-overview)
31
+ - [Protocol Specification](#protocol-specification)
32
+ - [Rust implementation](#rust-implementation)
33
+ - [Security model](#security-model)
34
+ - [Browser Compatibility](#browser-compatibility)
35
+ - [Family](#family)
36
+ - [License](#license)
37
+
13
38
  ## Install
14
39
 
15
40
  ```bash
@@ -34,9 +59,22 @@ Or via CDN:
34
59
  - **Reverse mode** -- register as a peer (via a signed peer record) and accept incoming connections through a relay
35
60
  - **File transfer** -- scp-like upload/download as `FileChunk` control messages in 64KB chunks
36
61
  - **MCP bridge** -- discover and invoke remote MCP tools through the control channel
37
- - **Session recording** -- asciicast v2 compatible recording and playback with seek/pause/resume
62
+ - **Session recording** -- own JSON schema (not asciicast v2 -- see note below) recording and playback with seek/pause/resume
38
63
  - **Key management** -- IndexedDB storage with OPFS encrypted backup (PBKDF2 + AES-256-GCM)
39
- - **95 message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, and more
64
+ - **97 message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, authorized-key management, and more
65
+
66
+ > **Session recording is not asciicast v2.** Both `SessionRecorder`
67
+ > implementations capture more than asciicast v2's three event codes
68
+ > (`o`/`i`/`r`) can represent -- session lifecycle events (`open`/`exit` in
69
+ > JS; `Start`/`Exit`/`Snapshot` in Rust) have no asciicast v2 equivalent,
70
+ > and dropping them wasn't worth it just to match the format. The JS and
71
+ > Rust recorders also use two different, mutually incompatible schemas from
72
+ > each other (neither is asciicast v2's newline-delimited-JSON shape).
73
+ > `RecordingExport`'s `format: "asciicast"` option is accepted on the wire
74
+ > but not yet implemented server-side -- requesting it currently returns
75
+ > the same custom JSONL as `format: "jsonl"`. If interop with standard
76
+ > asciicast players (`asciinema play`, etc.) is wanted, that needs a real
77
+ > (lossy -- lifecycle events would be dropped) export path, not a rename.
40
78
 
41
79
  ## Wire Protocol: QMux
42
80
 
@@ -208,7 +246,7 @@ so options the platform gains later need no change here.
208
246
  | `WshKeyStore` | Ed25519 key management via IndexedDB + OPFS encrypted backup |
209
247
  | `WshFileTransfer` | File upload/download over dedicated streams |
210
248
  | `WshMcpBridge` | Remote MCP tool discovery and invocation |
211
- | `SessionRecorder` | Record PTY I/O with timestamps (asciicast v2) |
249
+ | `SessionRecorder` | Record PTY I/O with timestamps (own schema, not asciicast v2) |
212
250
  | `SessionPlayer` | Replay recordings with original timing |
213
251
  | `generateKeyPair()` | Create Ed25519 key pair via Web Crypto |
214
252
  | `signChallenge()` | Build transcript + sign for auth handshake |
@@ -221,7 +259,7 @@ so options the platform gains later need no change here.
221
259
 
222
260
  | Export | Description |
223
261
  |--------|-------------|
224
- | `MSG` | 95 message type constants (hex opcodes) |
262
+ | `MSG` | 97 message type constants (hex opcodes) |
225
263
  | `CHANNEL_KIND` | Channel types: `pty`, `exec`, `meta`, `file`, `tcp`, `udp`, `job` |
226
264
  | `AUTH_METHOD` | Auth methods: `pubkey`, `password` |
227
265
  | `cborEncode` / `cborDecode` | CBOR codec (maps, arrays, strings, ints, bytes, bools, null, floats) |
@@ -254,6 +292,26 @@ Four crates, workspace-versioned at `0.1.0`:
254
292
  | `wsh-cli` | binary (`wsh`) | SSH-like CLI: connect, exec, scp-style copy, reverse tunnels, key management |
255
293
  | `wsh-server` | binary (`wsh-server`) | Server: WebTransport/WebSocket listener, real PTY sessions (`portable-pty`), relay/reverse-connect, WISP bridging |
256
294
 
295
+ ### Capability matrix: JS SDK vs Rust CLI/client
296
+
297
+ Two implementations of one protocol will always have *some* asymmetry --
298
+ the question wsh #59 asks is whether it is visible and deliberate, or
299
+ discovered by accident. This table is that answer, made explicit rather
300
+ than left to be found.
301
+
302
+ | Capability | JS SDK (browser) | Rust CLI/client | Unified via |
303
+ |---|---|---|---|
304
+ | 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 |
305
+ | Upload/download | `WshClient.upload()`/`.download()` | `file_transfer::upload()`/`download()`, `wsh scp` | **Wire-unified**: `FileChunk` over a `'file'`-kind channel (wsh #13) |
306
+ | 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 |
307
+ | 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 |
308
+ | Host identity / TOFU | `WshKnownHosts` (localStorage-backed) | `KnownHosts`/`HostStatus` (`~/.wsh/known_hosts`-backed) | **Record unified, policy is not** (wsh #59): both pin `ServerHello.host_fingerprint`, a field no `wsh-server` release populates yet (see [Security](#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" |
309
+ | Interactive shell UI | none -- this SDK is a protocol client, not a terminal emulator; pair with xterm.js/ghostty-web | `wsh connect`, `wsh sftp` (line-oriented REPL) | **Deliberately not unified** -- a browser embeds a terminal widget the host page owns; a CLI process owns its own TTY |
310
+ | Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()` | `wsh reverse`, `wsh agent` (persistent, with startup-unit install) | **Wire-unified** (registration, discovery, signed peer records); **daemonization is CLI-only** -- a browser tab cannot be a background OS service |
311
+ | Post-quantum E2E (experimental) | `initiateE2E()` (WebCrypto ML-KEM-768 or `@noble/post-quantum` fallback) | `E2eKeyExchange` (`ml-kem` crate) | **Wire-unified** algorithm and transcript; key material backends differ by platform necessity |
312
+ | Session recording/replay | `SessionRecorder`/`SessionPlayer` (asciicast v2) | none | **JS-only** -- no current Rust consumer needs playback; the format itself (asciicast v2) is not proprietary if one is added later |
313
+ | Self-signed cert pinning | `serverCertificateHashes` (WebTransport option) | N/A -- Rust dials a cert its own TLS stack already trusts, or `--generate-cert`'s self-signed cert out of band | **Deliberately not unified** -- this is a browser-specific WebTransport API shape, not a wire-protocol concept |
314
+
257
315
  ### Build and test
258
316
 
259
317
  ```sh
@@ -307,24 +365,71 @@ plus one `SHA256SUMS` file covering every archive in the release. These
307
365
  names are a stable contract other repos (clawser) pin and download by --
308
366
  do not change them without coordinating downstream.
309
367
 
310
- ## Security
368
+ ## Security model
369
+
370
+ wsh's protocol draws its line at authentication and message integrity: what
371
+ it guarantees is that a handshake and its messages are who and what they
372
+ claim to be. It does not guarantee confidentiality of every auth method, or
373
+ that a server's identity has actually been pinned yet -- both are called
374
+ out explicitly below rather than left to be discovered.
375
+
376
+ **What wsh guarantees:**
311
377
 
312
- - **Auth transcript binding** -- challenge signatures cover
378
+ - **Auth transcript binding.** Challenge signatures cover
313
379
  `SHA-256("wsh-v1\0" || lp(username) || lp(session_id) || nonce || channel_binding)`,
314
380
  so a signature can't be replayed against a different session or relabeled
315
381
  to a different username.
316
- - **Signed peer records** -- reverse-mode registration is self-signed by the
382
+ - **Signed peer records.** Reverse-mode registration is self-signed by the
317
383
  peer's identity key (the libp2p RFC 0002/0003 pattern), in a signing
318
384
  domain separate from the auth challenge. `listPeers()` verifies every
319
385
  entry client-side and reports a `verified` boolean, independent of
320
386
  trusting the relay.
321
- - **Hybrid post-quantum E2E (experimental)** --
322
- `initiateE2E(sessionId, 'X25519+ML-KEM-768')` combines X25519 ECDH with
323
- ML-KEM-768 via HKDF-SHA256, preferring native WebCrypto ML-KEM-768 (Node
324
- 24.7+) with the optional `@noble/post-quantum` pure-JS fallback, and
325
- falling back to classical X25519 automatically when the peer can't do
326
- hybrid (check the returned `hybrid` flag). The derived AES-256-GCM key is
327
- not yet wired to actual frame encryption.
387
+
388
+ **What is still yours:**
389
+
390
+ - **Hybrid post-quantum E2E is experimental and not fully wired (in
391
+ progress).** `initiateE2E(sessionId, 'X25519+ML-KEM-768')` combines
392
+ X25519 ECDH with ML-KEM-768 via HKDF-SHA256, preferring native WebCrypto
393
+ ML-KEM-768 (Node 24.7+) with the optional `@noble/post-quantum` pure-JS
394
+ fallback, and falling back to classical X25519 automatically when the
395
+ peer can't do hybrid (check the returned `hybrid` flag). The derived
396
+ AES-256-GCM key is not yet wired to actual frame encryption by default --
397
+ treat `initiateE2E` as key agreement, not confidentiality, unless you've
398
+ separately confirmed the session has sealing enabled.
399
+ - **Host identity (TOFU) has a known, named gap (wsh #59).**
400
+ `ServerHello.host_fingerprint` is the spec's formal host-identity slot:
401
+ the SHA-256 fingerprint of a server's persistent Ed25519 host key, meant
402
+ to be pinned across connections the way SSH pins a host key. **No
403
+ `wsh-server` release populates this field yet** -- minting and
404
+ persisting a server host keypair is a separate, security-sensitive
405
+ feature, deliberately not bundled into this change. Both clients ship
406
+ the store this field is for: `WshKnownHosts` (JS, `localStorage`-backed)
407
+ and `KnownHosts` (Rust, `~/.wsh/known_hosts`-backed) have matching
408
+ verify/add/remove/list semantics. Today, against every real server,
409
+ both report every host as unverified for host-identity purposes; the
410
+ Rust client additionally still falls back to `fingerprints[0]` (an
411
+ *authorized client key* this server happens to have, not this server's
412
+ own identity -- see `ServerHello.fingerprints`' doc comment in
413
+ `spec/wsh-v1.yaml`) with a logged warning, for backward compatibility.
414
+ Do not treat either store as a MITM defense until a server actually
415
+ populates `host_fingerprint`. `serverCertificateHashes` (below) is a
416
+ different, narrower mechanism -- it pins a certificate supplied *per
417
+ connection*, not a persisted record of what a host presented last time.
418
+ - **Password auth has no confidentiality of its own -- it relies entirely
419
+ on the transport.** `AUTH_METHOD.PASSWORD` sends the password as a plain
420
+ string inside the `auth` control message (`client.mjs`'s `#performAuth()`
421
+ calls `sendControl(authMsg({ method: AUTH_METHOD.PASSWORD, password }))`)
422
+ -- there is no client-side hashing, salting, or key derivation before it
423
+ goes on the wire. Its confidentiality is entirely a function of whether
424
+ the underlying connection is encrypted: fine over `wss:`/WebTransport
425
+ against a certificate the client actually trusts (CA-signed, or pinned
426
+ via `serverCertificateHashes`), but sent in the clear if the connection
427
+ is a plain, unencrypted `ws:`. Browsers block that combination from an
428
+ `https:` page as mixed content (see "Pinning a Self-Signed Certificate"
429
+ above), but nothing in this protocol or client stops a non-browser
430
+ caller (Node, the Rust CLI) from dialing `ws://` directly. Prefer pubkey
431
+ auth, or make sure the transport is actually encrypted, before sending a
432
+ password.
328
433
 
329
434
  ## Browser Compatibility
330
435
 
@@ -403,6 +508,24 @@ browser the optional `@noble/post-quantum` dependency is what actually
403
508
  runs, loaded dynamically by `src/mlkem.mjs`. Treat it as required, not
404
509
  optional, if you want the hybrid handshake on the web today.
405
510
 
511
+ ## Family
512
+
513
+ wsh isn't just a standalone remote-shell client -- its `WshClient` is the
514
+ designed transport backing one sibling package's network gateway, and
515
+ `WshClient`'s connected/authenticated shape is a drop-in for that sibling's
516
+ injected-client extension point.
517
+
518
+ - **[`@johnhenry/browsermesh-netway`](https://github.com/johnhenry/browsermesh)**
519
+ -- browsermesh-netway's `GatewayBackend` takes a `wshClient` in its
520
+ constructor and proxies every network operation (socket open/send/close)
521
+ through it via `sendControl()`, once the client reaches the
522
+ `'authenticated'` state (`isReady()` checks exactly that). This is a real,
523
+ verified dependency on wsh's client shape -- `GatewayBackend` is
524
+ duck-typed against a `WshClient` instance, not declared as an npm
525
+ dependency -- not a thematic pairing; see
526
+ `packages/browsermesh-netway/src/gateway-backend.mjs` in the browsermesh
527
+ repo. wsh itself has no dependency in the other direction.
528
+
406
529
  ## License
407
530
 
408
531
  MIT
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Web Shell — browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
7
7
  "exports": {
8
- ".": "./src/index.mjs"
8
+ ".": {
9
+ "types": "./src/index.d.ts",
10
+ "import": "./src/index.mjs"
11
+ }
9
12
  },
10
13
  "types": "./src/index.d.ts",
11
14
  "files": [
@@ -19,11 +22,13 @@
19
22
  "devDependencies": {
20
23
  "@fails-components/webtransport": "^1.6.4",
21
24
  "@fails-components/webtransport-transport-http3-quiche": "^1.6.4",
25
+ "typescript": "^7.0.0",
22
26
  "ws": "^8.21.0"
23
27
  },
24
28
  "scripts": {
25
29
  "test": "node --test test/*.test.mjs",
26
30
  "test:rust": "node --test test/rust/*.test.mjs",
31
+ "typecheck": "tsc --noEmit -p tsconfig.json",
27
32
  "codegen": "node spec/codegen.mjs",
28
33
  "codegen:check": "node scripts/codegen-check.mjs",
29
34
  "examples": "for f in examples/0*.mjs; do echo \"== $f\" && node \"$f\" || exit 1; done",
@@ -53,7 +58,7 @@
53
58
  },
54
59
  "author": "John Henry",
55
60
  "engines": {
56
- "node": ">=24.0.0"
61
+ "node": ">=26.0.0"
57
62
  },
58
63
  "homepage": "https://opensource.johnhenry.me/wsh/"
59
64
  }
package/src/client.mjs CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  keyExchange as keyExchangeMsg,
30
30
  fileOp as fileOpMsg,
31
31
  fileChunk as fileChunkMsg,
32
+ authorizedKeyAdd as authorizedKeyAddMsg,
32
33
  policyEval as policyEvalMsg, policyUpdate as policyUpdateMsg,
33
34
  detach as detachMsg,
34
35
  sessionListRequest as sessionListRequestMsg,
@@ -1738,6 +1739,45 @@ export class WshClient {
1738
1739
  /** Rename a remote file or directory. */
1739
1740
  async fileRename(oldPath, newPath, timeout) { return this.fileOperation('rename', oldPath, {}, timeout); }
1740
1741
 
1742
+ // ── Authorized-key management (wsh #59) ───────────────────────────
1743
+
1744
+ /**
1745
+ * Install a raw 32-byte Ed25519 public key into the remote host's
1746
+ * ~/.wsh/authorized_keys via the AuthorizedKeyAdd protocol message.
1747
+ *
1748
+ * Replaces `wsh copy-id`'s CLI-only, shell-command-built approach
1749
+ * (`crates/wsh-cli/src/commands/copy_id.rs`) with a real protocol
1750
+ * message every implementation can call -- including this one, the
1751
+ * browser SDK, which previously had no way to install a key at all
1752
+ * (no shell channel to build a command string for). See copy_id.rs's
1753
+ * doc comment for the full security reasoning: an authenticated
1754
+ * connection already has unrestricted exec/file access to this exact
1755
+ * file, so this adds no new privilege boundary.
1756
+ *
1757
+ * Idempotent: installing an already-present key resolves
1758
+ * `{ added: false }` rather than duplicating the line or erroring.
1759
+ *
1760
+ * @param {Uint8Array} publicKeyRaw - Raw 32-byte Ed25519 public key
1761
+ * @param {string} [comment] - Trailing comment for the authorized_keys line
1762
+ * @param {number} [timeout=10000]
1763
+ * @returns {Promise<{ added: boolean }>}
1764
+ */
1765
+ async addAuthorizedKey(publicKeyRaw, comment, timeout = DEFAULT_OPEN_TIMEOUT) {
1766
+ this.#assertAuthenticated('addAuthorizedKey');
1767
+ await this.#transport.sendControl(
1768
+ authorizedKeyAddMsg({ publicKey: publicKeyRaw, comment })
1769
+ );
1770
+ const result = await this.#waitForMessage(
1771
+ [MSG.AUTHORIZED_KEY_RESULT],
1772
+ timeout,
1773
+ 'Timed out waiting for authorized-key-add result'
1774
+ );
1775
+ if (!result.success) {
1776
+ throw new Error(result.error_message || 'key installation refused');
1777
+ }
1778
+ return { added: !!result.added };
1779
+ }
1780
+
1741
1781
  // ── Policy Engine ─────────────────────────────────────────────────
1742
1782
 
1743
1783
  /**
@@ -3,38 +3,36 @@
3
3
  *
4
4
  * Uploads and downloads delegate to the underlying client's upload()/
5
5
  * download() (FileChunk control messages over a 'file'-kind channel —
6
- * see client.mjs). list() is a separate helper that runs `ls -la` over
7
- * a plain exec channel.
6
+ * see client.mjs). list() (wsh #59) delegates to the client's fileList(),
7
+ * which sends a structured FileOp{op:"list"} and reads the typed
8
+ * FileResult.entries -- the same wire path upload/download already use
9
+ * for moving bytes, now extended to directory listings.
10
+ *
11
+ * Before wsh #59, list() ran `ls -la` over a plain exec channel and
12
+ * parsed the text output client-side -- a private, JS-only convention
13
+ * the Rust client had no equivalent of at all (see wsh #58/#59, and the
14
+ * FileEntry nested type in spec/wsh-v1.yaml this now shares with Rust).
8
15
  */
9
16
 
10
- import { MSG, open, CHANNEL_KIND } from './messages.mjs';
11
- import { waitForControlMessage } from './control-listener.mjs';
12
-
13
17
  /** Default timeout for waiting on control messages (30 seconds). */
14
18
  const RESPONSE_TIMEOUT_MS = 30_000;
15
19
 
16
20
  export class WshFileTransfer {
17
21
  /**
18
22
  * @type {{
19
- * upload: function, download: function,
20
- * sendControl: function, openStream: function, onControl: function,
23
+ * upload: function, download: function, fileList: function,
21
24
  * }}
22
25
  */
23
26
  #client;
24
27
 
25
28
  /**
26
- * @param {object} client - A WshClient (or any object exposing upload()/
27
- * download() with the same signatures — those are used directly for
28
- * upload/download). list() additionally needs, for the underlying exec
29
- * channel it runs `ls -la` over:
30
- * - sendControl(msg): send a control message
31
- * - openStream(): open a new bidirectional stream
32
- * - onControl: settable callback for incoming control messages
33
- * (or a method to add a listener — see _waitForMessage)
34
- *
35
- * Only `list()` needs the control-channel members, so they are checked
36
- * there rather than here: uploading and downloading through a client
37
- * that has `upload()`/`download()` and nothing else stays valid.
29
+ * @param {object} client - A WshClient (or any object exposing the same
30
+ * methods used below). `upload()`/`download()` are used directly for
31
+ * file transfer; `list()` uses `fileList()` (the structured
32
+ * FileOp/FileResult request-response helper already on WshClient).
33
+ * Each method checks for the members it specifically needs, so a
34
+ * client exposing only `upload()`/`download()` (and nothing else)
35
+ * stays valid for those two calls.
38
36
  */
39
37
  constructor(client) {
40
38
  if (!client) throw new Error('WshFileTransfer requires a client');
@@ -97,156 +95,47 @@ export class WshFileTransfer {
97
95
  }
98
96
 
99
97
  /**
100
- * List files at a remote path by executing `ls -la` via the client.
98
+ * List files at a remote path over the structured file channel (wsh #59):
99
+ * sends `FileOp{op:"list"}` via the client's `fileList()` and reads the
100
+ * typed `FileResult.entries` -- the same `FileEntry` shape
101
+ * `crates/wsh-core`'s Rust client is generated from, so a symlink can't
102
+ * be reported as a plain file here and correctly here-and-there.
101
103
  *
102
- * This uses a standard exec channel rather than the file transfer protocol,
103
- * parsing the output of `ls -la` into structured entries.
104
+ * A refusal (unimplemented op, unauthorized fs capability, no such
105
+ * directory) throws -- it is never coerced into an empty array, so a
106
+ * caller can't mistake "refused" for "empty directory" (wsh #58).
104
107
  *
105
108
  * @param {string} remotePath - Directory path on the remote host
106
- * @returns {Promise<Array<{ name: string, size: number, modified: string, type: string }>>}
109
+ * @param {object} [opts]
110
+ * @param {number} [opts.timeout] - Timeout in ms for the server response
111
+ * @returns {Promise<Array<{
112
+ * name: string, size: number, modified: Date,
113
+ * type: 'file'|'directory'|'symlink'|'device'|'pipe'|'socket',
114
+ * symlinkTarget?: string,
115
+ * }>>}
107
116
  */
108
- async list(remotePath) {
117
+ async list(remotePath, { timeout = RESPONSE_TIMEOUT_MS } = {}) {
109
118
  if (!remotePath || typeof remotePath !== 'string') {
110
119
  throw new Error('remotePath is required');
111
120
  }
112
- if (typeof this.#client.sendControl !== 'function' || typeof this.#client.openStream !== 'function') {
121
+ if (typeof this.#client.fileList !== 'function') {
113
122
  throw new TypeError(
114
- 'WshFileTransfer.list() requires a client exposing sendControl() and openStream() (e.g. WshClient)'
123
+ 'WshFileTransfer.list() requires a client exposing fileList() (e.g. WshClient)'
115
124
  );
116
125
  }
117
126
 
118
- // Use an exec channel to run ls
119
- const openMsg = open({
120
- kind: CHANNEL_KIND.EXEC,
121
- command: `ls -la ${this._shellEscape(remotePath)}`,
122
- });
123
-
124
- await this.#client.sendControl(openMsg);
125
-
126
- const response = await this._waitForMessage(
127
- (msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
128
- RESPONSE_TIMEOUT_MS
129
- );
130
-
131
- if (response.type === MSG.OPEN_FAIL) {
132
- throw new Error(`List failed: ${response.reason || 'unknown reason'}`);
133
- }
134
-
135
- // Read the output stream
136
- const stream = await this.#client.openStream();
137
- const reader = stream.readable.getReader();
138
- const chunks = [];
139
-
140
- try {
141
- while (true) {
142
- const { done, value } = await reader.read();
143
- if (done) break;
144
- chunks.push(value);
145
- }
146
- } finally {
147
- reader.releaseLock();
148
- }
149
-
150
- const output = new TextDecoder().decode(this._concatChunks(chunks));
151
- return this._parseLsOutput(output);
152
- }
153
-
154
- // ── Internal helpers ──────────────────────────────────────────────
155
-
156
- /**
157
- * Wait for a control message matching a predicate.
158
- *
159
- * Temporarily hooks into the client's control message flow and resolves
160
- * when a matching message arrives (or rejects on timeout).
161
- *
162
- * @param {function(object): boolean} predicate
163
- * @param {number} timeoutMs
164
- * @returns {Promise<object>}
165
- */
166
- _waitForMessage(predicate, timeoutMs) {
167
- return waitForControlMessage(this.#client, predicate, timeoutMs, 'File transfer response');
168
- }
169
-
170
- /**
171
- * Concatenate an array of Uint8Arrays into one.
172
- *
173
- * @param {Uint8Array[]} chunks
174
- * @param {number} [totalLength] - Pre-computed total, avoids re-summing
175
- * @returns {Uint8Array}
176
- */
177
- _concatChunks(chunks, totalLength) {
178
- if (chunks.length === 0) return new Uint8Array(0);
179
- if (chunks.length === 1) return chunks[0];
180
-
181
- const total = totalLength ?? chunks.reduce((sum, c) => sum + c.length, 0);
182
- const result = new Uint8Array(total);
183
- let offset = 0;
184
- for (const chunk of chunks) {
185
- result.set(chunk, offset);
186
- offset += chunk.length;
187
- }
188
- return result;
189
- }
190
-
191
- /**
192
- * Parse the output of `ls -la` into structured entries.
193
- *
194
- * Expected format per line:
195
- * drwxr-xr-x 2 user group 4096 Jan 15 12:00 dirname
196
- * -rw-r--r-- 1 user group 12345 Jan 15 12:00 filename.txt
197
- *
198
- * @param {string} output
199
- * @returns {Array<{ name: string, size: number, modified: string, type: string }>}
200
- */
201
- _parseLsOutput(output) {
202
- const lines = output.split('\n').filter((l) => l.trim());
203
- const entries = [];
204
-
205
- for (const line of lines) {
206
- // Skip the "total" header line
207
- if (line.startsWith('total ')) continue;
208
-
209
- // ls -la columns: perms links owner group size month day time name
210
- const parts = line.split(/\s+/);
211
- if (parts.length < 9) continue;
212
-
213
- const perms = parts[0];
214
- const size = parseInt(parts[4], 10) || 0;
215
- const month = parts[5];
216
- const day = parts[6];
217
- const time = parts[7];
218
- // Name may contain spaces — rejoin everything from column 8 onward
219
- const name = parts.slice(8).join(' ');
220
-
221
- // Skip . and .. entries
222
- if (name === '.' || name === '..') continue;
127
+ const result = await this.#client.fileList(remotePath, timeout);
223
128
 
224
- // Determine type from permission string first character
225
- let type = 'file';
226
- if (perms.startsWith('d')) type = 'directory';
227
- else if (perms.startsWith('l')) type = 'symlink';
228
- else if (perms.startsWith('c') || perms.startsWith('b')) type = 'device';
229
- else if (perms.startsWith('p')) type = 'pipe';
230
- else if (perms.startsWith('s')) type = 'socket';
231
-
232
- entries.push({
233
- name,
234
- size,
235
- modified: `${month} ${day} ${time}`,
236
- type,
237
- });
129
+ if (!result.success) {
130
+ throw new Error(result.error_message || `List failed: refused for ${remotePath}`);
238
131
  }
239
132
 
240
- return entries;
241
- }
242
-
243
- /**
244
- * Basic shell escaping for a path argument.
245
- * @param {string} str
246
- * @returns {string}
247
- */
248
- _shellEscape(str) {
249
- // Wrap in single quotes, escaping any embedded single quotes
250
- return "'" + str.replace(/'/g, "'\\''") + "'";
133
+ return (result.entries || []).map((entry) => ({
134
+ name: entry.name,
135
+ size: entry.size,
136
+ modified: new Date(entry.modified * 1000),
137
+ type: entry.type,
138
+ ...(entry.symlink_target !== undefined ? { symlinkTarget: entry.symlink_target } : {}),
139
+ }));
251
140
  }
252
141
  }
package/src/index.d.ts CHANGED
@@ -778,6 +778,14 @@ export function fileResult(opts?: {
778
778
  channelId?: number;
779
779
  success?: boolean;
780
780
  metadata?: Record<string, unknown>;
781
+ /** wsh #59: typed directory entries for a "list" op (see WshFileListEntry-shaped wire fields). */
782
+ entries?: Array<{
783
+ name: string;
784
+ size: number;
785
+ modified: number;
786
+ type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
787
+ symlink_target?: string;
788
+ }>;
781
789
  errorMessage?: string;
782
790
  }): WshMessage;
783
791
 
@@ -789,6 +797,18 @@ export function fileChunk(opts?: {
789
797
  totalSize?: number;
790
798
  }): WshMessage;
791
799
 
800
+ /** wsh #59: install a public key into the remote's authorized_keys (see WshClient.addAuthorizedKey). */
801
+ export function authorizedKeyAdd(opts?: {
802
+ publicKey?: Uint8Array;
803
+ comment?: string;
804
+ }): WshMessage;
805
+
806
+ export function authorizedKeyResult(opts?: {
807
+ success?: boolean;
808
+ added?: boolean;
809
+ errorMessage?: string;
810
+ }): WshMessage;
811
+
792
812
  export function policyEval(opts?: {
793
813
  requestId?: string;
794
814
  action?: string;
@@ -1649,6 +1669,28 @@ export interface WshE2EResult {
1649
1669
  hybrid: boolean;
1650
1670
  }
1651
1671
 
1672
+ /**
1673
+ * Raw `FileResult` response to a structured file-channel operation
1674
+ * (`WshClient.fileList`/`fileRemove`/etc.), as received off the wire --
1675
+ * wire field names (snake_case), not the camelCase shape the `fileResult()`
1676
+ * message constructor takes as input. `entries` is only populated for a
1677
+ * `"list"` op; `error_message` is only present on failure.
1678
+ */
1679
+ export interface WshFileOperationResult {
1680
+ type: number;
1681
+ channel_id: number;
1682
+ success: boolean;
1683
+ metadata: Record<string, unknown>;
1684
+ entries: Array<{
1685
+ name: string;
1686
+ size: number;
1687
+ modified: number;
1688
+ type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
1689
+ symlink_target?: string;
1690
+ }>;
1691
+ error_message?: string;
1692
+ }
1693
+
1652
1694
  /**
1653
1695
  * WshClient -- manages a wsh connection, authentication, and multiple sessions.
1654
1696
  *
@@ -1764,6 +1806,36 @@ export class WshClient {
1764
1806
  */
1765
1807
  revokeSessionAccess(sessionId: string, principal: string, reason?: string): Promise<void>;
1766
1808
 
1809
+ /**
1810
+ * List a remote directory over the structured file channel (`FileOp`
1811
+ * op `"list"`). Prefer `WshFileTransfer.list()` for a typed
1812
+ * `WshFileListEntry[]` result (with `modified` as a `Date`) -- this is
1813
+ * the raw wire-shaped `FileResult` response `fileOperation()` waits on.
1814
+ */
1815
+ fileList(path: string, timeout?: number): Promise<WshFileOperationResult>;
1816
+
1817
+ /**
1818
+ * Remove a remote file or directory (`FileOp` op `"remove"`). Refused
1819
+ * today by every `wsh-server` release ("not yet implemented" -- see
1820
+ * README's file-op parity table); wired here so clients built against
1821
+ * this SDK don't need to wait on the server to type-check against it.
1822
+ */
1823
+ fileRemove(path: string, timeout?: number): Promise<WshFileOperationResult>;
1824
+
1825
+ /**
1826
+ * Install a raw 32-byte Ed25519 public key into the remote host's
1827
+ * `~/.wsh/authorized_keys` (wsh #59's `AuthorizedKeyAdd` protocol
1828
+ * message -- replaces `wsh copy-id`'s previous CLI-only shell-command
1829
+ * approach with something every implementation, including this one,
1830
+ * can call directly). Idempotent: installing an already-present key
1831
+ * resolves `added: false` rather than duplicating the line or erroring.
1832
+ */
1833
+ addAuthorizedKey(
1834
+ publicKeyRaw: Uint8Array,
1835
+ comment?: string,
1836
+ timeout?: number,
1837
+ ): Promise<{ added: boolean }>;
1838
+
1767
1839
  /**
1768
1840
  * Gracefully disconnect: close all sessions and the transport.
1769
1841
  */
@@ -1993,6 +2065,48 @@ export class WshKeyStore {
1993
2065
  restore(passphrase: string): Promise<WshRestoreResult>;
1994
2066
  }
1995
2067
 
2068
+ // ============================================================================
2069
+ // known-hosts.mjs -- WshKnownHosts (wsh #59)
2070
+ // ============================================================================
2071
+
2072
+ /** Minimal storage interface `WshKnownHosts` needs -- `localStorage` already satisfies it. */
2073
+ export interface WshKnownHostsStorage {
2074
+ getItem(key: string): string | null;
2075
+ setItem(key: string, value: string): void;
2076
+ removeItem(key: string): void;
2077
+ }
2078
+
2079
+ /** Result of verifying a host's fingerprint against the stored record. */
2080
+ export interface WshHostVerifyResult {
2081
+ status: 'known' | 'unknown' | 'changed';
2082
+ /** Previously stored fingerprint, present only when status === 'changed'. */
2083
+ expected?: string;
2084
+ }
2085
+
2086
+ /**
2087
+ * WshKnownHosts -- trust-on-first-use (TOFU) host-identity store, mirroring
2088
+ * Rust's `KnownHosts`/`HostStatus` (`crates/wsh-client/src/known_hosts.rs`).
2089
+ * Pins `ServerHello.host_fingerprint` (spec/wsh-v1.yaml, wsh #59) -- no
2090
+ * `wsh-server` release populates that field yet, so this has nothing
2091
+ * authoritative to verify against for any real server today. See the
2092
+ * class doc comment in `known-hosts.mjs` for the full reasoning.
2093
+ */
2094
+ export class WshKnownHosts {
2095
+ constructor(opts?: { storage?: WshKnownHostsStorage; storageKey?: string });
2096
+
2097
+ /** Verify a host's fingerprint against the stored record. */
2098
+ verifyHost(host: string, fingerprint: string): WshHostVerifyResult;
2099
+
2100
+ /** Add or update a host's fingerprint. */
2101
+ addHost(host: string, fingerprint: string): void;
2102
+
2103
+ /** Remove a host entry. Returns true if an entry was removed. */
2104
+ removeHost(host: string): boolean;
2105
+
2106
+ /** List all known hosts and their fingerprints. */
2107
+ list(): Array<{ host: string; fingerprint: string }>;
2108
+ }
2109
+
1996
2110
  // ============================================================================
1997
2111
  // file-transfer.mjs -- WshFileTransfer
1998
2112
  // ============================================================================
@@ -2015,12 +2129,21 @@ export interface WshFileUploadResult {
2015
2129
  bytesTransferred: number;
2016
2130
  }
2017
2131
 
2018
- /** File listing entry. */
2132
+ /**
2133
+ * File listing entry (wsh #59: generated from spec/wsh-v1.yaml's
2134
+ * `FileEntry` nested type -- the same shape `crates/wsh-core`'s Rust
2135
+ * `FileEntry` struct is generated from, so a directory entry can no longer
2136
+ * describe a symlink as a plain file in one implementation and not the
2137
+ * other). `modified` is converted from the wire's Unix epoch seconds into
2138
+ * a `Date` for JS ergonomics. `symlinkTarget` is only present when
2139
+ * `type === 'symlink'`.
2140
+ */
2019
2141
  export interface WshFileListEntry {
2020
2142
  name: string;
2021
2143
  size: number;
2022
- modified: string;
2144
+ modified: Date;
2023
2145
  type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
2146
+ symlinkTarget?: string;
2024
2147
  }
2025
2148
 
2026
2149
  /**
package/src/index.mjs CHANGED
@@ -35,6 +35,7 @@ export {
35
35
  nodeAnnounce, nodeRedirect,
36
36
  sessionGrant, sessionRevoke,
37
37
  fileOp, fileResult, fileChunk,
38
+ authorizedKeyAdd, authorizedKeyResult,
38
39
  policyEval, policyResult, policyUpdate,
39
40
  terminalConfig,
40
41
  msgName, isValidMessage,
@@ -77,6 +78,9 @@ export { WshClient, MCP_CALL_ID_FEATURE } from './client.mjs';
77
78
  // Key storage
78
79
  export { WshKeyStore } from './keystore.mjs';
79
80
 
81
+ // Host identity (TOFU) -- wsh #59
82
+ export { WshKnownHosts } from './known-hosts.mjs';
83
+
80
84
  // File transfer
81
85
  export { WshFileTransfer } from './file-transfer.mjs';
82
86
 
@@ -0,0 +1,139 @@
1
+ /**
2
+ * WshKnownHosts — trust-on-first-use (TOFU) host-identity store for the
3
+ * browser SDK (wsh #59).
4
+ *
5
+ * ## Why this exists, and why it is not yet load-bearing
6
+ *
7
+ * wsh #59 asked for a decision on host identity: TOFU *policy* (when to
8
+ * prompt, how long to trust) is legitimately per-implementation, but the
9
+ * *host identity record* -- what a fingerprint covers, how it is computed,
10
+ * what counts as "the same host" -- must not be invented twice. This class
11
+ * mirrors Rust's `KnownHosts`/`HostStatus`
12
+ * (`crates/wsh-client/src/known_hosts.rs`) in shape and semantics --
13
+ * `verifyHost`/`addHost`/`removeHost`/`list`, keyed by a host label,
14
+ * storing one fingerprint per host -- so the browser gets the same
15
+ * mechanism rather than a second, differently-shaped answer arrived at
16
+ * later under pressure.
17
+ *
18
+ * What it pins is `ServerHello.host_fingerprint` (spec/wsh-v1.yaml, added
19
+ * by this same change): the server's own persistent Ed25519 host-identity
20
+ * key, SHA-256-fingerprinted the same way `fingerprint()` in `auth.mjs`
21
+ * already fingerprints user/peer keys. That field is new spec surface,
22
+ * not new server behavior -- no `wsh-server` release populates it yet.
23
+ * Minting and persisting a server host keypair is a separate,
24
+ * security-sensitive feature this change does not implement (see the
25
+ * wsh #59 PR description for the full reasoning). Until a server
26
+ * populates it, `verifyHost()` has nothing authoritative to check for any
27
+ * real deployment -- exactly the same degraded state Rust's own
28
+ * `verify_host_key` is in today when it falls back to `fingerprints[0]`
29
+ * (an authorized *client* key, not a host identity -- see that fallback's
30
+ * comment in `client.rs`). This store does not paper over that; a caller
31
+ * that gets `'unknown'` back from every real server today is seeing the
32
+ * honest state of host verification in this protocol version, not a bug
33
+ * in this class.
34
+ *
35
+ * The README's `serverCertificateHashes` pinning
36
+ * (see "Pinning a Self-Signed Certificate") is a different mechanism: it
37
+ * pins a certificate supplied *per connection*, decided by the caller in
38
+ * advance -- it is not a persisted record of what a host presented last
39
+ * time, so it does not already solve TOFU. `WshKnownHosts` is the
40
+ * missing persisted-record half.
41
+ *
42
+ * ## Storage
43
+ *
44
+ * Pluggable (defaults to `localStorage` when available, else an
45
+ * in-memory `Map` that does not survive a page reload) since "persist
46
+ * trust across sessions" needs a browser storage API this package has
47
+ * never assumed before. Pass `{ storage }` with a `getItem`/`setItem`/
48
+ * `removeItem` interface (the `Storage` interface `localStorage`
49
+ * already implements) to use IndexedDB-via-adapter, `sessionStorage`,
50
+ * or anything else.
51
+ */
52
+
53
+ const STORAGE_KEY = 'wsh:known-hosts:v1';
54
+
55
+ /** In-memory fallback storage, used when no persistent storage is available or injected. */
56
+ class MemoryStorage {
57
+ #map = new Map();
58
+ getItem(key) { return this.#map.has(key) ? this.#map.get(key) : null; }
59
+ setItem(key, value) { this.#map.set(key, value); }
60
+ removeItem(key) { this.#map.delete(key); }
61
+ }
62
+
63
+ function defaultStorage() {
64
+ if (typeof localStorage !== 'undefined' && localStorage) return localStorage;
65
+ return new MemoryStorage();
66
+ }
67
+
68
+ export class WshKnownHosts {
69
+ #storage;
70
+ #storageKey;
71
+
72
+ /**
73
+ * @param {object} [opts]
74
+ * @param {{getItem: function, setItem: function, removeItem: function}} [opts.storage]
75
+ * Storage backend (defaults to `localStorage` if present, else an
76
+ * in-memory `Map` scoped to this instance).
77
+ * @param {string} [opts.storageKey] - Override the storage key (useful
78
+ * for isolating multiple instances/tests under one storage backend).
79
+ */
80
+ constructor({ storage, storageKey = STORAGE_KEY } = {}) {
81
+ this.#storage = storage || defaultStorage();
82
+ this.#storageKey = storageKey;
83
+ }
84
+
85
+ /**
86
+ * Verify a host's fingerprint against the stored record.
87
+ *
88
+ * @param {string} host - Host label (e.g. "example.com:4422")
89
+ * @param {string} fingerprint - Hex SHA-256 fingerprint to check
90
+ * @returns {{ status: 'known' | 'unknown' | 'changed', expected?: string }}
91
+ */
92
+ verifyHost(host, fingerprint) {
93
+ const entries = this.#load();
94
+ if (!(host in entries)) {
95
+ return { status: 'unknown' };
96
+ }
97
+ if (entries[host] === fingerprint) {
98
+ return { status: 'known' };
99
+ }
100
+ return { status: 'changed', expected: entries[host] };
101
+ }
102
+
103
+ /** Add or update a host's fingerprint. */
104
+ addHost(host, fingerprint) {
105
+ const entries = this.#load();
106
+ entries[host] = fingerprint;
107
+ this.#save(entries);
108
+ }
109
+
110
+ /** Remove a host entry. Returns true if an entry was removed. */
111
+ removeHost(host) {
112
+ const entries = this.#load();
113
+ if (!(host in entries)) return false;
114
+ delete entries[host];
115
+ this.#save(entries);
116
+ return true;
117
+ }
118
+
119
+ /** List all known hosts and their fingerprints. */
120
+ list() {
121
+ const entries = this.#load();
122
+ return Object.entries(entries).map(([host, fingerprint]) => ({ host, fingerprint }));
123
+ }
124
+
125
+ #load() {
126
+ const raw = this.#storage.getItem(this.#storageKey);
127
+ if (!raw) return {};
128
+ try {
129
+ const parsed = JSON.parse(raw);
130
+ return parsed && typeof parsed === 'object' ? parsed : {};
131
+ } catch {
132
+ return {};
133
+ }
134
+ }
135
+
136
+ #save(entries) {
137
+ this.#storage.setItem(this.#storageKey, JSON.stringify(entries));
138
+ }
139
+ }
@@ -145,6 +145,10 @@ export const MSG = Object.freeze({
145
145
 
146
146
  // Terminal
147
147
  TERMINAL_CONFIG: 0x9e,
148
+
149
+ // Keys
150
+ AUTHORIZED_KEY_ADD: 0x9f,
151
+ AUTHORIZED_KEY_RESULT: 0xa0,
148
152
  });
149
153
 
150
154
  // Reverse lookup: number → name
@@ -187,13 +191,10 @@ export function hello({ username, features = [], authMethod = AUTH_METHOD.PUBKEY
187
191
  };
188
192
  }
189
193
 
190
- export function serverHello({ sessionId, features = [], fingerprints = [] } = {}) {
191
- return {
192
- type: MSG.SERVER_HELLO,
193
- session_id: sessionId,
194
- features,
195
- fingerprints,
196
- };
194
+ export function serverHello({ sessionId, features = [], fingerprints = [], hostFingerprint } = {}) {
195
+ const msg = { type: MSG.SERVER_HELLO, session_id: sessionId, features, fingerprints };
196
+ if (hostFingerprint !== undefined) msg.host_fingerprint = hostFingerprint;
197
+ return msg;
197
198
  }
198
199
 
199
200
  export function challenge({ nonce, sessionId } = {}) {
@@ -864,8 +865,8 @@ export function fileOp({ channelId, op, path, offset, length } = {}) {
864
865
  return msg;
865
866
  }
866
867
 
867
- export function fileResult({ channelId, success, metadata = {}, errorMessage } = {}) {
868
- const msg = { type: MSG.FILE_RESULT, channel_id: channelId, success, metadata };
868
+ export function fileResult({ channelId, success, metadata = {}, entries = [], errorMessage } = {}) {
869
+ const msg = { type: MSG.FILE_RESULT, channel_id: channelId, success, metadata, entries };
869
870
  if (errorMessage !== undefined) msg.error_message = errorMessage;
870
871
  return msg;
871
872
  }
@@ -915,6 +916,18 @@ export function terminalConfig({ channelId, frontend, options = {} } = {}) {
915
916
  };
916
917
  }
917
918
 
919
+ export function authorizedKeyAdd({ publicKey, comment } = {}) {
920
+ const msg = { type: MSG.AUTHORIZED_KEY_ADD, public_key: publicKey };
921
+ if (comment !== undefined) msg.comment = comment;
922
+ return msg;
923
+ }
924
+
925
+ export function authorizedKeyResult({ success, added = false, errorMessage } = {}) {
926
+ const msg = { type: MSG.AUTHORIZED_KEY_RESULT, success, added };
927
+ if (errorMessage !== undefined) msg.error_message = errorMessage;
928
+ return msg;
929
+ }
930
+
918
931
  // ── Relay forwarding ─────────────────────────────────────────────────
919
932
 
920
933
  // Message types a relay may forward inside a RelayForward wrapper.