@johnhenry/wsh 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -287,27 +287,56 @@ const s = await client.openSession({ type: 'exec', command: 'echo hello' });
287
287
  checked against the allowlist (a non-forwardable or undecodable inner is
288
288
  dropped, logged), and re-wrapped with the real sender; traffic from a connection
289
289
  that is not bridged is never forwarded.
290
- - **One operator per peer at a time**; a second `ReverseConnect` is rejected as
291
- `busy`, and one the peer does not answer within `connectTimeoutMs` (default
292
- 8 s) as `peer did not respond`. A bridge lasts as long as both connections:
293
- when either ends, the relay closes the other, so nothing an operator started
294
- outlives it (the reverse host kills its processes). `createReverseHost`
295
- redials with backoff by default (`reconnect: false` to turn it off), so it is
296
- registered again for the next operator.
290
+ - **One operator per peer at a time**, by design: a `RelayForward` names its
291
+ sender but not its recipient, and nothing on the wire tells a peer that one
292
+ of several operators left, so replies could not be addressed and a departed
293
+ operator's work could not be cleaned up. A second `ReverseConnect` to a peer
294
+ that already has an operator (or a request awaiting its answer) is rejected
295
+ with `ReverseReject.reason` `busy: this peer already has an operator ...`
296
+ (exported as `BUSY_PEER` from `src/server/relay.mjs`), and an operator that
297
+ already has a bridge or a request in flight is rejected with `busy: you
298
+ already have a bridge ...` (`BUSY_OPERATOR`); a request the peer does not
299
+ answer within `connectTimeoutMs` (default 8 s) is `peer did not respond`.
300
+ A bridge lasts as long as both connections: when either ends, the relay
301
+ closes the other, so nothing an operator started outlives it (the reverse
302
+ host kills its processes). `createReverseHost` redials with backoff by
303
+ default (`reconnect: false` to turn it off), so it is registered again for
304
+ the next operator. Lifting the limit needs a spec addition (a recipient on
305
+ `RelayForward`, a bridge-ended notice) shared with the Rust side; it is
306
+ tracked rather than built.
297
307
  - **On the peer**, the bridged operator is served by the same connection code as
298
308
  a direct client, over an in-memory message pipe instead of a socket. Nothing a
299
309
  client opens can cross the relay but control messages, so exec sessions are
300
310
  `data_mode: 'virtual'` there (stdin and output ride `SessionData`; there is no
301
311
  stdin EOF).
