@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/LICENSE +21 -0
- package/README.md +150 -0
- package/package.json +42 -0
- package/src/auth.mjs +314 -0
- package/src/cbor.mjs +389 -0
- package/src/client.mjs +1842 -0
- package/src/file-transfer.mjs +413 -0
- package/src/index.d.ts +1454 -0
- package/src/index.mjs +70 -0
- package/src/keystore.mjs +473 -0
- package/src/mcp-bridge.mjs +228 -0
- package/src/messages.gen.mjs +928 -0
- package/src/messages.mjs +3 -0
- package/src/recording.mjs +402 -0
- package/src/session.mjs +557 -0
- package/src/transport-ws.mjs +462 -0
- package/src/transport.mjs +343 -0
- package/src/virtual-session.mjs +160 -0
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
|
+
}
|