@johnhenry/wsh 0.20.0 → 0.22.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.
@@ -19,11 +19,13 @@ import { FrameDecoder, frameEncode } from '../cbor.mjs';
19
19
  import {
20
20
  MSG, serverHello, challenge, authOk, authFail, openOk, openFail, sessionData,
21
21
  exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk, mcpTools, mcpResult,
22
+ presence as presenceMsg, error as errorMsg, sessionList, detachOk, detachFail, reversePeers, reverseReject,
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';
27
29
  import { MCP_CALL_ID_FEATURE } from '../client.mjs';
28
30
  import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
29
31
 
@@ -33,6 +35,7 @@ const MAX_RENAME_PATH_BYTES = 4096;
33
35
  const WRITE_IDLE_MS = 30_000;
34
36
 
35
37
  const MAX_CHANNELS = 64;
38
+ const REPLAY_CHUNK_BYTES = 32 * 1024;
36
39
  const USERNAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
37
40
  const enc = new TextEncoder();
38
41
 
@@ -49,6 +52,8 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
49
52
  * @param {{ pubkey: boolean, password: Function | null }} cfg.methods - which auth methods are on
50
53
  * @param {object | null} cfg.hostKey - from loadHostKey
51
54
  * @param {object | null} cfg.mcp - from createMcpHost
55
+ * @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
56
+ * @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
52
57
  * @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
53
58
  * @param {number} cfg.bindTimeoutMs - how long exec output waits for the data stream
54
59
  * @param {(line: string) => void} cfg.log
@@ -56,12 +61,20 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
56
61
  export function createConnectionFactory(cfg) {
57
62
  let counter = 0;
58
63
 
59
- return function attach({ send: sendBytes, remote = {} }) {
64
+ /**
65
+ * Attach one connection. Two shapes:
66
+ * - bytes (the normal one): `{ send(bytes), remote, closeTransport() }` -> `{ receive(bytes), close() }`, speaking QMux.
67
+ * - messages: `{ sendMessage(msg), authenticated, dataStreams }` -> `{ receiveMessage(msg), bindStream(s), close() }`,
68
+ * for a transport that already frames messages itself (a relay bridge, WebTransport). `authenticated`
69
+ * (`{ username, fingerprint }`) skips the handshake for a peer something else has already authenticated;
70
+ * `dataStreams: false` means there are no client-opened streams to bind, so exec output rides SessionData.
71
+ */
72
+ return function attach({ send: sendBytes, sendMessage = null, remote = {}, authenticated = null, dataStreams = true, closeTransport = null }) {
60
73
  const cid = ++counter;
61
74
  const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
62
75
  const state = {
63
- sessionId: randomUUID(), nonce: null, username: null, authed: false,
64
- closed: false, nextChannel: 0, fingerprint: null,
76
+ sessionId: randomUUID(), nonce: null, username: authenticated?.username ?? null, authed: !!authenticated,
77
+ closed: false, nextChannel: 0, fingerprint: authenticated?.fingerprint ?? null,
65
78
  };
66
79
  /** @type {Map<number, any>} */
67
80
  const channels = new Map();
@@ -75,11 +88,16 @@ export function createConnectionFactory(cfg) {
75
88
  const pendingSends = [];
76
89
  let control = null;
77
90
  let chain = Promise.resolve();
91
+ /** This connection as the relay hub sees it (created on first need). */
92
+ let relayHandle = null;
78
93
 
79
- const qmux = new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
94
+ const qmux = sendMessage ? null : new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
80
95
 
81
96
  const send = (msg) => {
82
97
  if (state.closed) return Promise.resolve();
98
+ if (sendMessage) {
99
+ try { return Promise.resolve(sendMessage(msg)).catch(() => {}); } catch { return Promise.resolve(); }
100
+ }
83
101
  const bytes = frameEncode(msg);
84
102
  if (control) return control.write(bytes).catch(() => {});
85
103
  pendingSends.push(bytes);
@@ -89,18 +107,29 @@ export function createConnectionFactory(cfg) {
89
107
  function shutdown(why) {
90
108
  if (state.closed) return;
91
109
  state.closed = true;
92
- for (const ch of channels.values()) ch.kill?.();
110
+ // A hosted session survives its connection (it is detached, then expires or is resumed);
111
+ // everything else dies with it.
112
+ for (const ch of [...channels.values()]) (ch.detach ?? ch.kill)?.call(ch);
93
113
  channels.clear();
94
114
  for (const c of mcpCalls) c.abort(new Error('connection closed'));
95
115
  mcpCalls.clear();
96
116
  for (const w of fileWrites.values()) clearTimeout(w.timer);
97
117
  fileWrites.clear();
98
- try { qmux.destroy(new Error(why)); } catch { /* already gone */ }
118
+ try { qmux?.destroy(new Error(why)); } catch { /* already gone */ }
119
+ // The host ending the connection (a relay bridge ending, a refused login) must end the socket too.
120
+ try { closeTransport?.(); } catch { /* already gone */ }
121
+ relayHandle && cfg.relay?.drop(relayHandle);
99
122
  log(`closed (${why})`);
100
123
  }
101
124
 
125
+ // A transport that closes right behind a message (a client's `disconnect()` sends Close for
126
+ // every channel and then goes) must not make the host forget that message: it is processed
127
+ // first, then the connection is torn down.
128
+ const shutdownAfterQueue = (why) => { chain = chain.then(() => shutdown(why)); };
129
+
130
+ if (qmux) {
102
131
  qmux.onError = (e) => log(`qmux error: ${e.message}`);
103
- qmux.onClose = () => shutdown('peer sent CONNECTION_CLOSE');
132
+ qmux.onClose = () => shutdownAfterQueue('peer sent CONNECTION_CLOSE');
104
133
  qmux.onStreamOpen = (s) => {
105
134
  if (s.id !== 0) { bindDataStream(s); return; }
106
135
  control = s;
@@ -109,11 +138,12 @@ export function createConnectionFactory(cfg) {
109
138
  try { msgs = decoder.feed(d); } catch (e) { log(`frame decode error: ${e.message}`); return; }
110
139
  for (const m of msgs) chain = chain.then(() => handle(m)).catch((e) => log(`handler error: ${e.message}`));
111
140
  };
112
- s.onEnd = () => shutdown('control stream FIN');
113
- s.onReset = () => shutdown('control stream reset');
141
+ s.onEnd = () => shutdownAfterQueue('control stream FIN');
142
+ s.onReset = () => shutdownAfterQueue('control stream reset');
114
143
  for (const b of pendingSends.splice(0)) s.write(b).catch(() => {});
115
144
  };
116
145
  qmux.sendHandshake();
146
+ }
117
147
 
118
148
  function bindDataStream(s) {
119
149
  const channelId = pendingStreams.shift();
@@ -127,6 +157,7 @@ export function createConnectionFactory(cfg) {
127
157
  async function handle(m) {
128
158
  if (state.closed) return;
129
159
  if (!state.authed) return handleAuth(m);
160
+ if (await handleRelay(m)) return;
130
161
  switch (m.type) {
131
162
  case MSG.PING: return send(pong({ id: m.id }));
132
163
  case MSG.OPEN: return handleOpen(m);
@@ -136,6 +167,10 @@ export function createConnectionFactory(cfg) {
136
167
  case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
137
168
  case MSG.FILE_OP: return handleFileOp(m);
138
169
  case MSG.FILE_CHUNK: return (fileWrites.has(m.channel_id) ? handleWriteChunk(m) : channels.get(m.channel_id)?.chunk?.(m));
170
+ case MSG.ATTACH: return handleAttach(m, false);
171
+ case MSG.RESUME: return handleAttach(m, true);
172
+ case MSG.DETACH: return handleDetach(m);
173
+ case MSG.SESSION_LIST_REQUEST: return handleSessionList();
139
174
  case MSG.MCP_DISCOVER: return handleMcpDiscover();
140
175
  // Not awaited: the handler chain is serial, and a slow tool must not hold up
141
176
  // the next call (or a Close) behind it.
@@ -240,6 +275,56 @@ export function createConnectionFactory(cfg) {
240
275
  log(`authenticated ${state.username} (password)`);
241
276
  }
242
277
 
278
+ // ── Relay (createWshServer({ relay })) ────────────────────────────
279
+
280
+ const RELAY_TYPES = new Set([MSG.REVERSE_REGISTER, MSG.REVERSE_LIST, MSG.REVERSE_CONNECT, MSG.REVERSE_ACCEPT, MSG.REVERSE_REJECT, MSG.RELAY_FORWARD]);
281
+
282
+ function hubHandle() {
283
+ relayHandle ??= {
284
+ send, fingerprint: state.fingerprint, username: state.username,
285
+ close: () => shutdownAfterQueue('relay bridge ended'),
286
+ };
287
+ return relayHandle;
288
+ }
289
+
290
+ /** @returns {Promise<boolean>} true when the message was the relay's and has been dealt with */
291
+ async function handleRelay(m) {
292
+ const hub = cfg.relay;
293
+ if (!hub) {
294
+ // Not a relay: answer instead of leaving the client to time out.
295
+ if (m.type === MSG.REVERSE_LIST) { await send(reversePeers({ peers: [] })); return true; }
296
+ if (m.type === MSG.REVERSE_CONNECT) { await send(reverseReject({ targetFingerprint: String(m.target_fingerprint ?? ''), username: '', reason: 'relay is not enabled on this server' })); return true; }
297
+ if (m.type === MSG.REVERSE_REGISTER) { await send(errorMsg({ code: 3, message: 'relay is not enabled on this server' })); return true; }
298
+ return false;
299
+ }
300
+ if (!RELAY_TYPES.has(m.type)) {
301
+ // Anything else a bridged connection sends that may cross a bridge, does.
302
+ if (!relayHandle) return false;
303
+ hub.touch(relayHandle);
304
+ return hub.forward(relayHandle, m);
305
+ }
306
+ // Relay roles are keyed by an authenticated key: from_fingerprint means nothing for a password login.
307
+ if (!state.fingerprint) {
308
+ await send(errorMsg({ code: 2, message: 'relay operations need a key login' }));
309
+ return true;
310
+ }
311
+ const h = hubHandle();
312
+ switch (m.type) {
313
+ case MSG.REVERSE_REGISTER: {
314
+ const reason = await hub.register(h, m, { username: state.username, fingerprint: state.fingerprint });
315
+ if (reason) {
316
+ log(`ReverseRegister refused: ${reason}`);
317
+ await send(errorMsg({ code: 2, message: `registration refused: ${reason}` }));
318
+ }
319
+ return true;
320
+ }
321
+ case MSG.REVERSE_LIST: await send(await hub.list({ username: state.username, fingerprint: state.fingerprint })); return true;
322
+ case MSG.REVERSE_CONNECT: await hub.connect(h, m); return true;
323
+ case MSG.REVERSE_ACCEPT: case MSG.REVERSE_REJECT: hub.answer(h, m); return true;
324
+ default: hub.forward(h, m); return true; // a RelayForward the client wrote itself
325
+ }
326
+ }
327
+
243
328
  // ── MCP ───────────────────────────────────────────────────────────
244
329
 
245
330
  const principal = () => ({ username: state.username, fingerprint: state.fingerprint });
@@ -315,6 +400,118 @@ export function createConnectionFactory(cfg) {
315
400
  return send(exitMsg({ channelId, code })).then(() => send(closeMsg({ channelId })));
316
401
  }
317
402
 
403
+ // ── Hosted sessions (createWshServer({ sessions })) ───────────────
404
+
405
+ /** Who this connection authenticated as: its key, or its username for a password login. */
406
+ const principalKey = () => state.fingerprint ?? `password:${state.username}`;
407
+ const ownerRecord = () => ({ username: state.username, fingerprint: state.fingerprint, principal: principalKey() });
408
+
409
+ /**
410
+ * This connection's message-backed attachment to a hosted session: output, Presence and the
411
+ * final Exit/Close all go through one ordered chain, so a replay is never overtaken by live output.
412
+ */
413
+ function virtualAttachment(hosted, channelId, mode) {
414
+ let out = Promise.resolve();
415
+ const enqueue = (fn) => { out = out.then(fn).catch(() => {}); return out; };
416
+ return {
417
+ connId: cid, channelId, mode, username: state.username, principal: principalKey(),
418
+ write: (bytes) => enqueue(() => send(sessionData({ channelId, data: bytes }))),
419
+ notify: (msg) => enqueue(() => send(presenceMsg(msg))),
420
+ exit: (code) => enqueue(async () => {
421
+ channels.delete(channelId);
422
+ await send(exitMsg({ channelId, code }));
423
+ await send(closeMsg({ channelId }));
424
+ }),
425
+ flush: () => out,
426
+ };
427
+ }
428
+
429
+ /** The `channels` entry for a virtual attachment (an Attach/Resume, or a pty's own channel). */
430
+ function attachedChannel(hosted, att) {
431
+ const control = () => att.mode === 'control';
432
+ const ch = {
433
+ session: hosted, attachment: att,
434
+ input: (d) => { if (control()) hosted.handlers.input?.(d); },
435
+ resize: (c, r) => { if (control()) hosted.handlers.resize?.(c, r); },
436
+ signal: (name) => { if (control()) hosted.handlers.signal?.(name); },
437
+ // The client closed the channel: the session's owner ends it, anyone else just leaves it.
438
+ kill: () => {
439
+ if (att.principal === hosted.owner.principal && control()) hosted.kill();
440
+ else ch.detach();
441
+ },
442
+ detach: () => { channels.delete(att.channelId); hosted.detach(att); },
443
+ };
444
+ channels.set(att.channelId, ch);
445
+ return ch;
446
+ }
447
+
448
+ async function handleAttach(m, isResume) {
449
+ const reg = cfg.sessions;
450
+ const fail = (code, message) => send(errorMsg({ code, message }));
451
+ if (!reg) return fail(3, `${isResume ? 'resume' : 'attach'} is not enabled on this server (no "sessions" option)`);
452
+ const s = reg.get(m.session_id);
453
+ const mine = !!s && s.owner.principal === principalKey();
454
+ const tokenOk = !!s && m.token !== undefined && reg.checkToken(s, m.token);
455
+ // Attach: the token OR ownership. Resume: the token AND ownership (the credentialed connection coming back).
456
+ if (!s || !(isResume ? tokenOk && mine : tokenOk || mine)) {
457
+ log(`${isResume ? 'resume' : 'attach'} refused for ${String(m.session_id).slice(0, 8)}`);
458
+ return fail(2, 'unknown session or not authorized');
459
+ }
460
+ if ([...channels.values()].some((c) => c.session === s)) return fail(3, 'already attached to this session on this connection');
461
+ if (s.attachments.size >= MAX_ATTACHMENTS) return fail(3, 'too many attachments to this session');
462
+ if (channels.size >= MAX_CHANNELS) return fail(3, 'too many open channels');
463
+
464
+ let from = s.ring.start;
465
+ if (isResume) {
466
+ const last = m.last_seq;
467
+ if (!Number.isSafeInteger(last) || last < 0) return fail(3, 'last_seq must be a non-negative integer');
468
+ if (last > s.ring.end) return fail(4, `last_seq ${last} is ahead of the session (it has produced ${s.ring.end} bytes)`);
469
+ if (last < s.ring.start) {
470
+ 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`);
471
+ }
472
+ from = last;
473
+ }
474
+ const mode = !isResume && /^(readonly|read|view|ro)$/i.test(String(m.mode ?? '')) ? 'readonly' : 'control';
475
+ const channelId = ++state.nextChannel;
476
+ const att = virtualAttachment(s, channelId, mode);
477
+ attachedChannel(s, att);
478
+
479
+ // Reply, replay, then (if the process is already over) its exit -- all before any live output,
480
+ // which only reaches `att` once `s.attach(att)` registers it, after these are queued.
481
+ const self = { session_id: s.id, mode, username: state.username, channel_id: channelId, seq: from };
482
+ att.notify({ attachments: [self, ...s.roster()] });
483
+ const replay = s.ring.read(from);
484
+ for (let off = 0; off < replay.byteLength; off += REPLAY_CHUNK_BYTES) att.write(replay.subarray(off, off + REPLAY_CHUNK_BYTES));
485
+ if (s.state === 'exited') att.exit(s.exitCode ?? 0);
486
+ else s.attach(att);
487
+ log(`${isResume ? 'resumed' : `attached (${mode})`} session ${s.id.slice(0, 8)} on channel ${channelId} from seq ${from}`);
488
+ }
489
+
490
+ async function handleDetach(m) {
491
+ const s = cfg.sessions?.get(m.session_id);
492
+ const entry = s && [...channels.entries()].find(([, c]) => c.session === s);
493
+ if (!entry) return send(detachFail({ reason: 'not attached to this session on this connection' }));
494
+ const [channelId, ch] = entry;
495
+ const flushed = ch.attachment.flush();
496
+ ch.detach();
497
+ await flushed;
498
+ await send(closeMsg({ channelId }));
499
+ return send(detachOk({ sessionId: s.id }));
500
+ }
501
+
502
+ function handleSessionList() {
503
+ const now = Date.now();
504
+ const sessions = (cfg.sessions?.listFor(principalKey()) ?? []).map((s) => ({
505
+ session_id: s.id,
506
+ username: s.owner.username,
507
+ fingerprint_short: (s.owner.fingerprint ?? '').slice(0, 8),
508
+ created_at_secs: Math.floor(s.createdAt / 1000),
509
+ idle_secs: Math.max(0, Math.floor((now - s.lastActivity) / 1000)),
510
+ attached_count: s.attachments.size,
511
+ }));
512
+ return send(sessionList({ sessions }));
513
+ }
514
+
318
515
  async function openExec(m) {
319
516
  if (!cfg.execRunner) return send(openFail({ reason: 'exec is not enabled on this server' }));
320
517
  const command = String(m.command ?? '').trim();
@@ -322,6 +519,7 @@ export function createConnectionFactory(cfg) {
322
519
 
323
520
  const channelId = ++state.nextChannel;
324
521
  const abort = new AbortController();
522
+ const hosted = cfg.sessions ? cfg.sessions.create({ owner: ownerRecord(), kind: 'exec', command }) : null;
325
523
  let stream = null;
326
524
  let primerChecked = false;
327
525
  let wq = Promise.resolve();
@@ -331,13 +529,14 @@ export function createConnectionFactory(cfg) {
331
529
  const endCbs = [];
332
530
  const signalCbs = [];
333
531
 
532
+ const signalTo = (name) => {
533
+ const n = name.replace(/^SIG/, '').toUpperCase();
534
+ for (const cb of signalCbs) cb(n);
535
+ if (/^(INT|TERM|KILL|HUP)$/.test(n) && signalCbs.length === 0) abort.abort();
536
+ };
334
537
  const ch = {
335
538
  kill: () => abort.abort(),
336
- signal: (name) => {
337
- const n = name.replace(/^SIG/, '').toUpperCase();
338
- for (const cb of signalCbs) cb(n);
339
- if (/^(INT|TERM|KILL|HUP)$/.test(n) && signalCbs.length === 0) abort.abort();
340
- },
539
+ signal: signalTo,
341
540
  bind(s) {
342
541
  stream = s;
343
542
  s.onData = (d) => {
@@ -349,18 +548,68 @@ export function createConnectionFactory(cfg) {
349
548
  if (d.byteLength) for (const cb of inputCbs) cb(d);
350
549
  };
351
550
  s.onEnd = () => { for (const cb of endCbs) cb(); };
352
- s.onReset = () => abort.abort();
551
+ // A hosted session outlives its stream; an unhosted one dies with it.
552
+ s.onReset = () => { if (!hosted) abort.abort(); };
353
553
  const pending = buffered.splice(0);
354
554
  wq = wq.then(async () => { for (const b of pending) await s.write(b).catch(() => {}); });
355
555
  for (const r of boundWaiters.splice(0)) r();
356
556
  },
357
557
  };
358
558
  channels.set(channelId, ch);
359
- pendingStreams.push(channelId);
559
+ if (dataStreams) pendingStreams.push(channelId);
560
+ // No client-opened streams on this connection (a relay bridge): stdin arrives as SessionData.
561
+ else ch.input = (d) => { for (const cb of inputCbs) cb(d); };
360
562
 
361
563
  let timer = null;
362
564
  if (cfg.execOptions.timeoutMs > 0) timer = setTimeout(() => abort.abort(), cfg.execOptions.timeoutMs);
363
565
 
566
+ // Output to the opening connection's data stream (held until the client has opened it).
567
+ const toStream = (bytes) => {
568
+ if (!dataStreams) {
569
+ wq = wq.then(() => send(sessionData({ channelId, data: bytes })));
570
+ return wq;
571
+ }
572
+ if (!stream) { buffered.push(bytes); return Promise.resolve(); }
573
+ const s = stream;
574
+ wq = wq.then(() => s.write(bytes).catch(() => {}));
575
+ return wq;
576
+ };
577
+ // Output is ready but the client's stream may not have been bound yet (a host without
578
+ // announce support waits for the first byte): give it a bounded chance rather than
579
+ // dropping the output or hanging forever.
580
+ const closeStream = async (code) => {
581
+ if (dataStreams && !stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
582
+ if (dataStreams && !stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
583
+ await wq;
584
+ try { await stream?.close(); } catch { /* peer gone */ }
585
+ await finishChannel(channelId, Number.isInteger(code) ? code : 0);
586
+ };
587
+
588
+ if (hosted) {
589
+ const att = {
590
+ connId: cid, channelId, mode: 'control', username: state.username, principal: principalKey(),
591
+ write: toStream,
592
+ notify: (msg) => send(presenceMsg(msg)),
593
+ exit: (code) => closeStream(code),
594
+ flush: () => wq,
595
+ };
596
+ hosted.handlers = {
597
+ input: (d) => { for (const cb of inputCbs) cb(d); },
598
+ inputEnd: () => { for (const cb of endCbs) cb(); },
599
+ signal: signalTo,
600
+ resize: () => {},
601
+ kill: () => abort.abort(),
602
+ };
603
+ ch.session = hosted;
604
+ ch.attachment = att;
605
+ ch.detach = () => {
606
+ channels.delete(channelId);
607
+ hosted.detach(att);
608
+ try { stream?.close().catch(() => {}); } catch { /* gone */ }
609
+ };
610
+ hosted.attach(att);
611
+ }
612
+
364
613
  const io = {
365
614
  user: state.username,
366
615
  env: m.env && typeof m.env === 'object' && cfg.execOptions.clientEnv !== false ? m.env : {},
@@ -369,9 +618,8 @@ export function createConnectionFactory(cfg) {
369
618
  signal: abort.signal,
370
619
  write(data) {
371
620
  const bytes = toBytes(data);
372
- if (!stream) { buffered.push(bytes); return Promise.resolve(); }
373
- const s = stream;
374
- wq = wq.then(() => s.write(bytes).catch(() => {}));
621
+ if (!hosted) return toStream(bytes);
622
+ hosted.push(bytes);
375
623
  return wq;
376
624
  },
377
625
  onInput: (cb) => inputCbs.push(cb),
@@ -380,8 +628,8 @@ export function createConnectionFactory(cfg) {
380
628
  };
381
629
 
382
630
  await send(openOk({
383
- channelId, dataMode: 'stream', capabilities: ['signal'],
384
- sessionId: randomUUID(), token: randomBytes(16),
631
+ channelId, dataMode: dataStreams ? 'stream' : 'virtual', capabilities: ['signal'],
632
+ sessionId: hosted?.id ?? randomUUID(), token: hosted?.token ?? randomBytes(16),
385
633
  }));
386
634
  log(`exec channel ${channelId}: ${command}`);
387
635
 
@@ -390,20 +638,15 @@ export function createConnectionFactory(cfg) {
390
638
  runExec().catch((e) => log(`exec channel ${channelId}: ${e.message}`));
391
639
 
392
640
  async function runExec() {
393
- let code;
394
- try { code = await cfg.execRunner(command, io); } catch (e) {
395
- await io.write(`wsh: ${e.message}\n`);
396
- code = 1;
397
- }
398
- if (timer) clearTimeout(timer);
399
- // Output is ready but the client's stream may not have been bound yet
400
- // (a host without announce support waits for the first byte): give it
401
- // a bounded chance rather than dropping the output or hanging forever.
402
- if (!stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
403
- if (!stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
404
- await wq;
405
- try { await stream?.close(); } catch { /* peer gone */ }
406
- await finishChannel(channelId, Number.isInteger(code) ? code : 0);
641
+ let code;
642
+ try { code = await cfg.execRunner(command, io); } catch (e) {
643
+ await io.write(`wsh: ${e.message}\n`);
644
+ code = 1;
645
+ }
646
+ if (timer) clearTimeout(timer);
647
+ // Hosted: everyone attached (this connection's stream included, if it is still there) hears the exit.
648
+ if (hosted) { hosted.finish(Number.isInteger(code) ? code : 0); return; }
649
+ await closeStream(code);
407
650
  }
408
651
  }
409
652
 
@@ -423,13 +666,31 @@ export function createConnectionFactory(cfg) {
423
666
  } catch (e) {
424
667
  return send(openFail({ reason: `pty spawn failed: ${e.message}` }));
425
668
  }
426
- const ch = {
669
+ const ctl = {
427
670
  input: (data) => { try { proc.write(Buffer.from(data)); } catch { /* exited */ } },
428
671
  resize: (c, r) => { try { proc.resize(c || cols, r || rows); } catch { /* exited */ } },
429
672
  signal: (name) => { try { proc.kill(`SIG${name.replace(/^SIG/, '').toUpperCase()}`); } catch { /* exited */ } },
430
673
  kill: () => { try { proc.kill(); } catch { /* exited */ } },
431
674
  };
432
- channels.set(channelId, ch);
675
+
676
+ if (cfg.sessions) {
677
+ const hosted = cfg.sessions.create({ owner: ownerRecord(), kind: 'pty', command: m.command });
678
+ hosted.handlers = ctl;
679
+ const att = virtualAttachment(hosted, channelId, 'control');
680
+ attachedChannel(hosted, att);
681
+ proc.onData((d) => hosted.push(Uint8Array.from(toBytes(d))));
682
+ proc.onExit(({ exitCode }) => hosted.finish(exitCode ?? 0));
683
+ const opened = send(openOk({
684
+ channelId, dataMode: 'virtual', capabilities: ['resize', 'signal'],
685
+ sessionId: hosted.id, token: hosted.token,
686
+ }));
687
+ hosted.attach(att);
688
+ await opened;
689
+ log(`pty channel ${channelId} opened (${cols}x${rows}), session ${hosted.id.slice(0, 8)}`);
690
+ return;
691
+ }
692
+
693
+ channels.set(channelId, ctl);
433
694
  let out = Promise.resolve();
434
695
  proc.onData((d) => { out = out.then(() => send(sessionData({ channelId, data: toBytes(d) }))); });
435
696
  proc.onExit(({ exitCode }) => { out.then(() => finishChannel(channelId, exitCode ?? 0)); });
@@ -555,8 +816,10 @@ export function createConnectionFactory(cfg) {
555
816
  }
556
817
 
557
818
  return {
558
- receive(bytes) { if (!state.closed) qmux.receiveBytes(bytes); },
559
- close() { shutdown('transport closed'); },
819
+ receive(bytes) { if (!state.closed) qmux?.receiveBytes(bytes); },
820
+ receiveMessage(msg) { if (!state.closed) chain = chain.then(() => handle(msg)).catch((e) => log(`handler error: ${e.message}`)); },
821
+ bindStream: bindDataStream,
822
+ close() { shutdownAfterQueue('transport closed'); },
560
823
  };
561
824
  };
562
825
  }
@@ -158,6 +158,48 @@ export interface WshServerMcpOptions {
158
158
  timeoutMs?: number;
159
159
  }
160
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
+
172
+ /** A peer as the relay's policy callbacks see it. */
173
+ export interface WshRelayPeer {
174
+ /** Hex SHA-256 of the peer's key. */
175
+ fingerprint: string;
176
+ username: string;
177
+ capabilities: string[];
178
+ }
179
+
180
+ export interface WshServerRelayOptions {
181
+ /**
182
+ * May this authenticated connection register as a peer? `who` is the connection; `record` what it asks to
183
+ * advertise. Default: no (a relay with no policy admits nobody).
184
+ */
185
+ canRegister?: (
186
+ who: { username: string; fingerprint: string },
187
+ record: { username: string; capabilities: string[]; peerType: string; shellBackend: string },
188
+ ) => boolean | Promise<boolean>;
189
+ /**
190
+ * May `from` list and connect to `to`? A peer `from` may not connect to is neither listed nor distinguishable
191
+ * from an absent one. Default: no.
192
+ */
193
+ canConnect?: (
194
+ from: { username: string; fingerprint: string },
195
+ to: WshRelayPeer,
196
+ ) => boolean | Promise<boolean>;
197
+ /** Registered peers at once (default 1024). */
198
+ maxPeers?: number;
199
+ /** How long a `ReverseConnect` waits for its peer to answer before the operator is rejected (default 8000). */
200
+ connectTimeoutMs?: number;
201
+ }
202
+
161
203
  export interface WshServerOptions {
162
204
  /** Bind address (default `127.0.0.1`). */
163
205
  host?: string;
@@ -177,6 +219,20 @@ export interface WshServerOptions {
177
219
  hostKey?: true | { file: string } | CryptoKeyPair;
178
220
  /** Enable file ops and uploads/downloads under one directory. Off by default. */
179
221
  fs?: WshServerFsOptions;
222
+ /**
223
+ * Keep pty/exec sessions alive across disconnects so `resumeSession()` /
224
+ * `attachSession()` / `detach()` / `listRemoteSessions()` work against them.
225
+ * `true` for the defaults. Off by default: sessions then die with their connection.
226
+ */
227
+ sessions?: true | WshServerSessionsOptions;
228
+ /** Alias for `sessions.sessionSecret`. */
229
+ sessionSecret?: string | Uint8Array;
230
+ /**
231
+ * Act as a relay: peers (`createReverseHost`, or any client's `connectReverse()`) register a signed record,
232
+ * operators list and `reverseConnect()` to them, and traffic is carried between the two as `RelayForward`.
233
+ * Default deny -- give both `canRegister` and `canConnect`. Off by default.
234
+ */
235
+ relay?: WshServerRelayOptions;
180
236
  /** Serve MCP tools (`McpDiscover` / `McpCall`) and advertise `mcp-call-id`. Off by default. */
181
237
  mcp?: WshServerMcpOptions;
182
238
  /** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
@@ -196,6 +252,8 @@ export interface WshServer {
196
252
  close(): Promise<void>;
197
253
  /** The bound address, or `null` when not listening. */
198
254
  address(): WshServerAddress | null;
255
+ /** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
256
+ peerFingerprints(): string[];
199
257
  /** The advertised host identity; `null` before `listen()` resolves or without a `hostKey`. */
200
258
  hostKey(): WshServerHostKey | null;
201
259
  }
@@ -207,3 +265,40 @@ export function parseAuthorizedKeys(text: string): Uint8Array[];
207
265
 
208
266
  /** ServerHello feature: the host discovers client-opened exec data streams itself (no primer byte). */
209
267
  export const STREAM_ANNOUNCE: 'stream-announce';
268
+
269
+ export interface WshReverseHostOptions {
270
+ /** The relay to register with (`ws://` / `wss://`). */
271
+ url: string;
272
+ username: string;
273
+ /** This host's identity; its fingerprint is what operators connect to. */
274
+ keyPair: CryptoKeyPair;
275
+ /** Who may be bridged to this host. Default: nobody. */
276
+ accept?: (operator: { fingerprint: string; username: string }) => boolean | Promise<boolean>;
277
+ exec?: true | WshServerExecOptions | WshExecRunner;
278
+ pty?: WshServerPtyOptions;
279
+ fs?: WshServerFsOptions;
280
+ mcp?: WshServerMcpOptions;
281
+ /** Dial again, with backoff, after the relay connection ends -- which a relay does when a bridge does (default true). */
282
+ reconnect?: boolean;
283
+ /** Extra options for the relay connection (`expectHostKey`, `knownHosts`, `trustOnFirstUse`, ...). */
284
+ connect?: Record<string, unknown>;
285
+ peerType?: string;
286
+ onLog?: (line: string) => void;
287
+ }
288
+
289
+ export interface WshReverseHost {
290
+ /** Dial the relay and register (rejects if the first attempt fails). Registration is not acknowledged on the wire. */
291
+ start(): Promise<{ fingerprint: string }>;
292
+ /** Leave the relay and end any bridged session. */
293
+ close(): Promise<void>;
294
+ /** What operators connect to; `null` before `start()`. */
295
+ readonly fingerprint: string | null;
296
+ /** Is the relay connection up right now? */
297
+ readonly connected: boolean;
298
+ }
299
+
300
+ /**
301
+ * A host that dials OUT to a relay and serves the operator it bridges to it, with the same exec / pty / fs / mcp
302
+ * backends `createWshServer` serves direct clients with. For a machine that cannot accept connections.
303
+ */
304
+ export function createReverseHost(options: WshReverseHostOptions): WshReverseHost;