302
- - **Not provided:** end-to-end encryption between operator and peer -- `KeyExchange` /
303
- `EncryptedFrame` are not on the spec's `forwardable` list, so the relay sees the
304
- traffic in clear (serve it over `wss://`, and treat the relay operator as trusted).
305
- Client-side feature gates (`fileWrite`/`fileRename` need `file-write`/`file-rename`,
306
- `callTool` correlates on `mcp-call-id`) read the **relay's** `ServerHello`, which
307
- says nothing about the peer behind it: against a relay without `fs`, `fileWrite()`
308
- refuses locally (reads, listing, stat and exec are unaffected). Multiplexing
309
- several operators onto one peer needs a way to address replies, which `RelayForward`
310
- does not have.
312
+ - **End-to-end encryption through the bridge.** `KeyExchange` and `EncryptedFrame`
313
+ are on the spec's `forwardable` list, so an operator and a peer can run
314
+ `initiateE2E()` / `enableE2E()` with each other through the relay: it carries
315
+ the CBOR of each as opaque bytes and holds no key, so it sees only public
316
+ values and ciphertext (`test/server-relay-bridge.test.mjs` checks the relay
317
+ never holds the plaintext). **`KeyExchange` is unauthenticated**: this
318
+ protects against a relay that only observes, not against one that substitutes
319
+ the exchanged keys, so if the relay operator is not trusted, authenticate the
320
+ derived key yourself (compare it out of band, or sign it with the keys the
321
+ bridge already names). Only a stock-client peer (a browser tab, `connectReverse`)
322
+ speaks E2E: **`createReverseHost` does not answer `KeyExchange` or open
323
+ `EncryptedFrame`s** (the Node host has no E2E layer at all), so against it traffic
324
+ is in the clear to the relay -- serve the relay over `wss://` and treat its
325
+ operator as trusted. Key exchange from a connection that is not bridged is
326
+ never carried.
327
+ - **Feature gates follow the host behind the bridge.** The host's own features
328
+ (`file-write`, `file-rename`, `mcp-call-id`) are stated in the optional
329
+ `ReverseAccept.features`; the relay forwards the list (bounded: at most 64
330
+ strings of 128 characters, otherwise it is dropped) and, once
331
+ `reverseConnect()` resolves, `client.hasFeature()` / `client.features` answer
332
+ from it instead of the relay's `ServerHello`. `client.bridgedFeatures` is that
333
+ list (`null` before a bridge, or when the peer did not say, in which case the
334
+ gates fall back to `client.serverFeatures`, the relay's, as before).
335
+ `createReverseHost` always states its features (`reportFeatures: false` leaves
336
+ them out). A **Rust `wsh-server` / `wsh` older than `rust-v0.3.0` rejects a
337
+ `ReverseAccept` that carries `features`** (the message is `deny_unknown_fields`
338
+ there): against such a relay or operator, set `reportFeatures: false` on the
339
+ host (and use `ReverseAccept` without the field in your own peers).
311
340
 
312
341
  ### Sessions: attach / resume / detach
313
342
 
@@ -672,9 +701,14 @@ await client.listRemoteSessions(); // sessions this key can see
672
701
  ```
673
702
 
674
703
  `@johnhenry/wsh/server` implements all of it with its [`sessions`
675
- option](#sessions-attach--resume--detach). The Rust `wsh-server` replays its whole
676
- ring on `Resume` regardless of `last_seq` (the position is defined in
677
- [spec/wsh-v1.md](spec/wsh-v1.md)).
704
+ option](#sessions-attach--resume--detach), and so does the Rust `wsh-server`
705
+ (since `rust-v0.2.0`): `Resume` replays only the output after `last_seq`
706
+ (the position is defined in [spec/wsh-v1.md](spec/wsh-v1.md)), and Attach/Resume
707
+ answer with a Presence carrying the new `channel_id` and `seq`, replay and live
708
+ output arriving as `SessionData` on it. The Rust host's ring is a fixed 256 KiB
709
+ per session and its sessions end with the server process; a session that exits
710
+ while no one is attached is kept (for the replay and the exit) until it is
711
+ resumed or expires.
678
712
 
679
713
  ## Pinning a Self-Signed Certificate
680
714
 
@@ -822,8 +856,8 @@ than left to be found.
822
856
  | Host identity / TOFU | `WshKnownHosts` (localStorage-backed) | `KnownHosts`/`HostStatus` (`~/.wsh/known_hosts`-backed) | **Record unified, policy is not** (wsh #59): both pin `ServerHello.host_fingerprint`, which `@johnhenry/wsh/server` populates (with a proof of possession, [Host key](#host-key-fingerprint--tofu)) but no Rust `wsh-server` release does yet (see [Security](#security-model)). *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" |
823
857
  | Typed RPC channels (`openRpc`, `rpc` sessions; `wsh-host`, `wsh-fs`, `mcp`) | `WshClient.openRpc()`, `RpcChannel` | none yet (a Rust `wsh-server` does not advertise `rpc`; a JS client falls back to `FileOp` for files) | **JS server only, no wire change**: `Open { kind: 'rpc', command: <protocol> }` plus `rpc*` `ServerHello` features, negotiated so it fails fast elsewhere |
824
858
  | 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 |
825
- | 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` replays its whole ring and leaves them unset |
826
- | 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 |
859
+ | 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) |
860
+ | 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 (`busy:` reasons say so); E2E and bridged feature gates need `rust-v0.3.0`+ on the Rust side |
827
861
  | 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 |
828
862
  | 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 |
829
863
  | 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 |
@@ -895,6 +929,11 @@ out explicitly below rather than left to be discovered.
895
929
  `SHA-256("wsh-v1\0" || lp(username) || lp(session_id) || nonce || channel_binding)`,
896
930
  so a signature can't be replayed against a different session or relabeled
897
931
  to a different username.
