@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
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WshFileTransfer — dedicated stream file transfer (scp-like) over a wsh connection.
|
|
3
|
+
*
|
|
4
|
+
* Uploads and downloads files using the wsh channel protocol with 'file' kind
|
|
5
|
+
* channels. Data flows over a dedicated bidirectional stream in 64KB chunks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { MSG, open, CHANNEL_KIND } from './messages.mjs';
|
|
9
|
+
|
|
10
|
+
/** Transfer chunk size: 64KB. */
|
|
11
|
+
const CHUNK_SIZE = 65536;
|
|
12
|
+
|
|
13
|
+
/** Default timeout for waiting on control messages (30 seconds). */
|
|
14
|
+
const RESPONSE_TIMEOUT_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
export class WshFileTransfer {
|
|
17
|
+
/** @type {{ sendControl: function, openStream: function, onControl: function }} */
|
|
18
|
+
#client;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} client - A WshClient or any transport object exposing:
|
|
22
|
+
* - sendControl(msg): send a control message
|
|
23
|
+
* - openStream(): open a new bidirectional stream
|
|
24
|
+
* - onControl: settable callback for incoming control messages
|
|
25
|
+
* (or a method to add a listener — see _waitForMessage)
|
|
26
|
+
*/
|
|
27
|
+
constructor(client) {
|
|
28
|
+
if (!client) throw new Error('WshFileTransfer requires a client');
|
|
29
|
+
this.#client = client;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Upload data to a remote path.
|
|
34
|
+
*
|
|
35
|
+
* Protocol flow:
|
|
36
|
+
* 1. Send OPEN { kind: 'file', command: 'upload', path }
|
|
37
|
+
* 2. Receive OPEN_OK { channel_id, stream_ids }
|
|
38
|
+
* 3. Write data to dedicated stream in 64KB chunks
|
|
39
|
+
* 4. Close the write side of the stream
|
|
40
|
+
* 5. Wait for EXIT message with status code
|
|
41
|
+
*
|
|
42
|
+
* @param {Uint8Array | ArrayBuffer} data - File content to upload
|
|
43
|
+
* @param {string} remotePath - Destination path on the remote host
|
|
44
|
+
* @param {object} [opts]
|
|
45
|
+
* @param {function} [opts.onProgress] - Called with { sent: number, total: number }
|
|
46
|
+
* @param {number} [opts.timeout] - Timeout in ms for server responses
|
|
47
|
+
* @returns {Promise<{ success: boolean, bytesTransferred: number }>}
|
|
48
|
+
*/
|
|
49
|
+
async upload(data, remotePath, { onProgress, timeout = RESPONSE_TIMEOUT_MS } = {}) {
|
|
50
|
+
if (typeof this.#client.upload === 'function') {
|
|
51
|
+
await this.#client.upload(data, remotePath, {
|
|
52
|
+
onProgress: (value) => {
|
|
53
|
+
if (typeof value === 'number') {
|
|
54
|
+
onProgress?.({ sent: value, total: data.byteLength ?? data.length ?? 0 });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
onProgress?.(value);
|
|
58
|
+
},
|
|
59
|
+
timeout,
|
|
60
|
+
});
|
|
61
|
+
const bytes = data instanceof Uint8Array ? data.byteLength : data.byteLength ?? data.length ?? 0;
|
|
62
|
+
return { success: true, bytesTransferred: bytes };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
66
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
67
|
+
throw new TypeError('Data must be a Uint8Array or ArrayBuffer');
|
|
68
|
+
}
|
|
69
|
+
if (!remotePath || typeof remotePath !== 'string') {
|
|
70
|
+
throw new Error('remotePath is required');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 1. Request a file upload channel
|
|
74
|
+
const openMsg = open({
|
|
75
|
+
kind: CHANNEL_KIND.FILE,
|
|
76
|
+
command: 'upload',
|
|
77
|
+
});
|
|
78
|
+
openMsg.path = remotePath;
|
|
79
|
+
openMsg.size = bytes.length;
|
|
80
|
+
|
|
81
|
+
await this.#client.sendControl(openMsg);
|
|
82
|
+
|
|
83
|
+
// 2. Wait for OPEN_OK or OPEN_FAIL
|
|
84
|
+
const response = await this._waitForMessage(
|
|
85
|
+
(msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
|
|
86
|
+
timeout
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (response.type === MSG.OPEN_FAIL) {
|
|
90
|
+
throw new Error(`Upload rejected: ${response.reason || 'unknown reason'}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const channelId = response.channel_id;
|
|
94
|
+
|
|
95
|
+
// 3. Open a dedicated data stream and write in chunks
|
|
96
|
+
const stream = await this.#client.openStream();
|
|
97
|
+
const writer = stream.writable.getWriter();
|
|
98
|
+
let sent = 0;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
while (sent < bytes.length) {
|
|
102
|
+
const end = Math.min(sent + CHUNK_SIZE, bytes.length);
|
|
103
|
+
const chunk = bytes.subarray(sent, end);
|
|
104
|
+
await writer.write(chunk);
|
|
105
|
+
sent = end;
|
|
106
|
+
try {
|
|
107
|
+
onProgress?.({ sent, total: bytes.length });
|
|
108
|
+
} catch { /* ignore callback errors */ }
|
|
109
|
+
}
|
|
110
|
+
} finally {
|
|
111
|
+
// 4. Close the write side to signal end of data
|
|
112
|
+
try {
|
|
113
|
+
await writer.close();
|
|
114
|
+
} catch {
|
|
115
|
+
// Stream may already be closed on error
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 5. Wait for EXIT confirmation
|
|
120
|
+
const exitMsg = await this._waitForMessage(
|
|
121
|
+
(msg) => msg.type === MSG.EXIT && msg.channel_id === channelId,
|
|
122
|
+
timeout
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const success = exitMsg.code === 0;
|
|
126
|
+
if (!success) {
|
|
127
|
+
throw new Error(`Upload failed with exit code ${exitMsg.code}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { success, bytesTransferred: sent };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Download a file from a remote path.
|
|
135
|
+
*
|
|
136
|
+
* Protocol flow:
|
|
137
|
+
* 1. Send OPEN { kind: 'file', command: 'download', path }
|
|
138
|
+
* 2. Receive OPEN_OK { channel_id, stream_ids }
|
|
139
|
+
* 3. Read from the dedicated stream, accumulating chunks
|
|
140
|
+
* 4. Return complete file content as Uint8Array
|
|
141
|
+
*
|
|
142
|
+
* @param {string} remotePath - File path on the remote host
|
|
143
|
+
* @param {object} [opts]
|
|
144
|
+
* @param {function} [opts.onProgress] - Called with { received: number, total?: number }
|
|
145
|
+
* @param {number} [opts.timeout] - Timeout in ms for server responses
|
|
146
|
+
* @returns {Promise<Uint8Array>} File content
|
|
147
|
+
*/
|
|
148
|
+
async download(remotePath, { onProgress, timeout = RESPONSE_TIMEOUT_MS } = {}) {
|
|
149
|
+
if (typeof this.#client.download === 'function') {
|
|
150
|
+
return await this.#client.download(remotePath, { onProgress, timeout });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!remotePath || typeof remotePath !== 'string') {
|
|
154
|
+
throw new Error('remotePath is required');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 1. Request a file download channel
|
|
158
|
+
const openMsg = open({
|
|
159
|
+
kind: CHANNEL_KIND.FILE,
|
|
160
|
+
command: 'download',
|
|
161
|
+
});
|
|
162
|
+
openMsg.path = remotePath;
|
|
163
|
+
|
|
164
|
+
await this.#client.sendControl(openMsg);
|
|
165
|
+
|
|
166
|
+
// 2. Wait for OPEN_OK or OPEN_FAIL
|
|
167
|
+
const response = await this._waitForMessage(
|
|
168
|
+
(msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
|
|
169
|
+
timeout
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (response.type === MSG.OPEN_FAIL) {
|
|
173
|
+
throw new Error(`Download rejected: ${response.reason || 'unknown reason'}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const channelId = response.channel_id;
|
|
177
|
+
const totalSize = response.size; // Server may include total file size
|
|
178
|
+
|
|
179
|
+
// 3. Open a dedicated data stream and read chunks
|
|
180
|
+
const stream = await this.#client.openStream();
|
|
181
|
+
const reader = stream.readable.getReader();
|
|
182
|
+
const chunks = [];
|
|
183
|
+
let received = 0;
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
while (true) {
|
|
187
|
+
const { done, value } = await reader.read();
|
|
188
|
+
if (done) break;
|
|
189
|
+
|
|
190
|
+
chunks.push(value);
|
|
191
|
+
received += value.length;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
onProgress?.({ received, total: totalSize });
|
|
195
|
+
} catch { /* ignore callback errors */ }
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
reader.releaseLock();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 4. Wait for EXIT confirmation
|
|
202
|
+
try {
|
|
203
|
+
const exitMsg = await this._waitForMessage(
|
|
204
|
+
(msg) => msg.type === MSG.EXIT && msg.channel_id === channelId,
|
|
205
|
+
timeout
|
|
206
|
+
);
|
|
207
|
+
if (exitMsg.code !== 0) {
|
|
208
|
+
throw new Error(`Download failed with exit code ${exitMsg.code}`);
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
// If the stream closed cleanly and we got data, the EXIT may have
|
|
212
|
+
// already been consumed or the server may not send one for downloads.
|
|
213
|
+
// Only rethrow if we have no data.
|
|
214
|
+
if (received === 0) throw err;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Concatenate chunks into a single Uint8Array
|
|
218
|
+
return this._concatChunks(chunks, received);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* List files at a remote path by executing `ls -la` via the client.
|
|
223
|
+
*
|
|
224
|
+
* This uses a standard exec channel rather than the file transfer protocol,
|
|
225
|
+
* parsing the output of `ls -la` into structured entries.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} remotePath - Directory path on the remote host
|
|
228
|
+
* @returns {Promise<Array<{ name: string, size: number, modified: string, type: string }>>}
|
|
229
|
+
*/
|
|
230
|
+
async list(remotePath) {
|
|
231
|
+
if (!remotePath || typeof remotePath !== 'string') {
|
|
232
|
+
throw new Error('remotePath is required');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Use an exec channel to run ls
|
|
236
|
+
const openMsg = open({
|
|
237
|
+
kind: CHANNEL_KIND.EXEC,
|
|
238
|
+
command: `ls -la ${this._shellEscape(remotePath)}`,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await this.#client.sendControl(openMsg);
|
|
242
|
+
|
|
243
|
+
const response = await this._waitForMessage(
|
|
244
|
+
(msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
|
|
245
|
+
RESPONSE_TIMEOUT_MS
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
if (response.type === MSG.OPEN_FAIL) {
|
|
249
|
+
throw new Error(`List failed: ${response.reason || 'unknown reason'}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Read the output stream
|
|
253
|
+
const stream = await this.#client.openStream();
|
|
254
|
+
const reader = stream.readable.getReader();
|
|
255
|
+
const chunks = [];
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
while (true) {
|
|
259
|
+
const { done, value } = await reader.read();
|
|
260
|
+
if (done) break;
|
|
261
|
+
chunks.push(value);
|
|
262
|
+
}
|
|
263
|
+
} finally {
|
|
264
|
+
reader.releaseLock();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const output = new TextDecoder().decode(this._concatChunks(chunks));
|
|
268
|
+
return this._parseLsOutput(output);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Internal helpers ──────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Wait for a control message matching a predicate.
|
|
275
|
+
*
|
|
276
|
+
* Temporarily hooks into the client's control message flow and resolves
|
|
277
|
+
* when a matching message arrives (or rejects on timeout).
|
|
278
|
+
*
|
|
279
|
+
* @param {function(object): boolean} predicate
|
|
280
|
+
* @param {number} timeoutMs
|
|
281
|
+
* @returns {Promise<object>}
|
|
282
|
+
*/
|
|
283
|
+
_waitForMessage(predicate, timeoutMs) {
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
let timer = null;
|
|
286
|
+
let settled = false;
|
|
287
|
+
|
|
288
|
+
const cleanup = () => {
|
|
289
|
+
settled = true;
|
|
290
|
+
if (timer !== null) clearTimeout(timer);
|
|
291
|
+
// Remove listener
|
|
292
|
+
if (this.#client.removeControlListener) {
|
|
293
|
+
this.#client.removeControlListener(listener);
|
|
294
|
+
} else if (this.#client._controlListeners) {
|
|
295
|
+
const idx = this.#client._controlListeners.indexOf(listener);
|
|
296
|
+
if (idx !== -1) this.#client._controlListeners.splice(idx, 1);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const listener = (msg) => {
|
|
301
|
+
if (settled) return;
|
|
302
|
+
if (predicate(msg)) {
|
|
303
|
+
cleanup();
|
|
304
|
+
resolve(msg);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Register listener on the client
|
|
309
|
+
if (this.#client.addControlListener) {
|
|
310
|
+
this.#client.addControlListener(listener);
|
|
311
|
+
} else if (this.#client._controlListeners) {
|
|
312
|
+
this.#client._controlListeners.push(listener);
|
|
313
|
+
} else {
|
|
314
|
+
// Fallback: wrap existing onControl
|
|
315
|
+
const prev = this.#client.onControl;
|
|
316
|
+
this.#client.onControl = (msg) => {
|
|
317
|
+
prev?.(msg);
|
|
318
|
+
listener(msg);
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
timer = setTimeout(() => {
|
|
323
|
+
if (!settled) {
|
|
324
|
+
cleanup();
|
|
325
|
+
reject(new Error(`Timeout waiting for response (${timeoutMs}ms)`));
|
|
326
|
+
}
|
|
327
|
+
}, timeoutMs);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Concatenate an array of Uint8Arrays into one.
|
|
333
|
+
*
|
|
334
|
+
* @param {Uint8Array[]} chunks
|
|
335
|
+
* @param {number} [totalLength] - Pre-computed total, avoids re-summing
|
|
336
|
+
* @returns {Uint8Array}
|
|
337
|
+
*/
|
|
338
|
+
_concatChunks(chunks, totalLength) {
|
|
339
|
+
if (chunks.length === 0) return new Uint8Array(0);
|
|
340
|
+
if (chunks.length === 1) return chunks[0];
|
|
341
|
+
|
|
342
|
+
const total = totalLength ?? chunks.reduce((sum, c) => sum + c.length, 0);
|
|
343
|
+
const result = new Uint8Array(total);
|
|
344
|
+
let offset = 0;
|
|
345
|
+
for (const chunk of chunks) {
|
|
346
|
+
result.set(chunk, offset);
|
|
347
|
+
offset += chunk.length;
|
|
348
|
+
}
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Parse the output of `ls -la` into structured entries.
|
|
354
|
+
*
|
|
355
|
+
* Expected format per line:
|
|
356
|
+
* drwxr-xr-x 2 user group 4096 Jan 15 12:00 dirname
|
|
357
|
+
* -rw-r--r-- 1 user group 12345 Jan 15 12:00 filename.txt
|
|
358
|
+
*
|
|
359
|
+
* @param {string} output
|
|
360
|
+
* @returns {Array<{ name: string, size: number, modified: string, type: string }>}
|
|
361
|
+
*/
|
|
362
|
+
_parseLsOutput(output) {
|
|
363
|
+
const lines = output.split('\n').filter((l) => l.trim());
|
|
364
|
+
const entries = [];
|
|
365
|
+
|
|
366
|
+
for (const line of lines) {
|
|
367
|
+
// Skip the "total" header line
|
|
368
|
+
if (line.startsWith('total ')) continue;
|
|
369
|
+
|
|
370
|
+
// ls -la columns: perms links owner group size month day time name
|
|
371
|
+
const parts = line.split(/\s+/);
|
|
372
|
+
if (parts.length < 9) continue;
|
|
373
|
+
|
|
374
|
+
const perms = parts[0];
|
|
375
|
+
const size = parseInt(parts[4], 10) || 0;
|
|
376
|
+
const month = parts[5];
|
|
377
|
+
const day = parts[6];
|
|
378
|
+
const time = parts[7];
|
|
379
|
+
// Name may contain spaces — rejoin everything from column 8 onward
|
|
380
|
+
const name = parts.slice(8).join(' ');
|
|
381
|
+
|
|
382
|
+
// Skip . and .. entries
|
|
383
|
+
if (name === '.' || name === '..') continue;
|
|
384
|
+
|
|
385
|
+
// Determine type from permission string first character
|
|
386
|
+
let type = 'file';
|
|
387
|
+
if (perms.startsWith('d')) type = 'directory';
|
|
388
|
+
else if (perms.startsWith('l')) type = 'symlink';
|
|
389
|
+
else if (perms.startsWith('c') || perms.startsWith('b')) type = 'device';
|
|
390
|
+
else if (perms.startsWith('p')) type = 'pipe';
|
|
391
|
+
else if (perms.startsWith('s')) type = 'socket';
|
|
392
|
+
|
|
393
|
+
entries.push({
|
|
394
|
+
name,
|
|
395
|
+
size,
|
|
396
|
+
modified: `${month} ${day} ${time}`,
|
|
397
|
+
type,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return entries;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Basic shell escaping for a path argument.
|
|
406
|
+
* @param {string} str
|
|
407
|
+
* @returns {string}
|
|
408
|
+
*/
|
|
409
|
+
_shellEscape(str) {
|
|
410
|
+
// Wrap in single quotes, escaping any embedded single quotes
|
|
411
|
+
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
412
|
+
}
|
|
413
|
+
}
|