@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.
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { MSG, mcpDiscover, mcpCall } from './messages.mjs';
10
+ import { waitForControlMessage } from './control-listener.mjs';
11
+ import { MCP_CALL_ID_FEATURE } from './client.mjs';
10
12
 
11
13
  /** Default timeout for MCP operations (15 seconds). */
12
14
  const MCP_TIMEOUT_MS = 15_000;
@@ -18,14 +20,35 @@ export class WshMcpBridge {
18
20
  /** @type {Map<string, object>} Cached tool specs: name -> { name, description, parameters } */
19
21
  #tools = new Map();
20
22
 
23
+ /** @type {number} Monotonically increasing counter for call_id. */
24
+ #callCounter = 0;
25
+
26
+ /**
27
+ * Tail of the serialised call chain, used only against servers that do not
28
+ * advertise `mcp-call-id`. Never rejects.
29
+ * @type {Promise<void>}
30
+ */
31
+ #queue = Promise.resolve();
32
+
21
33
  /**
22
- * @param {object} client - A WshClient or transport exposing:
34
+ * @param {object} client - A `WshClient`, a `WshTransport`, or any object
35
+ * exposing:
23
36
  * - sendControl(msg): send a control message
24
37
  * - addControlListener(fn) / removeControlListener(fn): message listeners
25
- * (or _controlListeners array, or onControl callback)
38
+ * (or a `_controlListeners` array, or a settable `onControl` callback)
39
+ *
40
+ * `sendControl` is checked here rather than at first use: without it
41
+ * `discover()` failed with `TypeError: this.#client.sendControl is not
42
+ * a function` from inside a promise, naming a private field and not
43
+ * the argument that was actually wrong.
26
44
  */
27
45
  constructor(client) {
28
46
  if (!client) throw new Error('WshMcpBridge requires a client');
47
+ if (typeof client.sendControl !== 'function') {
48
+ throw new TypeError(
49
+ 'WshMcpBridge requires a client exposing sendControl() — pass a WshClient or a WshTransport'
50
+ );
51
+ }
29
52
  this.#client = client;
30
53
  }
31
54
 
@@ -73,6 +96,13 @@ export class WshMcpBridge {
73
96
  *
74
97
  * Sends an MCP_CALL message and waits for the MCP_RESULT response.
75
98
  *
99
+ * Safe to call concurrently. Against a server advertising `mcp-call-id`
100
+ * each call carries a correlation id and takes only its own reply;
101
+ * otherwise calls are queued one at a time, because without an id on the
102
+ * wire the replies are indistinguishable and `{ success, output }`
103
+ * normalisation strips whatever the payload might have identified itself
104
+ * by.
105
+ *
76
106
  * @param {string} toolName - Name of the tool to invoke
77
107
  * @param {object} [args={}] - Arguments to pass to the tool
78
108
  * @param {object} [opts]
@@ -91,10 +121,40 @@ export class WshMcpBridge {
91
121
  );
92
122
  }
93
123
 
94
- await this.#client.sendControl(mcpCall({ tool: toolName, arguments: args }));
124
+ if (this.#correlates()) return this.#callOnce(toolName, args, timeout);
125
+
126
+ const run = this.#queue.then(
127
+ () => this.#callOnce(toolName, args, timeout),
128
+ () => this.#callOnce(toolName, args, timeout),
129
+ );
130
+ this.#queue = run.then(() => undefined, () => undefined);
131
+ return run;
132
+ }
133
+
134
+ /** True when the connected server echoes call_id. @private */
135
+ #correlates() {
136
+ return this.#client?.hasFeature?.(MCP_CALL_ID_FEATURE) === true;
137
+ }
138
+
139
+ /** @private */
140
+ async #callOnce(toolName, args, timeout) {
141
+ const callId = this.#correlates()
142
+ ? `bridge-${++this.#callCounter}-${Date.now()}`
143
+ : undefined;
144
+
145
+ await this.#client.sendControl(
146
+ mcpCall({ tool: toolName, arguments: args, callId })
147
+ );
95
148
 
