@johnhenry/wsh 0.30.0 → 0.31.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 +76 -15
- package/package.json +5 -1
- package/src/auth.mjs +59 -0
- package/src/client.mjs +33 -29
- package/src/index.d.ts +44 -3
- package/src/index.mjs +1 -0
- package/src/memory-host.d.ts +48 -0
- package/src/memory-host.mjs +175 -0
- package/src/recording.mjs +18 -10
- package/src/server/connection.mjs +3 -4
- package/src/server/constants.mjs +10 -0
- package/src/server/fs.mjs +2 -1
- package/src/server/gateway.mjs +11 -4
- package/src/server/index.d.ts +23 -0
- package/src/server/index.mjs +56 -17
- package/src/server/pty.mjs +1 -1
- package/src/server/sessions.mjs +2 -1
- package/src/server/socket.mjs +73 -0
- package/src/transport-ws.mjs +18 -2
package/README.md
CHANGED
|
@@ -61,22 +61,39 @@ Or via CDN:
|
|
|
61
61
|
- **Reverse mode** -- register as a peer (via a signed peer record) and accept incoming connections through a relay
|
|
62
62
|
- **File transfer** -- scp-like upload/download as `FileChunk` control messages in 64KB chunks
|
|
63
63
|
- **MCP bridge** -- discover and invoke remote MCP tools through the control channel
|
|
64
|
-
- **Session recording** -- own JSON schema (not asciicast
|
|
64
|
+
- **Session recording** -- own JSON schema (asciicast-v2-shaped, not a playable asciicast file -- see note below) recording and playback with seek/pause/resume
|
|
65
65
|
- **Key management** -- IndexedDB storage with OPFS encrypted backup (PBKDF2 + AES-256-GCM)
|
|
66
66
|
- **98 message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, authorized-key management, and more
|
|
67
67
|
|
|
68
|
-
> **Session recording is not asciicast
|
|
69
|
-
>
|
|
70
|
-
>
|
|
71
|
-
>
|
|
72
|
-
>
|
|
73
|
-
>
|
|
74
|
-
>
|
|
75
|
-
>
|
|
76
|
-
>
|
|
77
|
-
>
|
|
78
|
-
>
|
|
79
|
-
> (
|
|
68
|
+
> **Session recording is asciicast-v2-shaped, but not an asciicast file.**
|
|
69
|
+
> The JS `SessionRecorder.toJSON()` export borrows asciicast v2's vocabulary --
|
|
70
|
+
> `version: 2`, `width`, `height`, `timestamp`, `env`, and events as
|
|
71
|
+
> `[seconds, "o" | "i" | "r", data]` -- so it is easy to convert, but it is not
|
|
72
|
+
> something `asciinema play` can read as-is:
|
|
73
|
+
>
|
|
74
|
+
> - it is **one JSON document** with an `events` array; asciicast v2 is
|
|
75
|
+
> newline-delimited JSON (a header line, then one event per line);
|
|
76
|
+
> - the header carries wsh's own `sessionId`, and `width`/`height` follow the
|
|
77
|
+
> latest resize rather than the size at the start;
|
|
78
|
+
> - session lifecycle events (`open`, `exit`) use codes asciicast v2 does not
|
|
79
|
+
> define (the Rust recorder also has `Start`/`Exit`/`Snapshot`, and uses a
|
|
80
|
+
> different, incompatible schema from the JS one);
|
|
81
|
+
> - `RecordingExport`'s `format: "asciicast"` option is accepted on the wire but
|
|
82
|
+
> not implemented server-side -- it returns the same custom JSONL as
|
|
83
|
+
> `format: "jsonl"`.
|
|
84
|
+
>
|
|
85
|
+
> To play a JS recording in asciinema, write the header and the `o`/`i`/`r`
|
|
86
|
+
> events out as lines (lossy: lifecycle events are dropped):
|
|
87
|
+
>
|
|
88
|
+
> ```js
|
|
89
|
+
> const { version, width, height, timestamp, env, events } = recorder.toJSON();
|
|
90
|
+
> const cast = [JSON.stringify({ version, width, height, timestamp, env }),
|
|
91
|
+
> ...events.filter((e) => 'oir'.includes(e[1])).map((e) => JSON.stringify(e))].join('\n') + '\n';
|
|
92
|
+
> // write `cast` to session.cast, then: asciinema play session.cast
|
|
93
|
+
> ```
|
|
94
|
+
>
|
|
95
|
+
> A first-class `.cast` exporter is not provided; the conversion above is the
|
|
96
|
+
> supported path (it is covered by `test/recording-format.test.mjs`).
|
|
80
97
|
|
|
81
98
|
## Wire Protocol: QMux
|
|
82
99
|
|
|
@@ -201,6 +218,50 @@ messages whose `type` is a string, from authenticated connections only. On a rel
|
|
|
201
218
|
`relay: { onUnreachable(from, targetFingerprint, request) }` is told when an operator asked for a peer that is not
|
|
202
219
|
registered (the operator still gets `no such peer`), so the host can reach that peer another way, such as a Web Push.
|
|
203
220
|
|
|
221
|
+
### Pre-upgraded transports: `accept()`
|
|
222
|
+
|
|
223
|
+
`createWshServer()` normally opens its own listener. If your application already owns the HTTP server (or Worker, or
|
|
224
|
+
test harness) and has upgraded the connection itself, hand the socket over instead of bridging it to a per-session
|
|
225
|
+
loopback listener (which any local process could also connect to):
|
|
226
|
+
|
|
227
|
+
```js
|
|
228
|
+
const wsh = createWshServer({ auth: { authorizedKeys }, exec: true }); // no listen()
|
|
229
|
+
wss.on('connection', (ws, req) => wsh.accept(ws, { remote: { address: req.socket.remoteAddress } }));
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`accept(socket, { remote })` takes any WebSocket-like duplex (`send`, `close`, `readyState`, and `on` or
|
|
233
|
+
`addEventListener`): a `ws` socket, a browser/Worker `WebSocket`, a test double. It applies the same auth, features,
|
|
234
|
+
rate limit and session registry as `listen()`'s connections, may be mixed with `listen()`, and resolves to
|
|
235
|
+
`{ close() }`. `remote.address` keys the password-failure throttle (sockets without one share a key). `server.close()`
|
|
236
|
+
drops every accepted socket.
|
|
237
|
+
|
|
238
|
+
### In-memory host (`@johnhenry/wsh/memory`)
|
|
239
|
+
|
|
240
|
+
A browser-safe host for demos and tests: no sockets, no ports, no Node (nothing in its import graph is `node:*` or `ws`).
|
|
241
|
+
|
|
242
|
+
```js
|
|
243
|
+
import { createMemoryHost } from '@johnhenry/wsh/memory';
|
|
244
|
+
|
|
245
|
+
const host = createMemoryHost({
|
|
246
|
+
auth: { authorizedKeys: [rawPublicKey] }, // same shapes as createWshServer
|
|
247
|
+
exec: async (command, io) => { await io.write(`ran ${command}\n`); return 0; },
|
|
248
|
+
});
|
|
249
|
+
const client = await host.connect({ username: 'demo', keyPair }); // or { signer } / { password }
|
|
250
|
+
const session = await client.openSession({ type: 'exec', command: 'hi' });
|
|
251
|
+
session.onData = (d) => console.log(new TextDecoder().decode(d)); // "ran hi"
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
It is the real connection handler speaking QMux over an in-memory socket pair, so the client code you test is the code
|
|
255
|
+
you ship. `host.createClient()` / `host.WebSocket` / `host.accept(socket)` / `createSocketPair()` are the lower-level pieces.
|
|
256
|
+
Supported: `auth`, `exec` (a function; there is no shell), `mcp`, `extensions`. Not available in memory: `pty`, `fs`,
|
|
257
|
+
`gateway`, `relay`, `sessions`, `rpc`, `hostKey` and WebTransport (use `@johnhenry/wsh/server`).
|
|
258
|
+
|
|
259
|
+
### Signing with a page-held signer
|
|
260
|
+
|
|
261
|
+
`connect()`, `WshClient.exec()` and `connectReverse()` take `signer: { publicKey, sign(data) }` instead of `keyPair`
|
|
262
|
+
(not both): `publicKey` is the raw 32-byte Ed25519 key or a public `CryptoKey`, `sign` returns the 64-byte signature
|
|
263
|
+
(sync or async). For keys that live in a wallet, a hardware token or another realm and are not a `CryptoKeyPair`.
|
|
264
|
+
|
|
204
265
|
### WebTransport listener
|
|
205
266
|
|
|
206
267
|
The client's ladder prefers WebTransport (`https://`) and falls back to `wss://`;
|
|
@@ -936,7 +997,7 @@ so options the platform gains later need no change here.
|
|
|
936
997
|
| `WshMcpBridge` | Remote MCP tool discovery and invocation |
|
|
937
998
|
| `RpcChannel` / `RpcError` | Typed RPC channel (JSON-RPC 2.0 over a CBOR sequence): `client.openRpc(protocol)`; `RPC_FEATURE`, `rpcProtocolFeature(name)`, `RPC_ERROR`, `CborSequenceDecoder` |
|
|
938
999
|
| `mcpClientTransport()` / `mcpServerAdapter()` (`/server`) | MCP SDK `Transport` over an `rpc` channel |
|
|
939
|
-
| `SessionRecorder` | Record PTY I/O with timestamps (
|
|
1000
|
+
| `SessionRecorder` | Record PTY I/O with timestamps (asciicast-v2-shaped JSON; convert to play in asciinema, see the note under Session recording) |
|
|
940
1001
|
| `SessionPlayer` | Replay recordings with original timing |
|
|
941
1002
|
| `generateKeyPair()` | Create Ed25519 key pair via Web Crypto |
|
|
942
1003
|
| `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 |
|
|
@@ -1003,7 +1064,7 @@ than left to be found.
|
|
|
1003
1064
|
| Attach / resume | `attachSession()`, `resumeSession()`, `WshSession.seq` | `attach_session()`, `resume_session()` | **Wire-unified** (`Attach`/`Resume`/`Presence`); `seq` = cumulative output bytes. `@johnhenry/wsh/server` honours `last_seq` (bounded ring, gap errors) and answers Presence with `channel_id`/`seq`; the Rust `wsh-server` does the same (`rust-v0.2.0`+; fixed 256 KiB ring) |
|
|
1004
1065
|
| Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()`; `@johnhenry/wsh/server`: `relay` option and `createReverseHost()` | `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. The Node relay is default-deny and bridges one operator per peer unless `maxOperatorsPerPeer` is raised (`busy:` reasons say so); E2E and bridged feature gates need `rust-v0.3.0`+ on the Rust side; several operators per peer and the signed `KeyExchange` are JS-relay only for now |
|
|
1005
1066
|
| 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 |
|
|
1006
|
-
| Session recording/replay | `SessionRecorder`/`SessionPlayer` (asciicast
|
|
1067
|
+
| Session recording/replay | `SessionRecorder`/`SessionPlayer` (asciicast-v2-shaped JSON) | none | **JS-only** -- no current Rust consumer needs playback; the vocabulary is asciicast v2's, so a Rust port could share it |
|
|
1007
1068
|
| 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 |
|
|
1008
1069
|
|
|
1009
1070
|
### Build and test
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/wsh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
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",
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"./server": {
|
|
13
13
|
"types": "./src/server/index.d.ts",
|
|
14
14
|
"import": "./src/server/index.mjs"
|
|
15
|
+
},
|
|
16
|
+
"./memory": {
|
|
17
|
+
"types": "./src/memory-host.d.ts",
|
|
18
|
+
"import": "./src/memory-host.mjs"
|
|
15
19
|
}
|
|
16
20
|
},
|
|
17
21
|
"types": "./src/index.d.ts",
|
package/src/auth.mjs
CHANGED
|
@@ -159,6 +159,65 @@ export async function sign(privateKey, data) {
|
|
|
159
159
|
return new Uint8Array(sig);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* A page-held signer: an object that can sign but need not expose a private key.
|
|
164
|
+
* `publicKey` is the raw 32-byte Ed25519 key (or a `CryptoKey`); `sign(data)` returns the
|
|
165
|
+
* 64-byte signature, synchronously or not. For keys held by a wallet, a hardware token, a
|
|
166
|
+
* Worker, or anything else that is not a `CryptoKeyPair`.
|
|
167
|
+
*
|
|
168
|
+
* @typedef {{ publicKey: Uint8Array | CryptoKey, sign: (data: Uint8Array) => Uint8Array | Promise<Uint8Array> }} WshSigner
|
|
169
|
+
*/
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Turn `{ keyPair }` or `{ signer }` into one `{ publicKeyRaw, sign }` pair, or `null` when neither is given.
|
|
173
|
+
* Throws a TypeError for both together or for a malformed signer.
|
|
174
|
+
*
|
|
175
|
+
* @param {{ keyPair?: CryptoKeyPair, signer?: WshSigner }} credentials
|
|
176
|
+
* @returns {Promise<{ publicKeyRaw: Uint8Array, sign: (data: Uint8Array) => Promise<Uint8Array> } | null>}
|
|
177
|
+
*/
|
|
178
|
+
export async function resolveSigner({ keyPair, signer } = {}) {
|
|
179
|
+
if (keyPair && signer) throw new TypeError('pass either keyPair or signer, not both');
|
|
180
|
+
if (keyPair) {
|
|
181
|
+
return { publicKeyRaw: await exportPublicKeyRaw(keyPair.publicKey), sign: (data) => sign(keyPair.privateKey, data) };
|
|
182
|
+
}
|
|
183
|
+
if (!signer) return null;
|
|
184
|
+
if (typeof signer !== 'object' || typeof signer.sign !== 'function') {
|
|
185
|
+
throw new TypeError('signer must be { publicKey, sign(data) }');
|
|
186
|
+
}
|
|
187
|
+
let publicKeyRaw;
|
|
188
|
+
if (signer.publicKey instanceof Uint8Array) publicKeyRaw = signer.publicKey;
|
|
189
|
+
else if (signer.publicKey && typeof signer.publicKey === 'object' && signer.publicKey.type === 'public') publicKeyRaw = await exportPublicKeyRaw(signer.publicKey);
|
|
190
|
+
if (!publicKeyRaw || publicKeyRaw.byteLength !== 32) {
|
|
191
|
+
throw new TypeError('signer.publicKey must be a raw 32-byte Ed25519 key or a public CryptoKey');
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
publicKeyRaw,
|
|
195
|
+
sign: async (data) => {
|
|
196
|
+
const sig = await signer.sign(data);
|
|
197
|
+
if (!(sig instanceof Uint8Array) || sig.byteLength !== 64) throw new TypeError('signer.sign() must return a 64-byte Ed25519 signature');
|
|
198
|
+
return sig;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `signChallenge` for a resolved signer (see `resolveSigner`).
|
|
205
|
+
* @returns {Promise<{ signature: Uint8Array, publicKeyRaw: Uint8Array }>}
|
|
206
|
+
*/
|
|
207
|
+
export async function signChallengeWith(signer, sessionId, nonce, opts) {
|
|
208
|
+
const transcript = await buildTranscript(sessionId, nonce, opts);
|
|
209
|
+
return { signature: await signer.sign(transcript), publicKeyRaw: signer.publicKeyRaw };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* `signPeerRecord` for a resolved signer (see `resolveSigner`).
|
|
214
|
+
* @returns {Promise<{ signature: Uint8Array, publicKeyRaw: Uint8Array }>}
|
|
215
|
+
*/
|
|
216
|
+
export async function signPeerRecordWith(signer, record) {
|
|
217
|
+
const transcript = await buildPeerRecordTranscript(record);
|
|
218
|
+
return { signature: await signer.sign(transcript), publicKeyRaw: signer.publicKeyRaw };
|
|
219
|
+
}
|
|
220
|
+
|
|
162
221
|
/**
|
|
163
222
|
* Verify an Ed25519 signature.
|
|
164
223
|
* @param {CryptoKey} publicKey
|
package/src/client.mjs
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
sessionGrant as sessionGrantMsg, sessionRevoke as sessionRevokeMsg,
|
|
38
38
|
isRelayForwardable,
|
|
39
39
|
} from './messages.mjs';
|
|
40
|
-
import {
|
|
40
|
+
import { resolveSigner, signChallengeWith, signPeerRecordWith, verifyPeerRecord, importPublicKeyRaw, fingerprint as computeFingerprint } from './auth.mjs';
|
|
41
41
|
import { generateMlKemKeyPair, mlKemEncapsulate, mlKemDecapsulate } from './mlkem.mjs';
|
|
42
42
|
import { compareBytes, combineHybridSecret, verifyKeyExchangeSignature } from './e2e-exchange.mjs';
|
|
43
43
|
import { WshSession } from './session.mjs';
|
|
@@ -467,7 +467,8 @@ export class WshClient {
|
|
|
467
467
|
* @param {string} url - Server URL (https:// for WebTransport, wss:// or ws:// for WebSocket)
|
|
468
468
|
* @param {object} opts
|
|
469
469
|
* @param {string} opts.username - Username for authentication
|
|
470
|
-
* @param {CryptoKeyPair} [opts.keyPair]
|
|
470
|
+
* @param {CryptoKeyPair} [opts.keyPair]
|
|
471
|
+
* @param {import('./auth.mjs').WshSigner} [opts.signer] - Page-held signer `{ publicKey, sign(data) }` instead of `keyPair`.
|
|
471
472
|
* @param {string} [opts.password] - Password for password auth
|
|
472
473
|
* @param {'wt'|'ws'|'auto'} [opts.transport] - Force a specific transport
|
|
473
474
|
* @param {object} [opts.webTransport] - Options forwarded to the
|
|
@@ -493,15 +494,16 @@ export class WshClient {
|
|
|
493
494
|
* @param {Function} [opts.onHostKey] - Per-connect `onHostKey`.
|
|
494
495
|
* @returns {Promise<string>} The server-assigned session ID
|
|
495
496
|
*/
|
|
496
|
-
async connect(url, { username, keyPair, password, transport: transportHint, webTransport, timeout = DEFAULT_AUTH_TIMEOUT, ...hostKeyOpts } = {}) {
|
|
497
|
+
async connect(url, { username, keyPair, signer, password, transport: transportHint, webTransport, timeout = DEFAULT_AUTH_TIMEOUT, ...hostKeyOpts } = {}) {
|
|
497
498
|
if (this.#state !== STATE_DISCONNECTED && this.#state !== STATE_CLOSED) {
|
|
498
499
|
throw new Error(`Client already ${this.#state}`);
|
|
499
500
|
}
|
|
500
501
|
if (!username) {
|
|
501
502
|
throw new Error('username is required');
|
|
502
503
|
}
|
|
503
|
-
|
|
504
|
-
|
|
504
|
+
const cred = await resolveSigner({ keyPair, signer });
|
|
505
|
+
if (!cred && !password) {
|
|
506
|
+
throw new Error('Either keyPair, signer or password is required for authentication');
|
|
505
507
|
}
|
|
506
508
|
|
|
507
509
|
this.#state = STATE_CONNECTING;
|
|
@@ -519,7 +521,7 @@ export class WshClient {
|
|
|
519
521
|
this.#state = STATE_CONNECTED;
|
|
520
522
|
|
|
521
523
|
// ── Auth handshake ────────────────────────────────────────────
|
|
522
|
-
const authMethod =
|
|
524
|
+
const authMethod = cred ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
|
|
523
525
|
const hostKeyNonce = newHostKeyNonce();
|
|
524
526
|
await transport.sendControl(
|
|
525
527
|
hello({ username, authMethod, features: [HOST_KEY_NONCE_PREFIX + hostKeyNonce] })
|
|
@@ -568,9 +570,8 @@ export class WshClient {
|
|
|
568
570
|
// Sign the challenge. Challenge.session_id (not the one from
|
|
569
571
|
// ServerHello) is authoritative for the transcript — see the
|
|
570
572
|
// comment in the CHALLENGE-first branch below for why.
|
|
571
|
-
const { signature, publicKeyRaw } = await
|
|
572
|
-
|
|
573
|
-
keyPair.publicKey,
|
|
573
|
+
const { signature, publicKeyRaw } = await signChallengeWith(
|
|
574
|
+
cred,
|
|
574
575
|
challengeMsg.session_id,
|
|
575
576
|
challengeMsg.nonce,
|
|
576
577
|
{ username }
|
|
@@ -595,7 +596,7 @@ export class WshClient {
|
|
|
595
596
|
} else if (firstResponse.type === MSG.CHALLENGE) {
|
|
596
597
|
// Some servers skip SERVER_HELLO and go straight to CHALLENGE.
|
|
597
598
|
await this.#verifyHostKey(null, hostCtx);
|
|
598
|
-
if (authMethod !== AUTH_METHOD.PUBKEY || !
|
|
599
|
+
if (authMethod !== AUTH_METHOD.PUBKEY || !cred) {
|
|
599
600
|
throw new Error('Server sent CHALLENGE but no key pair was provided');
|
|
600
601
|
}
|
|
601
602
|
|
|
@@ -607,9 +608,8 @@ export class WshClient {
|
|
|
607
608
|
// tempSessionId as a fallback for this.#sessionId below in case
|
|
608
609
|
// AUTH_OK's own session_id is ever absent.
|
|
609
610
|
tempSessionId = firstResponse.session_id;
|
|
610
|
-
const { signature, publicKeyRaw } = await
|
|
611
|
-
|
|
612
|
-
keyPair.publicKey,
|
|
611
|
+
const { signature, publicKeyRaw } = await signChallengeWith(
|
|
612
|
+
cred,
|
|
613
613
|
firstResponse.session_id,
|
|
614
614
|
firstResponse.nonce,
|
|
615
615
|
{ username }
|
|
@@ -996,18 +996,19 @@ export class WshClient {
|
|
|
996
996
|
* @param {object} opts
|
|
997
997
|
* @param {string} opts.username
|
|
998
998
|
* @param {CryptoKeyPair} [opts.keyPair]
|
|
999
|
+
* @param {import('./auth.mjs').WshSigner} [opts.signer] - Page-held signer `{ publicKey, sign(data) }` instead of `keyPair`.
|
|
999
1000
|
* @param {string} [opts.password]
|
|
1000
1001
|
* @param {number} [opts.timeout=60000] - Overall timeout in ms
|
|
1001
1002
|
* @param {boolean} [opts.primer=true] - See `openSession({ primer })`
|
|
1002
1003
|
* @returns {Promise<{stdout: Uint8Array, exitCode: number}>}
|
|
1003
1004
|
*/
|
|
1004
|
-
static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT, primer = true, ...hostKeyOpts } = {}) {
|
|
1005
|
+
static async exec(url, command, { username, keyPair, signer, password, timeout = DEFAULT_EXEC_TIMEOUT, primer = true, ...hostKeyOpts } = {}) {
|
|
1005
1006
|
const client = new WshClient();
|
|
1006
1007
|
const chunks = [];
|
|
1007
1008
|
let exitCode = -1;
|
|
1008
1009
|
|
|
1009
1010
|
try {
|
|
1010
|
-
await client.connect(url, { username, keyPair, password, ...hostKeyOpts });
|
|
1011
|
+
await client.connect(url, { username, keyPair, signer, password, ...hostKeyOpts });
|
|
1011
1012
|
|
|
1012
1013
|
const session = await client.openSession({ type: 'exec', command, primer });
|
|
1013
1014
|
|
|
@@ -1056,6 +1057,7 @@ export class WshClient {
|
|
|
1056
1057
|
* @param {object} opts
|
|
1057
1058
|
* @param {string} opts.username
|
|
1058
1059
|
* @param {CryptoKeyPair} [opts.keyPair]
|
|
1060
|
+
* @param {import('./auth.mjs').WshSigner} [opts.signer] - Page-held signer `{ publicKey, sign(data) }` instead of `keyPair`.
|
|
1059
1061
|
* @param {string} [opts.password]
|
|
1060
1062
|
* @param {object} [opts.expose] - Capabilities to expose { shell, exec, fs, tools }
|
|
1061
1063
|
* @param {string} [opts.peerType]
|
|
@@ -1069,6 +1071,7 @@ export class WshClient {
|
|
|
1069
1071
|
async connectReverse(url, {
|
|
1070
1072
|
username,
|
|
1071
1073
|
keyPair,
|
|
1074
|
+
signer,
|
|
1072
1075
|
password,
|
|
1073
1076
|
webTransport,
|
|
1074
1077
|
expose = {},
|
|
@@ -1081,7 +1084,7 @@ export class WshClient {
|
|
|
1081
1084
|
...hostKeyOpts
|
|
1082
1085
|
} = {}) {
|
|
1083
1086
|
// Authenticate normally first.
|
|
1084
|
-
const sessionId = await this.connect(url, { username, keyPair, password, webTransport, ...hostKeyOpts });
|
|
1087
|
+
const sessionId = await this.connect(url, { username, keyPair, signer, password, webTransport, ...hostKeyOpts });
|
|
1085
1088
|
|
|
1086
1089
|
// Build capabilities list from expose options.
|
|
1087
1090
|
const capabilities = [];
|
|
@@ -1091,10 +1094,9 @@ export class WshClient {
|
|
|
1091
1094
|
if (expose.tools) capabilities.push('tools');
|
|
1092
1095
|
|
|
1093
1096
|
// Export public key for peer identification.
|
|
1097
|
+
const cred = await resolveSigner({ keyPair, signer });
|
|
1094
1098
|
let publicKey = null;
|
|
1095
|
-
if (
|
|
1096
|
-
publicKey = await exportPublicKeyRaw(keyPair.publicKey);
|
|
1097
|
-
}
|
|
1099
|
+
if (cred) publicKey = cred.publicKeyRaw;
|
|
1098
1100
|
|
|
1099
1101
|
const effectiveShellBackend = shellBackend || (expose.shell ? 'virtual-shell' : 'exec-only');
|
|
1100
1102
|
const record = {
|
|
@@ -1118,8 +1120,8 @@ export class WshClient {
|
|
|
1118
1120
|
// (see auth.mjs's "Signed peer records" section). Only possible with
|
|
1119
1121
|
// a real identity key, same precondition `publicKey` already had.
|
|
1120
1122
|
let recordSignature;
|
|
1121
|
-
if (
|
|
1122
|
-
({ signature: recordSignature } = await
|
|
1123
|
+
if (cred) {
|
|
1124
|
+
({ signature: recordSignature } = await signPeerRecordWith(cred, record));
|
|
1123
1125
|
}
|
|
1124
1126
|
|
|
1125
1127
|
// Register as a reverse peer.
|
|
@@ -2360,18 +2362,20 @@ export class WshClient {
|
|
|
2360
2362
|
* @param {object} opts
|
|
2361
2363
|
* @param {string} opts.username
|
|
2362
2364
|
* @param {CryptoKeyPair} [opts.keyPair]
|
|
2365
|
+
* @param {import('./auth.mjs').WshSigner} [opts.signer] - Page-held signer `{ publicKey, sign(data) }` instead of `keyPair`.
|
|
2363
2366
|
* @param {string} [opts.password]
|
|
2364
2367
|
* @param {number} [opts.timeout]
|
|
2365
2368
|
* @returns {Promise<string>} Session ID
|
|
2366
2369
|
* @private
|
|
2367
2370
|
*/
|
|
2368
|
-
async #performAuth({ username, keyPair, password, timeout = DEFAULT_AUTH_TIMEOUT, url, ...hostKeyOpts } = {}) {
|
|
2371
|
+
async #performAuth({ username, keyPair, signer, password, timeout = DEFAULT_AUTH_TIMEOUT, url, ...hostKeyOpts } = {}) {
|
|
2369
2372
|
if (!username) throw new Error('username is required');
|
|
2370
|
-
|
|
2373
|
+
const cred = await resolveSigner({ keyPair, signer });
|
|
2374
|
+
if (!cred && !password) throw new Error('Either keyPair, signer or password is required');
|
|
2371
2375
|
|
|
2372
2376
|
this.#hostKey = null;
|
|
2373
2377
|
try {
|
|
2374
|
-
const authMethod =
|
|
2378
|
+
const authMethod = cred ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
|
|
2375
2379
|
const hostKeyNonce = newHostKeyNonce();
|
|
2376
2380
|
await this.#transport.sendControl(hello({ username, authMethod, features: [HOST_KEY_NONCE_PREFIX + hostKeyNonce] }));
|
|
2377
2381
|
const hostCtx = { ...hostKeyOpts, url, username, nonce: hostKeyNonce };
|
|
@@ -2409,8 +2413,8 @@ export class WshClient {
|
|
|
2409
2413
|
return this.#sessionId;
|
|
2410
2414
|
}
|
|
2411
2415
|
|
|
2412
|
-
const { signature, publicKeyRaw } = await
|
|
2413
|
-
|
|
2416
|
+
const { signature, publicKeyRaw } = await signChallengeWith(
|
|
2417
|
+
cred, challengeMsg.session_id, challengeMsg.nonce, { username }
|
|
2414
2418
|
);
|
|
2415
2419
|
|
|
2416
2420
|
await this.#transport.sendControl(authMsg({
|
|
@@ -2422,7 +2426,7 @@ export class WshClient {
|
|
|
2422
2426
|
}));
|
|
2423
2427
|
}
|
|
2424
2428
|
} else if (firstResponse.type === MSG.CHALLENGE) {
|
|
2425
|
-
if (!
|
|
2429
|
+
if (!cred) throw new Error('Server sent CHALLENGE but no key pair provided');
|
|
2426
2430
|
await this.#verifyHostKey(null, hostCtx);
|
|
2427
2431
|
|
|
2428
2432
|
// Challenge carries session_id directly — see connect()'s
|
|
@@ -2430,8 +2434,8 @@ export class WshClient {
|
|
|
2430
2434
|
// this.#sessionId below in case AUTH_OK's own session_id is ever
|
|
2431
2435
|
// absent.
|
|
2432
2436
|
tempSessionId = firstResponse.session_id;
|
|
2433
|
-
const { signature, publicKeyRaw } = await
|
|
2434
|
-
|
|
2437
|
+
const { signature, publicKeyRaw } = await signChallengeWith(
|
|
2438
|
+
cred, firstResponse.session_id, firstResponse.nonce, { username }
|
|
2435
2439
|
);
|
|
2436
2440
|
|
|
2437
2441
|
await this.#transport.sendControl(authMsg({
|
package/src/index.d.ts
CHANGED
|
@@ -1108,6 +1108,38 @@ export function signChallenge(
|
|
|
1108
1108
|
opts?: WshTranscriptOptions,
|
|
1109
1109
|
): Promise<{ signature: Uint8Array; publicKeyRaw: Uint8Array }>;
|
|
1110
1110
|
|
|
1111
|
+
/**
|
|
1112
|
+
* A signer that need not expose a private key: a wallet, a hardware token, a Worker.
|
|
1113
|
+
* `publicKey` is the raw 32-byte Ed25519 key or a public `CryptoKey`; `sign` returns the 64-byte signature.
|
|
1114
|
+
*/
|
|
1115
|
+
export interface WshSigner {
|
|
1116
|
+
publicKey: Uint8Array | CryptoKey;
|
|
1117
|
+
sign(data: Uint8Array): Uint8Array | Promise<Uint8Array>;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** A `keyPair` or `signer` normalized to a raw public key and an async `sign`; `null` when neither is given. */
|
|
1121
|
+
export interface WshResolvedSigner {
|
|
1122
|
+
publicKeyRaw: Uint8Array;
|
|
1123
|
+
sign(data: Uint8Array): Promise<Uint8Array>;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** Normalize `{ keyPair }` or `{ signer }`. Throws a TypeError for both together or a malformed signer. */
|
|
1127
|
+
export function resolveSigner(credentials?: { keyPair?: CryptoKeyPair; signer?: WshSigner }): Promise<WshResolvedSigner | null>;
|
|
1128
|
+
|
|
1129
|
+
/** `signChallenge` for a signer resolved by `resolveSigner`. */
|
|
1130
|
+
export function signChallengeWith(
|
|
1131
|
+
signer: WshResolvedSigner,
|
|
1132
|
+
sessionId: string,
|
|
1133
|
+
nonce: Uint8Array,
|
|
1134
|
+
opts?: WshTranscriptOptions,
|
|
1135
|
+
): Promise<{ signature: Uint8Array; publicKeyRaw: Uint8Array }>;
|
|
1136
|
+
|
|
1137
|
+
/** `signPeerRecord` for a signer resolved by `resolveSigner`. */
|
|
1138
|
+
export function signPeerRecordWith(
|
|
1139
|
+
signer: WshResolvedSigner,
|
|
1140
|
+
record: WshPeerRecord,
|
|
1141
|
+
): Promise<{ signature: Uint8Array; publicKeyRaw: Uint8Array }>;
|
|
1142
|
+
|
|
1111
1143
|
/**
|
|
1112
1144
|
* Server-side: verify a client's challenge response.
|
|
1113
1145
|
*/
|
|
@@ -1385,7 +1417,10 @@ export class SerialQueue<T = unknown> {
|
|
|
1385
1417
|
* Provides the same interface as WebTransportTransport so that upper
|
|
1386
1418
|
* layers (session, client) work identically over either transport.
|
|
1387
1419
|
*/
|
|
1388
|
-
export class WebSocketTransport extends WshTransport {
|
|
1420
|
+
export class WebSocketTransport extends WshTransport {
|
|
1421
|
+
/** `WebSocket`: a constructor to dial with instead of the global one (a test double, `createMemoryHost().WebSocket`). */
|
|
1422
|
+
constructor(options?: { WebSocket?: new (url: string) => any });
|
|
1423
|
+
}
|
|
1389
1424
|
|
|
1390
1425
|
/**
|
|
1391
1426
|
* @deprecated The old hand-rolled 5-byte mux's frame-type byte values.
|
|
@@ -1772,6 +1807,8 @@ export interface WshHostKeyOptions {
|
|
|
1772
1807
|
export interface WshConnectOptions extends WshHostKeyOptions {
|
|
1773
1808
|
username: string;
|
|
1774
1809
|
keyPair?: CryptoKeyPair;
|
|
1810
|
+
/** A page-held signer instead of `keyPair` (not both). */
|
|
1811
|
+
signer?: WshSigner;
|
|
1775
1812
|
password?: string;
|
|
1776
1813
|
transport?: 'wt' | 'ws';
|
|
1777
1814
|
/**
|
|
@@ -1845,6 +1882,8 @@ export interface WshResumeSessionOptions {
|
|
|
1845
1882
|
export interface WshConnectReverseOptions {
|
|
1846
1883
|
username: string;
|
|
1847
1884
|
keyPair?: CryptoKeyPair;
|
|
1885
|
+
/** A page-held signer instead of `keyPair` (not both). */
|
|
1886
|
+
signer?: WshSigner;
|
|
1848
1887
|
password?: string;
|
|
1849
1888
|
/** Forwarded to the underlying `connect()` -- see `WshConnectOptions`. */
|
|
1850
1889
|
webTransport?: WshWebTransportOptions;
|
|
@@ -1871,6 +1910,8 @@ export interface WshConnectReverseOptions {
|
|
|
1871
1910
|
export interface WshExecOptions extends WshHostKeyOptions {
|
|
1872
1911
|
username: string;
|
|
1873
1912
|
keyPair?: CryptoKeyPair;
|
|
1913
|
+
/** A page-held signer instead of `keyPair` (not both). */
|
|
1914
|
+
signer?: WshSigner;
|
|
1874
1915
|
password?: string;
|
|
1875
1916
|
timeout?: number;
|
|
1876
1917
|
/** See {@link WshOpenSessionOptions.primer}. */
|
|
@@ -2542,7 +2583,7 @@ export interface SessionRecordingEntry {
|
|
|
2542
2583
|
data: unknown;
|
|
2543
2584
|
}
|
|
2544
2585
|
|
|
2545
|
-
/**
|
|
2586
|
+
/** The `toJSON()` export: asciicast-v2-shaped (not a playable asciicast file -- one JSON document, plus `sessionId` and `open`/`exit` events). */
|
|
2546
2587
|
export interface SessionRecordingJSON {
|
|
2547
2588
|
version: 2;
|
|
2548
2589
|
width: number;
|
|
@@ -2580,7 +2621,7 @@ export class SessionRecorder {
|
|
|
2580
2621
|
record(type: SessionRecordingEventType | string, data: unknown): void;
|
|
2581
2622
|
|
|
2582
2623
|
/**
|
|
2583
|
-
* Export the recording as a JSON-serializable object (asciicast
|
|
2624
|
+
* Export the recording as a JSON-serializable object (asciicast-v2-shaped; see the README for converting it to a `.cast` file).
|
|
2584
2625
|
*/
|
|
2585
2626
|
toJSON(): SessionRecordingJSON;
|
|
2586
2627
|
|
package/src/index.mjs
CHANGED
|
@@ -47,6 +47,7 @@ export {
|
|
|
47
47
|
generateKeyPair, exportPublicKeyRaw, exportPublicKeySSH,
|
|
48
48
|
importPublicKeyRaw, exportPrivateKeyPKCS8, importPrivateKeyPKCS8,
|
|
49
49
|
sign, verify, buildTranscript, signChallenge, verifyChallenge,
|
|
50
|
+
resolveSigner, signChallengeWith, signPeerRecordWith,
|
|
50
51
|
buildPeerRecordTranscript, signPeerRecord, verifyPeerRecord,
|
|
51
52
|
fingerprint, shortFingerprint, generateNonce,
|
|
52
53
|
podId, fingerprintToPodId, podIdToFingerprint,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { WshClient } from './index.js';
|
|
2
|
+
import type { WshSigner } from './index.js';
|
|
3
|
+
import type {
|
|
4
|
+
WshServerAuthOptions, WshServerPrincipal, WshServerMcpOptions, WshExecRunner, WshServerOptions,
|
|
5
|
+
WshAcceptableSocket, WshAcceptOptions,
|
|
6
|
+
} from './server/index.js';
|
|
7
|
+
|
|
8
|
+
export interface WshMemoryHostOptions {
|
|
9
|
+
/** As for `createWshServer`. Omitted = refuse everyone. */
|
|
10
|
+
auth?: WshServerAuthOptions | ((who: WshServerPrincipal) => boolean | Promise<boolean>);
|
|
11
|
+
/** `run(command, io)`; there is no shell in memory, so there is no `true`. */
|
|
12
|
+
exec?: WshExecRunner | { run: WshExecRunner; timeoutMs?: number; clientEnv?: boolean };
|
|
13
|
+
mcp?: WshServerMcpOptions;
|
|
14
|
+
extensions?: WshServerOptions['extensions'];
|
|
15
|
+
bindTimeoutMs?: number;
|
|
16
|
+
onLog?: (line: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Credentials for `host.connect()`: the options of `WshClient.connect()` minus the URL. */
|
|
20
|
+
export interface WshMemoryConnectOptions {
|
|
21
|
+
username: string;
|
|
22
|
+
keyPair?: CryptoKeyPair;
|
|
23
|
+
signer?: WshSigner;
|
|
24
|
+
password?: string;
|
|
25
|
+
timeout?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WshMemoryHost {
|
|
29
|
+
/** The URL to give `client.connect()`; the host ignores it. */
|
|
30
|
+
readonly url: string;
|
|
31
|
+
/** A `WebSocket` constructor that dials this host. */
|
|
32
|
+
readonly WebSocket: new (url?: string) => any;
|
|
33
|
+
/** Serve any pre-upgraded socket (see `createSocketPair()`). */
|
|
34
|
+
accept(socket: WshAcceptableSocket, options?: WshAcceptOptions): Promise<{ close(): void }>;
|
|
35
|
+
socketPair: typeof createSocketPair;
|
|
36
|
+
/** A `WshClient` whose transport is this host: `await client.connect(host.url, { username, keyPair, transport: 'ws' })`. */
|
|
37
|
+
createClient(options?: ConstructorParameters<typeof WshClient>[0]): WshClient;
|
|
38
|
+
/** `createClient()` and `connect()` in one. */
|
|
39
|
+
connect(credentials: WshMemoryConnectOptions): Promise<WshClient>;
|
|
40
|
+
/** Drop every connection. */
|
|
41
|
+
close(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Two connected in-memory WebSocket-like sockets. */
|
|
45
|
+
export function createSocketPair(): [WshAcceptableSocket & EventTarget, WshAcceptableSocket & EventTarget];
|
|
46
|
+
|
|
47
|
+
/** A browser-safe wsh host in this JavaScript realm: no sockets, no Node. */
|
|
48
|
+
export function createMemoryHost(options?: WshMemoryHostOptions): WshMemoryHost;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@johnhenry/wsh/memory` -- a wsh host that lives in the same JavaScript realm as its clients, with no sockets, no
|
|
3
|
+
* ports and no Node. Browser-safe (a page, a Worker, Deno, Bun, Node): it imports nothing from `node:*`, and a test
|
|
4
|
+
* (`test/memory-host.test.mjs`) keeps it that way. For demos, docs and tests of an application that talks wsh.
|
|
5
|
+
*
|
|
6
|
+
* It is the real protocol end to end -- the same connection handler `createWshServer` runs (handshake, Ed25519 or
|
|
7
|
+
* password auth, exec channels, MCP tools, extensions), speaking QMux over a pair of in-memory WebSocket-like
|
|
8
|
+
* sockets -- so what a client does here is what it does against a server. It serves what a browser can: `exec` as a
|
|
9
|
+
* function you provide (there is no shell), `mcp` tools, `extensions`. Not available: `pty`, `fs`, `gateway`, `relay`,
|
|
10
|
+
* `sessions`, `rpc`, `hostKey`, WebTransport (they need Node; use `@johnhenry/wsh/server`).
|
|
11
|
+
*
|
|
12
|
+
* ```js
|
|
13
|
+
* import { createMemoryHost } from '@johnhenry/wsh/memory';
|
|
14
|
+
* const host = createMemoryHost({
|
|
15
|
+
* auth: { authorizedKeys: [rawPublicKey] },
|
|
16
|
+
* exec: async (command, io) => { await io.write(`you ran: ${command}\n`); return 0; },
|
|
17
|
+
* });
|
|
18
|
+
* const client = await host.connect({ username: 'demo', keyPair });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { WshClient } from './client.mjs';
|
|
23
|
+
import { WebSocketTransport } from './transport-ws.mjs';
|
|
24
|
+
import { createConnectionFactory } from './server/connection.mjs';
|
|
25
|
+
import { buildAuthorizer, authMethods, FailureLimiter } from './server/auth.mjs';
|
|
26
|
+
import { createMcpHost } from './server/mcp.mjs';
|
|
27
|
+
import { bindSocket } from './server/socket.mjs';
|
|
28
|
+
|
|
29
|
+
const CONNECTING = 0;
|
|
30
|
+
const OPEN = 1;
|
|
31
|
+
const CLOSING = 2;
|
|
32
|
+
const CLOSED = 3;
|
|
33
|
+
|
|
34
|
+
/** The URL to hand `client.connect()` (the host ignores it; it only has to look like a WebSocket URL). */
|
|
35
|
+
const MEMORY_URL = 'ws://memory.invalid/';
|
|
36
|
+
|
|
37
|
+
function event(type, props) {
|
|
38
|
+
const ev = new Event(type);
|
|
39
|
+
Object.assign(ev, props);
|
|
40
|
+
return ev;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One end of an in-memory socket pair: just enough WebSocket for `WebSocketTransport` and `bindSocket`. */
|
|
44
|
+
class MemorySocket extends EventTarget {
|
|
45
|
+
static CONNECTING = CONNECTING;
|
|
46
|
+
static OPEN = OPEN;
|
|
47
|
+
static CLOSING = CLOSING;
|
|
48
|
+
static CLOSED = CLOSED;
|
|
49
|
+
|
|
50
|
+
readyState = CONNECTING;
|
|
51
|
+
binaryType = 'arraybuffer';
|
|
52
|
+
/** @type {MemorySocket | null} */
|
|
53
|
+
peer = null;
|
|
54
|
+
|
|
55
|
+
send(data) {
|
|
56
|
+
if (this.readyState !== OPEN) throw new Error('WebSocket is not open');
|
|
57
|
+
if (typeof data === 'string') return; // wsh frames are binary
|
|
58
|
+
const view = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
59
|
+
const copy = view.slice().buffer;
|
|
60
|
+
const peer = this.peer;
|
|
61
|
+
queueMicrotask(() => { if (peer.readyState === OPEN) peer.dispatchEvent(event('message', { data: copy })); });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
close(code = 1000, reason = '') {
|
|
65
|
+
if (this.readyState >= CLOSING) return;
|
|
66
|
+
this.readyState = CLOSING;
|
|
67
|
+
const peer = this.peer;
|
|
68
|
+
queueMicrotask(() => {
|
|
69
|
+
for (const s of [this, peer]) {
|
|
70
|
+
if (s.readyState === CLOSED) continue;
|
|
71
|
+
s.readyState = CLOSED;
|
|
72
|
+
s.dispatchEvent(event('close', { code, reason, wasClean: true }));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Two connected sockets: what one sends, the other receives (asynchronously, in order). Both are open after a tick.
|
|
80
|
+
* @returns {[MemorySocket, MemorySocket]}
|
|
81
|
+
*/
|
|
82
|
+
export function createSocketPair() {
|
|
83
|
+
const a = new MemorySocket();
|
|
84
|
+
const b = new MemorySocket();
|
|
85
|
+
a.peer = b;
|
|
86
|
+
b.peer = a;
|
|
87
|
+
queueMicrotask(() => {
|
|
88
|
+
for (const s of [a, b]) { s.readyState = OPEN; s.dispatchEvent(new Event('open')); }
|
|
89
|
+
});
|
|
90
|
+
return [a, b];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {object} [options]
|
|
95
|
+
* @param {object | Function} [options.auth] - As for `createWshServer`. Omitted = refuse everyone.
|
|
96
|
+
* @param {Function | { run: Function, timeoutMs?: number, clientEnv?: boolean }} [options.exec]
|
|
97
|
+
* `run(command, io) => Promise<exitCode>` (see `ExecIo` in `server/exec.mjs`): the host has no shell, so there is no `true`.
|
|
98
|
+
* @param {{ tools?: object[] | object, client?: object, authorize?: Function, maxConcurrent?: number, timeoutMs?: number }} [options.mcp]
|
|
99
|
+
* @param {Record<string, Function>} [options.extensions] - Handlers for application-defined messages, as for `createWshServer`.
|
|
100
|
+
* @param {number} [options.bindTimeoutMs=3000]
|
|
101
|
+
* @param {(line: string) => void} [options.onLog]
|
|
102
|
+
*/
|
|
103
|
+
export function createMemoryHost({ auth, exec, mcp, extensions, bindTimeoutMs = 3000, onLog = () => {} } = {}) {
|
|
104
|
+
if (exec === true) throw new TypeError('createMemoryHost: exec must be a function (run(command, io)); there is no shell to run `true` with');
|
|
105
|
+
if (exec !== undefined && typeof exec !== 'function' && typeof exec?.run !== 'function') {
|
|
106
|
+
throw new TypeError('createMemoryHost: exec must be a function or { run }');
|
|
107
|
+
}
|
|
108
|
+
if (extensions !== undefined && (extensions === null || typeof extensions !== 'object')) throw new TypeError('createMemoryHost: extensions must be an object of handlers');
|
|
109
|
+
const execOptions = typeof exec === 'function' || !exec ? {} : exec;
|
|
110
|
+
const execRunner = typeof exec === 'function' ? exec : exec?.run ?? null;
|
|
111
|
+
const rl = auth?.rateLimit ?? {};
|
|
112
|
+
const rateLimit = {
|
|
113
|
+
limiter: new FailureLimiter(rl),
|
|
114
|
+
key: typeof rl.key === 'function' ? rl.key : ({ address }) => String(address ?? 'unknown'),
|
|
115
|
+
failureDelayMs: rl.failureDelayMs ?? 250,
|
|
116
|
+
};
|
|
117
|
+
const methods = authMethods(auth);
|
|
118
|
+
const mcpHost = mcp ? createMcpHost(mcp) : null;
|
|
119
|
+
|
|
120
|
+
const ready = buildAuthorizer(auth).then((authorize) => createConnectionFactory({
|
|
121
|
+
authorize, execRunner, execOptions, pty: null, files: null, bindTimeoutMs, log: onLog,
|
|
122
|
+
methods, hostKey: null, rateLimit, extensions, mcp: mcpHost, rpc: null, gateway: null, sessions: null, relay: null,
|
|
123
|
+
}));
|
|
124
|
+
ready.catch(() => {});
|
|
125
|
+
|
|
126
|
+
const accepted = new Set();
|
|
127
|
+
let closed = false;
|
|
128
|
+
|
|
129
|
+
async function accept(socket, options) {
|
|
130
|
+
if (closed) throw new Error('createMemoryHost: host is closed');
|
|
131
|
+
const handle = await bindSocket(socket, ready, options);
|
|
132
|
+
accepted.add(handle);
|
|
133
|
+
return handle;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A `WebSocket` constructor that dials this host: `new host.WebSocket(url)` ignores the URL. */
|
|
137
|
+
class HostWebSocket extends MemorySocket {
|
|
138
|
+
constructor() {
|
|
139
|
+
super();
|
|
140
|
+
const [client, server] = createSocketPair();
|
|
141
|
+
void accept(server, { remote: { address: 'memory' } }).catch(onLog);
|
|
142
|
+
return client;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const host = {
|
|
147
|
+
/** Pass to `client.connect()`. */
|
|
148
|
+
url: MEMORY_URL,
|
|
149
|
+
WebSocket: HostWebSocket,
|
|
150
|
+
accept,
|
|
151
|
+
socketPair: createSocketPair,
|
|
152
|
+
/** A `WshClient` whose transport is this host (`await client.connect(host.url, ...)`). */
|
|
153
|
+
createClient(clientOptions = {}) {
|
|
154
|
+
return new WshClient({
|
|
155
|
+
...clientOptions,
|
|
156
|
+
transportFactories: {
|
|
157
|
+
ws: () => new WebSocketTransport({ WebSocket: HostWebSocket }),
|
|
158
|
+
wt: () => { throw new Error('createMemoryHost: WebTransport is not available in memory'); },
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
/** `createClient()` and `connect()` in one: resolves to a connected client. */
|
|
163
|
+
async connect(credentials) {
|
|
164
|
+
const client = host.createClient();
|
|
165
|
+
await client.connect(MEMORY_URL, { transport: 'ws', ...credentials });
|
|
166
|
+
return client;
|
|
167
|
+
},
|
|
168
|
+
async close() {
|
|
169
|
+
closed = true;
|
|
170
|
+
for (const handle of accepted) handle.close();
|
|
171
|
+
accepted.clear();
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
return host;
|
|
175
|
+
}
|
package/src/recording.mjs
CHANGED
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
* Session recording and playback for wsh PTY sessions.
|
|
3
3
|
*
|
|
4
4
|
* Records all terminal I/O with timestamps for later replay.
|
|
5
|
-
* The export
|
|
6
|
-
*
|
|
5
|
+
* The export (`toJSON()`) is asciicast-v2-*shaped*, not an asciicast v2 file: the header
|
|
6
|
+
* fields (`version: 2`, `width`, `height`, `timestamp`, `env`) and the `[seconds, code, data]`
|
|
7
|
+
* events with the `o` / `i` / `r` codes are asciicast's, but it is one JSON document (asciicast
|
|
8
|
+
* v2 is newline-delimited JSON: a header line, then one event per line), the header carries
|
|
9
|
+
* wsh's own `sessionId`, `width`/`height` follow the latest resize rather than the start size,
|
|
10
|
+
* and the lifecycle events `open` / `exit` have no asciicast code. `asciinema play` therefore
|
|
11
|
+
* cannot read it as-is. The README ("Session recording") shows the short conversion to a real
|
|
12
|
+
* `.cast` file (header line + the `o`/`i`/`r` events); `fromJSON()` reads this document shape.
|
|
7
13
|
*/
|
|
8
14
|
|
|
9
15
|
// ── SessionRecorder ──────────────────────────────────────────────────
|
|
@@ -66,16 +72,17 @@ export class SessionRecorder {
|
|
|
66
72
|
/**
|
|
67
73
|
* Export the recording as a JSON-serializable object.
|
|
68
74
|
*
|
|
69
|
-
* The
|
|
75
|
+
* The shape follows asciicast v2 but this is NOT a playable asciicast file (see the file header):
|
|
70
76
|
* - version: 2
|
|
71
|
-
* - width, height: terminal dimensions at start
|
|
77
|
+
* - width, height: terminal dimensions as of the latest resize (asciicast: at start)
|
|
72
78
|
* - timestamp: Unix epoch seconds when recording started
|
|
73
|
-
* - env:
|
|
74
|
-
* -
|
|
79
|
+
* - env: environment metadata
|
|
80
|
+
* - sessionId: wsh's own field
|
|
81
|
+
* - events: array of [time_seconds, event_type, data] -- one JSON document, not one line per event
|
|
75
82
|
*
|
|
76
|
-
* Event types
|
|
77
|
-
* "o" = output, "i" = input, "r" = resize
|
|
78
|
-
*
|
|
83
|
+
* Event types from asciicast:
|
|
84
|
+
* "o" = output, "i" = input, "r" = resize ("COLSxROWS")
|
|
85
|
+
* Not in asciicast, so dropped by the conversion in the README: "open", "exit" (session lifecycle).
|
|
79
86
|
*
|
|
80
87
|
* @returns {object}
|
|
81
88
|
*/
|
|
@@ -111,7 +118,8 @@ export class SessionRecorder {
|
|
|
111
118
|
}
|
|
112
119
|
|
|
113
120
|
/**
|
|
114
|
-
* Import a recording from JSON (
|
|
121
|
+
* Import a recording from the JSON document `toJSON()` produces. An asciicast v2 `.cast` file is not read directly:
|
|
122
|
+
* parse its lines and gather them into `{ ...header, events }` first.
|
|
115
123
|
*
|
|
116
124
|
* @param {object | string} json - Recording object or JSON string
|
|
117
125
|
* @returns {SessionRecorder}
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
* dropped rather than forwarded to the process's stdin.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { randomUUID } from 'node:crypto';
|
|
17
16
|
import { QMuxConnection } from '../qmux-connection.mjs';
|
|
18
17
|
import { FrameDecoder, frameEncode } from '../cbor.mjs';
|
|
19
18
|
import {
|
|
@@ -24,8 +23,7 @@ import {
|
|
|
24
23
|
import {
|
|
25
24
|
generateNonce, verifyChallenge, fingerprint, importPublicKeyRaw,
|
|
26
25
|
} from '../auth.mjs';
|
|
27
|
-
import { FILE_CHUNK_BYTES } from './
|
|
28
|
-
import { MAX_ATTACHMENTS } from './sessions.mjs';
|
|
26
|
+
import { FILE_CHUNK_BYTES, MAX_ATTACHMENTS } from './constants.mjs';
|
|
29
27
|
import { MCP_CALL_ID_FEATURE } from '../client.mjs';
|
|
30
28
|
import { RpcChannel, RPC_FEATURE, RPC_MAX_MESSAGE_PREFIX, rpcProtocolFeature } from '../rpc.mjs';
|
|
31
29
|
import { sealFrame, openFrame, ROLE_TAGS } from '../e2e-frame.mjs';
|
|
@@ -70,6 +68,7 @@ const REPLAY_CHUNK_BYTES = 32 * 1024;
|
|
|
70
68
|
const USERNAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
|
|
71
69
|
const enc = new TextEncoder();
|
|
72
70
|
|
|
71
|
+
const randomUUID = () => crypto.randomUUID();
|
|
73
72
|
const randomBytes = (n) => crypto.getRandomValues(new Uint8Array(n));
|
|
74
73
|
const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
|
|
75
74
|
|
|
@@ -831,7 +830,7 @@ export function createConnectionFactory(cfg) {
|
|
|
831
830
|
const args = m.command ? ['-c', String(m.command)] : [];
|
|
832
831
|
proc = p.spawn(p.shell, args, {
|
|
833
832
|
name: p.term, cols, rows, cwd: p.cwd,
|
|
834
|
-
env: { ...(p.env ?? process
|
|
833
|
+
env: { ...(p.env ?? globalThis.process?.env), TERM: p.term, ...(m.env && typeof m.env === 'object' ? m.env : {}) },
|
|
835
834
|
});
|
|
836
835
|
} catch (e) {
|
|
837
836
|
return send(openFail({ reason: `pty spawn failed: ${e.message}` }));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Limits shared by the connection handler and the Node-only modules that also export them
|
|
3
|
+
* (kept here so the connection handler imports nothing from `node:*` -- see `src/memory-host.mjs`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Size of one FileChunk when a file is streamed to the client. */
|
|
7
|
+
export const FILE_CHUNK_BYTES = 64 * 1024;
|
|
8
|
+
|
|
9
|
+
/** Most clients attached to one detached-session at once. */
|
|
10
|
+
export const MAX_ATTACHMENTS = 16;
|
package/src/server/fs.mjs
CHANGED
|
@@ -14,7 +14,8 @@ import { constants as fsc } from 'node:fs';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
|
|
16
16
|
export const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
17
|
-
|
|
17
|
+
import { FILE_CHUNK_BYTES } from './constants.mjs';
|
|
18
|
+
export { FILE_CHUNK_BYTES };
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* @param {{ root: string, readOnly?: boolean, maxFileBytes?: number }} opts
|
package/src/server/gateway.mjs
CHANGED
|
@@ -22,10 +22,15 @@
|
|
|
22
22
|
* the host would have to resolve the name itself.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import net from 'node:net';
|
|
26
|
-
import dns from 'node:dns/promises';
|
|
27
25
|
import { MSG, gatewayOk, gatewayFail, gatewayClose, gatewayData, dnsResult, listenFail } from '../messages.gen.mjs';
|
|
28
26
|
|
|
27
|
+
// Loaded on first use so that importing the connection handler (which serves the gateway opcodes)
|
|
28
|
+
// does not pull `node:net` into a browser bundle (see `src/memory-host.mjs`).
|
|
29
|
+
let netModule = null;
|
|
30
|
+
let dnsModule = null;
|
|
31
|
+
const loadNet = async () => (netModule ??= (await import('node:net')).default);
|
|
32
|
+
const loadDns = async () => (dnsModule ??= (await import('node:dns/promises')).default);
|
|
33
|
+
|
|
29
34
|
/** Numeric codes of GatewayFail (the same table `@johnhenry/browsermesh-netway` decodes). */
|
|
30
35
|
export const GATEWAY_FAIL = Object.freeze({
|
|
31
36
|
CONNECTION_REFUSED: 1, HOST_UNREACHABLE: 2, DNS_FAILED: 3, POLICY_DENIED: 4, TIMEOUT: 5, CLOSED: 6, QUEUE_FULL: 7,
|
|
@@ -62,7 +67,8 @@ export function destinationAllowed(allow, host, port) {
|
|
|
62
67
|
* Resolves with the connected socket, or rejects with an Error carrying `.code`
|
|
63
68
|
* (a GATEWAY_FAIL value).
|
|
64
69
|
*/
|
|
65
|
-
export function socksConnect(proxy, host, port, { timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS } = {}) {
|
|
70
|
+
export async function socksConnect(proxy, host, port, { timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS } = {}) {
|
|
71
|
+
const net = await loadNet();
|
|
66
72
|
return new Promise((resolve, reject) => {
|
|
67
73
|
const name = Buffer.from(host, 'utf8');
|
|
68
74
|
const fail = (code, message, sock) => { sock?.destroy(); const e = new Error(message); e.code = code; reject(e); };
|
|
@@ -161,6 +167,7 @@ export function createGatewaySession(cfg, send, log = () => {}) {
|
|
|
161
167
|
pending.add(id);
|
|
162
168
|
let sock;
|
|
163
169
|
try {
|
|
170
|
+
const net = await loadNet();
|
|
164
171
|
if (cfg.socks) sock = await socksConnect(cfg.socks, m.host, m.port, { timeoutMs: cfg.connectTimeoutMs });
|
|
165
172
|
else {
|
|
166
173
|
sock = await new Promise((resolve, reject) => {
|
|
@@ -202,7 +209,7 @@ export function createGatewaySession(cfg, send, log = () => {}) {
|
|
|
202
209
|
try {
|
|
203
210
|
const family = m.record_type === 'AAAA' ? 6 : m.record_type === 'A' || m.record_type === undefined ? 4 : 0;
|
|
204
211
|
if (!family) return fail(id, GATEWAY_FAIL.DNS_FAILED, `unsupported record type ${String(m.record_type).slice(0, 8)}`);
|
|
205
|
-
const addresses = (await
|
|
212
|
+
const addresses = (await (await loadDns()).lookup(m.name, { family, all: true })).map((a) => a.address);
|
|
206
213
|
return reply(dnsResult({ gatewayId: id, addresses }));
|
|
207
214
|
} catch (e) {
|
|
208
215
|
return fail(id, GATEWAY_FAIL.DNS_FAILED, String(e.code === 'ENOTFOUND' ? 'name not found' : e.message).slice(0, 200));
|
package/src/server/index.d.ts
CHANGED
|
@@ -402,9 +402,32 @@ export interface WshServerAddress {
|
|
|
402
402
|
port: number;
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
/**
|
|
406
|
+
* An already-accepted WebSocket-like duplex: `send(bytes)`, `close(code?)`, `readyState` (1 = open), and either Node-style
|
|
407
|
+
* `on(event, fn)` or `addEventListener(event, fn)`. A `ws` socket, a browser/Worker `WebSocket`, or a test double.
|
|
408
|
+
*/
|
|
409
|
+
export interface WshAcceptableSocket {
|
|
410
|
+
send(data: Uint8Array): void;
|
|
411
|
+
close(code?: number): void;
|
|
412
|
+
readonly readyState: number;
|
|
413
|
+
on?(event: string, listener: (...args: any[]) => void): unknown;
|
|
414
|
+
addEventListener?(event: string, listener: (ev: any) => void): unknown;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export interface WshAcceptOptions {
|
|
418
|
+
/** What the embedder knows of the peer; `address` keys the password-failure throttle. */
|
|
419
|
+
remote?: { address?: string; headers?: Record<string, unknown> };
|
|
420
|
+
}
|
|
421
|
+
|
|
405
422
|
export interface WshServer {
|
|
406
423
|
/** Start listening. Requires the `ws` package. */
|
|
407
424
|
listen(): Promise<WshServerAddress>;
|
|
425
|
+
/**
|
|
426
|
+
* Serve a pre-upgraded transport -- a socket your own HTTP server, Worker or test already accepted -- with this
|
|
427
|
+
* server's auth, features and sessions, so no listener of wsh's own is needed (`listen()` is optional).
|
|
428
|
+
* Resolves once the connection is attached; the handle's `close()` drops it. `close()` of the server drops all of them.
|
|
429
|
+
*/
|
|
430
|
+
accept(socket: WshAcceptableSocket, options?: WshAcceptOptions): Promise<{ close(): void }>;
|
|
408
431
|
/** Drop every connection and stop listening. */
|
|
409
432
|
close(): Promise<void>;
|
|
410
433
|
/** The bound address, or `null` when not listening. */
|
package/src/server/index.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { startWebTransport } from './webtransport.mjs';
|
|
|
17
17
|
import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
|
|
18
18
|
import { normalizeGateway } from './gateway.mjs';
|
|
19
19
|
import { createRpcHost, mcpServerAdapter } from './rpc.mjs';
|
|
20
|
+
import { bindSocket } from './socket.mjs';
|
|
20
21
|
|
|
21
22
|
export { parseAuthorizedKeys, STREAM_ANNOUNCE, mcpServerAdapter };
|
|
22
23
|
export { createReverseHost } from './reverse-host.mjs';
|
|
@@ -74,7 +75,7 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
|
|
|
74
75
|
* through a SOCKS5 proxy (Tor) with the name unresolved. UDP and reverse tunnels are refused. Off unless given. See `gateway.mjs`.
|
|
75
76
|
* @param {number} [options.bindTimeoutMs=3000]
|
|
76
77
|
* @param {(line: string) => void} [options.onLog]
|
|
77
|
-
* @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
|
|
78
|
+
* @returns {{ listen(): Promise<{address: string, port: number}>, accept(socket: object, options?: object): Promise<{ close(): void }>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
|
|
78
79
|
*/
|
|
79
80
|
export function createWshServer({
|
|
80
81
|
host = '127.0.0.1', port = 0, tls, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, extensions, gateway, webTransport, bindTimeoutMs = 3000, onLog = () => {},
|
|
@@ -102,15 +103,13 @@ export function createWshServer({
|
|
|
102
103
|
const gatewayCfg = gateway === undefined ? null : normalizeGateway(gateway);
|
|
103
104
|
const sessionOptions = sessions ? (sessions === true ? {} : sessions) : null;
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
throw new Error('@johnhenry/wsh/server needs the "ws" package: npm install ws', { cause: err });
|
|
113
|
-
}
|
|
106
|
+
/** Sockets handed to `accept()` that are still open. */
|
|
107
|
+
const accepted = new Set();
|
|
108
|
+
/** The connection factory, built once on first use (`listen()` or `accept()`) and torn down by `close()`. */
|
|
109
|
+
let ready = null;
|
|
110
|
+
|
|
111
|
+
function start() {
|
|
112
|
+
ready ??= (async () => {
|
|
114
113
|
const authorize = await buildAuthorizer(auth);
|
|
115
114
|
host_ = hostKey ? await loadHostKey(hostKey) : null;
|
|
116
115
|
if (sessionOptions) {
|
|
@@ -125,10 +124,25 @@ export function createWshServer({
|
|
|
125
124
|
}
|
|
126
125
|
if (relay) hub = new RelayHub({ ...relay, log: onLog });
|
|
127
126
|
if (extensions !== undefined && (extensions === null || typeof extensions !== 'object')) throw new TypeError('createWshServer: extensions must be an object of handlers');
|
|
128
|
-
|
|
127
|
+
return createConnectionFactory({
|
|
129
128
|
authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
|
|
130
129
|
methods, hostKey: host_, rateLimit, extensions, mcp: mcpHost, rpc: rpcHost, gateway: gatewayCfg, sessions: registry, relay: hub,
|
|
131
130
|
});
|
|
131
|
+
})();
|
|
132
|
+
ready.catch(() => { ready = null; });
|
|
133
|
+
return ready;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
async listen() {
|
|
138
|
+
if (wss) throw new Error('createWshServer: already listening');
|
|
139
|
+
let WebSocketServer;
|
|
140
|
+
try {
|
|
141
|
+
({ WebSocketServer } = await import('ws'));
|
|
142
|
+
} catch (err) {
|
|
143
|
+
throw new Error('@johnhenry/wsh/server needs the "ws" package: npm install ws', { cause: err });
|
|
144
|
+
}
|
|
145
|
+
const attach = await start();
|
|
132
146
|
|
|
133
147
|
if (tls) {
|
|
134
148
|
const { createServer } = await import('node:https');
|
|
@@ -172,20 +186,45 @@ export function createWshServer({
|
|
|
172
186
|
return bound;
|
|
173
187
|
},
|
|
174
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Serve a pre-upgraded transport: an already-accepted WebSocket-like duplex (see `socket.mjs`) that the embedder's
|
|
191
|
+
* own HTTP server, Worker or test upgraded. Same auth, features and session registry as `listen()`'s connections, and
|
|
192
|
+
* no listener of this server's own is needed -- `accept()` works without `listen()`, and with both.
|
|
193
|
+
* `remote: { address, headers }` is what the embedder knows of the peer (`address` keys the password-failure throttle).
|
|
194
|
+
* `close()` drops every accepted socket. Resolves once the connection is attached.
|
|
195
|
+
*
|
|
196
|
+
* @param {object} socket - `send`, `close`, `readyState`, and `on` or `addEventListener`
|
|
197
|
+
* @param {{ remote?: { address?: string, headers?: object } }} [options]
|
|
198
|
+
* @returns {Promise<{ close(): void }>}
|
|
199
|
+
*/
|
|
200
|
+
async accept(socket, options = {}) {
|
|
201
|
+
const handle = await bindSocket(socket, start(), options);
|
|
202
|
+
const entry = { socket, handle };
|
|
203
|
+
accepted.add(entry);
|
|
204
|
+
const forget = () => accepted.delete(entry);
|
|
205
|
+
if (typeof socket.addEventListener === 'function') socket.addEventListener('close', forget);
|
|
206
|
+
else socket.on('close', forget);
|
|
207
|
+
return handle;
|
|
208
|
+
},
|
|
209
|
+
|
|
175
210
|
async close() {
|
|
176
|
-
|
|
211
|
+
for (const entry of [...accepted]) entry.handle.close();
|
|
212
|
+
accepted.clear();
|
|
177
213
|
const server = wss;
|
|
178
214
|
wss = null;
|
|
179
215
|
bound = null;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
216
|
+
if (server) {
|
|
217
|
+
for (const client of server.clients) client.terminate();
|
|
218
|
+
await wt?.close();
|
|
219
|
+
wt = null;
|
|
220
|
+
await new Promise((resolve) => server.close(resolve));
|
|
221
|
+
if (httpsServer) { const h = httpsServer; httpsServer = null; await new Promise((resolve) => h.close(resolve)); }
|
|
222
|
+
}
|
|
185
223
|
registry?.closeAll();
|
|
186
224
|
registry = null;
|
|
187
225
|
hub?.closeAll();
|
|
188
226
|
hub = null;
|
|
227
|
+
ready = null;
|
|
189
228
|
},
|
|
190
229
|
|
|
191
230
|
address() {
|
package/src/server/pty.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export function normalizePty(pty) {
|
|
|
17
17
|
}
|
|
18
18
|
return {
|
|
19
19
|
spawn: pty.spawn,
|
|
20
|
-
shell: pty.shell ?? process
|
|
20
|
+
shell: pty.shell ?? globalThis.process?.env?.SHELL ?? '/bin/sh',
|
|
21
21
|
cwd: pty.cwd,
|
|
22
22
|
env: pty.env,
|
|
23
23
|
term: pty.term ?? 'xterm-256color',
|
package/src/server/sessions.mjs
CHANGED
|
@@ -20,7 +20,8 @@ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypt
|
|
|
20
20
|
export const DEFAULT_RING_BYTES = 1024 * 1024;
|
|
21
21
|
export const DEFAULT_DETACH_TTL_MS = 300_000;
|
|
22
22
|
export const DEFAULT_MAX_DETACHED = 16;
|
|
23
|
-
|
|
23
|
+
import { MAX_ATTACHMENTS } from './constants.mjs';
|
|
24
|
+
export { MAX_ATTACHMENTS };
|
|
24
25
|
|
|
25
26
|
const NO_EXPIRY = Buffer.alloc(8, 0xff);
|
|
26
27
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve a pre-upgraded transport: an already-accepted WebSocket-like duplex handed to a host by whoever did the
|
|
3
|
+
* upgrade (an HTTP server of the embedder's, a Worker, a message port wrapper, a test). Browser-safe -- it touches
|
|
4
|
+
* nothing but the object it is given -- and shared by `createWshServer().accept()` and `createMemoryHost()`.
|
|
5
|
+
*
|
|
6
|
+
* The duplex needs `send(bytes)`, `close(code?)`, `readyState` (1 = open) and either Node-style `on(event, fn)`
|
|
7
|
+
* (`ws`) or `addEventListener(event, fn)` (browsers, Workers, undici). Incoming frames must be binary; a text frame
|
|
8
|
+
* is ignored, as the QMux framing never carries one.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const OPEN = 1;
|
|
12
|
+
|
|
13
|
+
/** @param {unknown} data - a message payload as `ws` (Buffer, ArrayBuffer, Buffer[]) or a browser (ArrayBuffer, view, Blob) delivers it */
|
|
14
|
+
async function toBytes(data) {
|
|
15
|
+
if (data instanceof Uint8Array) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
16
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
17
|
+
if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
18
|
+
if (Array.isArray(data)) {
|
|
19
|
+
const parts = await Promise.all(data.map(toBytes));
|
|
20
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0));
|
|
21
|
+
let off = 0;
|
|
22
|
+
for (const p of parts) { out.set(p, off); off += p.byteLength; }
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
if (data && typeof data.arrayBuffer === 'function') return new Uint8Array(await data.arrayBuffer());
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} socket - the WebSocket-like duplex
|
|
31
|
+
* @param {Promise<(opts: object) => { receive: Function, close: Function }>} ready - resolves to the connection factory's `attach`
|
|
32
|
+
* @param {{ remote?: { address?: string, headers?: object } }} [options] - what the host knows about the peer
|
|
33
|
+
* (`address` keys the password-failure throttle; the default key is shared by every socket without one)
|
|
34
|
+
* @returns {Promise<{ close(): void }>} resolves once the connection is attached; `close()` drops it
|
|
35
|
+
*/
|
|
36
|
+
export function bindSocket(socket, ready, { remote = {} } = {}) {
|
|
37
|
+
if (!socket || typeof socket.send !== 'function' || typeof socket.close !== 'function') {
|
|
38
|
+
throw new TypeError('accept: socket must be a WebSocket-like duplex with send() and close()');
|
|
39
|
+
}
|
|
40
|
+
const listen = typeof socket.addEventListener === 'function'
|
|
41
|
+
? (event, fn) => socket.addEventListener(event, (ev) => fn(event === 'message' ? ev?.data : ev))
|
|
42
|
+
: typeof socket.on === 'function'
|
|
43
|
+
? (event, fn) => socket.on(event, fn)
|
|
44
|
+
: null;
|
|
45
|
+
if (!listen) throw new TypeError('accept: socket must support addEventListener() or on()');
|
|
46
|
+
try { socket.binaryType = 'arraybuffer'; } catch { /* read-only on some implementations; toBytes copes with any */ }
|
|
47
|
+
|
|
48
|
+
// Listeners are attached now, before the host has finished starting, and the frames they see are
|
|
49
|
+
// chained behind `ready`: a peer that speaks first (a client's QMux handshake) is not dropped.
|
|
50
|
+
let conn = null;
|
|
51
|
+
let closed = false;
|
|
52
|
+
let chain = ready.then((attach) => {
|
|
53
|
+
if (closed) return;
|
|
54
|
+
conn = attach({
|
|
55
|
+
send: (bytes) => { if (socket.readyState === OPEN) socket.send(bytes); },
|
|
56
|
+
remote: { address: remote.address, headers: remote.headers ?? {} },
|
|
57
|
+
closeTransport: () => { try { socket.close(1000); } catch { /* already closed */ } },
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
const drop = () => { if (closed) return; closed = true; chain = chain.then(() => conn?.close()).catch(() => {}); };
|
|
61
|
+
listen('message', (data) => {
|
|
62
|
+
chain = chain.then(async () => {
|
|
63
|
+
if (closed || !conn) return;
|
|
64
|
+
const bytes = await toBytes(data);
|
|
65
|
+
if (bytes && !closed) conn.receive(bytes);
|
|
66
|
+
}).catch(() => {});
|
|
67
|
+
});
|
|
68
|
+
listen('close', drop);
|
|
69
|
+
listen('error', () => {});
|
|
70
|
+
return chain.then(() => ({
|
|
71
|
+
close() { drop(); try { socket.close(1000); } catch { /* already closed */ } },
|
|
72
|
+
}));
|
|
73
|
+
}
|
package/src/transport-ws.mjs
CHANGED
|
@@ -97,6 +97,9 @@ class QMuxStreamAdapter {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** `WebSocket.OPEN` (spelled out so a substitute constructor need not define it). */
|
|
101
|
+
const WS_OPEN = 1;
|
|
102
|
+
|
|
100
103
|
// ── WebSocket transport ──────────────────────────────────────────────
|
|
101
104
|
|
|
102
105
|
/**
|
|
@@ -133,6 +136,19 @@ export class WebSocketTransport extends WshTransport {
|
|
|
133
136
|
*/
|
|
134
137
|
#inbox = new SerialQueue((raw) => this.#handleControlBytes(raw));
|
|
135
138
|
|
|
139
|
+
/** @type {typeof WebSocket} */
|
|
140
|
+
#WebSocket;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {object} [options]
|
|
144
|
+
* @param {typeof WebSocket} [options.WebSocket] - A WebSocket constructor to dial with instead of the global one: a
|
|
145
|
+
* test double, or the in-memory host's (`createMemoryHost().WebSocket`, see `memory-host.mjs`).
|
|
146
|
+
*/
|
|
147
|
+
constructor({ WebSocket: Impl } = {}) {
|
|
148
|
+
super();
|
|
149
|
+
this.#WebSocket = Impl;
|
|
150
|
+
}
|
|
151
|
+
|
|
136
152
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
137
153
|
|
|
138
154
|
/** @override */
|
|
@@ -149,7 +165,7 @@ export class WebSocketTransport extends WshTransport {
|
|
|
149
165
|
.replace(/^https:\/\//, 'wss://')
|
|
150
166
|
.replace(/^http:\/\//, 'ws://');
|
|
151
167
|
|
|
152
|
-
const ws = new WebSocket(wsUrl);
|
|
168
|
+
const ws = new (this.#WebSocket ?? WebSocket)(wsUrl);
|
|
153
169
|
ws.binaryType = 'arraybuffer';
|
|
154
170
|
this.#ws = ws;
|
|
155
171
|
|
|
@@ -316,7 +332,7 @@ export class WebSocketTransport extends WshTransport {
|
|
|
316
332
|
* @param {Uint8Array} data
|
|
317
333
|
*/
|
|
318
334
|
#send(data) {
|
|
319
|
-
if (!this.#ws || this.#ws.readyState !==
|
|
335
|
+
if (!this.#ws || this.#ws.readyState !== WS_OPEN) {
|
|
320
336
|
throw new Error('WebSocket is not open');
|
|
321
337
|
}
|
|
322
338
|
this.#ws.send(data);
|