@johnhenry/wsh 0.19.1 → 0.21.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.
@@ -18,12 +18,15 @@ import { QMuxConnection } from '../qmux-connection.mjs';
18
18
  import { FrameDecoder, frameEncode } from '../cbor.mjs';
19
19
  import {
20
20
  MSG, serverHello, challenge, authOk, authFail, openOk, openFail, sessionData,
21
- exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk,
21
+ exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk, mcpTools, mcpResult,
22
+ presence as presenceMsg, error as errorMsg, sessionList, detachOk, detachFail,
22
23
  } from '../messages.gen.mjs';
23
24
  import {
24
25
  generateNonce, verifyChallenge, fingerprint, importPublicKeyRaw,
25
26
  } from '../auth.mjs';
26
27
  import { FILE_CHUNK_BYTES } from './fs.mjs';
28
+ import { MAX_ATTACHMENTS } from './sessions.mjs';
29
+ import { MCP_CALL_ID_FEATURE } from '../client.mjs';
27
30
  import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
28
31
 
29
32
  export const STREAM_ANNOUNCE = 'stream-announce';
@@ -32,6 +35,7 @@ const MAX_RENAME_PATH_BYTES = 4096;
32
35
  const WRITE_IDLE_MS = 30_000;
33
36
 
34
37
  const MAX_CHANNELS = 64;
38
+ const REPLAY_CHUNK_BYTES = 32 * 1024;
35
39
  const USERNAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
36
40
  const enc = new TextEncoder();
37
41
 
@@ -47,6 +51,8 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
47
51
  * @param {object | null} cfg.files - from createFileAccess
48
52
  * @param {{ pubkey: boolean, password: Function | null }} cfg.methods - which auth methods are on
49
53
  * @param {object | null} cfg.hostKey - from loadHostKey
54
+ * @param {object | null} cfg.mcp - from createMcpHost
55
+ * @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
50
56
  * @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
51
57
  * @param {number} cfg.bindTimeoutMs - how long exec output waits for the data stream
52
58
  * @param {(line: string) => void} cfg.log
