@johnhenry/wsh 0.17.1 → 0.19.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.
@@ -0,0 +1,493 @@
1
+ /**
2
+ * One wsh-v1 server connection, written against a byte pipe so it knows
3
+ * nothing about WebSocket: `attach({ send })` returns `{ receive, close }`.
4
+ * Speaks the same QMux-over-WebSocket framing the stock client's
5
+ * `WebSocketTransport` does.
6
+ *
7
+ * Exec data plane: exec sessions use `data_mode: 'stream'` -- output flows on
8
+ * the second, client-opened QMux stream. The client's transport announces that
9
+ * stream the moment it opens it (an empty STREAM frame), so the host sees it
10
+ * with no help; the host advertises `stream-announce` in ServerHello so the
11
+ * client knows not to write a primer byte. A client that does write one anyway
12
+ * (hosts that predate the announce required it) has the lone leading `0x00`
13
+ * dropped rather than forwarded to the process's stdin.
14
+ */
15
+
16
+ import { randomUUID } from 'node:crypto';
17
+ import { QMuxConnection } from '../qmux-connection.mjs';
18
+ import { FrameDecoder, frameEncode } from '../cbor.mjs';
19
+ import {
20
+ MSG, serverHello, challenge, authOk, authFail, openOk, openFail, sessionData,
21
+ exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk,
22
+ } from '../messages.gen.mjs';
23
+ import {
24
+ generateNonce, verifyChallenge, fingerprint, importPublicKeyRaw,
25
+ } from '../auth.mjs';
26
+ import { FILE_CHUNK_BYTES } from './fs.mjs';
27
+ import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
28
+
29
+ export const STREAM_ANNOUNCE = 'stream-announce';
30
+ const MAX_PENDING_WRITES = 8;
31
+ const MAX_RENAME_PATH_BYTES = 4096;
32
+ const WRITE_IDLE_MS = 30_000;
33
+
34
+ const MAX_CHANNELS = 64;
35
+ const USERNAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
36
+ const enc = new TextEncoder();
37
+
38
+ const randomBytes = (n) => crypto.getRandomValues(new Uint8Array(n));
39
+ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
40
+
41
+ /**
42
+ * @param {object} cfg
43
+ * @param {(who: object) => Promise<boolean>} cfg.authorize
44
+ * @param {Function | null} cfg.execRunner
45
+ * @param {object} cfg.execOptions
46
+ * @param {object | null} cfg.pty - normalized (see pty.mjs)
47
+ * @param {object | null} cfg.files - from createFileAccess
48
+ * @param {{ pubkey: boolean, password: Function | null }} cfg.methods - which auth methods are on
49
+ * @param {object | null} cfg.hostKey - from loadHostKey
50
+ * @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
51
+ * @param {number} cfg.bindTimeoutMs - how long exec output waits for the data stream
52
+ * @param {(line: string) => void} cfg.log
53
+ */
54
+ export function createConnectionFactory(cfg) {
55
+ let counter = 0;
56
+
57
+ return function attach({ send: sendBytes, remote = {} }) {
58
+ const cid = ++counter;
59
+ const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
60
+ const state = {
61
+ sessionId: randomUUID(), nonce: null, username: null, authed: false,
62
+ closed: false, nextChannel: 0,
63
+ };
64
+ /** @type {Map<number, any>} */
65
+ const channels = new Map();
66
+ /** FileOp `write`/`rename`s awaiting their FileChunk frames, by client-chosen channel id. */
67
+ const fileWrites = new Map();
68
+ /** Channel ids awaiting their client-opened data stream, in OpenOk order. */
69
+ const pendingStreams = [];
70
+ const decoder = new FrameDecoder();
71
+ const pendingSends = [];
72
+ let control = null;
73
+ let chain = Promise.resolve();
74
+
75
+ const qmux = new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
76
+
77
+ const send = (msg) => {
78
+ if (state.closed) return Promise.resolve();
79
+ const bytes = frameEncode(msg);
80
+ if (control) return control.write(bytes).catch(() => {});
81
+ pendingSends.push(bytes);
82
+ return Promise.resolve();
83
+ };
84
+
85
+ function shutdown(why) {
86
+ if (state.closed) return;
87
+ state.closed = true;
88
+ for (const ch of channels.values()) ch.kill?.();
89
+ channels.clear();
90
+ for (const w of fileWrites.values()) clearTimeout(w.timer);
91
+ fileWrites.clear();
92
+ try { qmux.destroy(new Error(why)); } catch { /* already gone */ }
93
+ log(`closed (${why})`);
94
+ }
95
+
96
+ qmux.onError = (e) => log(`qmux error: ${e.message}`);
97
+ qmux.onClose = () => shutdown('peer sent CONNECTION_CLOSE');
98
+ qmux.onStreamOpen = (s) => {
99
+ if (s.id !== 0) { bindDataStream(s); return; }
100
+ control = s;
101
+ s.onData = (d) => {
102
+ let msgs;
103
+ try { msgs = decoder.feed(d); } catch (e) { log(`frame decode error: ${e.message}`); return; }
104
+ for (const m of msgs) chain = chain.then(() => handle(m)).catch((e) => log(`handler error: ${e.message}`));
105
+ };
106
+ s.onEnd = () => shutdown('control stream FIN');
107
+ s.onReset = () => shutdown('control stream reset');
108
+ for (const b of pendingSends.splice(0)) s.write(b).catch(() => {});
109
+ };
110
+ qmux.sendHandshake();
111
+
112
+ function bindDataStream(s) {
113
+ const channelId = pendingStreams.shift();
114
+ const ch = channelId !== undefined ? channels.get(channelId) : undefined;
115
+ if (!ch?.bind) { s.close().catch(() => {}); return; }
116
+ ch.bind(s);
117
+ }
118
+
119
+ // ── Auth ──────────────────────────────────────────────────────────
120
+
121
+ async function handle(m) {
122
+ if (state.closed) return;
123
+ if (!state.authed) return handleAuth(m);
124
+ switch (m.type) {
125
+ case MSG.PING: return send(pong({ id: m.id }));
126
+ case MSG.OPEN: return handleOpen(m);
127
+ case MSG.SESSION_DATA: channels.get(m.channel_id)?.input?.(m.data); return;
128
+ case MSG.RESIZE: channels.get(m.channel_id)?.resize?.(m.cols, m.rows); return;
129
+ case MSG.SIGNAL: channels.get(m.channel_id)?.signal?.(String(m.signal ?? '')); return;
130
+ case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
131
+ case MSG.FILE_OP: return handleFileOp(m);
132
+ case MSG.FILE_CHUNK: return (fileWrites.has(m.channel_id) ? handleWriteChunk(m) : channels.get(m.channel_id)?.chunk?.(m));
133
+ default: log(`ignored message type 0x${m.type.toString(16)}`);
134
+ }
135
+ }
136
+
137
+ async function refuse(reason) {
138
+ log(`AUTH_FAIL: ${reason}`);
139
+ await send(authFail({ reason }));
140
+ setTimeout(() => shutdown('auth failed'), 50);
141
+ }
142
+
143
+ async function handleAuth(m) {
144
+ if (m.type === MSG.HELLO) {
145
+ const user = String(m.username ?? '');
146
+ if (!USERNAME_RE.test(user)) return refuse(`bad username ${JSON.stringify(user)}`);
147
+ const method = m.auth_method || 'pubkey';
148
+ if (method === 'password') {
149
+ if (!cfg.methods.password) return refuse('password auth is not enabled on this host');
150
+ } else if (method === 'pubkey') {
151
+ if (!cfg.methods.pubkey) return refuse('pubkey auth is not enabled on this host');
152
+ } else {
153
+ return refuse(`unsupported auth method ${JSON.stringify(method)}`);
154
+ }
155
+ state.username = user;
156
+ state.method = method;
157
+ const features = [STREAM_ANNOUNCE];
158
+ if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
159
+ let hostFingerprint;
160
+ if (cfg.hostKey) {
161
+ hostFingerprint = cfg.hostKey.fingerprint;
162
+ const clientNonce = findClientNonce(m.features);
163
+ if (clientNonce) {
164
+ const sig = await cfg.hostKey.sign(hostKeyProofMessage({ sessionId: state.sessionId, clientNonce, username: user }));
165
+ features.push(HOST_KEY_PREFIX + toHex(cfg.hostKey.publicKey), HOST_KEY_SIG_PREFIX + toHex(sig));
166
+ }
167
+ }
168
+ await send(serverHello({ sessionId: state.sessionId, features, hostFingerprint }));
169
+ if (method === 'pubkey') {
170
+ state.nonce = generateNonce();
171
+ await send(challenge({ nonce: state.nonce, sessionId: state.sessionId }));
172
+ } else {
173
+ state.awaitingPassword = true;
174
+ }
175
+ return;
176
+ }
177
+ if (m.type === MSG.AUTH && state.awaitingPassword) return handlePasswordAuth(m);
178
+ if (m.type === MSG.AUTH) {
179
+ if (!state.nonce) return;
180
+ if (m.method !== 'pubkey' || !(m.public_key instanceof Uint8Array) || !(m.signature instanceof Uint8Array)) {
181
+ return refuse('expected a pubkey AUTH with public_key and signature');
182
+ }
183
+ let fp;
184
+ let key;
185
+ try {
186
+ fp = await fingerprint(m.public_key);
187
+ key = await importPublicKeyRaw(m.public_key);
188
+ } catch { return refuse('malformed public key'); }
189
+ // Verify the signature first: nothing about who is on the allowlist
190
+ // is revealed to a caller who cannot even prove they hold a key.
191
+ let ok = false;
192
+ try {
193
+ ok = await verifyChallenge(key, m.signature, state.sessionId, state.nonce, { username: state.username });
194
+ } catch { ok = false; }
195
+ if (!ok) return refuse('signature does not verify over the transcript');
196
+ let allowed = false;
197
+ try {
198
+ allowed = await cfg.authorize({ username: state.username, fingerprint: fp, publicKey: m.public_key });
199
+ } catch (e) { log(`authorize threw: ${e.message}`); }
200
+ if (!allowed) return refuse('key not authorized');
201
+ state.authed = true;
202
+ state.nonce = null;
203
+ await send(authOk({ sessionId: state.sessionId, token: randomBytes(16), ttl: 3600 }));
204
+ log(`authenticated ${state.username} (${fp.slice(0, 12)}...)`);
205
+ }
206
+ }
207
+
208
+ async function handlePasswordAuth(m) {
209
+ state.awaitingPassword = false; // one guess per connection
210
+ if (m.method !== 'password' || typeof m.password !== 'string') return refuse('expected a password AUTH');
211
+ const rl = cfg.rateLimit;
212
+ const key = rl.key({ address: remote.address, headers: remote.headers, username: state.username });
213
+ const locked = rl.limiter.lockedFor(key);
214
+ if (locked > 0) {
215
+ // Not even evaluated: a locked-out caller learns nothing and costs nothing.
216
+ return refuse(`too many failed attempts; try again in ${Math.ceil(locked / 1000)}s`);
217
+ }
218
+ let ok = false;
219
+ try { ok = (await cfg.methods.password(state.username, m.password)) === true; } catch (e) { log(`password check threw: ${e.message}`); }
220
+ if (!ok) {
221
+ rl.limiter.fail(key);
222
+ if (rl.failureDelayMs > 0) await new Promise((r) => setTimeout(r, rl.failureDelayMs));
223
+ return refuse('authentication failed');
224
+ }
225
+ rl.limiter.succeed(key);
226
+ state.authed = true;
227
+ await send(authOk({ sessionId: state.sessionId, token: randomBytes(16), ttl: 3600 }));
228
+ log(`authenticated ${state.username} (password)`);
229
+ }
230
+
231
+ // ── Open ──────────────────────────────────────────────────────────
232
+
233
+ function handleOpen(m) {
234
+ if (channels.size >= MAX_CHANNELS) return send(openFail({ reason: 'too many open channels' }));
235
+ switch (m.kind) {
236
+ case 'exec': return openExec(m);
237
+ case 'pty': return openPty(m);
238
+ case 'file': return openFile(m);
239
+ default: return send(openFail({ reason: `kind "${m.kind}" is not supported by this server` }));
240
+ }
241
+ }
242
+
243
+ function finishChannel(channelId, code) {
244
+ if (!channels.has(channelId) || state.closed) return Promise.resolve();
245
+ channels.delete(channelId);
246
+ return send(exitMsg({ channelId, code })).then(() => send(closeMsg({ channelId })));
247
+ }
248
+
249
+ async function openExec(m) {
250
+ if (!cfg.execRunner) return send(openFail({ reason: 'exec is not enabled on this server' }));
251
+ const command = String(m.command ?? '').trim();
252
+ if (!command) return send(openFail({ reason: 'command is required for kind "exec"' }));
253
+
254
+ const channelId = ++state.nextChannel;
255
+ const abort = new AbortController();
256
+ let stream = null;
257
+ let primerChecked = false;
258
+ let wq = Promise.resolve();
259
+ const buffered = [];
260
+ const boundWaiters = [];
261
+ const inputCbs = [];
262
+ const endCbs = [];
263
+ const signalCbs = [];
264
+
265
+ const ch = {
266
+ kill: () => abort.abort(),
267
+ signal: (name) => {
268
+ const n = name.replace(/^SIG/, '').toUpperCase();
269
+ for (const cb of signalCbs) cb(n);
270
+ if (/^(INT|TERM|KILL|HUP)$/.test(n) && signalCbs.length === 0) abort.abort();
271
+ },
272
+ bind(s) {
273
+ stream = s;
274
+ s.onData = (d) => {
275
+ // A client that primes (see header) sends one lone 0x00 first.
276
+ if (!primerChecked) {
277
+ primerChecked = true;
278
+ if (d.byteLength === 1 && d[0] === 0) return;
279
+ }
280
+ if (d.byteLength) for (const cb of inputCbs) cb(d);
281
+ };
282
+ s.onEnd = () => { for (const cb of endCbs) cb(); };
283
+ s.onReset = () => abort.abort();
284
+ const pending = buffered.splice(0);
285
+ wq = wq.then(async () => { for (const b of pending) await s.write(b).catch(() => {}); });
286
+ for (const r of boundWaiters.splice(0)) r();
287
+ },
288
+ };
289
+ channels.set(channelId, ch);
290
+ pendingStreams.push(channelId);
291
+
292
+ let timer = null;
293
+ if (cfg.execOptions.timeoutMs > 0) timer = setTimeout(() => abort.abort(), cfg.execOptions.timeoutMs);
294
+
295
+ const io = {
296
+ user: state.username,
297
+ env: m.env && typeof m.env === 'object' && cfg.execOptions.clientEnv !== false ? m.env : {},
298
+ cols: m.cols || 80,
299
+ rows: m.rows || 24,
300
+ signal: abort.signal,
301
+ write(data) {
302
+ const bytes = toBytes(data);
303
+ if (!stream) { buffered.push(bytes); return Promise.resolve(); }
304
+ const s = stream;
305
+ wq = wq.then(() => s.write(bytes).catch(() => {}));
306
+ return wq;
307
+ },
308
+ onInput: (cb) => inputCbs.push(cb),
309
+ onInputEnd: (cb) => endCbs.push(cb),
310
+ onSignal: (cb) => signalCbs.push(cb),
311
+ };
312
+
313
+ await send(openOk({
314
+ channelId, dataMode: 'stream', capabilities: ['signal'],
315
+ sessionId: randomUUID(), token: randomBytes(16),
316
+ }));
317
+ log(`exec channel ${channelId}: ${command}`);
318
+
319
+ // Detached: the message chain must stay free to deliver this
320
+ // session's Signal/Close/SessionData while the command runs.
321
+ runExec().catch((e) => log(`exec channel ${channelId}: ${e.message}`));
322
+
323
+ async function runExec() {
324
+ let code;
325
+ try { code = await cfg.execRunner(command, io); } catch (e) {
326
+ await io.write(`wsh: ${e.message}\n`);
327
+ code = 1;
328
+ }
329
+ if (timer) clearTimeout(timer);
330
+ // Output is ready but the client's stream may not have been bound yet
331
+ // (a host without announce support waits for the first byte): give it
332
+ // a bounded chance rather than dropping the output or hanging forever.
333
+ if (!stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
334
+ if (!stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
335
+ await wq;
336
+ try { await stream?.close(); } catch { /* peer gone */ }
337
+ await finishChannel(channelId, Number.isInteger(code) ? code : 0);
338
+ }
339
+ }
340
+
341
+ async function openPty(m) {
342
+ if (!cfg.pty) return send(openFail({ reason: 'pty is not enabled on this server' }));
343
+ const channelId = ++state.nextChannel;
344
+ const cols = m.cols || 80;
345
+ const rows = m.rows || 24;
346
+ const p = cfg.pty;
347
+ let proc;
348
+ try {
349
+ const args = m.command ? ['-c', String(m.command)] : [];
350
+ proc = p.spawn(p.shell, args, {
351
+ name: p.term, cols, rows, cwd: p.cwd,
352
+ env: { ...(p.env ?? process.env), TERM: p.term, ...(m.env && typeof m.env === 'object' ? m.env : {}) },
353
+ });
354
+ } catch (e) {
355
+ return send(openFail({ reason: `pty spawn failed: ${e.message}` }));
356
+ }
357
+ const ch = {
358
+ input: (data) => { try { proc.write(Buffer.from(data)); } catch { /* exited */ } },
359
+ resize: (c, r) => { try { proc.resize(c || cols, r || rows); } catch { /* exited */ } },
360
+ signal: (name) => { try { proc.kill(`SIG${name.replace(/^SIG/, '').toUpperCase()}`); } catch { /* exited */ } },
361
+ kill: () => { try { proc.kill(); } catch { /* exited */ } },
362
+ };
363
+ channels.set(channelId, ch);
364
+ let out = Promise.resolve();
365
+ proc.onData((d) => { out = out.then(() => send(sessionData({ channelId, data: toBytes(d) }))); });
366
+ proc.onExit(({ exitCode }) => { out.then(() => finishChannel(channelId, exitCode ?? 0)); });
367
+ await send(openOk({
368
+ channelId, dataMode: 'virtual', capabilities: ['resize', 'signal'],
369
+ sessionId: randomUUID(), token: randomBytes(16),
370
+ }));
371
+ log(`pty channel ${channelId} opened (${cols}x${rows})`);
372
+ }
373
+
374
+ async function openFile(m) {
375
+ if (!cfg.files) return send(openFail({ reason: 'file access is not enabled on this server' }));
376
+ const spec = String(m.command ?? '');
377
+ const mm = spec.match(/^(upload|download):(.*)$/s);
378
+ if (!mm) return send(openFail({ reason: 'file channel expects upload:<path> or download:<path>' }));
379
+ const [, direction, filePath] = mm;
380
+ const channelId = ++state.nextChannel;
381
+
382
+ if (direction === 'download') {
383
+ let data;
384
+ try { data = await cfg.files.readWhole(filePath); } catch (e) { return send(openFail({ reason: e.message })); }
385
+ channels.set(channelId, { kill: () => channels.delete(channelId) });
386
+ await send(openOk({ channelId, dataMode: 'virtual', capabilities: [] }));
387
+ let off = 0;
388
+ do {
389
+ const end = Math.min(off + FILE_CHUNK_BYTES, data.byteLength);
390
+ await send(fileChunk({ channelId, offset: off, data: data.subarray(off, end), isFinal: end >= data.byteLength, totalSize: data.byteLength }));
391
+ off = end;
392
+ } while (off < data.byteLength);
393
+ log(`download ${filePath} (${data.byteLength} B)`);
394
+ return;
395
+ }
396
+
397
+ const ch = { buf: null, kill: () => channels.delete(channelId) };
398
+ channels.set(channelId, ch);
399
+ const done = async (code, why) => {
400
+ if (why) log(`upload ${filePath}: ${why}`);
401
+ await finishChannel(channelId, code);
402
+ };
403
+ ch.chunk = async (c) => {
404
+ if (ch.failed) return;
405
+ const total = Number(c.total_size ?? 0);
406
+ if (total > cfg.files.maxFileBytes) { ch.failed = true; return done(1, `refused: ${total} B exceeds ${cfg.files.maxFileBytes} B`); }
407
+ ch.buf ??= new Uint8Array(total);
408
+ const data = c.data instanceof Uint8Array ? c.data : new Uint8Array(0);
409
+ if (c.offset + data.byteLength > ch.buf.byteLength) { ch.failed = true; return done(1, 'chunk past total_size'); }
410
+ ch.buf.set(data, c.offset);
411
+ if (c.is_final) {
412
+ try { await cfg.files.writeWhole(filePath, ch.buf); } catch (e) { ch.failed = true; return done(1, e.message); }
413
+ return done(0, `stored ${ch.buf.byteLength} B`);
414
+ }
415
+ };
416
+ await send(openOk({ channelId, dataMode: 'virtual', capabilities: [] }));
417
+ }
418
+
419
+ async function handleFileOp(m) {
420
+ const channelId = m.channel_id;
421
+ if (!cfg.files) return send(fileResult({ channelId, success: false, errorMessage: 'file access is not enabled on this server' }));
422
+ if (m.op === 'write' || m.op === 'rename') return startPayloadOp(m);
423
+ try {
424
+ const r = await cfg.files.operate(m.op, m.path, { offset: m.offset, length: m.length });
425
+ return send(fileResult({ channelId, success: true, metadata: r.metadata ?? {}, entries: r.entries ?? [] }));
426
+ } catch (e) {
427
+ const message = e.wsh ? e.message : (e.code === 'ENOENT' ? 'no such file or directory' : `${m.op} failed`);
428
+ if (!e.wsh && e.code !== 'ENOENT') log(`file ${m.op} ${m.path}: ${e.message}`);
429
+ return send(fileResult({ channelId, success: false, errorMessage: message }));
430
+ }
431
+ }
432
+
433
+ // write / rename = FileOp then FileChunk frame(s) on the same channel id
434
+ // (rename's payload is the UTF-8 destination path); one FileResult once
435
+ // the last chunk lands (see WshClient.fileOperation).
436
+ async function startPayloadOp(m) {
437
+ const channelId = m.channel_id;
438
+ const failWrite = (errorMessage) => send(fileResult({ channelId, success: false, errorMessage }));
439
+ const rename = m.op === 'rename';
440
+ const length = Number(m.length);
441
+ if (!Number.isSafeInteger(length) || length < 0) return failWrite(`${m.op} needs a length`);
442
+ const limit = rename ? MAX_RENAME_PATH_BYTES : cfg.files.maxFileBytes;
443
+ if (length > limit) return failWrite(`${m.op} payload of ${length} bytes exceeds the ${limit} byte limit`);
444
+ if (channels.has(channelId) || fileWrites.has(channelId)) return failWrite('channel id already in use');
445
+ if (fileWrites.size >= MAX_PENDING_WRITES) return failWrite('too many writes in flight');
446
+ const w = { rename, path: m.path, offset: m.offset, length, buf: new Uint8Array(length), next: 0, timer: null };
447
+ const arm = () => {
448
+ clearTimeout(w.timer);
449
+ w.timer = setTimeout(() => {
450
+ if (fileWrites.delete(channelId)) failWrite('write timed out waiting for data');
451
+ }, WRITE_IDLE_MS);
452
+ w.timer.unref?.();
453
+ };
454
+ arm();
455
+ w.arm = arm;
456
+ fileWrites.set(channelId, w);
457
+ }
458
+
459
+ async function handleWriteChunk(m) {
460
+ const channelId = m.channel_id;
461
+ const w = fileWrites.get(channelId);
462
+ const abort = (errorMessage) => {
463
+ clearTimeout(w.timer);
464
+ fileWrites.delete(channelId);
465
+ return send(fileResult({ channelId, success: false, errorMessage }));
466
+ };
467
+ const data = m.data instanceof Uint8Array ? m.data : new Uint8Array(0);
468
+ if (m.offset !== w.next) return abort('chunks must arrive in order');
469
+ if (w.next + data.byteLength > w.length) return abort('chunk past the declared length');
470
+ w.buf.set(data, w.next);
471
+ w.next += data.byteLength;
472
+ if (!m.is_final) { w.arm(); return; }
473
+ clearTimeout(w.timer);
474
+ fileWrites.delete(channelId);
475
+ if (w.next !== w.length) return send(fileResult({ channelId, success: false, errorMessage: 'payload ended short of the declared length' }));
476
+ try {
477
+ const r = w.rename
478
+ ? await cfg.files.operate('rename', w.path, { newPath: new TextDecoder('utf-8', { fatal: true }).decode(w.buf) })
479
+ : await cfg.files.operate('write', w.path, { offset: w.offset, data: w.buf });
480
+ return send(fileResult({ channelId, success: true, metadata: r.metadata ?? {} }));
481
+ } catch (e) {
482
+ const message = e.wsh ? e.message : (e.code === 'ENOENT' ? 'no such file or directory' : `${w.rename ? 'rename' : 'write'} failed`);
483
+ if (!e.wsh && e.code !== 'ENOENT') log(`file write ${w.path}: ${e.message}`);
484
+ return send(fileResult({ channelId, success: false, errorMessage: message }));
485
+ }
486
+ }
487
+
488
+ return {
489
+ receive(bytes) { if (!state.closed) qmux.receiveBytes(bytes); },
490
+ close() { shutdown('transport closed'); },
491
+ };
492
+ };
493
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Default `exec` runner for `@johnhenry/wsh/server`: runs the command through
3
+ * a shell with `node:child_process` and wires it to the session `io`.
4
+ *
5
+ * A custom runner has the same shape -- `run(command, io) => Promise<exitCode>`
6
+ * -- so a restricted host (no real shell) can replace this wholesale.
7
+ */
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import { constants } from 'node:os';
11
+
12
+ /**
13
+ * @param {{ cwd?: string, env?: object, shell?: boolean | string }} [opts]
14
+ * @returns {(command: string, io: ExecIo) => Promise<number>}
15
+ */
16
+ export function spawnRunner({ cwd, env, shell = true } = {}) {
17
+ return (command, io) => new Promise((resolve) => {
18
+ let proc;
19
+ try {
20
+ proc = spawn(command, {
21
+ shell,
22
+ cwd,
23
+ env: { ...(env ?? process.env), ...io.env },
24
+ stdio: ['pipe', 'pipe', 'pipe'],
25
+ });
26
+ } catch (err) {
27
+ io.write(`wsh: ${err.message}\n`).finally(() => resolve(127));
28
+ return;
29
+ }
30
+
31
+ const out = (chunk) => { io.write(new Uint8Array(chunk)); };
32
+ proc.stdout.on('data', out);
33
+ proc.stderr.on('data', out);
34
+ proc.stdin.on('error', () => { /* process exited before stdin drained */ });
35
+ io.onInput((bytes) => { try { proc.stdin.write(bytes); } catch { /* exited */ } });
36
+ io.onInputEnd(() => { try { proc.stdin.end(); } catch { /* exited */ } });
37
+ io.onSignal((name) => { try { proc.kill(`SIG${name}`); } catch { /* exited or unknown */ } });
38
+ io.signal.addEventListener('abort', () => { try { proc.kill('SIGTERM'); } catch { /* exited */ } }, { once: true });
39
+
40
+ proc.on('error', (err) => {
41
+ io.write(`wsh: ${err.message}\n`).finally(() => resolve(127));
42
+ });
43
+ proc.on('close', (code, signal) => {
44
+ resolve(code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 0));
45
+ });
46
+ });
47
+ }
48
+
49
+ /**
50
+ * @typedef {object} ExecIo
51
+ * @property {string} user - Authenticated username.
52
+ * @property {Record<string,string>} env - Environment requested by the client.
53
+ * @property {number} cols
54
+ * @property {number} rows
55
+ * @property {AbortSignal} signal - Aborts on client Close, disconnect or timeout.
56
+ * @property {(data: Uint8Array | string) => Promise<void>} write - Send output; resolves when flow control accepts it.
57
+ * @property {(cb: (bytes: Uint8Array) => void) => void} onInput - Client stdin.
58
+ * @property {(cb: () => void) => void} onInputEnd - Client closed stdin.
59
+ * @property {(cb: (name: string) => void) => void} onSignal - Named signal (`INT`, `TERM`, ...).
60
+ */