932
+ - **E2E through a relay is opt-in and unauthenticated key agreement.** The relay
933
+ carries `KeyExchange` / `EncryptedFrame` between a bridged operator and peer
934
+ without being able to read the sealed frames, but `KeyExchange` carries no
935
+ signature: a relay that substitutes keys is not detected (see the [relay
936
+ section](#relay--reverse-mode)).
898
937
  - **Signed peer records.** Reverse-mode registration is self-signed by the
899
938
  peer's identity key (the libp2p RFC 0002/0003 pattern), in a signing
900
939
  domain separate from the auth challenge. `listPeers()` verifies every
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.25.0",
3
+ "version": "0.26.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",
package/src/client.mjs CHANGED
@@ -313,6 +313,11 @@ export class WshClient {
313
313
 
314
314
  /** @type {string[]} Server-advertised features from SERVER_HELLO. */
315
315
  #serverFeatures = [];
316
+ /**
317
+ * @type {string[]|null} Features of the host behind an accepted relay bridge, from
318
+ * `ReverseAccept.features`; `null` when no bridge is up or the peer stated none.
319
+ */
320
+ #bridgedFeatures = null;
316
321
 
317
322
  /** @type {number|null} Ping interval handle. */
318
323
  #pingTimer = null;
@@ -437,11 +442,29 @@ export class WshClient {
437
442
  return new Map(this.#sessions);
438
443
  }
439
444
 
440
- /** Server-advertised features from SERVER_HELLO. */
445
+ /**
446
+ * The features this connection's operations can rely on: those of the host
447
+ * behind an accepted relay bridge when it stated them (`ReverseAccept.features`,
448
+ * see `reverseConnect()`), otherwise the server's `SERVER_HELLO` features.
449
+ */
441
450
  get features() {
451
+ return [...(this.#bridgedFeatures ?? this.#serverFeatures)];
452
+ }
453
+
454
+ /** The `SERVER_HELLO` features of the server this connection is to (the relay, over a bridge). */
455
+ get serverFeatures() {
442
456
  return [...this.#serverFeatures];
443
457
  }
444
458
 
459
+ /**
460
+ * Features of the host behind an accepted relay bridge, as it stated them in
461
+ * `ReverseAccept.features`; `null` when there is no bridge, or the peer did
462
+ * not say (then `hasFeature()` falls back to the server's own).
463
+ */
464
+ get bridgedFeatures() {
465
+ return this.#bridgedFeatures ? [...this.#bridgedFeatures] : null;
466
+ }
467
+
445
468
  /**
446
469
  * Low-level transport reference.
447
470
  * Exposed for relay message replies (IncomingSession._sendReply).
@@ -458,7 +481,7 @@ export class WshClient {
458
481
  * @returns {boolean}
459
482
  */
460
483
  hasFeature(name) {
461
- return this.#serverFeatures.includes(name);
484
+ return (this.#bridgedFeatures ?? this.#serverFeatures).includes(name);
462
485
  }
463
486
 
464
487
  /**
@@ -980,6 +1003,7 @@ export class WshClient {
980
1003
 
981
1004
  this.#stopPing();
982
1005
  this.#state = STATE_CLOSED;
1006
+ this.#bridgedFeatures = null;
983
1007
 
984
1008
  // Close all sessions concurrently.
985
1009
  const closePromises = [];
@@ -1217,6 +1241,12 @@ export class WshClient {
1217
1241
 
1218
1242
  if (response.type === MSG.REVERSE_ACCEPT) {
1219
1243
  this.trustRelayPeer(response.target_fingerprint);
1244
+ // The relay's SERVER_HELLO describes the relay, not the host behind the
1245
+ // bridge: when that host says what it supports, feature gates
1246
+ // (file-write, file-rename, mcp-call-id) follow it instead.
1247
+ this.#bridgedFeatures = Array.isArray(response.features) && response.features.every((f) => typeof f === 'string')
1248
+ ? [...response.features]
1249
+ : null;
1220
1250
  }
1221
1251
 
1222
1252
  return response;
@@ -2747,6 +2777,7 @@ export class WshClient {
2747
2777
  if (this.#state === STATE_CLOSED) return;
2748
2778
 
2749
2779
  this.#state = STATE_CLOSED;
2780
+ this.#bridgedFeatures = null;
2750
2781
  this.#stopPing();
2751
2782
  this.#rejectAllWaiters(new Error('Transport closed'));
2752
2783
 
package/src/index.d.ts CHANGED
@@ -603,6 +603,8 @@ export function reverseAccept(opts?: {
603
603
  supportsReplay?: boolean;
604
604
  supportsEcho?: boolean;
605
605
  supportsTermSync?: boolean;
606
+ /** Features of this (bridged) host, in the vocabulary of `ServerHello.features`; omit rather than send `[]`. */
607
+ features?: string[];
606
608
  }): WshMessage;
607
609
 
608
610
  export function reverseReject(opts?: {
@@ -1970,8 +1972,15 @@ export class WshClient {
1970
1972
  /** Read-only view of active sessions. */
1971
1973
  readonly sessions: Map<number, WshSession>;
1972
1974
 
1973
- /** Server-advertised features from SERVER_HELLO. */
1975
+ /**
1976
+ * The features this connection can rely on: those of the host behind an accepted relay bridge when it
1977
+ * stated them (`ReverseAccept.features`), otherwise the server's `SERVER_HELLO` features.
1978
+ */
1974
1979
  readonly features: string[];
1980
+ /** The `SERVER_HELLO` features of the server connected to (the relay, over a bridge). */
1981
+ readonly serverFeatures: string[];
1982
+ /** Features of the host behind an accepted relay bridge as it stated them; `null` when there is no bridge or it did not say. */
1983
+ readonly bridgedFeatures: string[] | null;
1975
1984
 
1976
1985
  /**
1977
1986
  * Low-level transport reference.
@@ -2004,7 +2013,7 @@ export class WshClient {
2004
2013
  onGatewayMessage: ((msg: WshMessage) => void) | null;
2005
2014
 
2006
2015
  /**
2007
- * Check if the server advertised a specific feature.
2016
+ * Check if a feature is available: advertised by the server, or by the bridged host (see `features`).
2008
2017
  */
2009
2018
  hasFeature(name: string): boolean;
2010
2019
 
@@ -495,19 +495,10 @@ export function reverseConnect({ targetFingerprint, username, fromFingerprint }
495
495
  };
496
496
  }
497
497
 
498
- export function reverseAccept({ targetFingerprint, username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false } = {}) {
499
- return {
500
- type: MSG.REVERSE_ACCEPT,
501
- target_fingerprint: targetFingerprint,
502
- username,
503
- capabilities,
504
- peer_type: peerType,
505
- shell_backend: shellBackend,
506
- supports_attach: supportsAttach,
507
- supports_replay: supportsReplay,
508
- supports_echo: supportsEcho,
509
- supports_term_sync: supportsTermSync,
510
- };
498
+ export function reverseAccept({ targetFingerprint, username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false, features } = {}) {
499
+ const msg = { type: MSG.REVERSE_ACCEPT, target_fingerprint: targetFingerprint, username, capabilities, peer_type: peerType, shell_backend: shellBackend, supports_attach: supportsAttach, supports_replay: supportsReplay, supports_echo: supportsEcho, supports_term_sync: supportsTermSync };
500
+ if (features !== undefined) msg.features = features;
501
+ return msg;
511
502
  }
512
503
 
513
504
  export function reverseReject({ targetFingerprint, username, reason } = {}) {
@@ -962,6 +953,8 @@ export const RELAY_FORWARDABLE = new Set([
962
953
  MSG.ECHO_STATE,
963
954
  MSG.TERM_SYNC,
964
955
  MSG.TERM_DIFF,
956
+ MSG.KEY_EXCHANGE,
957
+ MSG.ENCRYPTED_FRAME,
965
958
  ]);
966
959
 
967
960
  /**
@@ -31,6 +31,22 @@ import { RpcChannel, RPC_FEATURE, RPC_MAX_MESSAGE_PREFIX, rpcProtocolFeature } f
31
31
  import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
32
32
 
33
33
  export const STREAM_ANNOUNCE = 'stream-announce';
34
+
35
+ /**
36
+ * The features a host built from `cfg` advertises (`ServerHello.features`, minus the host-key proof).
37
+ * Over a relay bridge (`dataStreams: false`) there are no client-opened streams, so the
38
+ * stream-based ones (`stream-announce`, `rpc`) are not offered; a reverse host reports this
39
+ * list to the operator in `ReverseAccept.features`.
40
+ */
41
+ export function hostFeatures(cfg, { dataStreams = true } = {}) {
42
+ const features = dataStreams ? [STREAM_ANNOUNCE] : [];
43
+ if (cfg.mcp) features.push(MCP_CALL_ID_FEATURE);
44
+ if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
45
+ if (cfg.rpc && dataStreams && cfg.rpc.protocols.size) {
46
+ features.push(RPC_FEATURE, ...[...cfg.rpc.protocols.keys()].map(rpcProtocolFeature), RPC_MAX_MESSAGE_PREFIX + cfg.rpc.maxMessageBytes);
47
+ }
48
+ return features;
49
+ }
34
50
  const MAX_PENDING_WRITES = 8;
35
51
  const MAX_RENAME_PATH_BYTES = 4096;
36
52
  const WRITE_IDLE_MS = 30_000;
@@ -201,12 +217,7 @@ export function createConnectionFactory(cfg) {
201
217
  }
202
218
  state.username = user;
203
219
  state.method = method;
204
- const features = [STREAM_ANNOUNCE];
205
- if (cfg.mcp) features.push(MCP_CALL_ID_FEATURE);
206
- if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
207
- if (cfg.rpc && dataStreams && cfg.rpc.protocols.size) {
208
- features.push(RPC_FEATURE, ...[...cfg.rpc.protocols.keys()].map(rpcProtocolFeature), RPC_MAX_MESSAGE_PREFIX + cfg.rpc.maxMessageBytes);
209
- }
220
+ const features = hostFeatures(cfg, { dataStreams });
210
221
  let hostFingerprint;
211
222
  if (cfg.hostKey) {
212
223
  hostFingerprint = cfg.hostKey.fingerprint;
@@ -27,8 +27,15 @@ export const DEFAULT_CONNECT_TIMEOUT_MS = 8000;
27
27
  const MAX_FORWARD_BYTES = 8 * 1024 * 1024;
28
28
  const MAX_REMEMBERED_SEQS = 4096;
29
29
  const MAX_CAPABILITIES = 32;
30
+ const MAX_FEATURES = 64;
31
+ const MAX_FEATURE_LENGTH = 128;
30
32
  const SHORT_PREFIX_MIN = 8;
31
33
 
34
+ /** `ReverseReject.reason` for a peer that already has an operator (or a request awaiting its answer). */
35
+ export const BUSY_PEER = 'busy: this peer already has an operator (the relay bridges one operator per peer at a time; retry when it leaves)';
36
+ /** `ReverseReject.reason` for an operator that already has a bridge, or a request in flight. */
37
+ export const BUSY_OPERATOR = 'busy: you already have a bridge or a pending connect on this relay (one at a time)';
38
+
32
39
  const isStr = (v, max) => typeof v === 'string' && v.length <= max;
33
40
 
34
41
  /**
@@ -186,9 +193,13 @@ export class RelayHub {
186
193
  fingerprint: target.meta.fingerprint, username: target.meta.username, capabilities: target.meta.capabilities,
187
194
  }))) return reject('no such peer');
188
195
  if (this.#peers.get(target.meta.fingerprint) !== target) return reject('no such peer');
189
- if (this.#pairs.has(operator) || this.#pairs.has(target.handle)
190
- || [...this.#pending.values()].some((p) => p.operator === operator) || this.#pending.has(target.handle)) {
191
- return reject('busy');
196
+ // One bridge per peer, and one per operator: a RelayForward names its sender but not its recipient, so a
197
+ // peer's reply could not be addressed to one of several operators (see the README's relay section).
198
+ if (this.#pairs.has(target.handle) || this.#pending.has(target.handle)) {
199
+ return reject(BUSY_PEER);
200
+ }
201
+ if (this.#pairs.has(operator) || [...this.#pending.values()].some((p) => p.operator === operator)) {
202
+ return reject(BUSY_OPERATOR);
192
203
  }
193
204
  const timer = setTimeout(() => {
194
205
  if (this.#pending.get(target.handle)?.operator !== operator) return;
@@ -213,8 +224,13 @@ export class RelayHub {
213
224
  if (!p) return;
214
225
  clearTimeout(p.timer);
215
226
  this.#pending.delete(peer);
216
- // The answer names the answering peer, not whatever it wrote.
217
- p.operator.send({ ...msg, target_fingerprint: peer.fingerprint });
227
+ // The answer names the answering peer, not whatever it wrote; its features claim is bounded.
228
+ const answer = { ...msg, target_fingerprint: peer.fingerprint };
229
+ if (msg.type === MSG.REVERSE_ACCEPT) {
230
+ if (Array.isArray(msg.features) && msg.features.length <= MAX_FEATURES && msg.features.every((f) => isStr(f, MAX_FEATURE_LENGTH))) answer.features = [...msg.features];
231
+ else delete answer.features;
232
+ }
233
+ p.operator.send(answer);
218
234
  if (msg.type === MSG.REVERSE_ACCEPT) {
219
235
  this.#pairs.set(p.operator, peer);
220
236
  this.#pairs.set(peer, p.operator);
@@ -13,7 +13,7 @@ import { WshClient } from '../client.mjs';
13
13
  import { exportPublicKeyRaw, fingerprint } from '../auth.mjs';
14
14
  import { reverseAccept, reverseReject } from '../messages.gen.mjs';
15
15
  import { buildBackends } from './backends.mjs';
16
- import { createConnectionFactory } from './connection.mjs';
16
+ import { createConnectionFactory, hostFeatures } from './connection.mjs';
17
17
 
18
18
  const BACKOFF_START_MS = 500;
19
19
  const BACKOFF_MAX_MS = 30_000;
@@ -32,10 +32,13 @@ const BACKOFF_MAX_MS = 30_000;
32
32
  * @param {boolean} [options.reconnect=true] - Dial again (with backoff) after the relay connection ends. A relay ends it when a bridge does.
33
33
  * @param {object} [options.connect] - Extra options for the relay connection (`expectHostKey`, `knownHosts`, ...).
34
34
  * @param {string} [options.peerType='host']
35
+ * @param {boolean} [options.reportFeatures=true] - State this host's features in `ReverseAccept.features`, so the operator's
36
+ * feature gates follow this host rather than the relay. Turn off only for a relay (a Rust `wsh-server` older than the field)
37
+ * that rejects a `ReverseAccept` carrying it.
35
38
  * @param {(line: string) => void} [options.onLog]
36
39
  */
37
40
  export function createReverseHost({
38
- url, username, keyPair, accept, exec, pty, fs, mcp, reconnect = true, connect = {}, peerType = 'host', onLog = () => {},
41
+ url, username, keyPair, accept, exec, pty, fs, mcp, reconnect = true, connect = {}, peerType = 'host', reportFeatures = true, onLog = () => {},
39
42
  } = {}) {
40
43
  if (!url) throw new TypeError('createReverseHost: url is required');
41
44
  if (!username) throw new TypeError('createReverseHost: username is required');
@@ -46,6 +49,9 @@ export function createReverseHost({
46
49
  const expose = { shell: !!backends.pty, exec: !!backends.execRunner, fs: !!backends.files, tools: !!backends.mcp };
47
50
  const capabilities = Object.entries(expose).filter(([, on]) => on).map(([k]) => k);
48
51
  const shellBackend = backends.pty ? 'pty' : 'exec-only';
52
+ // What this host can do over a bridge, for the operator's feature gates (`ReverseAccept.features`).
53
+ // `reportFeatures: false` leaves them out (a Rust peer or relay older than the field rejects a ReverseAccept that has it).
54
+ const bridgeFeatures = hostFeatures({ mcp: backends.mcp, files: backends.files }, { dataStreams: false });
49
55
  const factory = createConnectionFactory({
50
56
  authorize: async () => false, methods: { pubkey: false, password: null }, hostKey: null, rateLimit: null,
51
57
  execRunner: backends.execRunner, execOptions: backends.execOptions, pty: backends.pty, files: backends.files, mcp: backends.mcp,
@@ -74,7 +80,7 @@ export function createReverseHost({
74
80
  const from = req.from_fingerprint;
75
81
  const answer = (m) => c.sendRelayControl(m).catch(() => {});
76
82
  const refuse = (reason) => answer(reverseReject({ targetFingerprint: myFingerprint, username, reason }));
77
- if (bridge || typeof from !== 'string' || !from) { await refuse('busy'); return; }
83
+ if (bridge || typeof from !== 'string' || !from) { await refuse('busy: this host is already serving an operator'); return; }
78
84
  let ok = false;
79
85
  try { ok = (await accept?.({ fingerprint: from, username: String(req.username ?? '') })) === true; } catch (e) { onLog(`[reverse] accept threw: ${e.message}`); }
80
86
  if (!ok || bridge) { await refuse('not accepted'); return; }
@@ -89,6 +95,7 @@ export function createReverseHost({
89
95
  await answer(reverseAccept({
90
96
  targetFingerprint: myFingerprint, username, capabilities, peerType, shellBackend,
91
97
  supportsAttach: false, supportsReplay: false, supportsEcho: false, supportsTermSync: false,
98
+ features: reportFeatures ? bridgeFeatures : undefined,
92
99
  }));
93
100
  };
94
101
  c.onClose = () => {