@johnhenry/wsh 0.16.1 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.mjs CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  keyExchange as keyExchangeMsg,
30
30
  fileOp as fileOpMsg,
31
31
  fileChunk as fileChunkMsg,
32
+ authorizedKeyAdd as authorizedKeyAddMsg,
32
33
  policyEval as policyEvalMsg, policyUpdate as policyUpdateMsg,
33
34
  detach as detachMsg,
34
35
  sessionListRequest as sessionListRequestMsg,
@@ -52,7 +53,50 @@ const STATE_CLOSED = 'closed';
52
53
 
53
54
  const DEFAULT_AUTH_TIMEOUT = 10_000; // ms
54
55
  const DEFAULT_OPEN_TIMEOUT = 10_000; // ms
56
+
57
+ /**
58
+ * ServerHello feature advertising that McpResult echoes McpCall's call_id.
59
+ *
60
+ * Negotiated rather than assumed: McpCallPayload is `deny_unknown_fields` on
61
+ * the Rust side, so sending call_id to a server predating it does not
62
+ * degrade -- it makes the server reject the call.
63
+ */
64
+ export const MCP_CALL_ID_FEATURE = 'mcp-call-id';
65
+
66
+ /**
67
+ * Reject a missing argument that maps to a `required: true` wire field.
68
+ *
69
+ * JavaScript will happily let an omitted argument through, and
70
+ * `cborEncode` turns the resulting `undefined` into CBOR null rather
71
+ * than dropping the key -- so the message goes out looking well-formed,
72
+ * gets rejected by a spec-conformant server for a reason that has
73
+ * nothing to do with what the caller did wrong, and the local stack
74
+ * trace is long gone by then. This is exactly how `resumeSession()`
75
+ * shipped broken for two releases (CHANGELOG 0.14.0: `last_seq`
76
+ * "previously always sent as `undefined`, which the wire's
77
+ * `required: true` field never tolerated"). Failing here instead names
78
+ * the argument at the call site.
79
+ *
80
+ * @param {string} method - Method name, for the message.
81
+ * @param {Record<string, *>} args - Argument name -> value.
82
+ */
83
+ function requireArgs(method, args) {
84
+ for (const [name, value] of Object.entries(args)) {
85
+ if (value === undefined || value === null) {
86
+ throw new TypeError(
87
+ `${method}: "${name}" is required -- the corresponding wire field is not optional, ` +
88
+ 'and omitting it produces a message a conformant server will reject'
89
+ );
90
+ }
91
+ }
92
+ }
55
93
  const DEFAULT_PING_INTERVAL = 30_000; // ms
94
+ /*
95
+ * How long a peer may go without answering a ping before it is treated as
96
+ * gone. A multiple of the interval, so an ordinary missed pong is tolerated
97
+ * and a peer that has genuinely stopped answering is not.
98
+ */
99
+ const DEFAULT_PONG_TIMEOUT = 90_000; // ms
56
100
  const DEFAULT_EXEC_TIMEOUT = 60_000; // ms
57
101
  const FILE_CHUNK_SIZE = 65_536;
58
102
 
