@johnhenry/wsh 0.16.0 → 0.17.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 +208 -10
- package/package.json +9 -1
- package/src/auth.mjs +63 -1
- package/src/client.mjs +339 -35
- package/src/control-listener.mjs +130 -0
- package/src/e2e-frame.mjs +32 -1
- package/src/file-transfer.mjs +11 -45
- package/src/index.d.ts +136 -6
- package/src/index.mjs +6 -2
- package/src/mcp-bridge.mjs +70 -51
- package/src/messages.gen.mjs +8 -7
- package/src/mlkem.mjs +27 -0
- package/src/session.mjs +127 -23
- package/src/transport-ws.mjs +16 -2
- package/src/transport.mjs +201 -7
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control-message subscription for the helper classes that compose their
|
|
3
|
+
* own wire messages — `WshMcpBridge` and `WshFileTransfer`.
|
|
4
|
+
*
|
|
5
|
+
* Both accept "a WshClient, or any object exposing sendControl() plus a
|
|
6
|
+
* way to observe inbound control messages", and both used to inline the
|
|
7
|
+
* same three-way registration. The third branch of it — the fallback for
|
|
8
|
+
* an object whose only hook is a settable `onControl` property, which is
|
|
9
|
+
* what a bare `WshTransport` is — wrapped that property and **never
|
|
10
|
+
* unwrapped it**:
|
|
11
|
+
*
|
|
12
|
+
* ```js
|
|
13
|
+
* const prev = client.onControl;
|
|
14
|
+
* client.onControl = (msg) => { prev?.(msg); listener(msg); };
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* The matching `cleanup()` handled `removeControlListener` and
|
|
18
|
+
* `_controlListeners` and did nothing at all for this branch. So every
|
|
19
|
+
* `discover()` / `call()` / `list()` left another wrapper permanently in
|
|
20
|
+
* the chain. Measured against a real `WshTransport`, the number of frames
|
|
21
|
+
* an inbound control message traverses to reach the connection's own
|
|
22
|
+
* handler grew one-for-one with the number of operations — 1, 5, 20 and
|
|
23
|
+
* 50 operations gave 5, 9, 24 and 54 frames — with no ceiling.
|
|
24
|
+
*
|
|
25
|
+
* Attaching once per subscriber and detaching on cleanup keeps that flat.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Subscribe `listener` to a client's inbound control messages.
|
|
30
|
+
*
|
|
31
|
+
* Supports, in order of preference:
|
|
32
|
+
* 1. `addControlListener()` / `removeControlListener()` — what `WshClient`
|
|
33
|
+
* exposes, and the only shape that can deregister cleanly.
|
|
34
|
+
* 2. A `_controlListeners` array.
|
|
35
|
+
* 3. A settable `onControl` property (a bare `WshTransport`), wrapped
|
|
36
|
+
* once and restored on detach.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} client
|
|
39
|
+
* @param {function(object): void} listener
|
|
40
|
+
* @returns {function(): void} detach — idempotent; safe to call twice.
|
|
41
|
+
*/
|
|
42
|
+
export function attachControlListener(client, listener) {
|
|
43
|
+
if (typeof client.addControlListener === 'function') {
|
|
44
|
+
client.addControlListener(listener);
|
|
45
|
+
let detached = false;
|
|
46
|
+
return () => {
|
|
47
|
+
if (detached) return;
|
|
48
|
+
detached = true;
|
|
49
|
+
client.removeControlListener?.(listener);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(client._controlListeners)) {
|
|
54
|
+
client._controlListeners.push(listener);
|
|
55
|
+
let detached = false;
|
|
56
|
+
return () => {
|
|
57
|
+
if (detached) return;
|
|
58
|
+
detached = true;
|
|
59
|
+
const idx = client._controlListeners.indexOf(listener);
|
|
60
|
+
if (idx !== -1) client._controlListeners.splice(idx, 1);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fallback: wrap `onControl`. Restoring `prev` is only correct while our
|
|
65
|
+
// wrapper is still the installed handler — if something else has wrapped
|
|
66
|
+
// it since, putting `prev` back would drop that handler. In that case the
|
|
67
|
+
// wrapper stays but is made inert, so the chain stops growing per
|
|
68
|
+
// operation even though this one link cannot be spliced out.
|
|
69
|
+
const prev = client.onControl;
|
|
70
|
+
let live = true;
|
|
71
|
+
const wrapper = (msg) => {
|
|
72
|
+
prev?.(msg);
|
|
73
|
+
if (live) listener(msg);
|
|
74
|
+
};
|
|
75
|
+
client.onControl = wrapper;
|
|
76
|
+
|
|
77
|
+
let detached = false;
|
|
78
|
+
return () => {
|
|
79
|
+
if (detached) return;
|
|
80
|
+
detached = true;
|
|
81
|
+
live = false;
|
|
82
|
+
if (client.onControl === wrapper) client.onControl = prev;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wait for the first inbound control message matching `predicate`.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} client
|
|
90
|
+
* @param {function(object): boolean} predicate
|
|
91
|
+
* @param {number} timeoutMs
|
|
92
|
+
* @param {string} label - Used in the timeout message.
|
|
93
|
+
* @returns {Promise<object>}
|
|
94
|
+
*/
|
|
95
|
+
export function waitForControlMessage(client, predicate, timeoutMs, label) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
let settled = false;
|
|
98
|
+
let detach = () => {};
|
|
99
|
+
let timer = null;
|
|
100
|
+
|
|
101
|
+
const cleanup = () => {
|
|
102
|
+
settled = true;
|
|
103
|
+
if (timer !== null) clearTimeout(timer);
|
|
104
|
+
detach();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// `detach` is assigned after attach() returns, but a client that
|
|
108
|
+
// dispatches synchronously from inside attach() would fire the
|
|
109
|
+
// listener first — hence the `settled` guard rather than relying on
|
|
110
|
+
// detach() having been wired up.
|
|
111
|
+
detach = attachControlListener(client, (msg) => {
|
|
112
|
+
if (settled) return;
|
|
113
|
+
let matches = false;
|
|
114
|
+
try {
|
|
115
|
+
matches = predicate(msg);
|
|
116
|
+
} catch {
|
|
117
|
+
matches = false;
|
|
118
|
+
}
|
|
119
|
+
if (!matches) return;
|
|
120
|
+
cleanup();
|
|
121
|
+
resolve(msg);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
timer = setTimeout(() => {
|
|
125
|
+
if (settled) return;
|
|
126
|
+
cleanup();
|
|
127
|
+
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
128
|
+
}, timeoutMs);
|
|
129
|
+
});
|
|
130
|
+
}
|
package/src/e2e-frame.mjs
CHANGED
|
@@ -138,13 +138,44 @@ export async function sealFrame(key, sessionId, roleTag, counter, plaintext) {
|
|
|
138
138
|
* @param {{nonce: Uint8Array, ciphertext: Uint8Array}} frame
|
|
139
139
|
* @returns {Promise<Uint8Array>} plaintext
|
|
140
140
|
*/
|
|
141
|
-
export async function openFrame(key, sessionId, expectedCounter, { nonce, ciphertext }) {
|
|
141
|
+
export async function openFrame(key, sessionId, expectedCounter, { nonce, ciphertext, expectedRoleTag }) {
|
|
142
142
|
if (!key || typeof key !== 'object') {
|
|
143
143
|
throw new Error('e2e-frame: openFrame requires a CryptoKey');
|
|
144
144
|
}
|
|
145
145
|
if (!(nonce instanceof Uint8Array) || nonce.length !== NONCE_LENGTH) {
|
|
146
146
|
throw new Error(`e2e-frame: nonce must be a ${NONCE_LENGTH}-byte Uint8Array`);
|
|
147
147
|
}
|
|
148
|
+
/*
|
|
149
|
+
* The role tag is REQUIRED, and a caller that omits it gets an error rather
|
|
150
|
+
* than a skipped check.
|
|
151
|
+
*
|
|
152
|
+
* The tag exists so the two directions of one session are structurally
|
|
153
|
+
* distinguishable -- it is the reason a colliding nonce is impossible when
|
|
154
|
+
* both directions share one AES-GCM key. WshSession computed the peer's
|
|
155
|
+
* expected tag and stored it, and nothing ever read it: `grep -rn
|
|
156
|
+
* e2eRecvRoleTag` returned the declaration and the assignment and no third
|
|
157
|
+
* line. So the receiver accepted a frame sealed with its OWN tag, and the
|
|
158
|
+
* relay this layer exists to distrust could echo a peer's sealed frame
|
|
159
|
+
* straight back at it. Both counters start at zero, so the first frame of a
|
|
160
|
+
* session reflects cleanly.
|
|
161
|
+
*
|
|
162
|
+
* Optional would have reproduced the defect: a security check nobody is
|
|
163
|
+
* forced to pass is the same as no check, and this file already had one --
|
|
164
|
+
* see TimestampProof.verify's ignored verifyFn for the same shape.
|
|
165
|
+
*/
|
|
166
|
+
if (!(expectedRoleTag instanceof Uint8Array) || expectedRoleTag.length !== ROLE_TAG_LENGTH) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`e2e-frame: openFrame requires expectedRoleTag, a ${ROLE_TAG_LENGTH}-byte Uint8Array`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const actualRoleTag = nonce.subarray(COUNTER_LENGTH);
|
|
172
|
+
for (let i = 0; i < ROLE_TAG_LENGTH; i += 1) {
|
|
173
|
+
if (actualRoleTag[i] !== expectedRoleTag[i]) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
'e2e-frame: role tag mismatch -- this frame was sealed by the wrong side of the session'
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
148
179
|
const actualCounter = Number(new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength).getBigUint64(0, false));
|
|
149
180
|
if (actualCounter !== expectedCounter) {
|
|
150
181
|
throw new Error(
|
package/src/file-transfer.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { MSG, open, CHANNEL_KIND } from './messages.mjs';
|
|
11
|
+
import { waitForControlMessage } from './control-listener.mjs';
|
|
11
12
|
|
|
12
13
|
/** Default timeout for waiting on control messages (30 seconds). */
|
|
13
14
|
const RESPONSE_TIMEOUT_MS = 30_000;
|
|
@@ -30,6 +31,10 @@ export class WshFileTransfer {
|
|
|
30
31
|
* - openStream(): open a new bidirectional stream
|
|
31
32
|
* - onControl: settable callback for incoming control messages
|
|
32
33
|
* (or a method to add a listener — see _waitForMessage)
|
|
34
|
+
*
|
|
35
|
+
* Only `list()` needs the control-channel members, so they are checked
|
|
36
|
+
* there rather than here: uploading and downloading through a client
|
|
37
|
+
* that has `upload()`/`download()` and nothing else stays valid.
|
|
33
38
|
*/
|
|
34
39
|
constructor(client) {
|
|
35
40
|
if (!client) throw new Error('WshFileTransfer requires a client');
|
|
@@ -104,6 +109,11 @@ export class WshFileTransfer {
|
|
|
104
109
|
if (!remotePath || typeof remotePath !== 'string') {
|
|
105
110
|
throw new Error('remotePath is required');
|
|
106
111
|
}
|
|
112
|
+
if (typeof this.#client.sendControl !== 'function' || typeof this.#client.openStream !== 'function') {
|
|
113
|
+
throw new TypeError(
|
|
114
|
+
'WshFileTransfer.list() requires a client exposing sendControl() and openStream() (e.g. WshClient)'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
107
117
|
|
|
108
118
|
// Use an exec channel to run ls
|
|
109
119
|
const openMsg = open({
|
|
@@ -154,51 +164,7 @@ export class WshFileTransfer {
|
|
|
154
164
|
* @returns {Promise<object>}
|
|
155
165
|
*/
|
|
156
166
|
_waitForMessage(predicate, timeoutMs) {
|
|
157
|
-
return
|
|
158
|
-
let timer = null;
|
|
159
|
-
let settled = false;
|
|
160
|
-
|
|
161
|
-
const cleanup = () => {
|
|
162
|
-
settled = true;
|
|
163
|
-
if (timer !== null) clearTimeout(timer);
|
|
164
|
-
// Remove listener
|
|
165
|
-
if (this.#client.removeControlListener) {
|
|
166
|
-
this.#client.removeControlListener(listener);
|
|
167
|
-
} else if (this.#client._controlListeners) {
|
|
168
|
-
const idx = this.#client._controlListeners.indexOf(listener);
|
|
169
|
-
if (idx !== -1) this.#client._controlListeners.splice(idx, 1);
|
|
170
|
-
}
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
const listener = (msg) => {
|
|
174
|
-
if (settled) return;
|
|
175
|
-
if (predicate(msg)) {
|
|
176
|
-
cleanup();
|
|
177
|
-
resolve(msg);
|
|
178
|
-
}
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
// Register listener on the client
|
|
182
|
-
if (this.#client.addControlListener) {
|
|
183
|
-
this.#client.addControlListener(listener);
|
|
184
|
-
} else if (this.#client._controlListeners) {
|
|
185
|
-
this.#client._controlListeners.push(listener);
|
|
186
|
-
} else {
|
|
187
|
-
// Fallback: wrap existing onControl
|
|
188
|
-
const prev = this.#client.onControl;
|
|
189
|
-
this.#client.onControl = (msg) => {
|
|
190
|
-
prev?.(msg);
|
|
191
|
-
listener(msg);
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
timer = setTimeout(() => {
|
|
196
|
-
if (!settled) {
|
|
197
|
-
cleanup();
|
|
198
|
-
reject(new Error(`Timeout waiting for response (${timeoutMs}ms)`));
|
|
199
|
-
}
|
|
200
|
-
}, timeoutMs);
|
|
201
|
-
});
|
|
167
|
+
return waitForControlMessage(this.#client, predicate, timeoutMs, 'File transfer response');
|
|
202
168
|
}
|
|
203
169
|
|
|
204
170
|
/**
|
package/src/index.d.ts
CHANGED
|
@@ -228,6 +228,16 @@ export const AUTH_METHOD: {
|
|
|
228
228
|
/** Protocol version string. */
|
|
229
229
|
export const PROTOCOL_VERSION: 'wsh-v1';
|
|
230
230
|
|
|
231
|
+
/**
|
|
232
|
+
* ServerHello feature advertising that McpResult echoes McpCall's call_id.
|
|
233
|
+
*
|
|
234
|
+
* Compare against `WshClient.hasFeature()` before setting `callId`:
|
|
235
|
+
* McpCallPayload is `deny_unknown_fields` server-side, so sending the field
|
|
236
|
+
* to a server predating it makes the server reject the call rather than
|
|
237
|
+
* ignore the field.
|
|
238
|
+
*/
|
|
239
|
+
export const MCP_CALL_ID_FEATURE: 'mcp-call-id';
|
|
240
|
+
|
|
231
241
|
/** A wsh protocol control message (all messages have a numeric `type`). */
|
|
232
242
|
export interface WshMessage {
|
|
233
243
|
type: number;
|
|
@@ -396,10 +406,14 @@ export function mcpTools(opts?: {
|
|
|
396
406
|
export function mcpCall(opts?: {
|
|
397
407
|
tool?: string;
|
|
398
408
|
arguments?: Record<string, unknown>;
|
|
409
|
+
/** Correlates this call with its McpResult. Omitted from the message entirely when undefined. */
|
|
410
|
+
callId?: string;
|
|
399
411
|
}): WshMessage;
|
|
400
412
|
|
|
401
413
|
export function mcpResult(opts?: {
|
|
402
414
|
result?: unknown;
|
|
415
|
+
/** Echoed verbatim from the McpCall this answers. */
|
|
416
|
+
callId?: string;
|
|
403
417
|
}): WshMessage;
|
|
404
418
|
|
|
405
419
|
// -- Message constructors (reverse) --
|
|
@@ -817,7 +831,19 @@ export function isValidMessage(msg: unknown): boolean;
|
|
|
817
831
|
// ============================================================================
|
|
818
832
|
|
|
819
833
|
/**
|
|
820
|
-
*
|
|
834
|
+
* Whether this runtime's WebCrypto implements Ed25519, measured by
|
|
835
|
+
* generating a key rather than sniffed from a version string.
|
|
836
|
+
*
|
|
837
|
+
* There is no pure-JS fallback: one would need the private scalar as
|
|
838
|
+
* ordinary bytes, giving up the non-extractable `CryptoKey` property
|
|
839
|
+
* `WshKeyStore` is built around. Check this before committing to pubkey
|
|
840
|
+
* auth and fall back to password auth where it returns false.
|
|
841
|
+
*/
|
|
842
|
+
export function isEd25519Supported(): Promise<boolean>;
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Generate a new Ed25519 key pair. Throws with an actionable message
|
|
846
|
+
* where WebCrypto has no Ed25519 -- see `isEd25519Supported()`.
|
|
821
847
|
* @param extractable - Whether private key can be exported (default false)
|
|
822
848
|
*/
|
|
823
849
|
export function generateKeyPair(extractable?: boolean): Promise<CryptoKeyPair>;
|
|
@@ -1017,7 +1043,7 @@ export class WshTransport {
|
|
|
1017
1043
|
readonly state: 'disconnected' | 'connecting' | 'connected' | 'closed';
|
|
1018
1044
|
|
|
1019
1045
|
/** Connect to a wsh server. */
|
|
1020
|
-
connect(url: string): Promise<void>;
|
|
1046
|
+
connect(url: string, options?: object): Promise<void>;
|
|
1021
1047
|
|
|
1022
1048
|
/** Gracefully close the transport. */
|
|
1023
1049
|
close(): Promise<void>;
|
|
@@ -1044,7 +1070,7 @@ export class WshTransport {
|
|
|
1044
1070
|
protected _emitError(err: Error): void;
|
|
1045
1071
|
|
|
1046
1072
|
/** @protected Must be overridden by subclasses. */
|
|
1047
|
-
protected _doConnect(url: string): Promise<void>;
|
|
1073
|
+
protected _doConnect(url: string, options?: object): Promise<void>;
|
|
1048
1074
|
|
|
1049
1075
|
/** @protected Must be overridden by subclasses. */
|
|
1050
1076
|
protected _doClose(): Promise<void>;
|
|
@@ -1064,7 +1090,65 @@ export class WshTransport {
|
|
|
1064
1090
|
* - Subsequent streams carry raw byte data (no framing).
|
|
1065
1091
|
* - Server-initiated streams are surfaced via onStreamOpen.
|
|
1066
1092
|
*/
|
|
1067
|
-
export class WebTransportTransport extends WshTransport {
|
|
1093
|
+
export class WebTransportTransport extends WshTransport {
|
|
1094
|
+
/**
|
|
1095
|
+
* @param options Forwarded to the `WebTransport` constructor. Options
|
|
1096
|
+
* passed to `connect()` override these per call.
|
|
1097
|
+
*/
|
|
1098
|
+
constructor(options?: WshWebTransportOptions);
|
|
1099
|
+
|
|
1100
|
+
connect(url: string, options?: WshWebTransportOptions): Promise<void>;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* One entry of `serverCertificateHashes`. The digest may be given as raw
|
|
1105
|
+
* bytes, as hex (with or without `:` separators -- what
|
|
1106
|
+
* `openssl x509 -fingerprint -sha256` prints), or as base64/base64url.
|
|
1107
|
+
*/
|
|
1108
|
+
export interface WshCertificateHash {
|
|
1109
|
+
/** Defaults to `'sha-256'`, the only algorithm the platform accepts today. */
|
|
1110
|
+
algorithm?: string;
|
|
1111
|
+
value: BufferSource | string;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Options forwarded to the `WebTransport` constructor.
|
|
1116
|
+
*
|
|
1117
|
+
* `serverCertificateHashes` is the interesting one: it pins a specific
|
|
1118
|
+
* self-signed certificate by digest, which is the only mechanism in the
|
|
1119
|
+
* web platform that lets page JavaScript reach a server whose certificate
|
|
1120
|
+
* no certificate authority signed. The platform's own constraints apply --
|
|
1121
|
+
* `https:` URL, HTTP/3 only, ECDSA P-256 key, at most 14 days validity,
|
|
1122
|
+
* no connection pooling.
|
|
1123
|
+
*
|
|
1124
|
+
* Every other key is forwarded verbatim, so platform options not listed
|
|
1125
|
+
* here still work.
|
|
1126
|
+
*/
|
|
1127
|
+
export interface WshWebTransportOptions {
|
|
1128
|
+
serverCertificateHashes?: Array<WshCertificateHash | BufferSource | string>;
|
|
1129
|
+
allowPooling?: boolean;
|
|
1130
|
+
requireUnreliable?: boolean;
|
|
1131
|
+
congestionControl?: 'default' | 'throughput' | 'low-latency';
|
|
1132
|
+
anticipatedConcurrentIncomingUnidirectionalStreams?: number | null;
|
|
1133
|
+
anticipatedConcurrentIncomingBidirectionalStreams?: number | null;
|
|
1134
|
+
protocols?: string[];
|
|
1135
|
+
[key: string]: unknown;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Decode one `serverCertificateHashes` digest into bytes. Accepts a
|
|
1140
|
+
* `BufferSource`, hex (separators and an `... Fingerprint=` prefix are
|
|
1141
|
+
* stripped), or base64/base64url. Throws a `TypeError` on an
|
|
1142
|
+
* unrecognised shape and a `RangeError` on a digest of the wrong length
|
|
1143
|
+
* for the algorithm.
|
|
1144
|
+
*/
|
|
1145
|
+
export function parseCertificateHash(value: BufferSource | string, algorithm?: string): Uint8Array;
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Normalise a caller-supplied options bag into a `WebTransportOptions`
|
|
1149
|
+
* dictionary, or `undefined` when there is nothing to pass.
|
|
1150
|
+
*/
|
|
1151
|
+
export function normalizeWebTransportOptions(options?: WshWebTransportOptions): object | undefined;
|
|
1068
1152
|
|
|
1069
1153
|
/**
|
|
1070
1154
|
* Dispatch a fixed, already-available batch of items one at a time,
|
|
@@ -1307,8 +1391,21 @@ export class WshSession {
|
|
|
1307
1391
|
/** Called when the remote process exits. */
|
|
1308
1392
|
onExit: ((code: number) => void) | null;
|
|
1309
1393
|
|
|
1310
|
-
/**
|
|
1311
|
-
|
|
1394
|
+
/**
|
|
1395
|
+
* Called when the session is fully closed.
|
|
1396
|
+
*
|
|
1397
|
+
* Receives the same value as `closeReason`: an Error for a torn-down
|
|
1398
|
+
* session, null for an ordinary close.
|
|
1399
|
+
*/
|
|
1400
|
+
onClose: ((reason: Error | null) => void) | null;
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Why the session closed, or null for an ordinary close.
|
|
1404
|
+
*
|
|
1405
|
+
* A torn-down session and a clean exit are otherwise indistinguishable
|
|
1406
|
+
* from the outside.
|
|
1407
|
+
*/
|
|
1408
|
+
readonly closeReason: Error | null;
|
|
1312
1409
|
|
|
1313
1410
|
/** Called when the remote peer acknowledges speculative local echo. */
|
|
1314
1411
|
onEchoAck: ((payload: Record<string, unknown>) => void) | null;
|
|
@@ -1427,6 +1524,12 @@ export interface WshConnectOptions {
|
|
|
1427
1524
|
keyPair?: CryptoKeyPair;
|
|
1428
1525
|
password?: string;
|
|
1429
1526
|
transport?: 'wt' | 'ws';
|
|
1527
|
+
/**
|
|
1528
|
+
* Forwarded to the `WebTransport` constructor when the WebTransport
|
|
1529
|
+
* rung of the transport ladder is tried; ignored by the WebSocket rung,
|
|
1530
|
+
* which has no `serverCertificateHashes` equivalent.
|
|
1531
|
+
*/
|
|
1532
|
+
webTransport?: WshWebTransportOptions;
|
|
1430
1533
|
timeout?: number;
|
|
1431
1534
|
}
|
|
1432
1535
|
|
|
@@ -1466,6 +1569,8 @@ export interface WshConnectReverseOptions {
|
|
|
1466
1569
|
username: string;
|
|
1467
1570
|
keyPair?: CryptoKeyPair;
|
|
1468
1571
|
password?: string;
|
|
1572
|
+
/** Forwarded to the underlying `connect()` -- see `WshConnectOptions`. */
|
|
1573
|
+
webTransport?: WshWebTransportOptions;
|
|
1469
1574
|
expose?: {
|
|
1470
1575
|
shell?: boolean;
|
|
1471
1576
|
exec?: boolean;
|
|
@@ -1697,6 +1802,31 @@ export class WshClient {
|
|
|
1697
1802
|
*/
|
|
1698
1803
|
sendRelayControl(msg: WshMessage): Promise<void>;
|
|
1699
1804
|
|
|
1805
|
+
/**
|
|
1806
|
+
* Send a raw control message on the authenticated connection.
|
|
1807
|
+
*
|
|
1808
|
+
* Prefer the typed methods. This is the hook `WshMcpBridge` and
|
|
1809
|
+
* `WshFileTransfer` compose their own messages through.
|
|
1810
|
+
*/
|
|
1811
|
+
sendControl(msg: WshMessage): Promise<void>;
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Open a raw bidirectional stream on the underlying transport.
|
|
1815
|
+
*/
|
|
1816
|
+
openStream(): Promise<WshStream>;
|
|
1817
|
+
|
|
1818
|
+
/**
|
|
1819
|
+
* Observe every inbound control message this client handles. Listeners
|
|
1820
|
+
* are called past the RelayForward trust gate, so a message from an
|
|
1821
|
+
* unaccepted peer never reaches one.
|
|
1822
|
+
*/
|
|
1823
|
+
addControlListener(fn: (msg: WshMessage) => void): void;
|
|
1824
|
+
|
|
1825
|
+
/**
|
|
1826
|
+
* Stop observing inbound control messages.
|
|
1827
|
+
*/
|
|
1828
|
+
removeControlListener(fn: (msg: WshMessage) => void): void;
|
|
1829
|
+
|
|
1700
1830
|
/**
|
|
1701
1831
|
* Initiate end-to-end encryption for a session: `'X25519'` (default,
|
|
1702
1832
|
* classical ECDH) or `'X25519+ML-KEM-768'` (hybrid, with automatic
|
package/src/index.mjs
CHANGED
|
@@ -42,6 +42,7 @@ export {
|
|
|
42
42
|
|
|
43
43
|
// Auth + crypto
|
|
44
44
|
export {
|
|
45
|
+
isEd25519Supported,
|
|
45
46
|
generateKeyPair, exportPublicKeyRaw, exportPublicKeySSH,
|
|
46
47
|
importPublicKeyRaw, exportPrivateKeyPKCS8, importPrivateKeyPKCS8,
|
|
47
48
|
sign, verify, buildTranscript, signChallenge, verifyChallenge,
|
|
@@ -51,7 +52,10 @@ export {
|
|
|
51
52
|
} from './auth.mjs';
|
|
52
53
|
|
|
53
54
|
// Transport
|
|
54
|
-
export {
|
|
55
|
+
export {
|
|
56
|
+
WshTransport, WebTransportTransport, dispatchSerially, SerialQueue,
|
|
57
|
+
parseCertificateHash, normalizeWebTransportOptions,
|
|
58
|
+
} from './transport.mjs';
|
|
55
59
|
export { WebSocketTransport, WS_FRAME_TYPE } from './transport-ws.mjs';
|
|
56
60
|
|
|
57
61
|
// QMux (QUIC-v1 frames over an ordered byte stream, draft-ietf-quic-qmux-02)
|
|
@@ -68,7 +72,7 @@ export {
|
|
|
68
72
|
// Session + Client
|
|
69
73
|
export { WshSession } from './session.mjs';
|
|
70
74
|
export { WshVirtualSessionBackend, normalizeSessionData } from './virtual-session.mjs';
|
|
71
|
-
export { WshClient } from './client.mjs';
|
|
75
|
+
export { WshClient, MCP_CALL_ID_FEATURE } from './client.mjs';
|
|
72
76
|
|
|
73
77
|
// Key storage
|
|
74
78
|
export { WshKeyStore } from './keystore.mjs';
|
package/src/mcp-bridge.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
*
|
|
175
|
-
*
|
|
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
|
|
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
|
}
|
package/src/messages.gen.mjs
CHANGED
|
@@ -445,15 +445,16 @@ export function mcpTools({ tools } = {}) {
|
|
|
445
445
|
};
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
-
export function mcpCall({ tool, arguments: args } = {}) {
|
|
449
|
-
|
|
448
|
+
export function mcpCall({ tool, arguments: args, callId } = {}) {
|
|
449
|
+
const m = { type: MSG.MCP_CALL, tool, arguments: args };
|
|
450
|
+
if (callId !== undefined) m.call_id = callId;
|
|
451
|
+
return m;
|
|
450
452
|
}
|
|
451
453
|
|
|
452
|
-
export function mcpResult({ result } = {}) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
};
|
|
454
|
+
export function mcpResult({ result, callId } = {}) {
|
|
455
|
+
const msg = { type: MSG.MCP_RESULT, result };
|
|
456
|
+
if (callId !== undefined) msg.call_id = callId;
|
|
457
|
+
return msg;
|
|
457
458
|
}
|
|
458
459
|
|
|
459
460
|
export function reverseRegister({ username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false, publicKey, seq, recordSignature } = {}) {
|