96
149
  const response = await this._waitForMessage(
97
- (msg) => msg.type === MSG.MCP_RESULT || msg.type === MSG.ERROR,
150
+ (msg) => {
151
+ // ERROR carries no call_id and cannot be attributed to one call;
152
+ // it is taken by whichever call is waiting.
153
+ if (msg.type === MSG.ERROR) return true;
154
+ if (msg.type !== MSG.MCP_RESULT) return false;
155
+ if (callId === undefined) return true;
156
+ return msg.call_id === undefined || msg.call_id === callId;
157
+ },
98
158
  timeout
99
159
  );
100
160
 
@@ -171,58 +231,17 @@ export class WshMcpBridge {
171
231
  /**
172
232
  * Wait for a control message matching a predicate.
173
233
  *
174
- * Temporarily hooks into the client's control message flow and resolves
175
- * when a matching message arrives (or rejects on timeout).
234
+ * Subscribes to the client's control message flow and resolves when a
235
+ * matching message arrives (or rejects on timeout). Deregistration is
236
+ * `control-listener.mjs`'s job — the version inlined here leaked one
237
+ * permanent `onControl` wrapper per operation against any client whose
238
+ * only hook is that property.
176
239
  *
177
240
  * @param {function(object): boolean} predicate
178
241
  * @param {number} timeoutMs
179
242
  * @returns {Promise<object>}
180
243
  */
181
244
  _waitForMessage(predicate, timeoutMs) {
182
- return new Promise((resolve, reject) => {
183
- let timer = null;
184
- let settled = false;
185
-
186
- const cleanup = () => {
187
- settled = true;
188
- if (timer !== null) clearTimeout(timer);
189
- // Remove listener
190
- if (this.#client.removeControlListener) {
191
- this.#client.removeControlListener(listener);
192
- } else if (this.#client._controlListeners) {
193
- const idx = this.#client._controlListeners.indexOf(listener);
194
- if (idx !== -1) this.#client._controlListeners.splice(idx, 1);
195
- }
196
- };
197
-
198
- const listener = (msg) => {
199
- if (settled) return;
200
- if (predicate(msg)) {
201
- cleanup();
202
- resolve(msg);
203
- }
204
- };
205
-
206
- // Register listener on the client
207
- if (this.#client.addControlListener) {
208
- this.#client.addControlListener(listener);
209
- } else if (this.#client._controlListeners) {
210
- this.#client._controlListeners.push(listener);
211
- } else {
212
- // Fallback: wrap existing onControl
213
- const prev = this.#client.onControl;
214
- this.#client.onControl = (msg) => {
215
- prev?.(msg);
216
- listener(msg);
217
- };
218
- }
219
-
220
- timer = setTimeout(() => {
221
- if (!settled) {
222
- cleanup();
223
- reject(new Error(`MCP operation timed out after ${timeoutMs}ms`));
224
- }
225
- }, timeoutMs);
226
- });
245
+ return waitForControlMessage(this.#client, predicate, timeoutMs, 'MCP operation');
227
246
  }
228
247
  }
@@ -145,6 +145,10 @@ export const MSG = Object.freeze({
145
145
 
146
146
  // Terminal
147
147
  TERMINAL_CONFIG: 0x9e,
148
+
149
+ // Keys
150
+ AUTHORIZED_KEY_ADD: 0x9f,
151
+ AUTHORIZED_KEY_RESULT: 0xa0,
148
152
  });
149
153
 
150
154
  // Reverse lookup: number → name
@@ -187,13 +191,10 @@ export function hello({ username, features = [], authMethod = AUTH_METHOD.PUBKEY
187
191
  };
188
192
  }
189
193
 
