@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/README.md +349 -28
- package/package.json +16 -3
- package/src/auth.mjs +63 -1
- package/src/client.mjs +379 -35
- package/src/control-listener.mjs +130 -0
- package/src/e2e-frame.mjs +32 -1
- package/src/file-transfer.mjs +47 -192
- package/src/index.d.ts +261 -8
- package/src/index.mjs +10 -2
- package/src/known-hosts.mjs +139 -0
- package/src/mcp-bridge.mjs +70 -51
- package/src/messages.gen.mjs +30 -16
- package/src/mlkem.mjs +27 -0
- package/src/session.mjs +73 -21
- package/src/transport-ws.mjs +16 -2
- package/src/transport.mjs +201 -7
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}
|
|
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
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
|
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
|
-
|
|
1870
|
-
|
|
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
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|