@johnhenry/wsh 0.17.0 → 0.18.0

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,14 +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
- 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.
19
+ 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.
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
+ - [Node Server](#node-server)
29
+ - [Attach and Resume](#attach-and-resume)
30
+ - [Pinning a Self-Signed Certificate](#pinning-a-self-signed-certificate)
31
+ - [API Overview](#api-overview)
32
+ - [Protocol Specification](#protocol-specification)
33
+ - [Rust implementation](#rust-implementation)
34
+ - [Security model](#security-model)
35
+ - [Browser Compatibility](#browser-compatibility)
36
+ - [Family](#family)
37
+ - [License](#license)
12
38
 
13
39
  ## Install
14
40
 
@@ -34,9 +60,22 @@ Or via CDN:
34
60
  - **Reverse mode** -- register as a peer (via a signed peer record) and accept incoming connections through a relay
35
61
  - **File transfer** -- scp-like upload/download as `FileChunk` control messages in 64KB chunks
36
62
  - **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
63
+ - **Session recording** -- own JSON schema (not asciicast v2 -- see note below) recording and playback with seek/pause/resume
38
64
  - **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
65
+ - **97 message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, authorized-key management, and more
66
+
67
+ > **Session recording is not asciicast v2.** Both `SessionRecorder`
68
+ > implementations capture more than asciicast v2's three event codes
69
+ > (`o`/`i`/`r`) can represent -- session lifecycle events (`open`/`exit` in
70
+ > JS; `Start`/`Exit`/`Snapshot` in Rust) have no asciicast v2 equivalent,
71
+ > and dropping them wasn't worth it just to match the format. The JS and
72
+ > Rust recorders also use two different, mutually incompatible schemas from
73
+ > each other (neither is asciicast v2's newline-delimited-JSON shape).
74
+ > `RecordingExport`'s `format: "asciicast"` option is accepted on the wire
75
+ > but not yet implemented server-side -- requesting it currently returns
76
+ > the same custom JSONL as `format: "jsonl"`. If interop with standard
77
+ > asciicast players (`asciinema play`, etc.) is wanted, that needs a real
78
+ > (lossy -- lifecycle events would be dropped) export path, not a rename.
40
79
 
41
80
  ## Wire Protocol: QMux
42
81
 
@@ -107,6 +146,48 @@ console.log(new TextDecoder().decode(stdout));
107
146
  console.log('Exit code:', exitCode);
108
147
  ```
109
148
 
149
+ `exec` and `openSession({ type: 'exec' })` need no extra step to receive
150
+ output. Older hosts only discover the client-opened data stream once a byte
151
+ arrives on it, so against a host that does not advertise `stream-announce`
152
+ the client writes a one-byte primer for you (a host that strips it, as those
153
+ hosts do). Pass `primer: false` to never send it.
154
+
155
+ ## Node Server
156
+
157
+ `@johnhenry/wsh/server` is a Node host for the same protocol, over WebSocket
158
+ (QMux). It is a separate subpath -- the package root stays browser-safe and
159
+ never imports it -- and needs the optional peer dependency `ws`
160
+ (`npm install ws`).
161
+
162
+ ```js
163
+ import { createWshServer } from '@johnhenry/wsh/server';
164
+ import { readFileSync } from 'node:fs';
165
+
166
+ const server = createWshServer({
167
+ host: '127.0.0.1',
168
+ port: 4422, // 0 = pick a free port
169
+ auth: { authorizedKeys: readFileSync('authorized_keys', 'utf8') }, // ssh-ed25519 lines
170
+ exec: true, // opt in: run commands via /bin/sh
171
+ fs: { root: '/srv/share', readOnly: false }, // opt in: list/stat/read/mkdir/remove/upload/download
172
+ // pty: { spawn: nodePty.spawn }, // opt in: bring your own node-pty
173
+ });
174
+ const { port } = await server.listen(); // later: server.address(), await server.close()
175
+ ```
176
+
177
+ Everything that touches the machine -- `exec`, `pty`, `fs` -- is **off unless
178
+ you pass it**, and with no `auth` every connection is refused. `auth` is
179
+ `{ authorizedKeys, authorize }` (a key must be on the list and pass
180
+ `authorize({ username, fingerprint, publicKey })` when both are given) or
181
+ just an `authorize` function. `exec` can also be `{ cwd, env, shell,
182
+ timeoutMs, clientEnv }` or a custom `run(command, io)` for a restricted host
183
+ with no shell. `fs.root` confines every path: `..`, absolute paths and
184
+ symlinks pointing out of it are refused. The server advertises
185
+ `stream-announce`, so a current client writes no primer byte; a lone leading
186
+ `0x00` from a client that primes anyway is dropped rather than reaching stdin.
187
+
188
+ Not implemented: WebTransport, relay/reverse mode, attach/resume, MCP,
189
+ password auth.
190
+
110
191
  ## Attach and Resume
111
192
 
112
193
  Opening a PTY/exec session returns a session-scoped credential alongside the channel:
@@ -208,9 +289,10 @@ so options the platform gains later need no change here.
208
289
  | `WshKeyStore` | Ed25519 key management via IndexedDB + OPFS encrypted backup |
209
290
  | `WshFileTransfer` | File upload/download over dedicated streams |
210
291
  | `WshMcpBridge` | Remote MCP tool discovery and invocation |
211
- | `SessionRecorder` | Record PTY I/O with timestamps (asciicast v2) |
292
+ | `SessionRecorder` | Record PTY I/O with timestamps (own schema, not asciicast v2) |
212
293
  | `SessionPlayer` | Replay recordings with original timing |
213
294
  | `generateKeyPair()` | Create Ed25519 key pair via Web Crypto |
295
+ | `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 |
214
296
  | `signChallenge()` | Build transcript + sign for auth handshake |
215
297
  | `signPeerRecord()` / `verifyPeerRecord()` | Sign / verify reverse-mode peer records |
216
298
  | `fingerprint()` | SHA-256 hex fingerprint of a public key |
@@ -221,7 +303,7 @@ so options the platform gains later need no change here.
221
303
 
222
304
  | Export | Description |
223
305
  |--------|-------------|
224
- | `MSG` | 95 message type constants (hex opcodes) |
306
+ | `MSG` | 97 message type constants (hex opcodes) |
225
307
  | `CHANNEL_KIND` | Channel types: `pty`, `exec`, `meta`, `file`, `tcp`, `udp`, `job` |
226
308
  | `AUTH_METHOD` | Auth methods: `pubkey`, `password` |
227
309
  | `cborEncode` / `cborDecode` | CBOR codec (maps, arrays, strings, ints, bytes, bools, null, floats) |
@@ -254,6 +336,26 @@ Four crates, workspace-versioned at `0.1.0`:
254
336
  | `wsh-cli` | binary (`wsh`) | SSH-like CLI: connect, exec, scp-style copy, reverse tunnels, key management |
255
337
  | `wsh-server` | binary (`wsh-server`) | Server: WebTransport/WebSocket listener, real PTY sessions (`portable-pty`), relay/reverse-connect, WISP bridging |
256
338
 
339
+ ### Capability matrix: JS SDK vs Rust CLI/client
340
+
341
+ Two implementations of one protocol will always have *some* asymmetry --
342
+ the question wsh #59 asks is whether it is visible and deliberate, or
343
+ discovered by accident. This table is that answer, made explicit rather
344
+ than left to be found.
345
+
346
+ | Capability | JS SDK (browser) | Rust CLI/client | Unified via |
347
+ |---|---|---|---|
348
+ | 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 |
349
+ | Upload/download | `WshClient.upload()`/`.download()` | `file_transfer::upload()`/`download()`, `wsh scp` | **Wire-unified**: `FileChunk` over a `'file'`-kind channel (wsh #13) |
350
+ | 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 |
351
+ | 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 |
352
+ | 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" |
353
+ | 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 |
354
+ | 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 |
355
+ | 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 |
356
+ | 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 |
357
+ | 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 |
358
+
257
359
  ### Build and test
258
360
 
259
361
  ```sh
@@ -307,24 +409,71 @@ plus one `SHA256SUMS` file covering every archive in the release. These
307
409
  names are a stable contract other repos (clawser) pin and download by --
308
410
  do not change them without coordinating downstream.
309
411
 
310
- ## Security
412
+ ## Security model
311
413
 
312
- - **Auth transcript binding** -- challenge signatures cover
414
+ wsh's protocol draws its line at authentication and message integrity: what
415
+ it guarantees is that a handshake and its messages are who and what they
416
+ claim to be. It does not guarantee confidentiality of every auth method, or
417
+ that a server's identity has actually been pinned yet -- both are called
418
+ out explicitly below rather than left to be discovered.
419
+
420
+ **What wsh guarantees:**
421
+
422
+ - **Auth transcript binding.** Challenge signatures cover
313
423
  `SHA-256("wsh-v1\0" || lp(username) || lp(session_id) || nonce || channel_binding)`,
314
424
  so a signature can't be replayed against a different session or relabeled
315
425
  to a different username.
316
- - **Signed peer records** -- reverse-mode registration is self-signed by the
426
+ - **Signed peer records.** Reverse-mode registration is self-signed by the
317
427
  peer's identity key (the libp2p RFC 0002/0003 pattern), in a signing
318
428
  domain separate from the auth challenge. `listPeers()` verifies every
319
429
  entry client-side and reports a `verified` boolean, independent of
320
430
  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.
431
+
432
+ **What is still yours:**
433
+
434
+ - **Hybrid post-quantum E2E is experimental and not fully wired (in
435
+ progress).** `initiateE2E(sessionId, 'X25519+ML-KEM-768')` combines
436
+ X25519 ECDH with ML-KEM-768 via HKDF-SHA256, preferring native WebCrypto
437
+ ML-KEM-768 (Node 24.7+) with the optional `@noble/post-quantum` pure-JS
438
+ fallback, and falling back to classical X25519 automatically when the
439
+ peer can't do hybrid (check the returned `hybrid` flag). The derived
440
+ AES-256-GCM key is not yet wired to actual frame encryption by default --
441
+ treat `initiateE2E` as key agreement, not confidentiality, unless you've
442
+ separately confirmed the session has sealing enabled.
443
+ - **Host identity (TOFU) has a known, named gap (wsh #59).**
444
+ `ServerHello.host_fingerprint` is the spec's formal host-identity slot:
445
+ the SHA-256 fingerprint of a server's persistent Ed25519 host key, meant
446
+ to be pinned across connections the way SSH pins a host key. **No
447
+ `wsh-server` release populates this field yet** -- minting and
448
+ persisting a server host keypair is a separate, security-sensitive
449
+ feature, deliberately not bundled into this change. Both clients ship
450
+ the store this field is for: `WshKnownHosts` (JS, `localStorage`-backed)
451
+ and `KnownHosts` (Rust, `~/.wsh/known_hosts`-backed) have matching
452
+ verify/add/remove/list semantics. Today, against every real server,
453
+ both report every host as unverified for host-identity purposes; the
454
+ Rust client additionally still falls back to `fingerprints[0]` (an
455
+ *authorized client key* this server happens to have, not this server's
456
+ own identity -- see `ServerHello.fingerprints`' doc comment in
457
+ `spec/wsh-v1.yaml`) with a logged warning, for backward compatibility.
458
+ Do not treat either store as a MITM defense until a server actually
459
+ populates `host_fingerprint`. `serverCertificateHashes` (below) is a
460
+ different, narrower mechanism -- it pins a certificate supplied *per
461
+ connection*, not a persisted record of what a host presented last time.
462
+ - **Password auth has no confidentiality of its own -- it relies entirely
463
+ on the transport.** `AUTH_METHOD.PASSWORD` sends the password as a plain
464
+ string inside the `auth` control message (`client.mjs`'s `#performAuth()`
465
+ calls `sendControl(authMsg({ method: AUTH_METHOD.PASSWORD, password }))`)
466
+ -- there is no client-side hashing, salting, or key derivation before it
467
+ goes on the wire. Its confidentiality is entirely a function of whether
468
+ the underlying connection is encrypted: fine over `wss:`/WebTransport
469
+ against a certificate the client actually trusts (CA-signed, or pinned
470
+ via `serverCertificateHashes`), but sent in the clear if the connection
471
+ is a plain, unencrypted `ws:`. Browsers block that combination from an
472
+ `https:` page as mixed content (see "Pinning a Self-Signed Certificate"
473
+ above), but nothing in this protocol or client stops a non-browser
474
+ caller (Node, the Rust CLI) from dialing `ws://` directly. Prefer pubkey
475
+ auth, or make sure the transport is actually encrypted, before sending a
476
+ password.
328
477
 
329
478
  ## Browser Compatibility
330
479
 
@@ -403,6 +552,24 @@ browser the optional `@noble/post-quantum` dependency is what actually
403
552
  runs, loaded dynamically by `src/mlkem.mjs`. Treat it as required, not
404
553
  optional, if you want the hybrid handshake on the web today.
405
554
 
555
+ ## Family
556
+
557
+ wsh isn't just a standalone remote-shell client -- its `WshClient` is the
558
+ designed transport backing one sibling package's network gateway, and
559
+ `WshClient`'s connected/authenticated shape is a drop-in for that sibling's
560
+ injected-client extension point.
561
+
562
+ - **[`@johnhenry/browsermesh-netway`](https://github.com/johnhenry/browsermesh)**
563
+ -- browsermesh-netway's `GatewayBackend` takes a `wshClient` in its
564
+ constructor and proxies every network operation (socket open/send/close)
565
+ through it via `sendControl()`, once the client reaches the
566
+ `'authenticated'` state (`isReady()` checks exactly that). This is a real,
567
+ verified dependency on wsh's client shape -- `GatewayBackend` is
568
+ duck-typed against a `WshClient` instance, not declared as an npm
569
+ dependency -- not a thematic pairing; see
570
+ `packages/browsermesh-netway/src/gateway-backend.mjs` in the browsermesh
571
+ repo. wsh itself has no dependency in the other direction.
572
+
406
573
  ## License
407
574
 
408
575
  MIT
package/package.json CHANGED
@@ -1,11 +1,18 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.17.0",
4
- "description": "Web Shell — browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
3
+ "version": "0.18.0",
4
+ "description": "Web Shell \u2014 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
+ },
12
+ "./server": {
13
+ "types": "./src/server/index.d.ts",
14
+ "import": "./src/server/index.mjs"
15
+ }
9
16
  },
10
17
  "types": "./src/index.d.ts",
11
18
  "files": [
@@ -16,14 +23,24 @@
16
23
  "optionalDependencies": {
17
24
  "@noble/post-quantum": "^0.7.0"
18
25
  },
26
+ "peerDependencies": {
27
+ "ws": "^8.0.0"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "ws": {
31
+ "optional": true
32
+ }
33
+ },
19
34
  "devDependencies": {
20
35
  "@fails-components/webtransport": "^1.6.4",
21
36
  "@fails-components/webtransport-transport-http3-quiche": "^1.6.4",
37
+ "typescript": "^7.0.0",
22
38
  "ws": "^8.21.0"
23
39
  },
24
40
  "scripts": {
25
41
  "test": "node --test test/*.test.mjs",
26
42
  "test:rust": "node --test test/rust/*.test.mjs",
43
+ "typecheck": "tsc --noEmit -p tsconfig.json",
27
44
  "codegen": "node spec/codegen.mjs",
28
45
  "codegen:check": "node scripts/codegen-check.mjs",
29
46
  "examples": "for f in examples/0*.mjs; do echo \"== $f\" && node \"$f\" || exit 1; done",
@@ -53,7 +70,7 @@
53
70
  },
54
71
  "author": "John Henry",
55
72
  "engines": {
56
- "node": ">=24.0.0"
73
+ "node": ">=26.0.0"
57
74
  },
58
75
  "homepage": "https://opensource.johnhenry.me/wsh/"
59
76
  }
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,
@@ -62,6 +63,13 @@ const DEFAULT_OPEN_TIMEOUT = 10_000; // ms
62
63
  */
63
64
  export const MCP_CALL_ID_FEATURE = 'mcp-call-id';
64
65
 
66
+ /**
67
+ * ServerHello feature: the host discovers a client-opened exec data stream
68
+ * on its own (the transport announces it), so the client must NOT write the
69
+ * one-byte "primer" -- against such a host it would reach the process's stdin.
70
+ */
71
+ export const STREAM_ANNOUNCE_FEATURE = 'stream-announce';
72
+
65
73
  /**
66
74
  * Reject a missing argument that maps to a `required: true` wire field.
67
75
  *
@@ -586,9 +594,14 @@ export class WshClient {
586
594
  * @param {number} [opts.rows=24] - Initial terminal rows
587
595
  * @param {object} [opts.env] - Environment variables
588
596
  * @param {number} [opts.timeout] - Timeout in ms
597
+ * @param {boolean} [opts.primer=true] - For stream-mode `exec` sessions: write
598
+ * the one-byte "primer" some hosts need to discover the client-opened data
599
+ * stream (without it they never bind the stream and drop all output).
600
+ * Skipped automatically against a host advertising `stream-announce`; pass
601
+ * `false` to never send it (e.g. a host that forwards stdin verbatim).
589
602
  * @returns {Promise<WshSession>}
590
603
  */
591
- async openSession({ type = 'pty', command, cols = 80, rows = 24, env, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
604
+ async openSession({ type = 'pty', command, cols = 80, rows = 24, env, timeout = DEFAULT_OPEN_TIMEOUT, primer = true } = {}) {
592
605
  this.#assertAuthenticated('openSession');
593
606
  const requestedChannelId = this._nextChannelId();
594
607
 
@@ -604,6 +617,7 @@ export class WshClient {
604
617
  const entry = {
605
618
  mode: 'session',
606
619
  kind: type,
620
+ primer,
607
621
  requestedChannelId,
608
622
  resolve,
609
623
  reject,
@@ -841,9 +855,10 @@ export class WshClient {
841
855
  * @param {CryptoKeyPair} [opts.keyPair]
842
856
  * @param {string} [opts.password]
843
857
  * @param {number} [opts.timeout=60000] - Overall timeout in ms
858
+ * @param {boolean} [opts.primer=true] - See `openSession({ primer })`
844
859
  * @returns {Promise<{stdout: Uint8Array, exitCode: number}>}
845
860
  */
846
- static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT } = {}) {
861
+ static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT, primer = true } = {}) {
847
862
  const client = new WshClient();
848
863
  const chunks = [];
849
864
  let exitCode = -1;
@@ -851,7 +866,7 @@ export class WshClient {
851
866
  try {
852
867
  await client.connect(url, { username, keyPair, password });
853
868
 
854
- const session = await client.openSession({ type: 'exec', command });
869
+ const session = await client.openSession({ type: 'exec', command, primer });
855
870
 
856
871
  await new Promise((resolve, reject) => {
857
872
  const timer = setTimeout(() => {
@@ -1738,6 +1753,45 @@ export class WshClient {
1738
1753
  /** Rename a remote file or directory. */
1739
1754
  async fileRename(oldPath, newPath, timeout) { return this.fileOperation('rename', oldPath, {}, timeout); }
1740
1755
 
1756
+ // ── Authorized-key management (wsh #59) ───────────────────────────
1757
+
1758
+ /**
1759
+ * Install a raw 32-byte Ed25519 public key into the remote host's
1760
+ * ~/.wsh/authorized_keys via the AuthorizedKeyAdd protocol message.
1761
+ *
1762
+ * Replaces `wsh copy-id`'s CLI-only, shell-command-built approach
1763
+ * (`crates/wsh-cli/src/commands/copy_id.rs`) with a real protocol
1764
+ * message every implementation can call -- including this one, the
1765
+ * browser SDK, which previously had no way to install a key at all
1766
+ * (no shell channel to build a command string for). See copy_id.rs's
1767
+ * doc comment for the full security reasoning: an authenticated
1768
+ * connection already has unrestricted exec/file access to this exact
1769
+ * file, so this adds no new privilege boundary.
1770
+ *
1771
+ * Idempotent: installing an already-present key resolves
1772
+ * `{ added: false }` rather than duplicating the line or erroring.
1773
+ *
1774
+ * @param {Uint8Array} publicKeyRaw - Raw 32-byte Ed25519 public key
1775
+ * @param {string} [comment] - Trailing comment for the authorized_keys line
1776
+ * @param {number} [timeout=10000]
1777
+ * @returns {Promise<{ added: boolean }>}
1778
+ */
1779
+ async addAuthorizedKey(publicKeyRaw, comment, timeout = DEFAULT_OPEN_TIMEOUT) {
1780
+ this.#assertAuthenticated('addAuthorizedKey');
1781
+ await this.#transport.sendControl(
1782
+ authorizedKeyAddMsg({ publicKey: publicKeyRaw, comment })
1783
+ );
1784
+ const result = await this.#waitForMessage(
1785
+ [MSG.AUTHORIZED_KEY_RESULT],
1786
+ timeout,
1787
+ 'Timed out waiting for authorized-key-add result'
1788
+ );
1789
+ if (!result.success) {
1790
+ throw new Error(result.error_message || 'key installation refused');
1791
+ }
1792
+ return { added: !!result.added };
1793
+ }
1794
+
1741
1795
  // ── Policy Engine ─────────────────────────────────────────────────
1742
1796
 
1743
1797
  /**
@@ -2105,8 +2159,20 @@ export class WshClient {
2105
2159
  // so channel-scoped control messages arriving before the stream
2106
2160
  // finishes binding still route correctly.
2107
2161
  this.#transport.openStream().then(
2108
- (stream) => {
2162
+ async (stream) => {
2109
2163
  session._bind(stream.readable, stream.writable);
2164
+ // The transport has already announced the stream, which is all a
2165
+ // current host needs. Older hosts only bind a stream on its first
2166
+ // byte, so exec (which has no stdin to send) writes a one-byte
2167
+ // primer they strip -- unless the host says it needs none.
2168
+ if (pending.kind === 'exec' && pending.primer && !this.hasFeature(STREAM_ANNOUNCE_FEATURE)) {
2169
+ try {
2170
+ await session.write(new Uint8Array([0]));
2171
+ } catch (err) {
2172
+ pending.reject(err);
2173
+ return;
2174
+ }
2175
+ }
2110
2176
  pending.resolve(session);
2111
2177
  },
2112
2178
  (err) => pending.reject(err)