@@ -59,12 +65,14 @@ export function createConnectionFactory(cfg) {
59
65
  const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
60
66
  const state = {
61
67
  sessionId: randomUUID(), nonce: null, username: null, authed: false,
62
- closed: false, nextChannel: 0,
68
+ closed: false, nextChannel: 0, fingerprint: null,
63
69
  };
64
70
  /** @type {Map<number, any>} */
65
71
  const channels = new Map();
66
72
  /** FileOp `write`/`rename`s awaiting their FileChunk frames, by client-chosen channel id. */
67
73
  const fileWrites = new Map();
74
+ /** AbortControllers of in-flight MCP calls: aborted on disconnect, counted for the cap. */
75
+ const mcpCalls = new Set();
68
76
  /** Channel ids awaiting their client-opened data stream, in OpenOk order. */
69
77
  const pendingStreams = [];
70
78
  const decoder = new FrameDecoder();
@@ -85,16 +93,25 @@ export function createConnectionFactory(cfg) {
85
93
  function shutdown(why) {
86
94
  if (state.closed) return;
87
95
  state.closed = true;
88
- for (const ch of channels.values()) ch.kill?.();
96
+ // A hosted session survives its connection (it is detached, then expires or is resumed);
97
+ // everything else dies with it.
98
+ for (const ch of [...channels.values()]) (ch.detach ?? ch.kill)?.call(ch);
89
99
  channels.clear();
100
+ for (const c of mcpCalls) c.abort(new Error('connection closed'));
101
+ mcpCalls.clear();
90
102
  for (const w of fileWrites.values()) clearTimeout(w.timer);
91
103
  fileWrites.clear();
92
104
  try { qmux.destroy(new Error(why)); } catch { /* already gone */ }
93
105
  log(`closed (${why})`);
94
106
  }
95
107
 
108
+ // A transport that closes right behind a message (a client's `disconnect()` sends Close for
109
+ // every channel and then goes) must not make the host forget that message: it is processed
110
+ // first, then the connection is torn down.
111
+ const shutdownAfterQueue = (why) => { chain = chain.then(() => shutdown(why)); };
112
+
96
113
  qmux.onError = (e) => log(`qmux error: ${e.message}`);
97
- qmux.onClose = () => shutdown('peer sent CONNECTION_CLOSE');
114
+ qmux.onClose = () => shutdownAfterQueue('peer sent CONNECTION_CLOSE');
98
115
  qmux.onStreamOpen = (s) => {
99
116
  if (s.id !== 0) { bindDataStream(s); return; }
100
117
  control = s;
@@ -103,8 +120,8 @@ export function createConnectionFactory(cfg) {
103
120
  try { msgs = decoder.feed(d); } catch (e) { log(`frame decode error: ${e.message}`); return; }
104
121
  for (const m of msgs) chain = chain.then(() => handle(m)).catch((e) => log(`handler error: ${e.message}`));
105
122
  };
106
- s.onEnd = () => shutdown('control stream FIN');
107
- s.onReset = () => shutdown('control stream reset');
123
+ s.onEnd = () => shutdownAfterQueue('control stream FIN');
124
+ s.onReset = () => shutdownAfterQueue('control stream reset');
108
125
  for (const b of pendingSends.splice(0)) s.write(b).catch(() => {});
109
126
  };
110
127
  qmux.sendHandshake();
@@ -130,6 +147,14 @@ export function createConnectionFactory(cfg) {
130
147
  case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
131
148
  case MSG.FILE_OP: return handleFileOp(m);
132
149
  case MSG.FILE_CHUNK: return (fileWrites.has(m.channel_id) ? handleWriteChunk(m) : channels.get(m.channel_id)?.chunk?.(m));
150
+ case MSG.ATTACH: return handleAttach(m, false);
151
+ case MSG.RESUME: return handleAttach(m, true);
152
+ case MSG.DETACH: return handleDetach(m);
153
+ case MSG.SESSION_LIST_REQUEST: return handleSessionList();
154
+ case MSG.MCP_DISCOVER: return handleMcpDiscover();
155
+ // Not awaited: the handler chain is serial, and a slow tool must not hold up
156
+ // the next call (or a Close) behind it.
157
+ case MSG.MCP_CALL: handleMcpCall(m); return;
133
158
  default: log(`ignored message type 0x${m.type.toString(16)}`);
134
159
  }
135
160
  }
@@ -155,6 +180,7 @@ export function createConnectionFactory(cfg) {
155
180
  state.username = user;
156
181
  state.method = method;
157
182
  const features = [STREAM_ANNOUNCE];
183
+ if (cfg.mcp) features.push(MCP_CALL_ID_FEATURE);
158
184
  if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
159
185
  let hostFingerprint;
160
186
  if (cfg.hostKey) {
@@ -199,6 +225,7 @@ export function createConnectionFactory(cfg) {
199
225
  } catch (e) { log(`authorize threw: ${e.message}`); }
200
226
  if (!allowed) return refuse('key not authorized');
201
227
  state.authed = true;
228
+ state.fingerprint = fp;
202
229
  state.nonce = null;
203
230
  await send(authOk({ sessionId: state.sessionId, token: randomBytes(16), ttl: 3600 }));
204
231
  log(`authenticated ${state.username} (${fp.slice(0, 12)}...)`);
@@ -228,6 +255,63 @@ export function createConnectionFactory(cfg) {
228
255
  log(`authenticated ${state.username} (password)`);
229
256
  }
230
257
 
258
+ // ── MCP ───────────────────────────────────────────────────────────
259
+
260
+ const principal = () => ({ username: state.username, fingerprint: state.fingerprint });
261
+
262
+ async function handleMcpDiscover() {
263
+ let tools = [];
264
+ if (cfg.mcp) {
265
+ try { tools = await cfg.mcp.list(state.username, principal()); } catch (e) { log(`mcp discover failed: ${e.message}`); }
266
+ }
267
+ return send(mcpTools({ tools }));
268
+ }
269
+
270
+ function handleMcpCall(m) {
271
+ const callId = typeof m.call_id === 'string' ? m.call_id : undefined;
272
+ const reply = (result) => {
273
+ try { return send(mcpResult({ result, callId })); } catch (e) {
274
+ log(`mcp result for ${m.tool} not encodable: ${e.message}`);
275
+ return send(mcpResult({ result: { success: false, error: 'tool result could not be encoded' }, callId }));
276
+ }
277
+ };
278
+ const fail = (error) => reply({ success: false, error });
279
+ if (!cfg.mcp) { fail('mcp is not enabled on this server'); return; }
280
+ if (mcpCalls.size >= cfg.mcp.maxConcurrent) { fail('too many concurrent MCP calls'); return; }
281
+
282
+ const abort = new AbortController();
283
+ mcpCalls.add(abort);
284
+ (async () => {
285
+ const name = typeof m.tool === 'string' ? m.tool : '';
286
+ const tool = name ? await cfg.mcp.find(name, state.username, principal()) : null;
287
+ if (!tool) return fail(`unknown tool: ${name || String(m.tool)}`);
288
+ const args = m.arguments ?? {};
289
+ const problem = cfg.mcp.validate(tool, args);
290
+ if (problem) return fail(`invalid arguments: ${problem}`);
291
+
292
+ let timer = null;
293
+ const aborted = new Promise((_, reject) => {
294
+ abort.signal.addEventListener('abort', () => reject(abort.signal.reason ?? new Error('aborted')), { once: true });
295
+ });
296
+ aborted.catch(() => {});
297
+ if (cfg.mcp.timeoutMs > 0) {
298
+ timer = setTimeout(() => abort.abort(new Error(`tool ${name} timed out after ${cfg.mcp.timeoutMs}ms`)), cfg.mcp.timeoutMs);
299
+ }
300
+ try {
301
+ const ctx = { user: state.username, fingerprint: state.fingerprint, signal: abort.signal };
302
+ const out = await Promise.race([Promise.resolve().then(() => tool.call(args, ctx)), aborted]);
303
+ return reply(out === undefined ? null : out);
304
+ } catch (e) {
305
+ if (!state.closed) log(`mcp tool ${name} failed: ${e?.message ?? e}`);
306
+ return fail(String(e?.message ?? e));
307
+ } finally {
308
+ clearTimeout(timer);
309
+ }
310
+ })()
311
+ .catch((e) => log(`mcp call ${m.tool}: ${e.message}`))
312
+ .finally(() => mcpCalls.delete(abort));
313
+ }
314
+
231
315
  // ── Open ──────────────────────────────────────────────────────────
232
316
 
233
317
  function handleOpen(m) {
@@ -246,6 +330,118 @@ export function createConnectionFactory(cfg) {
246
330
  return send(exitMsg({ channelId, code })).then(() => send(closeMsg({ channelId })));
247
331
  }
248
332
 
333
+ // ── Hosted sessions (createWshServer({ sessions })) ───────────────
334
+
335
+ /** Who this connection authenticated as: its key, or its username for a password login. */
336
+ const principalKey = () => state.fingerprint ?? `password:${state.username}`;
337
+ const ownerRecord = () => ({ username: state.username, fingerprint: state.fingerprint, principal: principalKey() });
338
+
339
+ /**
340
+ * This connection's message-backed attachment to a hosted session: output, Presence and the
341
+ * final Exit/Close all go through one ordered chain, so a replay is never overtaken by live output.
342
+ */
343
+ function virtualAttachment(hosted, channelId, mode) {
344
+ let out = Promise.resolve();
345
+ const enqueue = (fn) => { out = out.then(fn).catch(() => {}); return out; };
346
+ return {
347
+ connId: cid, channelId, mode, username: state.username, principal: principalKey(),
348
+ write: (bytes) => enqueue(() => send(sessionData({ channelId, data: bytes }))),
349
+ notify: (msg) => enqueue(() => send(presenceMsg(msg))),
350
+ exit: (code) => enqueue(async () => {
351
+ channels.delete(channelId);
352
+ await send(exitMsg({ channelId, code }));
353
+ await send(closeMsg({ channelId }));
354
+ }),
355
+ flush: () => out,
356
+ };
357
+ }
358
+
359
+ /** The `channels` entry for a virtual attachment (an Attach/Resume, or a pty's own channel). */
360
+ function attachedChannel(hosted, att) {
361
+ const control = () => att.mode === 'control';
362
+ const ch = {
363
+ session: hosted, attachment: att,
364
+ input: (d) => { if (control()) hosted.handlers.input?.(d); },
365
+ resize: (c, r) => { if (control()) hosted.handlers.resize?.(c, r); },
366
+ signal: (name) => { if (control()) hosted.handlers.signal?.(name); },
367
+ // The client closed the channel: the session's owner ends it, anyone else just leaves it.
368
+ kill: () => {
369
+ if (att.principal === hosted.owner.principal && control()) hosted.kill();
370
+ else ch.detach();
371
+ },
372
+ detach: () => { channels.delete(att.channelId); hosted.detach(att); },
373
+ };
374
+ channels.set(att.channelId, ch);
375
+ return ch;
376
+ }
377
+
378
+ async function handleAttach(m, isResume) {
379
+ const reg = cfg.sessions;
380
+ const fail = (code, message) => send(errorMsg({ code, message }));
381
+ if (!reg) return fail(3, `${isResume ? 'resume' : 'attach'} is not enabled on this server (no "sessions" option)`);
382
+ const s = reg.get(m.session_id);
383
+ const mine = !!s && s.owner.principal === principalKey();
384
+ const tokenOk = !!s && m.token !== undefined && reg.checkToken(s, m.token);
385
+ // Attach: the token OR ownership. Resume: the token AND ownership (the credentialed connection coming back).
386
+ if (!s || !(isResume ? tokenOk && mine : tokenOk || mine)) {
387
+ log(`${isResume ? 'resume' : 'attach'} refused for ${String(m.session_id).slice(0, 8)}`);
388
+ return fail(2, 'unknown session or not authorized');
389
+ }
390
+ if ([...channels.values()].some((c) => c.session === s)) return fail(3, 'already attached to this session on this connection');
391
+ if (s.attachments.size >= MAX_ATTACHMENTS) return fail(3, 'too many attachments to this session');
392
+ if (channels.size >= MAX_CHANNELS) return fail(3, 'too many open channels');
393
+
394
+ let from = s.ring.start;
395
+ if (isResume) {
396
+ const last = m.last_seq;
397
+ if (!Number.isSafeInteger(last) || last < 0) return fail(3, 'last_seq must be a non-negative integer');
398
+ if (last > s.ring.end) return fail(4, `last_seq ${last} is ahead of the session (it has produced ${s.ring.end} bytes)`);
399
+ if (last < s.ring.start) {
400
+ return fail(4, `output gap: this session's retained output starts at seq ${s.ring.start} but last_seq is ${last}; attach for the retained output instead`);
401
+ }
402
+ from = last;
403
+ }
404
+ const mode = !isResume && /^(readonly|read|view|ro)$/i.test(String(m.mode ?? '')) ? 'readonly' : 'control';
405
+ const channelId = ++state.nextChannel;
406
+ const att = virtualAttachment(s, channelId, mode);
407
+ attachedChannel(s, att);
408
+
409
+ // Reply, replay, then (if the process is already over) its exit -- all before any live output,
410
+ // which only reaches `att` once `s.attach(att)` registers it, after these are queued.
411
+ const self = { session_id: s.id, mode, username: state.username, channel_id: channelId, seq: from };
412
+ att.notify({ attachments: [self, ...s.roster()] });
413
+ const replay = s.ring.read(from);
414
+ for (let off = 0; off < replay.byteLength; off += REPLAY_CHUNK_BYTES) att.write(replay.subarray(off, off + REPLAY_CHUNK_BYTES));
415
+ if (s.state === 'exited') att.exit(s.exitCode ?? 0);
416
+ else s.attach(att);
417
+ log(`${isResume ? 'resumed' : `attached (${mode})`} session ${s.id.slice(0, 8)} on channel ${channelId} from seq ${from}`);
418
+ }
419
+
420
+ async function handleDetach(m) {
421
+ const s = cfg.sessions?.get(m.session_id);
422
+ const entry = s && [...channels.entries()].find(([, c]) => c.session === s);
423
+ if (!entry) return send(detachFail({ reason: 'not attached to this session on this connection' }));
424
+ const [channelId, ch] = entry;
425
+ const flushed = ch.attachment.flush();
426
+ ch.detach();
427
+ await flushed;
428
+ await send(closeMsg({ channelId }));
429
+ return send(detachOk({ sessionId: s.id }));
430
+ }
431
+
432
+ function handleSessionList() {
433
+ const now = Date.now();
434
+ const sessions = (cfg.sessions?.listFor(principalKey()) ?? []).map((s) => ({
435
+ session_id: s.id,
436
+ username: s.owner.username,
437
+ fingerprint_short: (s.owner.fingerprint ?? '').slice(0, 8),
438
+ created_at_secs: Math.floor(s.createdAt / 1000),
439
+ idle_secs: Math.max(0, Math.floor((now - s.lastActivity) / 1000)),
440
+ attached_count: s.attachments.size,
441
+ }));
442
+ return send(sessionList({ sessions }));
443
+ }
444
+
249
445
  async function openExec(m) {
250
446
  if (!cfg.execRunner) return send(openFail({ reason: 'exec is not enabled on this server' }));
251
447
  const command = String(m.command ?? '').trim();
@@ -253,6 +449,7 @@ export function createConnectionFactory(cfg) {
253
449
 
254
450
  const channelId = ++state.nextChannel;
255
451
  const abort = new AbortController();
452
+ const hosted = cfg.sessions ? cfg.sessions.create({ owner: ownerRecord(), kind: 'exec', command }) : null;
256
453
  let stream = null;
257
454
  let primerChecked = false;
258
455
  let wq = Promise.resolve();
@@ -262,13 +459,14 @@ export function createConnectionFactory(cfg) {
262
459
  const endCbs = [];
263
460
  const signalCbs = [];
264
461
 
462
+ const signalTo = (name) => {
463
+ const n = name.replace(/^SIG/, '').toUpperCase();
464
+ for (const cb of signalCbs) cb(n);
465
+ if (/^(INT|TERM|KILL|HUP)$/.test(n) && signalCbs.length === 0) abort.abort();
466
+ };
265
467
  const ch = {
266
468
  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
- },
469
+ signal: signalTo,
272
470
  bind(s) {
273
471
  stream = s;
274
472
  s.onData = (d) => {
@@ -280,7 +478,8 @@ export function createConnectionFactory(cfg) {
280
478
  if (d.byteLength) for (const cb of inputCbs) cb(d);
281
479
  };
282
480
  s.onEnd = () => { for (const cb of endCbs) cb(); };
283
- s.onReset = () => abort.abort();
481
+ // A hosted session outlives its stream; an unhosted one dies with it.
482
+ s.onReset = () => { if (!hosted) abort.abort(); };
284
483
  const pending = buffered.splice(0);
285
484
  wq = wq.then(async () => { for (const b of pending) await s.write(b).catch(() => {}); });
286
485
  for (const r of boundWaiters.splice(0)) r();
@@ -292,6 +491,49 @@ export function createConnectionFactory(cfg) {
292
491
  let timer = null;
293
492
  if (cfg.execOptions.timeoutMs > 0) timer = setTimeout(() => abort.abort(), cfg.execOptions.timeoutMs);
294
493
 
494
+ // Output to the opening connection's data stream (held until the client has opened it).
495
+ const toStream = (bytes) => {
496
+ if (!stream) { buffered.push(bytes); return Promise.resolve(); }
497
+ const s = stream;
498
+ wq = wq.then(() => s.write(bytes).catch(() => {}));
499
+ return wq;
500
+ };
501
+ // Output is ready but the client's stream may not have been bound yet (a host without
502
+ // announce support waits for the first byte): give it a bounded chance rather than
503
+ // dropping the output or hanging forever.
504
+ const closeStream = async (code) => {
505
+ if (!stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
506
+ if (!stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
507
+ await wq;
508
+ try { await stream?.close(); } catch { /* peer gone */ }
509
+ await finishChannel(channelId, Number.isInteger(code) ? code : 0);
510
+ };
511
+
512
+ if (hosted) {
513
+ const att = {
514
+ connId: cid, channelId, mode: 'control', username: state.username, principal: principalKey(),
515
+ write: toStream,
516
+ notify: (msg) => send(presenceMsg(msg)),
517
+ exit: (code) => closeStream(code),
518
+ flush: () => wq,
519
+ };
520
+ hosted.handlers = {
521
+ input: (d) => { for (const cb of inputCbs) cb(d); },
522
+ inputEnd: () => { for (const cb of endCbs) cb(); },
523
+ signal: signalTo,
524
+ resize: () => {},
525
+ kill: () => abort.abort(),
526
+ };
527
+ ch.session = hosted;
528
+ ch.attachment = att;
529
+ ch.detach = () => {
530
+ channels.delete(channelId);
531
+ hosted.detach(att);
532
+ try { stream?.close().catch(() => {}); } catch { /* gone */ }
533
+ };
534
+ hosted.attach(att);
535
+ }
536
+
295
537
  const io = {
296
538
  user: state.username,
297
539
  env: m.env && typeof m.env === 'object' && cfg.execOptions.clientEnv !== false ? m.env : {},
@@ -300,9 +542,8 @@ export function createConnectionFactory(cfg) {
300
542
  signal: abort.signal,
301
543
  write(data) {
302
544
  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(() => {}));
545
+ if (!hosted) return toStream(bytes);
546
+ hosted.push(bytes);
306
547
  return wq;
307
548
  },
308
549
  onInput: (cb) => inputCbs.push(cb),
@@ -312,7 +553,7 @@ export function createConnectionFactory(cfg) {
312
553
 
313
554
  await send(openOk({
314
555
  channelId, dataMode: 'stream', capabilities: ['signal'],
315
- sessionId: randomUUID(), token: randomBytes(16),
556
+ sessionId: hosted?.id ?? randomUUID(), token: hosted?.token ?? randomBytes(16),
316
557
  }));
317
558
  log(`exec channel ${channelId}: ${command}`);
318
559
 
@@ -321,20 +562,15 @@ export function createConnectionFactory(cfg) {
321
562
  runExec().catch((e) => log(`exec channel ${channelId}: ${e.message}`));
322
563
 
323
564
  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);
565
+ let code;
566
+ try { code = await cfg.execRunner(command, io); } catch (e) {
567
+ await io.write(`wsh: ${e.message}\n`);
568
+ code = 1;
569
+ }
570
+ if (timer) clearTimeout(timer);
571
+ // Hosted: everyone attached (this connection's stream included, if it is still there) hears the exit.
572
+ if (hosted) { hosted.finish(Number.isInteger(code) ? code : 0); return; }
573
+ await closeStream(code);
338
574
  }
339
575
  }
340
576
 
@@ -354,13 +590,31 @@ export function createConnectionFactory(cfg) {
354
590
  } catch (e) {
355
591
  return send(openFail({ reason: `pty spawn failed: ${e.message}` }));
356
592
  }
357
- const ch = {
593
+ const ctl = {
358
594
  input: (data) => { try { proc.write(Buffer.from(data)); } catch { /* exited */ } },
359
595
  resize: (c, r) => { try { proc.resize(c || cols, r || rows); } catch { /* exited */ } },
360
596
  signal: (name) => { try { proc.kill(`SIG${name.replace(/^SIG/, '').toUpperCase()}`); } catch { /* exited */ } },
361
597
  kill: () => { try { proc.kill(); } catch { /* exited */ } },
362
598
  };
363
- channels.set(channelId, ch);
599
+
600
+ if (cfg.sessions) {
601
+ const hosted = cfg.sessions.create({ owner: ownerRecord(), kind: 'pty', command: m.command });
602
+ hosted.handlers = ctl;
603
+ const att = virtualAttachment(hosted, channelId, 'control');
604
+ attachedChannel(hosted, att);
605
+ proc.onData((d) => hosted.push(Uint8Array.from(toBytes(d))));
606
+ proc.onExit(({ exitCode }) => hosted.finish(exitCode ?? 0));
607
+ const opened = send(openOk({
608
+ channelId, dataMode: 'virtual', capabilities: ['resize', 'signal'],
609
+ sessionId: hosted.id, token: hosted.token,
610
+ }));
611
+ hosted.attach(att);
612
+ await opened;
613
+ log(`pty channel ${channelId} opened (${cols}x${rows}), session ${hosted.id.slice(0, 8)}`);
614
+ return;
615
+ }
616
+
617
+ channels.set(channelId, ctl);
364
618
  let out = Promise.resolve();
365
619
  proc.onData((d) => { out = out.then(() => send(sessionData({ channelId, data: toBytes(d) }))); });
366
620
  proc.onExit(({ exitCode }) => { out.then(() => finishChannel(channelId, exitCode ?? 0)); });
@@ -487,7 +741,7 @@ export function createConnectionFactory(cfg) {
487
741
 
488
742
  return {
489
743
  receive(bytes) { if (!state.closed) qmux.receiveBytes(bytes); },
490
- close() { shutdown('transport closed'); },
744
+ close() { shutdownAfterQueue('transport closed'); },
491
745
  };
492
746
  };
493
747
  }
@@ -119,6 +119,56 @@ export interface WshServerFsOptions {
119
119
  maxFileBytes?: number;
120
120
  }
121
121
 
122
+ /** Context handed to an MCP tool's `call()`. */
123
+ export interface WshMcpCallContext {
124
+ /** Authenticated username. */
125
+ user: string;
126
+ /** Hex SHA-256 of the client's key (`null` for a password login). */
127
+ fingerprint: string | null;
128
+ /** Aborts on client disconnect or `timeoutMs`. A tool that ignores it keeps running but its reply is dropped. */
129
+ signal: AbortSignal;
130
+ }
131
+
132
+ /** One tool exposed over `McpDiscover` / `McpCall`. */
133
+ export interface WshMcpTool {
134
+ name: string;
135
+ description?: string;
136
+ /** JSON Schema for the arguments (default `{ type: 'object' }`). Enforced before `call()` runs; keywords outside the supported subset are refused at startup. Advertised to clients as `parameters`. */
137
+ inputSchema?: Record<string, unknown> | boolean;
138
+ /** The result is sent as `McpResult.result` verbatim (any CBOR-encodable value). Throwing sends `{ success: false, error: message }`. */
139
+ call(args: any, ctx: WshMcpCallContext): unknown | Promise<unknown>;
140
+ }
141
+
142
+ /** The part of an `@modelcontextprotocol/sdk` `Client` that the server proxies. */
143
+ export interface WshMcpClientLike {
144
+ listTools(): Promise<{ tools: Array<{ name: string; description?: string; inputSchema?: Record<string, unknown> }> }>;
145
+ callTool(params: { name: string; arguments: unknown }, resultSchema?: unknown, options?: { signal?: AbortSignal }): Promise<{ content?: unknown; structuredContent?: unknown; isError?: boolean }>;
146
+ }
147
+
148
+ export interface WshServerMcpOptions {
149
+ /** Tools to serve: a list, or a `{ name: { description, inputSchema, call } }` map. */
150
+ tools?: WshMcpTool[] | Record<string, Omit<WshMcpTool, 'name'>>;
151
+ /** An MCP client whose tools are proxied (local `tools` win on a name clash). */
152
+ client?: WshMcpClientLike;
153
+ /** Per-principal filter, applied to discovery and to calls alike (a hidden tool looks unknown). */
154
+ authorize?: (user: string, tool: { name: string; description: string; inputSchema: unknown }, who: { username: string; fingerprint: string | null }) => boolean | Promise<boolean>;
155
+ /** In-flight calls per connection (default 8); further calls get an error result. */
156
+ maxConcurrent?: number;
157
+ /** Per-call time limit in ms (default 30000; 0 = none). */
158
+ timeoutMs?: number;
159
+ }
160
+
161
+ export interface WshServerSessionsOptions {
162
+ /** How long a session with nobody attached keeps running (default 300000). `0` kills it the moment its last connection goes. */
163
+ detachTtlMs?: number;
164
+ /** Unattended sessions kept at once (default 16); beyond that the longest-detached is killed. */
165
+ maxDetached?: number;
166
+ /** Output history kept per session, in bytes (default 1 MiB) -- what a resume can replay. Older output is dropped; a Resume older than it is refused with an "output gap" error. */
167
+ ringBytes?: number;
168
+ /** Secret the session tokens are minted with (default: random per process). Fixing it makes tokens verifiable across restarts. */
169
+ sessionSecret?: string | Uint8Array;
170
+ }
171
+
122
172
  export interface WshServerOptions {
123
173
  /** Bind address (default `127.0.0.1`). */
124
174
  host?: string;
@@ -138,6 +188,16 @@ export interface WshServerOptions {
138
188
  hostKey?: true | { file: string } | CryptoKeyPair;
139
189
  /** Enable file ops and uploads/downloads under one directory. Off by default. */
140
190
  fs?: WshServerFsOptions;
191
+ /**
192
+ * Keep pty/exec sessions alive across disconnects so `resumeSession()` /
193
+ * `attachSession()` / `detach()` / `listRemoteSessions()` work against them.
194
+ * `true` for the defaults. Off by default: sessions then die with their connection.
195
+ */
196
+ sessions?: true | WshServerSessionsOptions;
197
+ /** Alias for `sessions.sessionSecret`. */
198
+ sessionSecret?: string | Uint8Array;
199
+ /** Serve MCP tools (`McpDiscover` / `McpCall`) and advertise `mcp-call-id`. Off by default. */
200
+ mcp?: WshServerMcpOptions;
141
201
  /** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
142
202
  bindTimeoutMs?: number;
143
203
  onLog?: (line: string) => void;
@@ -13,6 +13,8 @@ import { loadHostKey } from './host-key.mjs';
13
13
  import { spawnRunner } from './exec.mjs';
14
14
  import { normalizePty } from './pty.mjs';
15
15
  import { createFileAccess } from './fs.mjs';
16
+ import { createMcpHost } from './mcp.mjs';
17
+ import { SessionRegistry } from './sessions.mjs';
16
18
  import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
17
19
 
18
20
  export { parseAuthorizedKeys, STREAM_ANNOUNCE };
@@ -26,6 +28,14 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
26
28
  * `{ cwd, env, shell, timeoutMs, clientEnv }`, or a custom `run(command, io)` function.
27
29
  * @param {{ spawn: Function, shell?: string, cwd?: string, env?: object }} [options.pty]
28
30
  * @param {{ root: string, readOnly?: boolean, maxFileBytes?: number }} [options.fs]
31
+ * @param {{ tools?: object[] | object, client?: object, authorize?: Function, maxConcurrent?: number, timeoutMs?: number }} [options.mcp]
32
+ * MCP tools served over `McpDiscover` / `McpCall`: `tools` (`{ name, description, inputSchema, call(args, { user, signal }) }`),
33
+ * and/or `client` (an `@modelcontextprotocol/sdk` Client to proxy). Off unless given.
34
+ * @param {true | { detachTtlMs?: number, maxDetached?: number, ringBytes?: number, sessionSecret?: string | Uint8Array }} [options.sessions]
35
+ * Keep pty/exec sessions alive across disconnects so a client can `resumeSession()` / `attachSession()`
36
+ * them: a per-server registry, a ring buffer of the newest `ringBytes` (default 1 MiB) of output, and
37
+ * `detachTtlMs` (default 300000; 0 = kill on disconnect) / `maxDetached` (default 16) bounds. Off unless given.
38
+ * @param {string | Uint8Array} [options.sessionSecret] - Alias for `sessions.sessionSecret`.
29
39
  * @param {true | { file: string } | CryptoKeyPair} [options.hostKey] - The server's own Ed25519
30
40
  * identity, advertised (with proof of possession) so clients can pin it. See `src/host-key.mjs`.
31
41
  * @param {object} [options.auth.rateLimit] - Password-failure throttle: `{ maxFailures=5, windowMs=60000,
@@ -35,7 +45,7 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
35
45
  * @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
36
46
  */
37
47
  export function createWshServer({
38
- host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, bindTimeoutMs = 3000, onLog = () => {},
48
+ host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, bindTimeoutMs = 3000, onLog = () => {},
39
49
  } = {}) {
40
50
  let wss = null;
41
51
  let bound = null;
@@ -50,6 +60,7 @@ export function createWshServer({
50
60
  }
51
61
  const ptyConfig = pty ? normalizePty(pty) : null;
52
62
  const files = fs ? createFileAccess(fs) : null;
63
+ const mcpHost = mcp ? createMcpHost(mcp) : null;
53
64
  const methods = authMethods(auth);
54
65
  const rl = auth?.rateLimit ?? {};
55
66
  const rateLimit = {
@@ -58,6 +69,8 @@ export function createWshServer({
58
69
  failureDelayMs: rl.failureDelayMs ?? 250,
59
70
  };
60
71
  let host_ = null;
72
+ let registry = null;
73
+ const sessionOptions = sessions ? (sessions === true ? {} : sessions) : null;
61
74
 
62
75
  return {
63
76
  async listen() {
@@ -70,9 +83,19 @@ export function createWshServer({
70
83
  }
71
84
  const authorize = await buildAuthorizer(auth);
72
85
  host_ = hostKey ? await loadHostKey(hostKey) : null;
86
+ if (sessionOptions) {
87
+ const secret = sessionOptions.sessionSecret ?? sessionSecret;
88
+ registry = new SessionRegistry({
89
+ secret: secret === undefined ? undefined : Buffer.from(secret),
90
+ detachTtlMs: sessionOptions.detachTtlMs,
91
+ maxDetached: sessionOptions.maxDetached,
92
+ ringBytes: sessionOptions.ringBytes,
93
+ log: onLog,
94
+ });
95
+ }
73
96
  const attach = createConnectionFactory({
74
97
  authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
75
- methods, hostKey: host_, rateLimit,
98
+ methods, hostKey: host_, rateLimit, mcp: mcpHost, sessions: registry,
76
99
  });
77
100
 
78
101
  wss = new WebSocketServer({ host, port });
@@ -104,6 +127,8 @@ export function createWshServer({
104
127
  bound = null;
105
128
  for (const client of server.clients) client.terminate();
106
129
  await new Promise((resolve) => server.close(resolve));
130
+ registry?.closeAll();
131
+ registry = null;
107
132
  },
108
133
 
109
134
  address() {