@johnhenry/wsh 0.17.0 → 0.18.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.
@@ -145,6 +145,10 @@ export const MSG = Object.freeze({
145
145
 
146
146
  // Terminal
147
147
  TERMINAL_CONFIG: 0x9e,
148
+
149
+ // Keys
150
+ AUTHORIZED_KEY_ADD: 0x9f,
151
+ AUTHORIZED_KEY_RESULT: 0xa0,
148
152
  });
149
153
 
150
154
  // Reverse lookup: number → name
@@ -187,13 +191,10 @@ export function hello({ username, features = [], authMethod = AUTH_METHOD.PUBKEY
187
191
  };
188
192
  }
189
193
 
190
- export function serverHello({ sessionId, features = [], fingerprints = [] } = {}) {
191
- return {
192
- type: MSG.SERVER_HELLO,
193
- session_id: sessionId,
194
- features,
195
- fingerprints,
196
- };
194
+ export function serverHello({ sessionId, features = [], fingerprints = [], hostFingerprint } = {}) {
195
+ const msg = { type: MSG.SERVER_HELLO, session_id: sessionId, features, fingerprints };
196
+ if (hostFingerprint !== undefined) msg.host_fingerprint = hostFingerprint;
197
+ return msg;
197
198
  }
198
199
 
199
200
  export function challenge({ nonce, sessionId } = {}) {
@@ -864,8 +865,8 @@ export function fileOp({ channelId, op, path, offset, length } = {}) {
864
865
  return msg;
865
866
  }
866
867
 
867
- export function fileResult({ channelId, success, metadata = {}, errorMessage } = {}) {
868
- const msg = { type: MSG.FILE_RESULT, channel_id: channelId, success, metadata };
868
+ export function fileResult({ channelId, success, metadata = {}, entries = [], errorMessage } = {}) {
869
+ const msg = { type: MSG.FILE_RESULT, channel_id: channelId, success, metadata, entries };
869
870
  if (errorMessage !== undefined) msg.error_message = errorMessage;
870
871
  return msg;
871
872
  }
@@ -915,6 +916,18 @@ export function terminalConfig({ channelId, frontend, options = {} } = {}) {
915
916
  };
916
917
  }
917
918
 
919
+ export function authorizedKeyAdd({ publicKey, comment } = {}) {
920
+ const msg = { type: MSG.AUTHORIZED_KEY_ADD, public_key: publicKey };
921
+ if (comment !== undefined) msg.comment = comment;
922
+ return msg;
923
+ }
924
+
925
+ export function authorizedKeyResult({ success, added = false, errorMessage } = {}) {
926
+ const msg = { type: MSG.AUTHORIZED_KEY_RESULT, success, added };
927
+ if (errorMessage !== undefined) msg.error_message = errorMessage;
928
+ return msg;
929
+ }
930
+
918
931
  // ── Relay forwarding ─────────────────────────────────────────────────
919
932
 
920
933
  // Message types a relay may forward inside a RelayForward wrapper.
@@ -134,6 +134,27 @@ class QMuxStream {
134
134
  }
135
135
  }
136
136
 
137
+ /**
138
+ * Make this locally-initiated stream visible to the peer without sending
139
+ * any payload. A QMux/QUIC stream is created lazily on the wire: opening
140
+ * one only allocates an id locally, and the peer first learns it exists
141
+ * from the first STREAM frame it sees. A zero-length STREAM frame (no FIN)
142
+ * is legal QUIC and is exactly that first frame, so the peer's
143
+ * `onStreamOpen` fires now instead of whenever (if ever) the first real
144
+ * byte is written -- which for an exec session, with no stdin, is never.
145
+ * No-op once anything has been sent on the stream.
146
+ */
147
+ announce() {
148
+ if (this.#sendState !== SEND_STATE.READY) return;
149
+ this.#sendState = SEND_STATE.SEND;
150
+ this.#conn._sendFrame(encodeStream({
151
+ streamId: this.id,
152
+ offset: this.#sendOffset,
153
+ data: new Uint8Array(0),
154
+ fin: false,
155
+ }), 0);
156
+ }
157
+
137
158
  /** Half-close the send side: send FIN. No more write() calls after this. */