@@ -181,9 +225,27 @@ export class WshClient {
181
225
  */
182
226
  #acceptedRelayPeers = new Set();
183
227
 
228
+ /**
229
+ * Observers registered through addControlListener() — see that method
230
+ * and #dispatchControlListeners.
231
+ * @type {Set<function(object): void>}
232
+ */
233
+ #controlListeners = new Set();
234
+
184
235
  /** @type {number} Monotonically increasing channel ID counter. */
185
236
  #channelCounter = 0;
186
237
 
238
+ /** @type {number} Monotonically increasing MCP call counter for call_id. */
239
+ #mcpCallCounter = 0;
240
+
241
+ /**
242
+ * Tail of the serialised MCP call chain, used only against servers that do
243
+ * not advertise `mcp-call-id`. Never rejects: each link swallows so one
244
+ * failed call does not poison the queue.
245
+ * @type {Promise<void>}
246
+ */
247
+ #mcpQueue = Promise.resolve();
248
+
187
249
  /**
188
250
  * Pending message waiters: Map<messageType, Array<{resolve, reject, timer}>>
189
251
  * Multiple waiters can exist for the same message type.
@@ -201,12 +263,21 @@ export class WshClient {
201
263
 
202
264
  /** @type {number|null} Timestamp of last pong received. */
203
265
  #lastPong = null;
266
+ #pingIntervalMs = DEFAULT_PING_INTERVAL;
267
+ #pongTimeoutMs = DEFAULT_PONG_TIMEOUT;
204
268
 
205
- constructor({ transportFactories } = {}) {
269
+ constructor({ transportFactories, pingIntervalMs, pongTimeoutMs } = {}) {
206
270
  this.#transportFactories = transportFactories || {
207
271
  wt: () => new WebTransportTransport(),
208
272
  ws: () => new WebSocketTransport(),
209
273
  };
274
+ /*
275
+ * Overridable so the keepalive is testable at all. Left as constants,
276
+ * the only way to observe a 90s timeout is to wait 90s or fake the
277
+ * clock -- which is why nothing observed it before.
278
+ */
279
+ this.#pingIntervalMs = pingIntervalMs ?? DEFAULT_PING_INTERVAL;
280
+ this.#pongTimeoutMs = pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT;
210
281
  }
211
282
 
212
283
  // ── Callbacks ───────────────────────────────────────────────────────
@@ -342,10 +413,18 @@ export class WshClient {
342
413
  * @param {CryptoKeyPair} [opts.keyPair] - Ed25519 key pair for pubkey auth
343
414
  * @param {string} [opts.password] - Password for password auth
344
415
  * @param {'wt'|'ws'|'auto'} [opts.transport] - Force a specific transport
416
+ * @param {object} [opts.webTransport] - Options forwarded to the
417
+ * `WebTransport` constructor when the WebTransport rung of the ladder
418
+ * is tried. Most usefully `serverCertificateHashes`, which pins a
419
+ * specific self-signed certificate by SHA-256 digest -- the one way
420
+ * page JavaScript can reach a server whose certificate no certificate
421
+ * authority signed. Values may be raw bytes, hex (colon-separated is
422
+ * fine), or base64. Ignored by the WebSocket rung, which has no
423
+ * equivalent mechanism.
345
424
  * @param {number} [opts.timeout] - Auth handshake timeout in ms
346
425
  * @returns {Promise<string>} The server-assigned session ID
347
426
  */
348
- async connect(url, { username, keyPair, password, transport: transportHint, timeout = DEFAULT_AUTH_TIMEOUT } = {}) {
427
+ async connect(url, { username, keyPair, password, transport: transportHint, webTransport, timeout = DEFAULT_AUTH_TIMEOUT } = {}) {
349
428
  if (this.#state !== STATE_DISCONNECTED && this.#state !== STATE_CLOSED) {
350
429
  throw new Error(`Client already ${this.#state}`);
351
430
  }
@@ -365,7 +444,7 @@ export class WshClient {
365
444
 
366
445
  try {
367
446
  // ── Select and connect transport ──────────────────────────────
368
- const transport = await this.#connectTransport(url, transportHint);
447
+ const transport = await this.#connectTransport(url, transportHint, webTransport);
369
448
  this.#transport = transport;
370
449
  this.#state = STATE_CONNECTED;
371
450
 
@@ -834,6 +913,7 @@ export class WshClient {
834
913
  username,
835
914
  keyPair,
836
915
  password,
916
+ webTransport,
837
917
  expose = {},
838
918
  peerType = 'browser-shell',
839
919
  shellBackend,
@@ -843,7 +923,7 @@ export class WshClient {
843
923
  supportsTermSync,
844
924
  } = {}) {
845
925
  // Authenticate normally first.
846
- const sessionId = await this.connect(url, { username, keyPair, password });
926
+ const sessionId = await this.connect(url, { username, keyPair, password, webTransport });
847
927
 
848
928
  // Build capabilities list from expose options.
849
929
  const capabilities = [];
@@ -985,6 +1065,71 @@ export class WshClient {
985
1065
  await this.#transport.sendControl(msg);
986
1066
  }
987
1067
 
1068
+ // ── Raw control channel (used by WshMcpBridge / WshFileTransfer) ─────
1069
+ //
1070
+ // `WshMcpBridge` and `WshFileTransfer` both document their constructor
1071
+ // argument as "a WshClient", and both drive the connection through
1072
+ // `sendControl()` + `addControlListener()`/`removeControlListener()`
1073
+ // (`WshFileTransfer.list()` also needs `openStream()`). None of those
1074
+ // existed on this class, so every one of those code paths failed with
1075
+ // `TypeError: this.#client.sendControl is not a function` the moment a
1076
+ // real client was passed instead of a hand-written stand-in — the same
1077
+ // shape as the 0.14.0 `attachSession()`/`resumeSession()` bug, where a
1078
+ // whole method was unreachable while the suite stayed green because
1079
+ // every test spoke to a mock that answered whatever it was asked.
1080
+ //
1081
+ // These four members are thin, deliberate delegations rather than a new
1082
+ // subsystem: the transport already owns the socket and the framing, and
1083
+ // `#handleControl` already sees every inbound control message.
1084
+
1085
+ /**
1086
+ * Send a raw control message on the authenticated connection.
1087
+ *
1088
+ * Prefer the typed methods (`openSession`, `callTool`, `upload`, …).
1089
+ * This exists for the helper classes that compose their own messages —
1090
+ * see `WshMcpBridge` and `WshFileTransfer`.
1091
+ *
1092
+ * @param {object} msg
1093
+ * @returns {Promise<void>}
1094
+ */
1095
+ async sendControl(msg) {
1096
+ this.#assertAuthenticated('sendControl');
1097
+ await this.#transport.sendControl(msg);
1098
+ }
1099
+
1100
+ /**
1101
+ * Open a raw bidirectional stream on the underlying transport.
1102
+ *
1103
+ * @returns {Promise<{ readable: ReadableStream, writable: WritableStream }>}
1104
+ */
1105
+ async openStream() {
1106
+ this.#assertAuthenticated('openStream');
1107
+ return this.#transport.openStream();
1108
+ }
1109
+
1110
+ /**
1111
+ * Observe every inbound control message this client handles.
1112
+ *
1113
+ * Listeners are called after the RelayForward trust gate, so a relayed
1114
+ * message from an untrusted peer is never handed to one, and a trusted
1115
+ * RelayForward is delivered as its unwrapped inner message. A listener
1116
+ * that throws is logged and does not stop the client's own dispatch.
1117
+ *
1118
+ * @param {function(object): void} fn
1119
+ */
1120
+ addControlListener(fn) {
1121
+ if (typeof fn !== 'function') throw new TypeError('addControlListener requires a function');
1122
+ this.#controlListeners.add(fn);
1123
+ }
1124
+
1125
+ /**
1126
+ * Stop observing inbound control messages.
1127
+ * @param {function(object): void} fn
1128
+ */
1129
+ removeControlListener(fn) {
1130
+ this.#controlListeners.delete(fn);
1131
+ }
1132
+
988
1133
  /**
989
1134
  * Mark a peer fingerprint as an accepted reverse-connect bridge partner.
990
1135
  *
@@ -1135,22 +1280,60 @@ export class WshClient {
1135
1280
  /**
1136
1281
  * Call an MCP tool on the remote server.
1137
1282
  *
1283
+ * Safe to call concurrently. Against a server advertising the
1284
+ * `mcp-call-id` feature each call carries a correlation id and takes only
1285
+ * its own reply. Against an older server there is no id on the wire and
1286
+ * replies are indistinguishable, so calls are queued one at a time on this
1287
+ * connection -- slower, but never the wrong tool's result.
1288
+ *
1138
1289
  * @param {string} name - Tool name
1139
- * @param {object} args - Tool arguments
1290
+ * @param {object} [args={}] - Tool arguments. Defaults to `{}` rather
1291
+ * than being omitted: `McpCall.arguments` is a required wire field,
1292
+ * and a tool that takes no arguments is an ordinary thing to call.
1140
1293
  * @param {number} [timeout=30000]
1141
1294
  * @returns {Promise<*>} Tool result
1142
1295
  */
1143
- async callTool(name, args, timeout = 30_000) {
1296
+ async callTool(name, args = {}, timeout = 30_000) {
1144
1297
  this.#assertAuthenticated('callTool');
1298
+ requireArgs('callTool', { name });
1299
+
1300
+ if (this.hasFeature(MCP_CALL_ID_FEATURE)) {
1301
+ return this.#callToolCorrelated(name, args, timeout);
1302
+ }
1303
+ // No correlation available: serialise so a concurrent caller cannot be
1304
+ // handed someone else's result.
1305
+ const run = this.#mcpQueue.then(
1306
+ () => this.#callToolCorrelated(name, args, timeout),
1307
+ () => this.#callToolCorrelated(name, args, timeout),
1308
+ );
1309
+ this.#mcpQueue = run.then(() => undefined, () => undefined);
1310
+ return run;
1311
+ }
1312
+
1313
+ /**
1314
+ * Send one McpCall and wait for the McpResult that answers it.
1315
+ * @private
1316
+ */
1317
+ async #callToolCorrelated(name, args, timeout) {
1318
+ const correlate = this.hasFeature(MCP_CALL_ID_FEATURE);
1319
+ // Only sent when the peer understands it: McpCallPayload is
1320
+ // deny_unknown_fields on the Rust side, so an unsolicited call_id would
1321
+ // make an older server reject the call outright rather than ignore it.
1322
+ const callId = correlate ? `mcp-${++this.#mcpCallCounter}-${Date.now()}` : undefined;
1145
1323
 
1146
1324
  await this.#transport.sendControl(
1147
- mcpCallMsg({ tool: name, arguments: args })
1325
+ mcpCallMsg({ tool: name, arguments: args, callId })
1148
1326
  );
1149
1327
 
1150
1328
  const response = await this.#waitForMessage(
1151
1329
  [MSG.MCP_RESULT],
1152
1330
  timeout,
1153
- `Timed out waiting for MCP tool result (${name})`
1331
+ `Timed out waiting for MCP tool result (${name})`,
1332
+ // A responder that echoes no call_id gets matched on type, which is
1333
+ // the pre-correlation behaviour and all an older peer can offer.
1334
+ callId === undefined
1335
+ ? undefined
1336
+ : (msg) => msg.call_id === undefined || msg.call_id === callId,
1154
1337
  );
1155
1338
 
1156
1339
  return response.result;
@@ -1207,6 +1390,7 @@ export class WshClient {
1207
1390
  */
1208
1391
  async inviteGuest(sessionId, ttl, permissions = ['read'], timeout = DEFAULT_OPEN_TIMEOUT) {
1209
1392
  this.#assertAuthenticated('inviteGuest');
1393
+ requireArgs('inviteGuest', { sessionId, ttl });
1210
1394
  await this.#transport.sendControl(guestInviteMsg({ sessionId, ttl, permissions }));
1211
1395
  return this.#waitForMessage(
1212
1396
  [MSG.GUEST_INVITE],
@@ -1248,12 +1432,17 @@ export class WshClient {
1248
1432
  * Share a session for multi-attach.
1249
1433
  * @param {string} sessionId - Session to share
1250
1434
  * @param {string} [mode='read'] - Share mode
1251
- * @param {number} [ttl] - Share TTL in seconds
1435
+ * @param {number} ttl - Share lifetime in seconds. Not optional,
1436
+ * despite its position after a defaulted parameter:
1437
+ * `ShareSession.ttl` is a required wire field, and there is no
1438
+ * sensible default lifetime for a share link to invent on the
1439
+ * caller's behalf.
1252
1440
  * @param {number} [timeout=10000]
1253
1441
  * @returns {Promise<object>} Share response with share_id
1254
1442
  */
1255
1443
  async shareSession(sessionId, mode = 'read', ttl, timeout = DEFAULT_OPEN_TIMEOUT) {
1256
1444
  this.#assertAuthenticated('shareSession');
1445
+ requireArgs('shareSession', { sessionId, ttl });
1257
1446
  await this.#transport.sendControl(shareSessionMsg({ sessionId, mode, ttl }));
1258
1447
  return this.#waitForMessage(
1259
1448
  [MSG.SHARE_SESSION],
@@ -1301,6 +1490,7 @@ export class WshClient {
1301
1490
  */
1302
1491
  async setRateControl(sessionId, maxBytesPerSec, policy = 'pause') {
1303
1492
  this.#assertAuthenticated('setRateControl');
1493
+ requireArgs('setRateControl', { sessionId, maxBytesPerSec });
1304
1494
  await this.#transport.sendControl(rateControlMsg({ sessionId, maxBytesPerSec, policy }));
1305
1495
  }
1306
1496
 
@@ -1340,6 +1530,7 @@ export class WshClient {
1340
1530
  */
1341
1531
  async copilotAttach(sessionId, model, contextWindow) {
1342
1532
  this.#assertAuthenticated('copilotAttach');
1533
+ requireArgs('copilotAttach', { sessionId, model });
1343
1534
  await this.#transport.sendControl(
1344
1535
  copilotAttachMsg({ sessionId, model, contextWindow })
1345
1536
  );
@@ -1401,6 +1592,30 @@ export class WshClient {
1401
1592
  this.#assertAuthenticated('initiateE2E');
1402
1593
  const wantHybrid = algorithm === 'X25519+ML-KEM-768';
1403
1594
 
1595
+ // Register the waiter before ANY await, key generation included.
1596
+ //
1597
+ // Two peers establishing E2E both call this at once, and #handleControl
1598
+ // DROPS a KEY_EXCHANGE that no waiter is listening for. Whichever side
1599
+ // finishes its local key generation first sends its round-1 message into
1600
+ // the other's blind window -- the other is still inside generateKey /
1601
+ // exportKey below and has nothing registered. That message is gone, and
1602
+ // the side that lost it then waits the full timeout for something the
1603
+ // peer has already sent and will not send again.
1604
+ //
1605
+ // Registering after the crypto, or merely before sendControl, leaves the
1606
+ // window open: measured at ~10% of runs before, and still ~5% with the
1607
+ // waiter registered after key generation. The only safe point is here,
1608
+ // before this function yields for the first time.
1609
+ const peerMsgPromise = this.#waitForMessage(
1610
+ [MSG.KEY_EXCHANGE],
1611
+ timeout,
1612
+ 'Timed out waiting for peer key exchange'
1613
+ );
1614
+ // Mark it handled so a failure in the key generation or send below does
1615
+ // not surface as an unhandled rejection when this waiter later times
1616
+ // out. Awaiting the promise still throws normally.
1617
+ peerMsgPromise.catch(() => {});
1618
+
1404
1619
  // Generate ephemeral X25519 key pair (and, for hybrid, a fresh
1405
1620
  // ML-KEM-768 key pair too).
1406
1621
  const ephemeral = await crypto.subtle.generateKey(
@@ -1417,11 +1632,30 @@ export class WshClient {
1417
1632
  keyExchangeMsg({ algorithm, publicKey: localPub, sessionId, kemPublicKey: localKem?.publicKey })
1418
1633
  );
1419
1634
 
1420
- const peerMsg = await this.#waitForMessage(
1421
- [MSG.KEY_EXCHANGE],
1422
- timeout,
1423
- 'Timed out waiting for peer key exchange'
1424
- );
1635
+ const peerMsg = await peerMsgPromise;
1636
+
1637
+ // Round 2 has the same shape as round 1, so it needs the same treatment.
1638
+ // Whether we encapsulate or decapsulate is decided entirely by data
1639
+ // already in hand, so decide it now and register the ciphertext waiter
1640
+ // BEFORE the derive below. Registering it inside the `else` branch, after
1641
+ // importKey and deriveBits, let the peer's ciphertext arrive while this
1642
+ // side was still deriving -- dropped, then a full-timeout wait for a
1643
+ // message already sent. That surfaced as "Timed out waiting for peer
1644
+ // ML-KEM-768 ciphertext", which retryOnKnownFlake retries and the test
1645
+ // file attributes to a Node provider stall.
1646
+ const hybridActive = wantHybrid && !!localKem && !!peerMsg.kem_public_key;
1647
+ const isEncapsulator = hybridActive
1648
+ && compareBytes(localPub, new Uint8Array(peerMsg.public_key)) < 0;
1649
+
1650
+ let ctMsgPromise = null;
1651
+ if (hybridActive && !isEncapsulator) {
1652
+ ctMsgPromise = this.#waitForMessage(
1653
+ [MSG.KEY_EXCHANGE],
1654
+ timeout,
1655
+ 'Timed out waiting for peer ML-KEM-768 ciphertext'
1656
+ );
1657
+ ctMsgPromise.catch(() => {});
1658
+ }
1425
1659
 
1426
1660
  // Import peer's public key and derive the classical shared secret.
1427
1661
  const peerKey = await crypto.subtle.importKey(
@@ -1437,12 +1671,10 @@ export class WshClient {
1437
1671
  256
1438
1672
  ));
1439
1673
 
1440
- const hybridActive = wantHybrid && !!localKem && !!peerMsg.kem_public_key;
1441
1674
  let combinedBits = sharedBits;
1442
1675
 
1443
1676
  if (hybridActive) {
1444
1677
  const peerKemPublicKey = new Uint8Array(peerMsg.kem_public_key);
1445
- const isEncapsulator = compareBytes(localPub, new Uint8Array(peerMsg.public_key)) < 0;
1446
1678
 
1447
1679
  let kemSharedSecret;
1448
1680
  if (isEncapsulator) {
@@ -1450,11 +1682,7 @@ export class WshClient {
1450
1682
  kemSharedSecret = sharedSecret;
1451
1683
  await this.#transport.sendControl(keyExchangeMsg({ algorithm, sessionId, kemCiphertext: ciphertext }));
1452
1684
  } else {
1453
- const ctMsg = await this.#waitForMessage(
1454
- [MSG.KEY_EXCHANGE],
1455
- timeout,
1456
- 'Timed out waiting for peer ML-KEM-768 ciphertext'
1457
- );
1685
+ const ctMsg = await ctMsgPromise;
1458
1686
  kemSharedSecret = await mlKemDecapsulate(localKem.secretKeySeed, new Uint8Array(ctMsg.kem_ciphertext));
1459
1687
  }
1460
1688
 
@@ -1511,6 +1739,45 @@ export class WshClient {
1511
1739
  /** Rename a remote file or directory. */
1512
1740
  async fileRename(oldPath, newPath, timeout) { return this.fileOperation('rename', oldPath, {}, timeout); }
1513
1741
 
1742
+ // ── Authorized-key management (wsh #59) ───────────────────────────
1743
+
1744
+ /**
1745
+ * Install a raw 32-byte Ed25519 public key into the remote host's
1746
+ * ~/.wsh/authorized_keys via the AuthorizedKeyAdd protocol message.
1747
+ *
1748
+ * Replaces `wsh copy-id`'s CLI-only, shell-command-built approach
1749
+ * (`crates/wsh-cli/src/commands/copy_id.rs`) with a real protocol
1750
+ * message every implementation can call -- including this one, the
1751
+ * browser SDK, which previously had no way to install a key at all
1752
+ * (no shell channel to build a command string for). See copy_id.rs's
1753
+ * doc comment for the full security reasoning: an authenticated
1754
+ * connection already has unrestricted exec/file access to this exact
1755
+ * file, so this adds no new privilege boundary.
1756
+ *
1757
+ * Idempotent: installing an already-present key resolves
1758
+ * `{ added: false }` rather than duplicating the line or erroring.
1759
+ *
1760
+ * @param {Uint8Array} publicKeyRaw - Raw 32-byte Ed25519 public key
1761
+ * @param {string} [comment] - Trailing comment for the authorized_keys line
1762
+ * @param {number} [timeout=10000]
1763
+ * @returns {Promise<{ added: boolean }>}
1764
+ */
1765
+ async addAuthorizedKey(publicKeyRaw, comment, timeout = DEFAULT_OPEN_TIMEOUT) {
1766
+ this.#assertAuthenticated('addAuthorizedKey');
1767
+ await this.#transport.sendControl(
1768
+ authorizedKeyAddMsg({ publicKey: publicKeyRaw, comment })
1769
+ );
1770
+ const result = await this.#waitForMessage(
1771
+ [MSG.AUTHORIZED_KEY_RESULT],
1772
+ timeout,
1773
+ 'Timed out waiting for authorized-key-add result'
1774
+ );
1775
+ if (!result.success) {
1776
+ throw new Error(result.error_message || 'key installation refused');
1777
+ }
1778
+ return { added: !!result.added };
1779
+ }
1780
+
1514
1781
  // ── Policy Engine ─────────────────────────────────────────────────
1515
1782
 
1516
1783
  /**
@@ -1542,6 +1809,7 @@ export class WshClient {
1542
1809
  */
1543
1810
  async updatePolicy(policyId, rules, version) {
1544
1811
  this.#assertAuthenticated('updatePolicy');
1812
+ requireArgs('updatePolicy', { policyId, rules, version });
1545
1813
  await this.#transport.sendControl(
1546
1814
  policyUpdateMsg({ policyId, rules, version })
1547
1815
  );
@@ -1554,17 +1822,23 @@ export class WshClient {
1554
1822
  *
1555
1823
  * @param {string} url
1556
1824
  * @param {'wt'|'ws'|'auto'} [hint]
1825
+ * @param {object} [webTransport] - Options for the `WebTransport`
1826
+ * constructor, applied only to a `wt` attempt (see
1827
+ * `WebTransportTransport`). The `ws` rung of the ladder ignores them:
1828
+ * a `serverCertificateHashes` pin has no WebSocket equivalent, so a
1829
+ * `wss:` fallback against that same self-signed certificate will
1830
+ * still fail the usual certificate-authority check.
1557
1831
  * @returns {Promise<import('./transport.mjs').WshTransport>}
1558
1832
  * @private
1559
1833
  */
1560
- async #connectTransport(url, hint) {
1834
+ async #connectTransport(url, hint, webTransport) {
1561
1835
  const attempts = this.#buildTransportAttempts(url, hint);
1562
1836
  const errors = [];
1563
1837
 
1564
1838
  for (const attempt of attempts) {
1565
1839
  const transport = this.#createTransport(attempt.kind);
1566
1840
  try {
1567
- await transport.connect(attempt.url);
1841
+ await transport.connect(attempt.url, attempt.kind === 'wt' ? webTransport : undefined);
1568
1842
  this.#attachTransportHandlers(transport);
1569
1843
  return transport;
1570
1844
  } catch (err) {
@@ -1606,6 +1880,16 @@ export class WshClient {
1606
1880
  if (hint === 'ws') {
1607
1881
  return [{ kind: 'ws', url }];
1608
1882
  }
1883
+ if (hint !== undefined && hint !== null && hint !== 'auto') {
1884
+ // Anything else is a typo, and silently treating it as 'auto' is
1885
+ // the worst available answer: `transport: 'webtransport'` would
1886
+ // quietly fall back to WebSocket, dropping any
1887
+ // `webTransport.serverCertificateHashes` pin along with it and
1888
+ // connecting over a transport the caller explicitly tried to avoid.
1889
+ throw new Error(
1890
+ `Unknown transport hint: ${JSON.stringify(hint)} (expected 'wt', 'ws', or 'auto')`
1891
+ );
1892
+ }
1609
1893
  if (/^wss?:\/\//i.test(url)) {
1610
1894
  return [{ kind: 'ws', url }];
1611
1895
  }
@@ -1804,6 +2088,13 @@ export class WshClient {
1804
2088
  return;
1805
2089
  }
1806
2090
 
2091
+ // Observers registered via addControlListener() see the message here —
2092
+ // past the RelayForward trust gate above (so an untrusted peer's
2093
+ // message never reaches one, and a trusted one arrives unwrapped) and
2094
+ // before this client's own dispatch, so a helper waiting on a reply
2095
+ // cannot miss it to an ordering accident.
2096
+ this.#dispatchControlListeners(msg);
2097
+
1807
2098
  // OPEN_OK/OPEN_FAIL: handled as a dedicated case (not the generic
1808
2099
  // waiter mechanism below) so the WshSession gets constructed and
1809
2100
  // registered in #sessions synchronously, in the same dispatch step as
@@ -1864,10 +2155,13 @@ export class WshClient {
1864
2155
  }
1865
2156
 
1866
2157
  // First, check if any waiters are listening for this message type.
2158
+ // A waiter carrying a `match` predicate only takes messages it claims,
2159
+ // so the queue is scanned rather than shifted blindly.
1867
2160
  if (this.#waiters.has(type)) {
1868
2161
  const queue = this.#waiters.get(type);
1869
- if (queue.length > 0) {
1870
- const waiter = queue.shift();
2162
+ for (let i = 0; i < queue.length; i++) {
2163
+ if (queue[i].match && !queue[i].match(msg)) continue;
2164
+ const waiter = queue.splice(i, 1)[0];
1871
2165
  if (queue.length === 0) this.#waiters.delete(type);
1872
2166
  clearTimeout(waiter.timer);
1873
2167
  waiter.resolve(msg);
@@ -1879,13 +2173,13 @@ export class WshClient {
1879
2173
  for (const [key, queue] of this.#waiters) {
1880
2174
  if (typeof key === 'string' && key.startsWith('multi:')) {
1881
2175
  for (let i = 0; i < queue.length; i++) {
1882
- if (queue[i].types?.includes(type)) {
1883
- const waiter = queue.splice(i, 1)[0];
1884
- if (queue.length === 0) this.#waiters.delete(key);
1885
- clearTimeout(waiter.timer);
1886
- waiter.resolve(msg);
1887
- return;
1888
- }
2176
+ if (!queue[i].types?.includes(type)) continue;
2177
+ if (queue[i].match && !queue[i].match(msg)) continue;
2178
+ const waiter = queue.splice(i, 1)[0];
2179
+ if (queue.length === 0) this.#waiters.delete(key);
2180
+ clearTimeout(waiter.timer);
2181
+ waiter.resolve(msg);
2182
+ return;
1889
2183
  }
1890
2184
  }
1891
2185
  }
@@ -2063,7 +2357,7 @@ export class WshClient {
2063
2357
  * @returns {Promise<object>}
2064
2358
  * @private
2065
2359
  */
2066
- #waitForMessage(types, timeout, timeoutMessage) {
2360
+ #waitForMessage(types, timeout, timeoutMessage, match) {
2067
2361
  const typeArr = Array.isArray(types) ? types : [types];
2068
2362
 
2069
2363
  return new Promise((resolve, reject) => {
@@ -2073,7 +2367,10 @@ export class WshClient {
2073
2367
  reject(new Error(timeoutMessage));
2074
2368
  }, timeout);
2075
2369
 
2076
- const waiter = { resolve, reject, timer, types: typeArr };
2370
+ // `match` lets a waiter claim only the reply that belongs to it --
2371
+ // correlation, rather than "first waiter of this type wins". Waiters
2372
+ // without one match on type alone, as before.
2373
+ const waiter = { resolve, reject, timer, types: typeArr, match };
2077
2374
 
2078
2375
  // For multi-type waiting, use a synthetic key.
2079
2376
  const key = typeArr.length === 1
@@ -2138,12 +2435,37 @@ export class WshClient {
2138
2435
  return;
2139
2436
  }
2140
2437
 
2438
+ /*
2439
+ * Act on the pong, rather than merely recording it.
2440
+ *
2441
+ * `#lastPong` was written on every PONG and read nowhere -- `grep -arn
2442
+ * lastPong src/` returned the declaration and two assignments and no
2443
+ * third line. So the keepalive proved the connection was alive when it
2444
+ * was, and said nothing when it stopped being: pings went out forever
2445
+ * at every peer, dead or not, and a caller waiting for data waited
2446
+ * without a reason.
2447
+ *
2448
+ * Compared against a deadline rather than armed as a timer, which
2449
+ * matters: a timer re-armed on each tick is cleared before it can fire
2450
+ * whenever the interval is shorter than the timeout, and is then
2451
+ * incapable of firing at all. (browsermesh#32 shipped exactly that.) A
2452
+ * timestamp comparison has no such failure mode.
2453
+ */
2454
+ if (this.#lastPong !== null && Date.now() - this.#lastPong > this.#pongTimeoutMs) {
2455
+ const silentFor = Date.now() - this.#lastPong;
2456
+ this.#stopPing();
2457
+ this.#emitError(
2458
+ new Error(`wsh: peer stopped answering keepalive pings ${silentFor}ms ago`)
2459
+ );
2460
+ return;
2461
+ }
2462
+
2141
2463
  this.#transport?.sendControl(
2142
2464
  pingMsg({ id: ++this.#pingId })
2143
2465
  ).catch((err) => {
2144
2466
  console.warn('[wsh:client] Failed to send ping:', err.message);
2145
2467
  });
2146
- }, DEFAULT_PING_INTERVAL);
2468
+ }, this.#pingIntervalMs);
2147
2469
 
2148
2470
  // Don't let the ping timer prevent Node.js/Deno from exiting.
2149
2471
  if (typeof this.#pingTimer === 'object' && this.#pingTimer.unref) {
@@ -2199,6 +2521,28 @@ export class WshClient {
2199
2521
  }
2200
2522
  }
2201
2523
 
2524
+ /**
2525
+ * Hand a control message to every addControlListener() observer.
2526
+ *
2527
+ * Iterates a copy, so a listener that deregisters itself (the normal
2528
+ * case — a one-shot reply waiter) does not perturb the iteration, and
2529
+ * isolates throws so one bad observer cannot break the client's own
2530
+ * dispatch of the same message.
2531
+ *
2532
+ * @param {object} msg
2533
+ * @private
2534
+ */
2535
+ #dispatchControlListeners(msg) {
2536
+ if (this.#controlListeners.size === 0) return;
2537
+ for (const fn of [...this.#controlListeners]) {
2538
+ try {
2539
+ fn(msg);
2540
+ } catch (err) {
2541
+ console.error('[wsh:client] control listener error:', err);
2542
+ }
2543
+ }
2544
+ }
2545
+
2202
2546
  /**
2203
2547
  * Emit an error through the callback.
2204
2548
  * @param {Error} err