@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 +186 -19
- package/package.json +21 -4
- package/src/client.mjs +70 -4
- package/src/file-transfer.mjs +45 -156
- package/src/index.d.ts +149 -6
- package/src/index.mjs +5 -1
- package/src/known-hosts.mjs +139 -0
- package/src/messages.gen.mjs +22 -9
- package/src/qmux-connection.mjs +21 -0
- package/src/server/auth.mjs +75 -0
- package/src/server/connection.mjs +381 -0
- package/src/server/exec.mjs +60 -0
- package/src/server/fs.mjs +134 -0
- package/src/server/index.d.ts +130 -0
- package/src/server/index.mjs +95 -0
- package/src/server/pty.mjs +25 -0
- package/src/transport-ws.mjs +4 -0
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`
|
|
7
|
-
>
|
|
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
|
+
[](https://www.npmjs.com/package/@johnhenry/wsh)
|
|
12
|
+
[](https://github.com/johnhenry/wsh/actions/workflows/ci.yml)
|
|
13
|
+
[](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
|
|
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
|
-
- **
|
|
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` |
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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.
|
|
4
|
-
"description": "Web Shell
|
|
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
|
-
".":
|
|
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": ">=
|
|
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)
|