190
- export function serverHello({ sessionId, features = [], fingerprints = [] } = {}) {
191
- return {
192
- type: MSG.SERVER_HELLO,
193
- session_id: sessionId,
194
- features,
195
- fingerprints,
196
- };
194
+ export function serverHello({ sessionId, features = [], fingerprints = [], hostFingerprint } = {}) {
195
+ const msg = { type: MSG.SERVER_HELLO, session_id: sessionId, features, fingerprints };
196
+ if (hostFingerprint !== undefined) msg.host_fingerprint = hostFingerprint;
197
+ return msg;
197
198
  }
198
199
 
199
200
  export function challenge({ nonce, sessionId } = {}) {
@@ -445,15 +446,16 @@ export function mcpTools({ tools } = {}) {
445
446
  };
446
447
  }
447
448
 
448
- export function mcpCall({ tool, arguments: args } = {}) {
449
- return { type: MSG.MCP_CALL, tool, arguments: args };
449
+ export function mcpCall({ tool, arguments: args, callId } = {}) {
450
+ const m = { type: MSG.MCP_CALL, tool, arguments: args };
451
+ if (callId !== undefined) m.call_id = callId;
452
+ return m;
450
453
  }
451
454
 
452
- export function mcpResult({ result } = {}) {
453
- return {
454
- type: MSG.MCP_RESULT,
455
- result,
456
- };
455
+ export function mcpResult({ result, callId } = {}) {
456
+ const msg = { type: MSG.MCP_RESULT, result };
457
+ if (callId !== undefined) msg.call_id = callId;
458
+ return msg;
457
459
  }
458
460
 
459
461
  export function reverseRegister({ username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false, publicKey, seq, recordSignature } = {}) {
@@ -863,8 +865,8 @@ export function fileOp({ channelId, op, path, offset, length } = {}) {
863
865
  return msg;
864
866
  }
865
867
 
866
- export function fileResult({ channelId, success, metadata = {}, errorMessage } = {}) {
867
- const msg = { type: MSG.FILE_RESULT, channel_id: channelId, success, metadata };
868
+ export function fileResult({ channelId, success, metadata = {}, entries = [], errorMessage } = {}) {
869
+ const msg = { type: MSG.FILE_RESULT, channel_id: channelId, success, metadata, entries };
868
870
  if (errorMessage !== undefined) msg.error_message = errorMessage;
869
871
  return msg;
870
872
  }
@@ -914,6 +916,18 @@ export function terminalConfig({ channelId, frontend, options = {} } = {}) {
914
916
  };
915
917
  }
916
918
 
919
+ export function authorizedKeyAdd({ publicKey, comment } = {}) {
920
+ const msg = { type: MSG.AUTHORIZED_KEY_ADD, public_key: publicKey };
921
+ if (comment !== undefined) msg.comment = comment;
922
+ return msg;
923
+ }
924
+
925
+ export function authorizedKeyResult({ success, added = false, errorMessage } = {}) {
926
+ const msg = { type: MSG.AUTHORIZED_KEY_RESULT, success, added };
927
+ if (errorMessage !== undefined) msg.error_message = errorMessage;
928
+ return msg;
929
+ }
930
+
917
931
  // ── Relay forwarding ─────────────────────────────────────────────────
918
932
 
919
933
  // Message types a relay may forward inside a RelayForward wrapper.
package/src/mlkem.mjs CHANGED
@@ -66,7 +66,34 @@ function bytesEqual(a, b) {
66
66
  return diff === 0;
67
67
  }
68
68
 
69
+ /**
70
+ * Which implementation serves ML-KEM-768, with an override for testing.
71
+ *
72
+ * The noble branch is the ONLY path a browser can take -- no browser
73
+ * implements the experimental WebCrypto ML-KEM draft -- and until this
74
+ * override existed no test had ever executed a line of it. `getBackend()`
75
+ * memoised a probe that always succeeds on the repo's target runtime, so
76
+ * every assertion ran against native, and every noble call
77
+ * (`ml_kem768.keygen`, `.encapsulate`, `.decapsulate`) was untouched. A
78
+ * change in noble's argument order or seed semantics -- it is pinned only to
79
+ * `^0.7.0` and called through three positional APIs -- would break hybrid PQ
80
+ * key exchange for every browser consumer while this repo stayed green.
81
+ *
82
+ * `WSH_MLKEM_BACKEND=noble` forces it. Read defensively: this module runs in
83
+ * browsers, where `process` does not exist.
84
+ */
85
+ function backendOverride() {
86
+ try {
87
+ const value = globalThis.process?.env?.WSH_MLKEM_BACKEND;
88
+ return value === 'noble' || value === 'native' ? value : null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
69
94
  async function getBackend() {
95
+ const forced = backendOverride();
96
+ if (forced) return forced;
70
97
  if (!backendPromise) {
71
98
  backendPromise = probeNativeSupport().then((supported) => (supported ? 'native' : 'noble'));
72
99
  }
package/src/session.mjs CHANGED
@@ -153,6 +153,8 @@ export class WshSession {
153
153
  * @type {ReturnType<typeof setTimeout>|null}
154
154
  */
155
155
  #closeGraceTimer = null;
156
+ /** Why the session closed, when it closed for a reason worth naming. */
157
+ #closeReason = null;
156
158
 
157
159
  // ── Callbacks ───────────────────────────────────────────────────────
158
160
 
@@ -475,7 +477,7 @@ export class WshSession {
475
477
  for (const { nonce, ciphertext } of wireChunks) {
476
478
  const counter = this.#e2eRecvCounter++;
477
479
  try {
478
- const plaintext = await openFrame(this.#e2eKey, this.#sessionId, counter, { nonce, ciphertext });
480
+ const plaintext = await openFrame(this.#e2eKey, this.#sessionId, counter, { nonce, ciphertext, expectedRoleTag: this.#e2eRecvRoleTag });
479
481
  plaintexts.push(plaintext);
480
482
  } catch (err) {
481
483
  console.error(
@@ -682,7 +684,7 @@ export class WshSession {
682
684
  // delivery of *this* frame's plaintext to onData is deferred by
683
685
  // one microtask/macrotask relative to synchronous cases.
684
686
  const counter = this.#e2eRecvCounter++;
685
- openFrame(this.#e2eKey, this.#sessionId, counter, { nonce: msg.nonce, ciphertext: msg.ciphertext })
687
+ openFrame(this.#e2eKey, this.#sessionId, counter, { nonce: msg.nonce, ciphertext: msg.ciphertext, expectedRoleTag: this.#e2eRecvRoleTag })
686
688
  .then((plaintext) => {
687
689
  this.#virtualBackend?.pushData(plaintext);
688
690
  try {
@@ -724,14 +726,7 @@ export class WshSession {
724
726
  // any pending data-EOF grace timer, regardless of which arrived
725
727
  // first.
726
728
  this.#clearCloseGraceTimer();
727
- if (this.#state !== STATE_CLOSED) {
728
- this.#state = STATE_CLOSED;
729
- this.#abort.abort();
730
- this.#virtualBackend?.close();
731
- this.#releaseStreams();
732
- this.#resolvePendingFileChunk(null);
733
- this.#emitClose();
734
- }
729
+ this.#closeNow();
735
730
  break;
736
731
  }
737
732
 
@@ -874,11 +869,32 @@ export class WshSession {
874
869
  if (this.#e2eKey !== null && this.#streamAccumulator) {
875
870
  const deliverable = await this.#openStreamChunks(value);
876
871
  if (deliverable === null) {
877
- // Authentication failure (or corrupt framing) -- matches
878
- // the strict "no skip-and-continue" philosophy of
879
- // virtual-mode's counter check: tear the session down
880
- // rather than deliver anything from this point on.
881
- break;
872
+ /*
873
+ * Authentication failure (or corrupt framing) -- the strict
874
+ * "no skip-and-continue" philosophy of virtual-mode's counter
875
+ * check: tear the session down rather than deliver anything
876
+ * from this point on.
877
+ *
878
+ * `break` alone did not tear anything down. It left the loop,
879
+ * and control fell through to the data-EOF grace timer, so the
880
+ * session stayed `active` and writable for the full 300ms and
881
+ * then closed as an ordinary EOF: onClose with no error,
882
+ * onExit never fired, exitCode still null. An application could
883
+ * not tell "the peer forged data at me" from "the process
884
+ * exited and the server forgot to send CLOSE" -- the two were
885
+ * byte-identical to observe.
886
+ *
887
+ * Closed immediately, and the reason recorded so the two are
888
+ * distinguishable. `StreamAuthenticationError` was already
889
+ * built here and handed only to console.error; it is now the
890
+ * close reason as well.
891
+ */
892
+ this.#closeReason = new StreamAuthenticationError(
893
+ 'wsh: stream authentication failed -- session torn down'
894
+ );
895
+ this.#clearCloseGraceTimer();
896
+ this.#closeNow();
897
+ return;
882
898
  }
883
899
  for (const plaintext of deliverable) {
884
900
  try {
@@ -918,11 +934,7 @@ export class WshSession {
918
934
  if (this.#state !== STATE_CLOSED && this.#closeGraceTimer === null) {
919
935
  this.#closeGraceTimer = setTimeout(() => {
920
936
  this.#closeGraceTimer = null;
921
- if (this.#state !== STATE_CLOSED) {
922
- this.#state = STATE_CLOSED;
923
- this.#releaseStreams();
924
- this.#emitClose();
925
- }
937
+ this.#closeNow();
926
938
  }, DATA_EOF_CLOSE_GRACE_MS);
927
939
  this.#closeGraceTimer?.unref?.();
928
940
  }
@@ -935,6 +947,36 @@ export class WshSession {
935
947
  * whether or not a timer is currently scheduled.
936
948
  * @private
937
949
  */
950
+ /**
951
+ * Close the session once, releasing everything a close has to release.
952
+ *
953
+ * There were two ways a stream-mode session reached `closed` and they did
954
+ * not agree. The CLOSE control handler aborted, closed the virtual backend,
955
+ * released the streams AND settled any parked file chunk. The data-EOF
956
+ * grace fallback -- the path taken when a server ends the data stream
957
+ * without sending CLOSE, which the comment above it says is real -- only
958
+ * released the streams and fired onClose.
959
+ *
960
+ * So `download()`, parked on `_readFileChunk()`, was never settled on that
961
+ * path. It documents returning `null` for a truncated transfer and
962
+ * `download()` turns that into a thrown error; via the fallback it returned
963
+ * nothing at all and the download hung forever, while `onClose` had already
964
+ * fired and the session read `closed`.
965
+ *
966
+ * One method rather than two call sites kept in step by hand: the drift is
967
+ * the bug, and the next field added to a close would have drifted the same
968
+ * way.
969
+ */
970
+ #closeNow() {
971
+ if (this.#state === STATE_CLOSED) return;
972
+ this.#state = STATE_CLOSED;
973
+ this.#abort.abort();
974
+ this.#virtualBackend?.close();
975
+ this.#releaseStreams();
976
+ this.#resolvePendingFileChunk(null);
977
+ this.#emitClose();
978
+ }
979
+
938
980
  #clearCloseGraceTimer() {
939
981
  if (this.#closeGraceTimer !== null) {
940
982
  clearTimeout(this.#closeGraceTimer);
@@ -961,9 +1003,19 @@ export class WshSession {
961
1003
  * Emit the onClose callback exactly once.
962
1004
  * @private
963
1005
  */
1006
+ /**
1007
+ * Why the session closed, or null for an ordinary close.
1008
+ *
1009
+ * A torn-down session and a clean exit used to be indistinguishable from
1010
+ * the outside; this is what tells them apart.
1011
+ */
1012
+ get closeReason() {
1013
+ return this.#closeReason;
1014
+ }
1015
+
964
1016
  #emitClose() {
965
1017
  try {
966
- this.onClose?.();
1018
+ this.onClose?.(this.#closeReason);
967
1019
  } catch (err) {
968
1020
  console.error('[wsh:session] onClose handler error:', err);
969
1021
  }
@@ -214,8 +214,22 @@ export class WebSocketTransport extends WshTransport {
214
214
  // own stream objects -- without this, any reader still waiting on a
215
215
  // stream's readable side would hang forever, since nothing else
216
216
  // errors or closes it once the peer can no longer respond.
217
- this.#qmux?.close();
218
- this.#qmux?.destroy(new Error('Transport closed'));
217
+ //
218
+ // Each step is guarded separately. The courtesy CONNECTION_CLOSE is
219
+ // the one that can realistically throw -- it writes to the socket,
220
+ // and a socket that is already broken is the single most likely
221
+ // reason we are closing at all. Letting that throw would skip both
222
+ // the local `destroy()` (hanging every waiting reader, the exact
223
+ // thing above) and the socket close (leaking the connection), which
224
+ // makes the failure mode strictly worse than the failure that
225
+ // triggered it.
226
+ try {
227
+ this.#qmux?.close();
228
+ } catch { /* peer is unreachable; the local teardown below still matters */ }
229
+
230
+ try {
231
+ this.#qmux?.destroy(new Error('Transport closed'));
232
+ } catch { /* a stream's error callback threw; keep tearing down */ }
219
233
 
220
234
  if (this.#ws) {
221
235
  try {