@johnhenry/wsh 0.0.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/src/client.mjs ADDED
@@ -0,0 +1,1842 @@
1
+ /**
2
+ * WshClient — manages a wsh connection, authentication, and multiple sessions.
3
+ *
4
+ * Handles the full lifecycle: transport selection, handshake, challenge-response
5
+ * or password auth, channel multiplexing, ping/pong keepalive, and teardown.
6
+ *
7
+ * Supports forward connections (client opens sessions on a remote server) and
8
+ * reverse mode (client registers as a peer for incoming connections).
9
+ */
10
+
11
+ import { WebTransportTransport } from './transport.mjs';
12
+ import { WebSocketTransport } from './transport-ws.mjs';
13
+ import {
14
+ MSG, AUTH_METHOD,
15
+ hello, auth as authMsg, open as openMsg, close as closeMsg,
16
+ attach as attachMsg, resume as resumeMsg, ping as pingMsg, pong as pongMsg,
17
+ reverseRegister as reverseRegisterMsg, reverseList as reverseListMsg,
18
+ reverseConnect as reverseConnectMsg,
19
+ mcpDiscover as mcpDiscoverMsg, mcpCall as mcpCallMsg,
20
+ suspendSession as suspendSessionMsg, restartPty as restartPtyMsg,
21
+ metricsRequest as metricsRequestMsg,
22
+ guestInvite as guestInviteMsg, guestJoin as guestJoinMsg, guestRevoke as guestRevokeMsg,
23
+ shareSession as shareSessionMsg, shareRevoke as shareRevokeMsg,
24
+ compressBegin as compressBeginMsg, compressAck as compressAckMsg,
25
+ rateControl as rateControlMsg,
26
+ sessionLink as sessionLinkMsg, sessionUnlink as sessionUnlinkMsg,
27
+ copilotAttach as copilotAttachMsg, copilotSuggest as copilotSuggestMsg,
28
+ copilotDetach as copilotDetachMsg,
29
+ keyExchange as keyExchangeMsg,
30
+ fileOp as fileOpMsg,
31
+ policyEval as policyEvalMsg, policyUpdate as policyUpdateMsg,
32
+ } from './messages.mjs';
33
+ import { signChallenge, exportPublicKeyRaw } from './auth.mjs';
34
+ import { WshSession } from './session.mjs';
35
+
36
+ // ── Client states ─────────────────────────────────────────────────────
37
+
38
+ const STATE_DISCONNECTED = 'disconnected';
39
+ const STATE_CONNECTING = 'connecting';
40
+ const STATE_CONNECTED = 'connected';
41
+ const STATE_AUTHENTICATED = 'authenticated';
42
+ const STATE_CLOSED = 'closed';
43
+
44
+ // ── Defaults ──────────────────────────────────────────────────────────
45
+
46
+ const DEFAULT_AUTH_TIMEOUT = 10_000; // ms
47
+ const DEFAULT_OPEN_TIMEOUT = 10_000; // ms
48
+ const DEFAULT_PING_INTERVAL = 30_000; // ms
49
+ const DEFAULT_EXEC_TIMEOUT = 60_000; // ms
50
+ const FILE_CHUNK_SIZE = 65_536;
51
+
52
+ const textEncoder = new TextEncoder();
53
+
54
+ function buildUploadHeader(path, totalSize) {
55
+ const pathBytes = textEncoder.encode(path);
56
+ const header = new Uint8Array(4 + pathBytes.length + 8);
57
+ const view = new DataView(header.buffer);
58
+ view.setUint32(0, pathBytes.length);
59
+ header.set(pathBytes, 4);
60
+ writeU64(header, 4 + pathBytes.length, totalSize);
61
+ return header;
62
+ }
63
+
64
+ function buildDownloadHeader(path) {
65
+ const pathBytes = textEncoder.encode(path);
66
+ const header = new Uint8Array(4 + pathBytes.length);
67
+ const view = new DataView(header.buffer);
68
+ view.setUint32(0, pathBytes.length);
69
+ header.set(pathBytes, 4);
70
+ return header;
71
+ }
72
+
73
+ function writeU64(buffer, offset, value) {
74
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
75
+ const big = BigInt(value);
76
+ view.setBigUint64(offset, big);
77
+ }
78
+
79
+ function decodeU64(bytes) {
80
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
81
+ return Number(view.getBigUint64(0));
82
+ }
83
+
84
+ function createSessionReader(session) {
85
+ let remainder = new Uint8Array();
86
+ return {
87
+ async readExact(totalBytes) {
88
+ const output = new Uint8Array(totalBytes);
89
+ let offset = 0;
90
+
91
+ while (offset < totalBytes) {
92
+ if (remainder.byteLength === 0) {
93
+ const chunk = await session.read();
94
+ if (chunk === null) {
95
+ return null;
96
+ }
97
+ remainder = chunk;
98
+ }
99
+
100
+ const take = Math.min(totalBytes - offset, remainder.byteLength);
101
+ output.set(remainder.subarray(0, take), offset);
102
+ offset += take;
103
+ remainder = remainder.subarray(take);
104
+ }
105
+
106
+ return output;
107
+ },
108
+ async readChunk() {
109
+ if (remainder.byteLength > 0) {
110
+ const chunk = remainder;
111
+ remainder = new Uint8Array();
112
+ return chunk;
113
+ }
114
+ return await session.read();
115
+ },
116
+ };
117
+ }
118
+
119
+ // ── Client class ──────────────────────────────────────────────────────
120
+
121
+ export class WshClient {
122
+
123
+ /** @type {'disconnected'|'connecting'|'connected'|'authenticated'|'closed'} */
124
+ #state = STATE_DISCONNECTED;
125
+
126
+ /** @type {string|null} Session ID assigned by the server after authentication. */
127
+ #sessionId = null;
128
+
129
+ /** @type {string|null} Resume token from AUTH_OK. */
130
+ #resumeToken = null;
131
+
132
+ /** @type {import('./transport.mjs').WshTransport|null} Active transport. */
133
+ #transport = null;
134
+
135
+ /** @type {{ wt: () => import('./transport.mjs').WshTransport, ws: () => import('./transport.mjs').WshTransport }} */
136
+ #transportFactories;
137
+
138
+ /** @type {Map<number, WshSession>} Active sessions keyed by channel ID. */
139
+ #sessions = new Map();
140
+
141
+ /** @type {number} Monotonically increasing channel ID counter. */
142
+ #channelCounter = 0;
143
+
144
+ /**
145
+ * Pending message waiters: Map<messageType, Array<{resolve, reject, timer}>>
146
+ * Multiple waiters can exist for the same message type.
147
+ */
148
+ #waiters = new Map();
149
+
150
+ /** @type {string[]} Server-advertised features from SERVER_HELLO. */
151
+ #serverFeatures = [];
152
+
153
+ /** @type {number|null} Ping interval handle. */
154
+ #pingTimer = null;
155
+
156
+ /** @type {number} Current ping ID for matching pongs. */
157
+ #pingId = 0;
158
+
159
+ /** @type {number|null} Timestamp of last pong received. */
160
+ #lastPong = null;
161
+
162
+ constructor({ transportFactories } = {}) {
163
+ this.#transportFactories = transportFactories || {
164
+ wt: () => new WebTransportTransport(),
165
+ ws: () => new WebSocketTransport(),
166
+ };
167
+ }
168
+
169
+ // ── Callbacks ───────────────────────────────────────────────────────
170
+
171
+ /** Called when the connection is closed (intentionally or otherwise). */
172
+ onClose = null;
173
+
174
+ /** Called on connection-level errors. */
175
+ onError = null;
176
+
177
+ /**
178
+ * Called when a reverse-connect request arrives (reverse mode only).
179
+ * @type {function(object): void|null}
180
+ */
181
+ onReverseConnect = null;
182
+
183
+ /**
184
+ * Called when a clipboard sync message arrives (OSC 52).
185
+ * The default handler writes to navigator.clipboard automatically.
186
+ * @type {function(object): void|null}
187
+ */
188
+ onClipboard = null;
189
+
190
+ /**
191
+ * Called when a relay-forwarded message arrives from a remote peer.
192
+ *
193
+ * In reverse mode, the relay bridge forwards messages from the CLI peer
194
+ * to this browser client. Messages that the client would not normally
195
+ * receive as a peer (Open, McpCall, McpDiscover, Close, Resize, Signal)
196
+ * are routed here instead of being silently dropped.
197
+ *
198
+ * @type {function(object): void|null}
199
+ */
200
+ onRelayMessage = null;
201
+
202
+ /**
203
+ * Called when a rate warning message arrives from the server.
204
+ * @type {function(object): void|null}
205
+ */
206
+ onRateWarning = null;
207
+
208
+ /**
209
+ * Called when a copilot suggestion arrives from an attached copilot.
210
+ * @type {function(object): void|null}
211
+ */
212
+ onCopilotSuggest = null;
213
+
214
+ /**
215
+ * Called when a key exchange message arrives from a peer.
216
+ * @type {function(object): void|null}
217
+ */
218
+ onKeyExchange = null;
219
+
220
+ /**
221
+ * Called when a gateway-subsystem control message arrives (opcodes 0x70-0x7f).
222
+ *
223
+ * The gateway subsystem proxies TCP/UDP connections and DNS lookups through
224
+ * the server. This callback receives every gateway message that is not
225
+ * consumed by an active waiter (e.g. a pending GatewayOk/GatewayFail for
226
+ * an in-flight request).
227
+ *
228
+ * Typical use: wire this to the netway GatewayBackend so it can route
229
+ * GatewayOk, GatewayFail, GatewayClose, DnsResult, InboundOpen, ListenOk,
230
+ * and ListenFail messages to the correct virtual sockets and listeners.
231
+ *
232
+ * @type {function(object): void|null}
233
+ * @param {object} msg - Decoded control message with at least:
234
+ * - `type` {number} — message opcode (0x70-0x7f)
235
+ * - `gateway_id` or `listener_id` {number} — correlator
236
+ * - Plus message-specific fields (see wsh-v1.yaml gateway section)
237
+ */
238
+ onGatewayMessage = null;
239
+
240
+ // ── Public properties ───────────────────────────────────────────────
241
+
242
+ /** Current client state. */
243
+ get state() {
244
+ return this.#state;
245
+ }
246
+
247
+ /** Server-assigned session ID. */
248
+ get sessionId() {
249
+ return this.#sessionId;
250
+ }
251
+
252
+ /** Read-only view of active sessions. */
253
+ get sessions() {
254
+ return new Map(this.#sessions);
255
+ }
256
+
257
+ /** Server-advertised features from SERVER_HELLO. */
258
+ get features() {
259
+ return [...this.#serverFeatures];
260
+ }
261
+
262
+ /**
263
+ * Low-level transport reference.
264
+ * Exposed for relay message replies (IncomingSession._sendReply).
265
+ * Prefer higher-level methods (openSession, callTool, etc.) for normal use.
266
+ * @returns {import('./transport.mjs').WshTransport|null}
267
+ */
268
+ get _transport() {
269
+ return this.#transport;
270
+ }
271
+
272
+ /**
273
+ * Check if the server advertised a specific feature.
274
+ * @param {string} name - Feature name (e.g. 'gateway', 'reverse', 'mcp')
275
+ * @returns {boolean}
276
+ */
277
+ hasFeature(name) {
278
+ return this.#serverFeatures.includes(name);
279
+ }
280
+
281
+ // ── Connection ──────────────────────────────────────────────────────
282
+
283
+ /**
284
+ * Connect to a wsh server, authenticate, and return the session ID.
285
+ *
286
+ * @param {string} url - Server URL (https:// for WebTransport, wss:// or ws:// for WebSocket)
287
+ * @param {object} opts
288
+ * @param {string} opts.username - Username for authentication
289
+ * @param {CryptoKeyPair} [opts.keyPair] - Ed25519 key pair for pubkey auth
290
+ * @param {string} [opts.password] - Password for password auth
291
+ * @param {'wt'|'ws'|'auto'} [opts.transport] - Force a specific transport
292
+ * @param {number} [opts.timeout] - Auth handshake timeout in ms
293
+ * @returns {Promise<string>} The server-assigned session ID
294
+ */
295
+ async connect(url, { username, keyPair, password, transport: transportHint, timeout = DEFAULT_AUTH_TIMEOUT } = {}) {
296
+ if (this.#state !== STATE_DISCONNECTED && this.#state !== STATE_CLOSED) {
297
+ throw new Error(`Client already ${this.#state}`);
298
+ }
299
+ if (!username) {
300
+ throw new Error('username is required');
301
+ }
302
+ if (!keyPair && !password) {
303
+ throw new Error('Either keyPair or password is required for authentication');
304
+ }
305
+
306
+ this.#state = STATE_CONNECTING;
307
+ this.#sessions.clear();
308
+ this.#channelCounter = 0;
309
+ this.#waiters.clear();
310
+ this.#sessionId = null;
311
+ this.#resumeToken = null;
312
+
313
+ try {
314
+ // ── Select and connect transport ──────────────────────────────
315
+ const transport = await this.#connectTransport(url, transportHint);
316
+ this.#transport = transport;
317
+ this.#state = STATE_CONNECTED;
318
+
319
+ // ── Auth handshake ────────────────────────────────────────────
320
+ const authMethod = keyPair ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
321
+ await transport.sendControl(
322
+ hello({ username, authMethod })
323
+ );
324
+
325
+ // Wait for SERVER_HELLO (which may include a session ID directly) or CHALLENGE.
326
+ const firstResponse = await this.#waitForMessage(
327
+ [MSG.SERVER_HELLO, MSG.CHALLENGE, MSG.AUTH_FAIL],
328
+ timeout,
329
+ 'Auth handshake timed out waiting for server response'
330
+ );
331
+
332
+ if (firstResponse.type === MSG.AUTH_FAIL) {
333
+ throw new Error(`Authentication failed: ${firstResponse.reason || 'unknown'}`);
334
+ }
335
+
336
+ let tempSessionId = null;
337
+
338
+ if (firstResponse.type === MSG.SERVER_HELLO) {
339
+ // Server may proceed directly to auth if it accepted the hello.
340
+ tempSessionId = firstResponse.session_id;
341
+ this.#serverFeatures = firstResponse.features || [];
342
+
343
+ // If pubkey auth, we still need a challenge.
344
+ if (authMethod === AUTH_METHOD.PUBKEY) {
345
+ const challengeMsg = await this.#waitForMessage(
346
+ [MSG.CHALLENGE, MSG.AUTH_OK],
347
+ timeout,
348
+ 'Auth handshake timed out waiting for challenge'
349
+ );
350
+
351
+ if (challengeMsg.type === MSG.AUTH_OK) {
352
+ // Server accepted without challenge (e.g. trusted key).
353
+ this.#sessionId = challengeMsg.session_id || tempSessionId;
354
+ this.#resumeToken = challengeMsg.token || null;
355
+ this.#state = STATE_AUTHENTICATED;
356
+ this.#startPing();
357
+ return this.#sessionId;
358
+ }
359
+
360
+ // Sign the challenge.
361
+ const { signature, publicKeyRaw } = await signChallenge(
362
+ keyPair.privateKey,
363
+ keyPair.publicKey,
364
+ tempSessionId,
365
+ challengeMsg.nonce
366
+ );
367
+
368
+ await transport.sendControl(
369
+ authMsg({
370
+ method: AUTH_METHOD.PUBKEY,
371
+ signature,
372
+ publicKey: publicKeyRaw,
373
+ })
374
+ );
375
+ } else {
376
+ // Password auth — send immediately after SERVER_HELLO.
377
+ await transport.sendControl(
378
+ authMsg({
379
+ method: AUTH_METHOD.PASSWORD,
380
+ password,
381
+ })
382
+ );
383
+ }
384
+ } else if (firstResponse.type === MSG.CHALLENGE) {
385
+ // Some servers skip SERVER_HELLO and go straight to CHALLENGE.
386
+ if (authMethod !== AUTH_METHOD.PUBKEY || !keyPair) {
387
+ throw new Error('Server sent CHALLENGE but no key pair was provided');
388
+ }
389
+
390
+ // We need a session ID for the transcript. Use the nonce-derived ID
391
+ // or a placeholder if the server hasn't provided one.
392
+ tempSessionId = tempSessionId || 'pending';
393
+
394
+ const { signature, publicKeyRaw } = await signChallenge(
395
+ keyPair.privateKey,
396
+ keyPair.publicKey,
397
+ tempSessionId,
398
+ firstResponse.nonce
399
+ );
400
+
401
+ await transport.sendControl(
402
+ authMsg({
403
+ method: AUTH_METHOD.PUBKEY,
404
+ signature,
405
+ publicKey: publicKeyRaw,
406
+ })
407
+ );
408
+ }
409
+
410
+ // Wait for AUTH_OK or AUTH_FAIL.
411
+ const authResult = await this.#waitForMessage(
412
+ [MSG.AUTH_OK, MSG.AUTH_FAIL],
413
+ timeout,
414
+ 'Auth handshake timed out waiting for auth result'
415
+ );
416
+
417
+ if (authResult.type === MSG.AUTH_FAIL) {
418
+ throw new Error(`Authentication failed: ${authResult.reason || 'rejected'}`);
419
+ }
420
+
421
+ this.#sessionId = authResult.session_id || tempSessionId;
422
+ this.#resumeToken = authResult.token || null;
423
+ this.#state = STATE_AUTHENTICATED;
424
+ this.#startPing();
425
+
426
+ return this.#sessionId;
427
+
428
+ } catch (err) {
429
+ // Clean up on failure.
430
+ this.#state = STATE_CLOSED;
431
+ await this.#transport?.close().catch(() => {});
432
+ this.#transport = null;
433
+ this.#rejectAllWaiters(err);
434
+ throw err;
435
+ }
436
+ }
437
+
438
+ // ── Session management ──────────────────────────────────────────────
439
+
440
+ /**
441
+ * Open a new PTY or exec session on the remote server.
442
+ *
443
+ * @param {object} opts
444
+ * @param {'pty'|'exec'} opts.type - Channel kind
445
+ * @param {string} [opts.command] - Command to execute (required for exec, optional for pty)
446
+ * @param {number} [opts.cols=80] - Initial terminal columns
447
+ * @param {number} [opts.rows=24] - Initial terminal rows
448
+ * @param {object} [opts.env] - Environment variables
449
+ * @param {number} [opts.timeout] - Timeout in ms
450
+ * @returns {Promise<WshSession>}
451
+ */
452
+ async openSession({ type = 'pty', command, cols = 80, rows = 24, env, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
453
+ this.#assertAuthenticated('openSession');
454
+ const requestedChannelId = this._nextChannelId();
455
+
456
+ await this.#transport.sendControl(
457
+ openMsg({ kind: type, command, cols, rows, env })
458
+ );
459
+
460
+ // Wait for OPEN_OK or OPEN_FAIL.
461
+ const response = await this.#waitForMessage(
462
+ [MSG.OPEN_OK, MSG.OPEN_FAIL],
463
+ timeout,
464
+ 'Timed out waiting for session open response'
465
+ );
466
+
467
+ if (response.type === MSG.OPEN_FAIL) {
468
+ throw new Error(`Failed to open session: ${response.reason || 'rejected'}`);
469
+ }
470
+
471
+ const serverChannelId = response.channel_id ?? requestedChannelId;
472
+ const streamIds = response.stream_ids ?? {};
473
+ const dataMode = response.data_mode === 'virtual' ? 'virtual' : 'stream';
474
+ const capabilities = Array.isArray(response.capabilities) ? response.capabilities : [];
475
+
476
+ // Create the session object.
477
+ const session = new WshSession(
478
+ this.#transport,
479
+ serverChannelId,
480
+ streamIds,
481
+ type,
482
+ { dataMode, capabilities }
483
+ );
484
+ this.#sessions.set(serverChannelId, session);
485
+
486
+ if (dataMode === 'virtual') {
487
+ session._activateVirtual((msg) => this.sendRelayControl(msg));
488
+ return session;
489
+ }
490
+
491
+ // Open the data stream and bind it to the session.
492
+ const stream = await this.#transport.openStream();
493
+ session._bind(stream.readable, stream.writable);
494
+
495
+ return session;
496
+ }
497
+
498
+ /**
499
+ * List locally tracked sessions with their current state.
500
+ * @returns {Array<{channelId: number, kind: string, state: string}>}
501
+ */
502
+ listSessions() {
503
+ const result = [];
504
+ for (const [channelId, session] of this.#sessions) {
505
+ result.push({
506
+ channelId,
507
+ kind: session.kind,
508
+ state: session.state,
509
+ });
510
+ }
511
+ return result;
512
+ }
513
+
514
+ /**
515
+ * Attach to an existing remote session (collaborative or read-only).
516
+ *
517
+ * @param {string} targetSessionId - Remote session ID to attach to
518
+ * @param {object} [opts]
519
+ * @param {boolean} [opts.readOnly=false] - Attach in read-only mode
520
+ * @param {number} [opts.timeout] - Timeout in ms
521
+ * @returns {Promise<object>} Server's response
522
+ */
523
+ async attachSession(targetSessionId, { readOnly = false, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
524
+ this.#assertAuthenticated('attachSession');
525
+
526
+ const mode = readOnly ? 'readonly' : 'control';
527
+ await this.#transport.sendControl(
528
+ attachMsg({ sessionId: targetSessionId, token: this.#resumeToken, mode })
529
+ );
530
+
531
+ const response = await this.#waitForMessage(
532
+ [MSG.OPEN_OK, MSG.OPEN_FAIL],
533
+ timeout,
534
+ 'Timed out waiting for attach response'
535
+ );
536
+
537
+ if (response.type === MSG.OPEN_FAIL) {
538
+ throw new Error(`Failed to attach: ${response.reason || 'rejected'}`);
539
+ }
540
+
541
+ return response;
542
+ }
543
+
544
+ /**
545
+ * Resume a previously disconnected session.
546
+ *
547
+ * @param {string} targetSessionId - Session ID to resume
548
+ * @param {string} token - Resume token from original AUTH_OK
549
+ * @param {number} [opts.timeout] - Timeout in ms
550
+ * @returns {Promise<object>} Server's response
551
+ */
552
+ async resumeSession(targetSessionId, token, { timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
553
+ this.#assertAuthenticated('resumeSession');
554
+
555
+ await this.#transport.sendControl(
556
+ resumeMsg({ sessionId: targetSessionId, token })
557
+ );
558
+
559
+ const response = await this.#waitForMessage(
560
+ [MSG.AUTH_OK, MSG.AUTH_FAIL],
561
+ timeout,
562
+ 'Timed out waiting for resume response'
563
+ );
564
+
565
+ if (response.type === MSG.AUTH_FAIL) {
566
+ throw new Error(`Failed to resume: ${response.reason || 'rejected'}`);
567
+ }
568
+
569
+ return response;
570
+ }
571
+
572
+ // ── Disconnect ──────────────────────────────────────────────────────
573
+
574
+ /**
575
+ * Gracefully disconnect: close all sessions and the transport.
576
+ */
577
+ async disconnect() {
578
+ if (this.#state === STATE_DISCONNECTED || this.#state === STATE_CLOSED) return;
579
+
580
+ this.#stopPing();
581
+ this.#state = STATE_CLOSED;
582
+
583
+ // Close all sessions concurrently.
584
+ const closePromises = [];
585
+ for (const session of this.#sessions.values()) {
586
+ closePromises.push(session.close().catch(() => {}));
587
+ }
588
+ await Promise.allSettled(closePromises);
589
+ this.#sessions.clear();
590
+
591
+ // Close the transport.
592
+ if (this.#transport) {
593
+ await this.#transport.close().catch(() => {});
594
+ this.#transport = null;
595
+ }
596
+
597
+ this.#rejectAllWaiters(new Error('Client disconnected'));
598
+ }
599
+
600
+ // ── Static one-shot exec ────────────────────────────────────────────
601
+
602
+ /**
603
+ * One-shot command execution: connect, authenticate, run a command,
604
+ * collect all output, disconnect, and return the result.
605
+ *
606
+ * @param {string} url - Server URL
607
+ * @param {string} command - Command to execute
608
+ * @param {object} opts
609
+ * @param {string} opts.username
610
+ * @param {CryptoKeyPair} [opts.keyPair]
611
+ * @param {string} [opts.password]
612
+ * @param {number} [opts.timeout=60000] - Overall timeout in ms
613
+ * @returns {Promise<{stdout: Uint8Array, exitCode: number}>}
614
+ */
615
+ static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT } = {}) {
616
+ const client = new WshClient();
617
+ const chunks = [];
618
+ let exitCode = -1;
619
+
620
+ try {
621
+ await client.connect(url, { username, keyPair, password });
622
+
623
+ const session = await client.openSession({ type: 'exec', command });
624
+
625
+ await new Promise((resolve, reject) => {
626
+ const timer = setTimeout(() => {
627
+ reject(new Error(`exec timed out after ${timeout}ms`));
628
+ }, timeout);
629
+
630
+ session.onData = (data) => {
631
+ chunks.push(data);
632
+ };
633
+
634
+ session.onExit = (code) => {
635
+ exitCode = code;
636
+ };
637
+
638
+ session.onClose = () => {
639
+ clearTimeout(timer);
640
+ resolve();
641
+ };
642
+ });
643
+
644
+ } finally {
645
+ await client.disconnect().catch(() => {});
646
+ }
647
+
648
+ // Concatenate output chunks.
649
+ const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
650
+ const stdout = new Uint8Array(totalLength);
651
+ let offset = 0;
652
+ for (const chunk of chunks) {
653
+ stdout.set(chunk, offset);
654
+ offset += chunk.byteLength;
655
+ }
656
+
657
+ return { stdout, exitCode };
658
+ }
659
+
660
+ // ── Reverse mode ────────────────────────────────────────────────────
661
+
662
+ /**
663
+ * Connect in reverse mode: register as a peer that can accept incoming
664
+ * connections from other clients.
665
+ *
666
+ * @param {string} url - Server URL
667
+ * @param {object} opts
668
+ * @param {string} opts.username
669
+ * @param {CryptoKeyPair} [opts.keyPair]
670
+ * @param {string} [opts.password]
671
+ * @param {object} [opts.expose] - Capabilities to expose { shell, exec, fs, tools }
672
+ * @param {string} [opts.peerType]
673
+ * @param {string} [opts.shellBackend]
674
+ * @param {boolean} [opts.supportsAttach]
675
+ * @param {boolean} [opts.supportsReplay]
676
+ * @param {boolean} [opts.supportsEcho]
677
+ * @param {boolean} [opts.supportsTermSync]
678
+ * @returns {Promise<string>} Session ID
679
+ */
680
+ async connectReverse(url, {
681
+ username,
682
+ keyPair,
683
+ password,
684
+ expose = {},
685
+ peerType = 'browser-shell',
686
+ shellBackend,
687
+ supportsAttach,
688
+ supportsReplay,
689
+ supportsEcho,
690
+ supportsTermSync,
691
+ } = {}) {
692
+ // Authenticate normally first.
693
+ const sessionId = await this.connect(url, { username, keyPair, password });
694
+
695
+ // Build capabilities list from expose options.
696
+ const capabilities = [];
697
+ if (expose.shell) capabilities.push('shell');
698
+ if (expose.exec) capabilities.push('exec');
699
+ if (expose.fs) capabilities.push('fs');
700
+ if (expose.tools) capabilities.push('tools');
701
+
702
+ // Export public key for peer identification.
703
+ let publicKey = null;
704
+ if (keyPair) {
705
+ publicKey = await exportPublicKeyRaw(keyPair.publicKey);
706
+ }
707
+
708
+ const effectiveShellBackend = shellBackend || (expose.shell ? 'virtual-shell' : 'exec-only');
709
+
710
+ // Register as a reverse peer.
711
+ await this.#transport.sendControl(
712
+ reverseRegisterMsg({
713
+ username,
714
+ capabilities,
715
+ peerType,
716
+ shellBackend: effectiveShellBackend,
717
+ supportsAttach: supportsAttach ?? effectiveShellBackend !== 'exec-only',
718
+ supportsReplay: supportsReplay ?? effectiveShellBackend !== 'exec-only',
719
+ supportsEcho: supportsEcho ?? effectiveShellBackend === 'virtual-shell',
720
+ supportsTermSync: supportsTermSync ?? effectiveShellBackend === 'virtual-shell',
721
+ publicKey,
722
+ })
723
+ );
724
+
725
+ return sessionId;
726
+ }
727
+
728
+ /**
729
+ * List peers registered on the relay server.
730
+ *
731
+ * @param {number} [timeout=10000] - Timeout in ms
732
+ * @returns {Promise<Array<{fingerprint_short: string, username: string, capabilities: string[], last_seen: number|null}>>}
733
+ */
734
+ async listPeers(timeout = DEFAULT_OPEN_TIMEOUT) {
735
+ this.#assertAuthenticated('listPeers');
736
+
737
+ await this.#transport.sendControl(reverseListMsg());
738
+
739
+ const response = await this.#waitForMessage(
740
+ [MSG.REVERSE_PEERS],
741
+ timeout,
742
+ 'Timed out waiting for peer list'
743
+ );
744
+
745
+ return response.peers || [];
746
+ }
747
+
748
+ /**
749
+ * Initiate a reverse connection to a registered peer.
750
+ *
751
+ * @param {string} targetFingerprint - Fingerprint (or prefix) of the target peer
752
+ * @param {number} [timeout=10000] - Timeout in ms
753
+ * @returns {Promise<void>}
754
+ */
755
+ async reverseConnectTo(targetFingerprint, timeout = DEFAULT_OPEN_TIMEOUT) {
756
+ this.#assertAuthenticated('reverseConnectTo');
757
+
758
+ const response = await this.reverseConnect(targetFingerprint, timeout);
759
+ return response;
760
+ }
761
+
762
+ /**
763
+ * Initiate a reverse connection and wait for accept/reject.
764
+ *
765
+ * @param {string} targetFingerprint
766
+ * @param {number} [timeout=10000]
767
+ * @returns {Promise<object>}
768
+ */
769
+ async reverseConnect(targetFingerprint, timeout = DEFAULT_OPEN_TIMEOUT) {
770
+ this.#assertAuthenticated('reverseConnect');
771
+
772
+ await this.#transport.sendControl(
773
+ reverseConnectMsg({ targetFingerprint, username: '' })
774
+ );
775
+
776
+ return this.#waitForMessage(
777
+ [MSG.REVERSE_ACCEPT, MSG.REVERSE_REJECT],
778
+ timeout,
779
+ 'Timed out waiting for reverse-connect response'
780
+ );
781
+ }
782
+
783
+ /**
784
+ * Send a control message over the authenticated relay connection.
785
+ *
786
+ * Browser reverse handlers use this for peer replies instead of touching
787
+ * the transport internals directly.
788
+ *
789
+ * @param {object} msg
790
+ * @returns {Promise<void>}
791
+ */
792
+ async sendRelayControl(msg) {
793
+ this.#assertAuthenticated('sendRelayControl');
794
+ await this.#transport.sendControl(msg);
795
+ }
796
+
797
+ // ── File transfer ───────────────────────────────────────────────────
798
+
799
+ /**
800
+ * Upload a blob to a remote path.
801
+ *
802
+ * Opens a file channel, writes the data, and waits for acknowledgment.
803
+ *
804
+ * @param {Blob|Uint8Array} blob - Data to upload
805
+ * @param {string} remotePath - Destination path on the server
806
+ * @param {object} [opts]
807
+ * @param {function(number): void} [opts.onProgress] - Progress callback (bytes sent)
808
+ */
809
+ async upload(blob, remotePath, { onProgress } = {}) {
810
+ this.#assertAuthenticated('upload');
811
+ const session = await this.openSession({ type: 'file', command: `upload:${remotePath}` });
812
+ const data = blob instanceof Blob
813
+ ? new Uint8Array(await blob.arrayBuffer())
814
+ : blob;
815
+ const total = data.byteLength;
816
+ const header = buildUploadHeader(remotePath, total);
817
+ let sent = 0;
818
+
819
+ try {
820
+ await session.write(header);
821
+ for (let i = 0; i < total; i += FILE_CHUNK_SIZE) {
822
+ const end = Math.min(i + FILE_CHUNK_SIZE, total);
823
+ await session.write(data.subarray(i, end));
824
+ sent = end;
825
+ onProgress?.(sent);
826
+ }
827
+
828
+ const ack = await session.read();
829
+ if (ack === null) {
830
+ throw new Error('Upload failed: unexpected EOF waiting for server acknowledgement');
831
+ }
832
+ } finally {
833
+ await session.close().catch(() => {});
834
+ }
835
+ }
836
+
837
+ /**
838
+ * Download a file from a remote path.
839
+ *
840
+ * @param {string} remotePath - Source path on the server
841
+ * @returns {Promise<Uint8Array>} File contents
842
+ */
843
+ async download(remotePath) {
844
+ this.#assertAuthenticated('download');
845
+ const session = await this.openSession({ type: 'file', command: `download:${remotePath}` });
846
+
847
+ try {
848
+ const reader = createSessionReader(session);
849
+ await session.write(buildDownloadHeader(remotePath));
850
+
851
+ const sizeChunk = await reader.readExact(8);
852
+ if (sizeChunk === null) {
853
+ throw new Error('Download failed: unexpected EOF reading file size');
854
+ }
855
+ const totalSize = decodeU64(sizeChunk);
856
+ const data = new Uint8Array(totalSize);
857
+ let offset = 0;
858
+
859
+ while (offset < totalSize) {
860
+ const chunk = await reader.readChunk();
861
+ if (chunk === null) {
862
+ throw new Error('Download failed: unexpected EOF reading file payload');
863
+ }
864
+ const remaining = totalSize - offset;
865
+ data.set(chunk.subarray(0, remaining), offset);
866
+ offset += Math.min(chunk.byteLength, remaining);
867
+ }
868
+
869
+ return data;
870
+ } finally {
871
+ await session.close().catch(() => {});
872
+ }
873
+ }
874
+
875
+ // ── MCP integration ─────────────────────────────────────────────────
876
+
877
+ /**
878
+ * Discover MCP tools available on the remote server.
879
+ *
880
+ * @param {number} [timeout=10000]
881
+ * @returns {Promise<Array>} Tool definitions
882
+ */
883
+ async discoverTools(timeout = DEFAULT_OPEN_TIMEOUT) {
884
+ this.#assertAuthenticated('discoverTools');
885
+
886
+ await this.#transport.sendControl(mcpDiscoverMsg());
887
+
888
+ const response = await this.#waitForMessage(
889
+ [MSG.MCP_TOOLS],
890
+ timeout,
891
+ 'Timed out waiting for MCP tool discovery response'
892
+ );
893
+
894
+ return response.tools || [];
895
+ }
896
+
897
+ /**
898
+ * Call an MCP tool on the remote server.
899
+ *
900
+ * @param {string} name - Tool name
901
+ * @param {object} args - Tool arguments
902
+ * @param {number} [timeout=30000]
903
+ * @returns {Promise<*>} Tool result
904
+ */
905
+ async callTool(name, args, timeout = 30_000) {
906
+ this.#assertAuthenticated('callTool');
907
+
908
+ await this.#transport.sendControl(
909
+ mcpCallMsg({ tool: name, arguments: args })
910
+ );
911
+
912
+ const response = await this.#waitForMessage(
913
+ [MSG.MCP_RESULT],
914
+ timeout,
915
+ `Timed out waiting for MCP tool result (${name})`
916
+ );
917
+
918
+ return response.result;
919
+ }
920
+
921
+ // ── Suspend / Restart ───────────────────────────────────────────────
922
+
923
+ /**
924
+ * Suspend a session on the server.
925
+ * @param {string} sessionId - Session to suspend
926
+ * @param {string} [action='suspend'] - Action: 'suspend' or 'hibernate'
927
+ */
928
+ async suspendSession(sessionId, action = 'suspend') {
929
+ this.#assertAuthenticated('suspendSession');
930
+ await this.#transport.sendControl(suspendSessionMsg({ sessionId, action }));
931
+ }
932
+
933
+ /**
934
+ * Restart the PTY process in a session.
935
+ * @param {string} sessionId - Session whose PTY to restart
936
+ * @param {string} [command] - Optional new command (defaults to original)
937
+ */
938
+ async restartPty(sessionId, command) {
939
+ this.#assertAuthenticated('restartPty');
940
+ await this.#transport.sendControl(restartPtyMsg({ sessionId, command }));
941
+ }
942
+
943
+ // ── Metrics ────────────────────────────────────────────────────────
944
+
945
+ /**
946
+ * Request server metrics (CPU, memory, sessions, RTT).
947
+ * @param {number} [timeout=10000]
948
+ * @returns {Promise<object>} Metrics response
949
+ */
950
+ async requestMetrics(timeout = DEFAULT_OPEN_TIMEOUT) {
951
+ this.#assertAuthenticated('requestMetrics');
952
+ await this.#transport.sendControl(metricsRequestMsg());
953
+ return this.#waitForMessage(
954
+ [MSG.METRICS],
955
+ timeout,
956
+ 'Timed out waiting for metrics'
957
+ );
958
+ }
959
+
960
+ // ── Guest Sessions ────────────────────────────────────────────────
961
+
962
+ /**
963
+ * Invite a guest to a session.
964
+ * @param {string} sessionId - Session to share
965
+ * @param {number} ttl - Invitation TTL in seconds
966
+ * @param {string[]} [permissions=['read']] - Guest permissions
967
+ * @param {number} [timeout=10000]
968
+ * @returns {Promise<object>} Invite response with token
969
+ */
970
+ async inviteGuest(sessionId, ttl, permissions = ['read'], timeout = DEFAULT_OPEN_TIMEOUT) {
971
+ this.#assertAuthenticated('inviteGuest');
972
+ await this.#transport.sendControl(guestInviteMsg({ sessionId, ttl, permissions }));
973
+ return this.#waitForMessage(
974
+ [MSG.GUEST_INVITE],
975
+ timeout,
976
+ 'Timed out waiting for guest invite confirmation'
977
+ );
978
+ }
979
+
980
+ /**
981
+ * Join a session as a guest.
982
+ * @param {string} token - Invitation token
983
+ * @param {string} [deviceLabel] - Device identifier
984
+ * @param {number} [timeout=10000]
985
+ * @returns {Promise<object>} Join response
986
+ */
987
+ async joinAsGuest(token, deviceLabel, timeout = DEFAULT_OPEN_TIMEOUT) {
988
+ this.#assertAuthenticated('joinAsGuest');
989
+ await this.#transport.sendControl(guestJoinMsg({ token, deviceLabel }));
990
+ return this.#waitForMessage(
991
+ [MSG.PRESENCE, MSG.AUTH_FAIL],
992
+ timeout,
993
+ 'Timed out waiting for guest join response'
994
+ );
995
+ }
996
+
997
+ /**
998
+ * Revoke a guest invitation.
999
+ * @param {string} token - Token to revoke
1000
+ * @param {string} [reason] - Reason for revocation
1001
+ */
1002
+ async revokeGuest(token, reason) {
1003
+ this.#assertAuthenticated('revokeGuest');
1004
+ await this.#transport.sendControl(guestRevokeMsg({ token, reason }));
1005
+ }
1006
+
1007
+ // ── Session Sharing ───────────────────────────────────────────────
1008
+
1009
+ /**
1010
+ * Share a session for multi-attach.
1011
+ * @param {string} sessionId - Session to share
1012
+ * @param {string} [mode='read'] - Share mode
1013
+ * @param {number} [ttl] - Share TTL in seconds
1014
+ * @param {number} [timeout=10000]
1015
+ * @returns {Promise<object>} Share response with share_id
1016
+ */
1017
+ async shareSession(sessionId, mode = 'read', ttl, timeout = DEFAULT_OPEN_TIMEOUT) {
1018
+ this.#assertAuthenticated('shareSession');
1019
+ await this.#transport.sendControl(shareSessionMsg({ sessionId, mode, ttl }));
1020
+ return this.#waitForMessage(
1021
+ [MSG.SHARE_SESSION],
1022
+ timeout,
1023
+ 'Timed out waiting for share confirmation'
1024
+ );
1025
+ }
1026
+
1027
+ /**
1028
+ * Revoke a session share.
1029
+ * @param {string} shareId - Share ID to revoke
1030
+ * @param {string} [reason] - Reason for revocation
1031
+ */
1032
+ async revokeShare(shareId, reason) {
1033
+ this.#assertAuthenticated('revokeShare');
1034
+ await this.#transport.sendControl(shareRevokeMsg({ shareId, reason }));
1035
+ }
1036
+
1037
+ // ── Compression ───────────────────────────────────────────────────
1038
+
1039
+ /**
1040
+ * Negotiate compression with the server.
1041
+ * @param {string} algorithm - Compression algorithm (e.g. 'zstd', 'lz4')
1042
+ * @param {number} [level=3] - Compression level
1043
+ * @param {number} [timeout=10000]
1044
+ * @returns {Promise<object>} CompressAck response
1045
+ */
1046
+ async negotiateCompression(algorithm, level = 3, timeout = DEFAULT_OPEN_TIMEOUT) {
1047
+ this.#assertAuthenticated('negotiateCompression');
1048
+ await this.#transport.sendControl(compressBeginMsg({ algorithm, level }));
1049
+ return this.#waitForMessage(
1050
+ [MSG.COMPRESS_ACK],
1051
+ timeout,
1052
+ 'Timed out waiting for compression acknowledgment'
1053
+ );
1054
+ }
1055
+
1056
+ // ── Rate Control ──────────────────────────────────────────────────
1057
+
1058
+ /**
1059
+ * Set rate control parameters for a session.
1060
+ * @param {string} sessionId - Session to rate-limit
1061
+ * @param {number} maxBytesPerSec - Maximum throughput
1062
+ * @param {string} [policy='pause'] - Rate limit policy
1063
+ */
1064
+ async setRateControl(sessionId, maxBytesPerSec, policy = 'pause') {
1065
+ this.#assertAuthenticated('setRateControl');
1066
+ await this.#transport.sendControl(rateControlMsg({ sessionId, maxBytesPerSec, policy }));
1067
+ }
1068
+
1069
+ // ── Session Linking ───────────────────────────────────────────────
1070
+
1071
+ /**
1072
+ * Link two sessions across hosts.
1073
+ * @param {string} sourceSession - Source session ID
1074
+ * @param {string} targetHost - Target host
1075
+ * @param {number} targetPort - Target port
1076
+ * @param {string} [targetUser] - Target username
1077
+ */
1078
+ async linkSession(sourceSession, targetHost, targetPort, targetUser) {
1079
+ this.#assertAuthenticated('linkSession');
1080
+ await this.#transport.sendControl(
1081
+ sessionLinkMsg({ sourceSession, targetHost, targetPort, targetUser })
1082
+ );
1083
+ }
1084
+
1085
+ /**
1086
+ * Unlink a previously linked session.
1087
+ * @param {string} linkId - Link ID to remove
1088
+ * @param {string} [reason] - Reason for unlinking
1089
+ */
1090
+ async unlinkSession(linkId, reason) {
1091
+ this.#assertAuthenticated('unlinkSession');
1092
+ await this.#transport.sendControl(sessionUnlinkMsg({ linkId, reason }));
1093
+ }
1094
+
1095
+ // ── Copilot ───────────────────────────────────────────────────────
1096
+
1097
+ /**
1098
+ * Attach a copilot to a session.
1099
+ * @param {string} sessionId - Session to attach to
1100
+ * @param {string} model - Model name
1101
+ * @param {number} [contextWindow] - Context window size
1102
+ */
1103
+ async copilotAttach(sessionId, model, contextWindow) {
1104
+ this.#assertAuthenticated('copilotAttach');
1105
+ await this.#transport.sendControl(
1106
+ copilotAttachMsg({ sessionId, model, contextWindow })
1107
+ );
1108
+ }
1109
+
1110
+ /**
1111
+ * Send a copilot suggestion.
1112
+ * @param {string} sessionId - Session ID
1113
+ * @param {string} suggestion - Suggestion text
1114
+ * @param {number} [confidence] - Confidence score 0-1
1115
+ */
1116
+ async copilotSuggest(sessionId, suggestion, confidence) {
1117
+ this.#assertAuthenticated('copilotSuggest');
1118
+ await this.#transport.sendControl(
1119
+ copilotSuggestMsg({ sessionId, suggestion, confidence })
1120
+ );
1121
+ }
1122
+
1123
+ /**
1124
+ * Detach a copilot from a session.
1125
+ * @param {string} sessionId - Session ID
1126
+ * @param {string} [reason] - Reason for detaching
1127
+ */
1128
+ async copilotDetach(sessionId, reason) {
1129
+ this.#assertAuthenticated('copilotDetach');
1130
+ await this.#transport.sendControl(copilotDetachMsg({ sessionId, reason }));
1131
+ }
1132
+
1133
+ // ── E2E Encryption ────────────────────────────────────────────────
1134
+
1135
+ /**
1136
+ * Initiate end-to-end encryption for a session using X25519 key exchange.
1137
+ * @param {string} sessionId - Session ID
1138
+ * @param {string} [algorithm='X25519'] - Key exchange algorithm
1139
+ * @param {number} [timeout=10000]
1140
+ * @returns {Promise<{sharedSecret: CryptoKey, peerPublicKey: Uint8Array}>}
1141
+ */
1142
+ async initiateE2E(sessionId, algorithm = 'X25519', timeout = DEFAULT_OPEN_TIMEOUT) {
1143
+ this.#assertAuthenticated('initiateE2E');
1144
+
1145
+ // Generate ephemeral X25519 key pair
1146
+ const ephemeral = await crypto.subtle.generateKey(
1147
+ { name: 'X25519' },
1148
+ false,
1149
+ ['deriveBits']
1150
+ );
1151
+
1152
+ const localPub = new Uint8Array(
1153
+ await crypto.subtle.exportKey('raw', ephemeral.publicKey)
1154
+ );
1155
+
1156
+ await this.#transport.sendControl(
1157
+ keyExchangeMsg({ algorithm, publicKey: localPub, sessionId })
1158
+ );
1159
+
1160
+ const peerMsg = await this.#waitForMessage(
1161
+ [MSG.KEY_EXCHANGE],
1162
+ timeout,
1163
+ 'Timed out waiting for peer key exchange'
1164
+ );
1165
+
1166
+ // Import peer's public key and derive shared secret
1167
+ const peerKey = await crypto.subtle.importKey(
1168
+ 'raw',
1169
+ peerMsg.public_key,
1170
+ { name: 'X25519' },
1171
+ false,
1172
+ []
1173
+ );
1174
+
1175
+ const sharedBits = await crypto.subtle.deriveBits(
1176
+ { name: 'X25519', public: peerKey },
1177
+ ephemeral.privateKey,
1178
+ 256
1179
+ );
1180
+
1181
+ // Derive an AES-GCM key from the shared secret
1182
+ const sharedSecret = await crypto.subtle.importKey(
1183
+ 'raw',
1184
+ sharedBits,
1185
+ { name: 'AES-GCM', length: 256 },
1186
+ false,
1187
+ ['encrypt', 'decrypt']
1188
+ );
1189
+
1190
+ return { sharedSecret, peerPublicKey: new Uint8Array(peerMsg.public_key) };
1191
+ }
1192
+
1193
+ // ── Structured File Channel ───────────────────────────────────────
1194
+
1195
+ /**
1196
+ * Perform a file operation on the remote host.
1197
+ * @param {string} op - Operation: 'stat', 'list', 'read', 'write', 'mkdir', 'remove', 'rename'
1198
+ * @param {string} path - File path
1199
+ * @param {object} [opts] - Optional: offset, length for read; data for write; newPath for rename
1200
+ * @param {number} [timeout=10000]
1201
+ * @returns {Promise<object>} FileResult response
1202
+ */
1203
+ async fileOperation(op, path, opts = {}, timeout = DEFAULT_OPEN_TIMEOUT) {
1204
+ this.#assertAuthenticated('fileOperation');
1205
+ const channelId = this._nextChannelId();
1206
+ await this.#transport.sendControl(
1207
+ fileOpMsg({ channelId, op, path, offset: opts.offset, length: opts.length })
1208
+ );
1209
+ return this.#waitForMessage(
1210
+ [MSG.FILE_RESULT],
1211
+ timeout,
1212
+ `Timed out waiting for file ${op} result`
1213
+ );
1214
+ }
1215
+
1216
+ /** Stat a remote file. */
1217
+ async fileStat(path, timeout) { return this.fileOperation('stat', path, {}, timeout); }
1218
+ /** List a remote directory. */
1219
+ async fileList(path, timeout) { return this.fileOperation('list', path, {}, timeout); }
1220
+ /** Read a remote file. */
1221
+ async fileRead(path, offset, length, timeout) { return this.fileOperation('read', path, { offset, length }, timeout); }
1222
+ /** Write to a remote file. */
1223
+ async fileWrite(path, data, offset, timeout) { return this.fileOperation('write', path, { offset }, timeout); }
1224
+ /** Create a remote directory. */
1225
+ async fileMkdir(path, timeout) { return this.fileOperation('mkdir', path, {}, timeout); }
1226
+ /** Remove a remote file or directory. */
1227
+ async fileRemove(path, timeout) { return this.fileOperation('remove', path, {}, timeout); }
1228
+ /** Rename a remote file or directory. */
1229
+ async fileRename(oldPath, newPath, timeout) { return this.fileOperation('rename', oldPath, {}, timeout); }
1230
+
1231
+ // ── Policy Engine ─────────────────────────────────────────────────
1232
+
1233
+ /**
1234
+ * Evaluate a policy on the server.
1235
+ * @param {string} action - Action to evaluate
1236
+ * @param {string} principal - Principal requesting the action
1237
+ * @param {object} [context={}] - Additional context
1238
+ * @param {number} [timeout=10000]
1239
+ * @returns {Promise<object>} PolicyResult response
1240
+ */
1241
+ async evaluatePolicy(action, principal, context = {}, timeout = DEFAULT_OPEN_TIMEOUT) {
1242
+ this.#assertAuthenticated('evaluatePolicy');
1243
+ const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
1244
+ await this.#transport.sendControl(
1245
+ policyEvalMsg({ requestId, action, principal, context })
1246
+ );
1247
+ return this.#waitForMessage(
1248
+ [MSG.POLICY_RESULT],
1249
+ timeout,
1250
+ 'Timed out waiting for policy evaluation result'
1251
+ );
1252
+ }
1253
+
1254
+ /**
1255
+ * Update a policy on the server.
1256
+ * @param {string} policyId - Policy to update
1257
+ * @param {object} rules - New policy rules
1258
+ * @param {number} version - Policy version
1259
+ */
1260
+ async updatePolicy(policyId, rules, version) {
1261
+ this.#assertAuthenticated('updatePolicy');
1262
+ await this.#transport.sendControl(
1263
+ policyUpdateMsg({ policyId, rules, version })
1264
+ );
1265
+ }
1266
+
1267
+ // ── Internal: transport creation ────────────────────────────────────
1268
+
1269
+ /**
1270
+ * Create the appropriate transport based on URL scheme and hint.
1271
+ *
1272
+ * @param {string} url
1273
+ * @param {'wt'|'ws'|'auto'} [hint]
1274
+ * @returns {Promise<import('./transport.mjs').WshTransport>}
1275
+ * @private
1276
+ */
1277
+ async #connectTransport(url, hint) {
1278
+ const attempts = this.#buildTransportAttempts(url, hint);
1279
+ const errors = [];
1280
+
1281
+ for (const attempt of attempts) {
1282
+ const transport = this.#createTransport(attempt.kind);
1283
+ try {
1284
+ await transport.connect(attempt.url);
1285
+ this.#attachTransportHandlers(transport);
1286
+ return transport;
1287
+ } catch (err) {
1288
+ errors.push(`${attempt.kind}: ${err?.message || err}`);
1289
+ try {
1290
+ await transport.close();
1291
+ } catch {
1292
+ // Ignore cleanup errors after a failed connect attempt.
1293
+ }
1294
+ }
1295
+ }
1296
+
1297
+ throw new Error(`Connection failed across transports (${errors.join('; ')})`);
1298
+ }
1299
+
1300
+ /**
1301
+ * @param {'wt'|'ws'} kind
1302
+ * @returns {import('./transport.mjs').WshTransport}
1303
+ * @private
1304
+ */
1305
+ #createTransport(kind) {
1306
+ const factory = this.#transportFactories[kind];
1307
+ if (!factory) {
1308
+ throw new Error(`Unsupported transport: ${kind}`);
1309
+ }
1310
+ return factory();
1311
+ }
1312
+
1313
+ /**
1314
+ * @param {string} url
1315
+ * @param {'wt'|'ws'|'auto'} [hint]
1316
+ * @returns {{ kind: 'wt'|'ws', url: string }[]}
1317
+ * @private
1318
+ */
1319
+ #buildTransportAttempts(url, hint) {
1320
+ if (hint === 'wt') {
1321
+ return [{ kind: 'wt', url }];
1322
+ }
1323
+ if (hint === 'ws') {
1324
+ return [{ kind: 'ws', url }];
1325
+ }
1326
+ if (/^wss?:\/\//i.test(url)) {
1327
+ return [{ kind: 'ws', url }];
1328
+ }
1329
+ return [
1330
+ { kind: 'wt', url },
1331
+ { kind: 'ws', url },
1332
+ ];
1333
+ }
1334
+
1335
+ /**
1336
+ * @param {import('./transport.mjs').WshTransport} transport
1337
+ * @private
1338
+ */
1339
+ #attachTransportHandlers(transport) {
1340
+ transport.onControl = (msg) => this.#handleControl(msg);
1341
+ transport.onClose = () => this.#handleTransportClose();
1342
+ transport.onError = (err) => this.#handleTransportError(err);
1343
+ }
1344
+
1345
+ /**
1346
+ * Create a client with a pre-configured transport instance.
1347
+ * Useful for WebSocket transport or custom transports.
1348
+ *
1349
+ * @param {import('./transport.mjs').WshTransport} transport
1350
+ * @returns {WshClient}
1351
+ */
1352
+ static withTransport(transport) {
1353
+ const client = new WshClient();
1354
+ client.#transport = transport;
1355
+ return client;
1356
+ }
1357
+
1358
+ /**
1359
+ * Connect using an externally created transport.
1360
+ * Use this when you need WebSocket or a custom transport.
1361
+ *
1362
+ * @param {import('./transport.mjs').WshTransport} transport - An already-constructed transport
1363
+ * @param {string} url - Server URL to connect to
1364
+ * @param {object} opts - Same options as connect()
1365
+ * @returns {Promise<string>} Session ID
1366
+ */
1367
+ async connectWithTransport(transport, url, opts) {
1368
+ this.#state = STATE_CONNECTING;
1369
+ await transport.connect(url);
1370
+ this.#attachTransportHandlers(transport);
1371
+ this.#transport = transport;
1372
+ this.#state = STATE_CONNECTED;
1373
+
1374
+ // Proceed with auth using the same logic.
1375
+ // We can't call this.connect() directly because it would create a new
1376
+ // transport, so we duplicate the auth portion.
1377
+ return this.#performAuth(opts);
1378
+ }
1379
+
1380
+ // ── Internal: auth handshake ────────────────────────────────────────
1381
+
1382
+ /**
1383
+ * Perform the authentication handshake after transport is connected.
1384
+ * Extracted so connectWithTransport can reuse it.
1385
+ *
1386
+ * @param {object} opts
1387
+ * @param {string} opts.username
1388
+ * @param {CryptoKeyPair} [opts.keyPair]
1389
+ * @param {string} [opts.password]
1390
+ * @param {number} [opts.timeout]
1391
+ * @returns {Promise<string>} Session ID
1392
+ * @private
1393
+ */
1394
+ async #performAuth({ username, keyPair, password, timeout = DEFAULT_AUTH_TIMEOUT } = {}) {
1395
+ if (!username) throw new Error('username is required');
1396
+ if (!keyPair && !password) throw new Error('Either keyPair or password is required');
1397
+
1398
+ try {
1399
+ const authMethod = keyPair ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
1400
+ await this.#transport.sendControl(hello({ username, authMethod }));
1401
+
1402
+ const firstResponse = await this.#waitForMessage(
1403
+ [MSG.SERVER_HELLO, MSG.CHALLENGE, MSG.AUTH_FAIL],
1404
+ timeout,
1405
+ 'Auth handshake timed out'
1406
+ );
1407
+
1408
+ if (firstResponse.type === MSG.AUTH_FAIL) {
1409
+ throw new Error(`Authentication failed: ${firstResponse.reason || 'unknown'}`);
1410
+ }
1411
+
1412
+ let tempSessionId = null;
1413
+
1414
+ if (firstResponse.type === MSG.SERVER_HELLO) {
1415
+ tempSessionId = firstResponse.session_id;
1416
+ this.#serverFeatures = firstResponse.features || [];
1417
+
1418
+ if (authMethod === AUTH_METHOD.PUBKEY) {
1419
+ const challengeMsg = await this.#waitForMessage(
1420
+ [MSG.CHALLENGE, MSG.AUTH_OK],
1421
+ timeout,
1422
+ 'Timed out waiting for challenge'
1423
+ );
1424
+
1425
+ if (challengeMsg.type === MSG.AUTH_OK) {
1426
+ this.#sessionId = challengeMsg.session_id || tempSessionId;
1427
+ this.#resumeToken = challengeMsg.token || null;
1428
+ this.#state = STATE_AUTHENTICATED;
1429
+ this.#startPing();
1430
+ return this.#sessionId;
1431
+ }
1432
+
1433
+ const { signature, publicKeyRaw } = await signChallenge(
1434
+ keyPair.privateKey, keyPair.publicKey, tempSessionId, challengeMsg.nonce
1435
+ );
1436
+
1437
+ await this.#transport.sendControl(authMsg({
1438
+ method: AUTH_METHOD.PUBKEY, signature, publicKey: publicKeyRaw,
1439
+ }));
1440
+ } else {
1441
+ await this.#transport.sendControl(authMsg({
1442
+ method: AUTH_METHOD.PASSWORD, password,
1443
+ }));
1444
+ }
1445
+ } else if (firstResponse.type === MSG.CHALLENGE) {
1446
+ if (!keyPair) throw new Error('Server sent CHALLENGE but no key pair provided');
1447
+
1448
+ tempSessionId = tempSessionId || 'pending';
1449
+ const { signature, publicKeyRaw } = await signChallenge(
1450
+ keyPair.privateKey, keyPair.publicKey, tempSessionId, firstResponse.nonce
1451
+ );
1452
+
1453
+ await this.#transport.sendControl(authMsg({
1454
+ method: AUTH_METHOD.PUBKEY, signature, publicKey: publicKeyRaw,
1455
+ }));
1456
+ }
1457
+
1458
+ const authResult = await this.#waitForMessage(
1459
+ [MSG.AUTH_OK, MSG.AUTH_FAIL],
1460
+ timeout,
1461
+ 'Timed out waiting for auth result'
1462
+ );
1463
+
1464
+ if (authResult.type === MSG.AUTH_FAIL) {
1465
+ throw new Error(`Authentication failed: ${authResult.reason || 'rejected'}`);
1466
+ }
1467
+
1468
+ this.#sessionId = authResult.session_id || tempSessionId;
1469
+ this.#resumeToken = authResult.token || null;
1470
+ this.#state = STATE_AUTHENTICATED;
1471
+ this.#startPing();
1472
+
1473
+ return this.#sessionId;
1474
+ } catch (err) {
1475
+ this.#state = STATE_CLOSED;
1476
+ await this.#transport?.close().catch(() => {});
1477
+ this.#transport = null;
1478
+ this.#rejectAllWaiters(err);
1479
+ throw err;
1480
+ }
1481
+ }
1482
+
1483
+ // ── Internal: control message dispatch ──────────────────────────────
1484
+
1485
+ /**
1486
+ * Route incoming control messages to the appropriate handler.
1487
+ * @param {object} msg
1488
+ * @private
1489
+ */
1490
+ #handleControl(msg) {
1491
+ const type = msg.type;
1492
+
1493
+ // First, check if any waiters are listening for this message type.
1494
+ if (this.#waiters.has(type)) {
1495
+ const queue = this.#waiters.get(type);
1496
+ if (queue.length > 0) {
1497
+ const waiter = queue.shift();
1498
+ if (queue.length === 0) this.#waiters.delete(type);
1499
+ clearTimeout(waiter.timer);
1500
+ waiter.resolve(msg);
1501
+ return;
1502
+ }
1503
+ }
1504
+
1505
+ // Also check multi-type waiters (stored under a synthetic key).
1506
+ for (const [key, queue] of this.#waiters) {
1507
+ if (typeof key === 'string' && key.startsWith('multi:')) {
1508
+ for (let i = 0; i < queue.length; i++) {
1509
+ if (queue[i].types?.includes(type)) {
1510
+ const waiter = queue.splice(i, 1)[0];
1511
+ if (queue.length === 0) this.#waiters.delete(key);
1512
+ clearTimeout(waiter.timer);
1513
+ waiter.resolve(msg);
1514
+ return;
1515
+ }
1516
+ }
1517
+ }
1518
+ }
1519
+
1520
+ // Route gateway messages (0x70–0x7f) to the gateway handler.
1521
+ if (type >= 0x70 && type <= 0x7f) {
1522
+ try {
1523
+ this.onGatewayMessage?.(msg);
1524
+ } catch (err) {
1525
+ console.error('[wsh:client] onGatewayMessage handler error:', err);
1526
+ }
1527
+ return;
1528
+ }
1529
+
1530
+ // Dispatch channel-specific messages to sessions.
1531
+ const channelId = msg.channel_id;
1532
+ if (channelId !== undefined && this.#sessions.has(channelId)) {
1533
+ const session = this.#sessions.get(channelId);
1534
+ session._handleControlMessage(msg);
1535
+
1536
+ // Remove session from tracking if it's closed.
1537
+ if (type === MSG.CLOSE) {
1538
+ this.#sessions.delete(channelId);
1539
+ }
1540
+ return;
1541
+ }
1542
+
1543
+ // Route relay-forwarded messages from remote CLI peers.
1544
+ // In reverse mode, the server's relay bridge forwards Open, McpCall,
1545
+ // McpDiscover, etc. from the CLI to this browser client. Channel-bound
1546
+ // traffic is given to active sessions first so stream and virtual
1547
+ // sessions share the same top-level API.
1548
+ if (this.onRelayMessage && this._isRelayForwardable(type)) {
1549
+ try {
1550
+ this.onRelayMessage(msg);
1551
+ } catch (err) {
1552
+ console.error('[wsh:client] onRelayMessage handler error:', err);
1553
+ }
1554
+ return;
1555
+ }
1556
+
1557
+ // Handle transport-level messages.
1558
+ switch (type) {
1559
+ case MSG.PING:
1560
+ // Respond to server pings immediately.
1561
+ this.#transport?.sendControl(pongMsg({ id: msg.id })).catch(() => {});
1562
+ break;
1563
+
1564
+ case MSG.PONG:
1565
+ this.#lastPong = Date.now();
1566
+ break;
1567
+
1568
+ case MSG.ERROR:
1569
+ console.error('[wsh:client] Server error:', msg.code, msg.message);
1570
+ this.#emitError(new Error(`Server error ${msg.code}: ${msg.message}`));
1571
+ break;
1572
+
1573
+ case MSG.SHUTDOWN:
1574
+ console.warn('[wsh:client] Server shutdown:', msg.reason);
1575
+ this.disconnect().catch(() => {});
1576
+ break;
1577
+
1578
+ case MSG.IDLE_WARNING:
1579
+ // Respond with a ping to indicate we're still active.
1580
+ this.#transport?.sendControl(pingMsg({ id: ++this.#pingId })).catch(() => {});
1581
+ break;
1582
+
1583
+ case MSG.REVERSE_CONNECT:
1584
+ try {
1585
+ this.onReverseConnect?.(msg);
1586
+ } catch (err) {
1587
+ console.error('[wsh:client] onReverseConnect handler error:', err);
1588
+ }
1589
+ break;
1590
+
1591
+ case MSG.CLIPBOARD:
1592
+ // OSC 52 clipboard sync — write to navigator.clipboard if available.
1593
+ if (msg.direction === 'server_to_client' && msg.data) {
1594
+ try {
1595
+ const text = atob(msg.data);
1596
+ navigator.clipboard?.writeText(text).catch(() => {});
1597
+ } catch { /* ignore decode errors */ }
1598
+ }
1599
+ try {
1600
+ this.onClipboard?.(msg);
1601
+ } catch (err) {
1602
+ console.error('[wsh:client] onClipboard handler error:', err);
1603
+ }
1604
+ break;
1605
+
1606
+ case MSG.PRESENCE:
1607
+ case MSG.CONTROL_CHANGED:
1608
+ case MSG.METRICS:
1609
+ // Informational messages — no default handling needed.
1610
+ break;
1611
+
1612
+ case MSG.COMPRESS_BEGIN:
1613
+ // Server wants to negotiate compression. Browser can't decompress
1614
+ // CBOR frames yet, so decline.
1615
+ this.#transport?.sendControl(
1616
+ compressAckMsg({ algorithm: msg.algorithm, accepted: false })
1617
+ ).catch(() => {});
1618
+ break;
1619
+
1620
+ case MSG.RATE_WARNING:
1621
+ try {
1622
+ this.onRateWarning?.(msg);
1623
+ } catch (err) {
1624
+ console.error('[wsh:client] onRateWarning handler error:', err);
1625
+ }
1626
+ break;
1627
+
1628
+ case MSG.COPILOT_SUGGEST:
1629
+ try {
1630
+ this.onCopilotSuggest?.(msg);
1631
+ } catch (err) {
1632
+ console.error('[wsh:client] onCopilotSuggest handler error:', err);
1633
+ }
1634
+ break;
1635
+
1636
+ case MSG.KEY_EXCHANGE:
1637
+ try {
1638
+ this.onKeyExchange?.(msg);
1639
+ } catch (err) {
1640
+ console.error('[wsh:client] onKeyExchange handler error:', err);
1641
+ }
1642
+ break;
1643
+
1644
+ default:
1645
+ // Unrecognized message — ignore gracefully.
1646
+ break;
1647
+ }
1648
+ }
1649
+
1650
+ // ── Internal: transport events ──────────────────────────────────────
1651
+
1652
+ /**
1653
+ * @private
1654
+ */
1655
+ #handleTransportClose() {
1656
+ if (this.#state === STATE_CLOSED) return;
1657
+
1658
+ this.#state = STATE_CLOSED;
1659
+ this.#stopPing();
1660
+ this.#rejectAllWaiters(new Error('Transport closed'));
1661
+
1662
+ // Close all sessions.
1663
+ for (const session of this.#sessions.values()) {
1664
+ session._handleControlMessage({ type: MSG.CLOSE });
1665
+ }
1666
+ this.#sessions.clear();
1667
+
1668
+ try {
1669
+ this.onClose?.();
1670
+ } catch (err) {
1671
+ console.error('[wsh:client] onClose handler error:', err);
1672
+ }
1673
+ }
1674
+
1675
+ /**
1676
+ * @private
1677
+ */
1678
+ #handleTransportError(err) {
1679
+ this.#emitError(err);
1680
+ }
1681
+
1682
+ // ── Internal: message waiter system ─────────────────────────────────
1683
+
1684
+ /**
1685
+ * Wait for the next control message matching one of the given types.
1686
+ *
1687
+ * @param {number|number[]} types - Message type(s) to wait for
1688
+ * @param {number} timeout - Timeout in ms
1689
+ * @param {string} timeoutMessage - Error message on timeout
1690
+ * @returns {Promise<object>}
1691
+ * @private
1692
+ */
1693
+ #waitForMessage(types, timeout, timeoutMessage) {
1694
+ const typeArr = Array.isArray(types) ? types : [types];
1695
+
1696
+ return new Promise((resolve, reject) => {
1697
+ const timer = setTimeout(() => {
1698
+ // Remove this waiter.
1699
+ this.#removeWaiter(key, waiter);
1700
+ reject(new Error(timeoutMessage));
1701
+ }, timeout);
1702
+
1703
+ const waiter = { resolve, reject, timer, types: typeArr };
1704
+
1705
+ // For multi-type waiting, use a synthetic key.
1706
+ const key = typeArr.length === 1
1707
+ ? typeArr[0]
1708
+ : `multi:${typeArr.join(',')}`;
1709
+
1710
+ if (!this.#waiters.has(key)) {
1711
+ this.#waiters.set(key, []);
1712
+ }
1713
+ this.#waiters.get(key).push(waiter);
1714
+ });
1715
+ }
1716
+
1717
+ /**
1718
+ * Remove a specific waiter from the queue.
1719
+ * @param {*} key
1720
+ * @param {object} waiter
1721
+ * @private
1722
+ */
1723
+ #removeWaiter(key, waiter) {
1724
+ const queue = this.#waiters.get(key);
1725
+ if (!queue) return;
1726
+ const idx = queue.indexOf(waiter);
1727
+ if (idx !== -1) queue.splice(idx, 1);
1728
+ if (queue.length === 0) this.#waiters.delete(key);
1729
+ }
1730
+
1731
+ /**
1732
+ * Reject all pending waiters with the given error.
1733
+ * @param {Error} err
1734
+ * @private
1735
+ */
1736
+ #rejectAllWaiters(err) {
1737
+ for (const [, queue] of this.#waiters) {
1738
+ for (const waiter of queue) {
1739
+ clearTimeout(waiter.timer);
1740
+ waiter.reject(err);
1741
+ }
1742
+ }
1743
+ this.#waiters.clear();
1744
+ }
1745
+
1746
+ // ── Internal: ping/pong keepalive ───────────────────────────────────
1747
+
1748
+ /**
1749
+ * Start periodic ping messages.
1750
+ * @private
1751
+ */
1752
+ #startPing() {
1753
+ this.#stopPing();
1754
+ this.#lastPong = Date.now();
1755
+
1756
+ this.#pingTimer = setInterval(() => {
1757
+ if (this.#state !== STATE_AUTHENTICATED) {
1758
+ this.#stopPing();
1759
+ return;
1760
+ }
1761
+
1762
+ this.#transport?.sendControl(
1763
+ pingMsg({ id: ++this.#pingId })
1764
+ ).catch((err) => {
1765
+ console.warn('[wsh:client] Failed to send ping:', err.message);
1766
+ });
1767
+ }, DEFAULT_PING_INTERVAL);
1768
+
1769
+ // Don't let the ping timer prevent Node.js/Deno from exiting.
1770
+ if (typeof this.#pingTimer === 'object' && this.#pingTimer.unref) {
1771
+ this.#pingTimer.unref();
1772
+ }
1773
+ }
1774
+
1775
+ /**
1776
+ * Stop the ping interval.
1777
+ * @private
1778
+ */
1779
+ #stopPing() {
1780
+ if (this.#pingTimer !== null) {
1781
+ clearInterval(this.#pingTimer);
1782
+ this.#pingTimer = null;
1783
+ }
1784
+ }
1785
+
1786
+ // ── Internal: helpers ───────────────────────────────────────────────
1787
+
1788
+ /**
1789
+ * Check whether a message type is relay-forwardable — i.e. a message
1790
+ * that a client would not normally receive from the server, but that
1791
+ * arrives via the relay bridge from a remote CLI peer.
1792
+ *
1793
+ * @param {number} type - Message opcode
1794
+ * @returns {boolean}
1795
+ */
1796
+ _isRelayForwardable(type) {
1797
+ return [
1798
+ MSG.OPEN, MSG.MCP_DISCOVER, MSG.MCP_CALL,
1799
+ MSG.CLOSE, MSG.RESIZE, MSG.SIGNAL,
1800
+ MSG.SESSION_DATA,
1801
+ MSG.REVERSE_ACCEPT, MSG.REVERSE_REJECT,
1802
+ MSG.GUEST_JOIN, MSG.GUEST_REVOKE, // Unit 4
1803
+ MSG.COPILOT_ATTACH, MSG.COPILOT_DETACH, // Unit 9
1804
+ MSG.FILE_OP, // Unit 11
1805
+ MSG.POLICY_EVAL, // Unit 12
1806
+ MSG.ECHO_ACK, MSG.ECHO_STATE,
1807
+ MSG.TERM_SYNC, MSG.TERM_DIFF,
1808
+ ].includes(type);
1809
+ }
1810
+
1811
+ /**
1812
+ * Get the next channel ID.
1813
+ * @returns {number}
1814
+ */
1815
+ _nextChannelId() {
1816
+ return ++this.#channelCounter;
1817
+ }
1818
+
1819
+ /**
1820
+ * Assert that the client is authenticated.
1821
+ * @param {string} action
1822
+ * @private
1823
+ */
1824
+ #assertAuthenticated(action) {
1825
+ if (this.#state !== STATE_AUTHENTICATED) {
1826
+ throw new Error(`Cannot ${action}: client is ${this.#state} (expected authenticated)`);
1827
+ }
1828
+ }
1829
+
1830
+ /**
1831
+ * Emit an error through the callback.
1832
+ * @param {Error} err
1833
+ * @private
1834
+ */
1835
+ #emitError(err) {
1836
+ try {
1837
+ this.onError?.(err);
1838
+ } catch (e) {
1839
+ console.error('[wsh:client] onError handler error:', e);
1840
+ }
1841
+ }
1842
+ }