138
159
  async close() {
139
160
  if (this.#sendState === SEND_STATE.RESET_SENT || this.#sendState === SEND_STATE.DATA_SENT) return;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Server-side authentication for `@johnhenry/wsh/server`: an allowlist of
3
+ * Ed25519 public keys (SSH `authorized_keys` style) and/or a custom callback.
4
+ */
5
+
6
+ import {
7
+ parseSSHPublicKey, extractRawFromSSHWire, fingerprint,
8
+ } from '../auth.mjs';
9
+
10
+ /**
11
+ * Parse an `authorized_keys`-style blob (one `ssh-ed25519 AAAA... [comment]`
12
+ * per line; blank lines and `#` comments ignored) into raw 32-byte keys.
13
+ * Malformed lines are skipped rather than failing the whole list.
14
+ *
15
+ * @param {string} text
16
+ * @returns {Uint8Array[]}
17
+ */
18
+ export function parseAuthorizedKeys(text) {
19
+ const keys = [];
20
+ for (const raw of String(text ?? '').split('\n')) {
21
+ const line = raw.trim();
22
+ if (!line || line.startsWith('#')) continue;
23
+ try {
24
+ const parsed = parseSSHPublicKey(line);
25
+ keys.push(extractRawFromSSHWire(parsed.data));
26
+ } catch {
27
+ // skip malformed line
28
+ }
29
+ }
30
+ return keys;
31
+ }
32
+
33
+ /**
34
+ * Normalize the `auth` option into one `authorize()` function.
35
+ *
36
+ * `auth` is an `authorize` function itself, or an object with
37
+ * `authorizedKeys` (a string of `ssh-ed25519` lines, a raw 32-byte
38
+ * `Uint8Array`, or an array of either) and/or `authorize`. A key must be on
39
+ * the list AND pass `authorize` (when both are given). With no `auth` at all
40
+ * every connection is refused -- a server never defaults to open.
41
+ *
42
+ * @param {undefined | Function | {
43
+ * authorizedKeys?: string | Uint8Array | Array<string | Uint8Array>,
44
+ * authorize?: (who: {username: string, fingerprint: string, publicKey: Uint8Array}) => boolean | Promise<boolean>,
45
+ * }} auth
46
+ * @returns {Promise<(who: {username: string, fingerprint: string, publicKey: Uint8Array}) => Promise<boolean>>}
47
+ */
48
+ export async function buildAuthorizer(auth) {
49
+ if (typeof auth === 'function') return async (who) => (await auth(who)) === true;
50
+ if (!auth) return async () => false;
51
+
52
+ /** @type {Set<string> | null} */
53
+ let allowed = null;
54
+ if (auth.authorizedKeys !== undefined) {
55
+ allowed = new Set();
56
+ const items = Array.isArray(auth.authorizedKeys) ? auth.authorizedKeys : [auth.authorizedKeys];
57
+ for (const item of items) {
58
+ const raws = typeof item === 'string' ? parseAuthorizedKeys(item) : [item];
59
+ for (const raw of raws) {
60
+ if (!(raw instanceof Uint8Array) || raw.byteLength !== 32) {
61
+ throw new TypeError('createWshServer: auth.authorizedKeys entries must be ssh-ed25519 lines or raw 32-byte Ed25519 keys');
62
+ }
63
+ allowed.add(await fingerprint(raw));
64
+ }
65
+ }
66
+ }
67
+ const custom = typeof auth.authorize === 'function' ? auth.authorize : null;
68
+ if (!allowed && !custom) return async () => false;
69
+
70
+ return async (who) => {
71
+ if (allowed && !allowed.has(who.fingerprint)) return false;
72
+ if (custom) return (await custom(who)) === true;
73
+ return true;
74
+ };
75
+ }
@@ -0,0 +1,381 @@
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
+
28
+ export const STREAM_ANNOUNCE = 'stream-announce';
29
+
30
+ const MAX_CHANNELS = 64;
31
+ const USERNAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
32
+ const enc = new TextEncoder();
33
+
34
+ const randomBytes = (n) => crypto.getRandomValues(new Uint8Array(n));
35
+ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
36
+
37
+ /**
38
+ * @param {object} cfg
39
+ * @param {(who: object) => Promise<boolean>} cfg.authorize
40
+ * @param {Function | null} cfg.execRunner
41
+ * @param {object} cfg.execOptions
42
+ * @param {object | null} cfg.pty - normalized (see pty.mjs)
43
+ * @param {object | null} cfg.files - from createFileAccess
44
+ * @param {number} cfg.bindTimeoutMs - how long exec output waits for the data stream
45
+ * @param {(line: string) => void} cfg.log
46
+ */
47
+ export function createConnectionFactory(cfg) {
48
+ let counter = 0;
49
+
50
+ return function attach({ send: sendBytes }) {
51
+ const cid = ++counter;
52
+ const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
53
+ const state = {
54
+ sessionId: randomUUID(), nonce: null, username: null, authed: false,
55
+ closed: false, nextChannel: 0,
56
+ };
57
+ /** @type {Map<number, any>} */
58
+ const channels = new Map();
59
+ /** Channel ids awaiting their client-opened data stream, in OpenOk order. */
60
+ const pendingStreams = [];
61
+ const decoder = new FrameDecoder();
62
+ const pendingSends = [];
63
+ let control = null;
64
+ let chain = Promise.resolve();
65
+
66
+ const qmux = new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
67
+
68
+ const send = (msg) => {
69
+ if (state.closed) return Promise.resolve();
70
+ const bytes = frameEncode(msg);
71
+ if (control) return control.write(bytes).catch(() => {});
72
+ pendingSends.push(bytes);
73
+ return Promise.resolve();
74
+ };
75
+
76
+ function shutdown(why) {
77
+ if (state.closed) return;
78
+ state.closed = true;
79
+ for (const ch of channels.values()) ch.kill?.();
80
+ channels.clear();
81
+ try { qmux.destroy(new Error(why)); } catch { /* already gone */ }
82
+ log(`closed (${why})`);
83
+ }
84
+
85
+ qmux.onError = (e) => log(`qmux error: ${e.message}`);
86
+ qmux.onClose = () => shutdown('peer sent CONNECTION_CLOSE');
87
+ qmux.onStreamOpen = (s) => {
88
+ if (s.id !== 0) { bindDataStream(s); return; }
89
+ control = s;
90
+ s.onData = (d) => {
91
+ let msgs;
92
+ try { msgs = decoder.feed(d); } catch (e) { log(`frame decode error: ${e.message}`); return; }
93
+ for (const m of msgs) chain = chain.then(() => handle(m)).catch((e) => log(`handler error: ${e.message}`));
94
+ };
95
+ s.onEnd = () => shutdown('control stream FIN');
96
+ s.onReset = () => shutdown('control stream reset');
97
+ for (const b of pendingSends.splice(0)) s.write(b).catch(() => {});
98
+ };
99
+ qmux.sendHandshake();
100
+
101
+ function bindDataStream(s) {
102
+ const channelId = pendingStreams.shift();
103
+ const ch = channelId !== undefined ? channels.get(channelId) : undefined;
104
+ if (!ch?.bind) { s.close().catch(() => {}); return; }
105
+ ch.bind(s);
106
+ }
107
+
108
+ // ── Auth ──────────────────────────────────────────────────────────
109
+
110
+ async function handle(m) {
111
+ if (state.closed) return;
112
+ if (!state.authed) return handleAuth(m);
113
+ switch (m.type) {
114
+ case MSG.PING: return send(pong({ id: m.id }));
115
+ case MSG.OPEN: return handleOpen(m);
116
+ case MSG.SESSION_DATA: channels.get(m.channel_id)?.input?.(m.data); return;
117
+ case MSG.RESIZE: channels.get(m.channel_id)?.resize?.(m.cols, m.rows); return;
118
+ case MSG.SIGNAL: channels.get(m.channel_id)?.signal?.(String(m.signal ?? '')); return;
119
+ case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
120
+ case MSG.FILE_OP: return handleFileOp(m);
121
+ case MSG.FILE_CHUNK: return channels.get(m.channel_id)?.chunk?.(m);
122
+ default: log(`ignored message type 0x${m.type.toString(16)}`);
123
+ }
124
+ }
125
+
126
+ async function refuse(reason) {
127
+ log(`AUTH_FAIL: ${reason}`);
128
+ await send(authFail({ reason }));
129
+ setTimeout(() => shutdown('auth failed'), 50);
130
+ }
131
+
132
+ async function handleAuth(m) {
133
+ if (m.type === MSG.HELLO) {
134
+ const user = String(m.username ?? '');
135
+ if (!USERNAME_RE.test(user)) return refuse(`bad username ${JSON.stringify(user)}`);
136
+ if (m.auth_method && m.auth_method !== 'pubkey') return refuse('this host only accepts Ed25519 pubkey auth');
137
+ state.username = user;
138
+ state.nonce = generateNonce();
139
+ const features = [STREAM_ANNOUNCE];
140
+ if (cfg.files) features.push('file-transfer');
141
+ await send(serverHello({ sessionId: state.sessionId, features }));
142
+ await send(challenge({ nonce: state.nonce, sessionId: state.sessionId }));
143
+ return;
144
+ }
145
+ if (m.type === MSG.AUTH) {
146
+ if (!state.nonce) return;
147
+ if (m.method !== 'pubkey' || !(m.public_key instanceof Uint8Array) || !(m.signature instanceof Uint8Array)) {
148
+ return refuse('expected a pubkey AUTH with public_key and signature');
149
+ }
150
+ let fp;
151
+ let key;
152
+ try {
153
+ fp = await fingerprint(m.public_key);
154
+ key = await importPublicKeyRaw(m.public_key);
155
+ } catch { return refuse('malformed public key'); }
156
+ // Verify the signature first: nothing about who is on the allowlist
157
+ // is revealed to a caller who cannot even prove they hold a key.
158
+ let ok = false;
159
+ try {
160
+ ok = await verifyChallenge(key, m.signature, state.sessionId, state.nonce, { username: state.username });
161
+ } catch { ok = false; }
162
+ if (!ok) return refuse('signature does not verify over the transcript');
163
+ let allowed = false;
164
+ try {
165
+ allowed = await cfg.authorize({ username: state.username, fingerprint: fp, publicKey: m.public_key });
166
+ } catch (e) { log(`authorize threw: ${e.message}`); }
167
+ if (!allowed) return refuse('key not authorized');
168
+ state.authed = true;
169
+ state.nonce = null;
170
+ await send(authOk({ sessionId: state.sessionId, token: randomBytes(16), ttl: 3600 }));
171
+ log(`authenticated ${state.username} (${fp.slice(0, 12)}...)`);
172
+ }
173
+ }
174
+
175
+ // ── Open ──────────────────────────────────────────────────────────
176
+
177
+ function handleOpen(m) {
178
+ if (channels.size >= MAX_CHANNELS) return send(openFail({ reason: 'too many open channels' }));
179
+ switch (m.kind) {
180
+ case 'exec': return openExec(m);
181
+ case 'pty': return openPty(m);
182
+ case 'file': return openFile(m);
183
+ default: return send(openFail({ reason: `kind "${m.kind}" is not supported by this server` }));
184
+ }
185
+ }
186
+
187
+ function finishChannel(channelId, code) {
188
+ if (!channels.has(channelId) || state.closed) return Promise.resolve();
189
+ channels.delete(channelId);
190
+ return send(exitMsg({ channelId, code })).then(() => send(closeMsg({ channelId })));
191
+ }
192
+
193
+ async function openExec(m) {
194
+ if (!cfg.execRunner) return send(openFail({ reason: 'exec is not enabled on this server' }));
195
+ const command = String(m.command ?? '').trim();
196
+ if (!command) return send(openFail({ reason: 'command is required for kind "exec"' }));
197
+
198
+ const channelId = ++state.nextChannel;
199
+ const abort = new AbortController();
200
+ let stream = null;
201
+ let primerChecked = false;
202
+ let wq = Promise.resolve();
203
+ const buffered = [];
204
+ const boundWaiters = [];
205
+ const inputCbs = [];
206
+ const endCbs = [];
207
+ const signalCbs = [];
208
+
209
+ const ch = {
210
+ kill: () => abort.abort(),
211
+ signal: (name) => {
212
+ const n = name.replace(/^SIG/, '').toUpperCase();
213
+ for (const cb of signalCbs) cb(n);
214
+ if (/^(INT|TERM|KILL|HUP)$/.test(n) && signalCbs.length === 0) abort.abort();
215
+ },
216
+ bind(s) {
217
+ stream = s;
218
+ s.onData = (d) => {
219
+ // A client that primes (see header) sends one lone 0x00 first.
220
+ if (!primerChecked) {
221
+ primerChecked = true;
222
+ if (d.byteLength === 1 && d[0] === 0) return;
223
+ }
224
+ if (d.byteLength) for (const cb of inputCbs) cb(d);
225
+ };
226
+ s.onEnd = () => { for (const cb of endCbs) cb(); };
227
+ s.onReset = () => abort.abort();
228
+ const pending = buffered.splice(0);
229
+ wq = wq.then(async () => { for (const b of pending) await s.write(b).catch(() => {}); });
230
+ for (const r of boundWaiters.splice(0)) r();
231
+ },
232
+ };
233
+ channels.set(channelId, ch);
234
+ pendingStreams.push(channelId);
235
+
236
+ let timer = null;
237
+ if (cfg.execOptions.timeoutMs > 0) timer = setTimeout(() => abort.abort(), cfg.execOptions.timeoutMs);
238
+
239
+ const io = {
240
+ user: state.username,
241
+ env: m.env && typeof m.env === 'object' && cfg.execOptions.clientEnv !== false ? m.env : {},
242
+ cols: m.cols || 80,
243
+ rows: m.rows || 24,
244
+ signal: abort.signal,
245
+ write(data) {
246
+ const bytes = toBytes(data);
247
+ if (!stream) { buffered.push(bytes); return Promise.resolve(); }
248
+ const s = stream;
249
+ wq = wq.then(() => s.write(bytes).catch(() => {}));
250
+ return wq;
251
+ },
252
+ onInput: (cb) => inputCbs.push(cb),
253
+ onInputEnd: (cb) => endCbs.push(cb),
254
+ onSignal: (cb) => signalCbs.push(cb),
255
+ };
256
+
257
+ await send(openOk({
258
+ channelId, dataMode: 'stream', capabilities: ['signal'],
259
+ sessionId: randomUUID(), token: randomBytes(16),
260
+ }));
261
+ log(`exec channel ${channelId}: ${command}`);
262
+
263
+ // Detached: the message chain must stay free to deliver this
264
+ // session's Signal/Close/SessionData while the command runs.
265
+ runExec().catch((e) => log(`exec channel ${channelId}: ${e.message}`));
266
+
267
+ async function runExec() {
268
+ let code;
269
+ try { code = await cfg.execRunner(command, io); } catch (e) {
270
+ await io.write(`wsh: ${e.message}\n`);
271
+ code = 1;
272
+ }
273
+ if (timer) clearTimeout(timer);
274
+ // Output is ready but the client's stream may not have been bound yet
275
+ // (a host without announce support waits for the first byte): give it
276
+ // a bounded chance rather than dropping the output or hanging forever.
277
+ if (!stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
278
+ if (!stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
279
+ await wq;
280
+ try { await stream?.close(); } catch { /* peer gone */ }
281
+ await finishChannel(channelId, Number.isInteger(code) ? code : 0);
282
+ }
283
+ }
284
+
285
+ async function openPty(m) {
286
+ if (!cfg.pty) return send(openFail({ reason: 'pty is not enabled on this server' }));
287
+ const channelId = ++state.nextChannel;
288
+ const cols = m.cols || 80;
289
+ const rows = m.rows || 24;
290
+ const p = cfg.pty;
291
+ let proc;
292
+ try {
293
+ const args = m.command ? ['-c', String(m.command)] : [];
294
+ proc = p.spawn(p.shell, args, {
295
+ name: p.term, cols, rows, cwd: p.cwd,
296
+ env: { ...(p.env ?? process.env), TERM: p.term, ...(m.env && typeof m.env === 'object' ? m.env : {}) },
297
+ });
298
+ } catch (e) {
299
+ return send(openFail({ reason: `pty spawn failed: ${e.message}` }));
300
+ }
301
+ const ch = {
302
+ input: (data) => { try { proc.write(Buffer.from(data)); } catch { /* exited */ } },
303
+ resize: (c, r) => { try { proc.resize(c || cols, r || rows); } catch { /* exited */ } },
304
+ signal: (name) => { try { proc.kill(`SIG${name.replace(/^SIG/, '').toUpperCase()}`); } catch { /* exited */ } },
305
+ kill: () => { try { proc.kill(); } catch { /* exited */ } },
306
+ };
307
+ channels.set(channelId, ch);
308
+ let out = Promise.resolve();
309
+ proc.onData((d) => { out = out.then(() => send(sessionData({ channelId, data: toBytes(d) }))); });
310
+ proc.onExit(({ exitCode }) => { out.then(() => finishChannel(channelId, exitCode ?? 0)); });
311
+ await send(openOk({
312
+ channelId, dataMode: 'virtual', capabilities: ['resize', 'signal'],
313
+ sessionId: randomUUID(), token: randomBytes(16),
314
+ }));
315
+ log(`pty channel ${channelId} opened (${cols}x${rows})`);
316
+ }
317
+
318
+ async function openFile(m) {
319
+ if (!cfg.files) return send(openFail({ reason: 'file access is not enabled on this server' }));
320
+ const spec = String(m.command ?? '');
321
+ const mm = spec.match(/^(upload|download):(.*)$/s);
322
+ if (!mm) return send(openFail({ reason: 'file channel expects upload:<path> or download:<path>' }));
323
+ const [, direction, filePath] = mm;
324
+ const channelId = ++state.nextChannel;
325
+
326
+ if (direction === 'download') {
327
+ let data;
328
+ try { data = await cfg.files.readWhole(filePath); } catch (e) { return send(openFail({ reason: e.message })); }
329
+ channels.set(channelId, { kill: () => channels.delete(channelId) });
330
+ await send(openOk({ channelId, dataMode: 'virtual', capabilities: [] }));
331
+ let off = 0;
332
+ do {
333
+ const end = Math.min(off + FILE_CHUNK_BYTES, data.byteLength);
334
+ await send(fileChunk({ channelId, offset: off, data: data.subarray(off, end), isFinal: end >= data.byteLength, totalSize: data.byteLength }));
335
+ off = end;
336
+ } while (off < data.byteLength);
337
+ log(`download ${filePath} (${data.byteLength} B)`);
338
+ return;
339
+ }
340
+
341
+ const ch = { buf: null, kill: () => channels.delete(channelId) };
342
+ channels.set(channelId, ch);
343
+ const done = async (code, why) => {
344
+ if (why) log(`upload ${filePath}: ${why}`);
345
+ await finishChannel(channelId, code);
346
+ };
347
+ ch.chunk = async (c) => {
348
+ if (ch.failed) return;
349
+ const total = Number(c.total_size ?? 0);
350
+ if (total > cfg.files.maxFileBytes) { ch.failed = true; return done(1, `refused: ${total} B exceeds ${cfg.files.maxFileBytes} B`); }
351
+ ch.buf ??= new Uint8Array(total);
352
+ const data = c.data instanceof Uint8Array ? c.data : new Uint8Array(0);
353
+ if (c.offset + data.byteLength > ch.buf.byteLength) { ch.failed = true; return done(1, 'chunk past total_size'); }
354
+ ch.buf.set(data, c.offset);
355
+ if (c.is_final) {
356
+ try { await cfg.files.writeWhole(filePath, ch.buf); } catch (e) { ch.failed = true; return done(1, e.message); }
357
+ return done(0, `stored ${ch.buf.byteLength} B`);
358
+ }
359
+ };
360
+ await send(openOk({ channelId, dataMode: 'virtual', capabilities: [] }));
361
+ }
362
+
363
+ async function handleFileOp(m) {
364
+ const channelId = m.channel_id;
365
+ if (!cfg.files) return send(fileResult({ channelId, success: false, errorMessage: 'file access is not enabled on this server' }));
366
+ try {
367
+ const r = await cfg.files.operate(m.op, m.path, { offset: m.offset, length: m.length });
368
+ return send(fileResult({ channelId, success: true, metadata: r.metadata ?? {}, entries: r.entries ?? [] }));
369
+ } catch (e) {
370
+ const message = e.wsh ? e.message : (e.code === 'ENOENT' ? 'no such file or directory' : `${m.op} failed`);
371
+ if (!e.wsh && e.code !== 'ENOENT') log(`file ${m.op} ${m.path}: ${e.message}`);
372
+ return send(fileResult({ channelId, success: false, errorMessage: message }));
373
+ }
374
+ }
375
+
376
+ return {
377
+ receive(bytes) { if (!state.closed) qmux.receiveBytes(bytes); },
378
+ close() { shutdown('transport closed'); },
379
+ };
380
+ };
381
+ }
@@ -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
+ */