@claudecollab/cli 0.1.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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/package.json +36 -0
  4. package/packages/cli/bin/claude-share.js +1319 -0
  5. package/packages/cli/package.json +15 -0
  6. package/packages/cli/src/brain/card.js +157 -0
  7. package/packages/cli/src/brain/commands.js +122 -0
  8. package/packages/cli/src/brain/drafts.js +557 -0
  9. package/packages/cli/src/brain/gate.js +134 -0
  10. package/packages/cli/src/brain/knocks.js +27 -0
  11. package/packages/cli/src/brain/log.js +123 -0
  12. package/packages/cli/src/brain/queue.js +81 -0
  13. package/packages/cli/src/brain/state.js +245 -0
  14. package/packages/cli/src/hooks.js +243 -0
  15. package/packages/cli/src/invite.js +43 -0
  16. package/packages/cli/src/known-relays.js +66 -0
  17. package/packages/cli/src/pty.js +175 -0
  18. package/packages/cli/src/relay-client.js +271 -0
  19. package/packages/cli/src/renderer.js +230 -0
  20. package/packages/cli/src/screen-snapshot.js +139 -0
  21. package/packages/cli/src/term-chatter.js +89 -0
  22. package/packages/relay/auth.js +18 -0
  23. package/packages/relay/bin/serve.js +73 -0
  24. package/packages/relay/names.js +27 -0
  25. package/packages/relay/package.json +13 -0
  26. package/packages/relay/public/client.js +1553 -0
  27. package/packages/relay/public/index.html +110 -0
  28. package/packages/relay/public/style.css +952 -0
  29. package/packages/relay/rooms.js +149 -0
  30. package/packages/relay/server.js +652 -0
  31. package/packages/relay/web.js +334 -0
  32. package/packages/relay/wordlists.js +38 -0
  33. package/packages/shared/package.json +8 -0
  34. package/packages/shared/protocol.js +189 -0
@@ -0,0 +1,652 @@
1
+ // The relay ssh server — a dumb front door (spec §architecture: "the relay
2
+ // understands nothing"). It hands out room codes, forwards bytes stamped with
3
+ // who sent them, and stores nothing durable. All product intelligence lives in
4
+ // the host brain; this file only routes.
5
+ //
6
+ // Two connection kinds, told apart by the ssh username (the spike-proven trick):
7
+ // • username 'host' → speaks the JSON-lines protocol (packages/shared)
8
+ // • username '<code>' → a guest terminal, raw bytes only
9
+ //
10
+ // Guest join flow (spec §knock): reject `none` auth so stock clients proceed to
11
+ // publickey (keyboard-interactive for keyless) → name prompt for an unseen key →
12
+ // knock the host → 60s waiting screen → on admit, mirror the host's screen and
13
+ // forward the guest's keystrokes/resizes labeled by id. Kicks ban fingerprints.
14
+
15
+ import ssh2 from 'ssh2';
16
+ import { readFileSync } from 'node:fs';
17
+ import { createHash, randomUUID } from 'node:crypto';
18
+ import { createRegistry } from './rooms.js';
19
+ import { secretsMatch } from './auth.js';
20
+ import { startWebDoor } from './web.js';
21
+ import { sanitizeName } from './names.js';
22
+ import { TYPES, encode, validate, Decoder } from '../shared/protocol.js';
23
+
24
+ const { Server, utils } = ssh2;
25
+ const { parseKey } = utils;
26
+
27
+ const DEFAULT_KNOCK_TIMEOUT_MS = 60 * 1000; // spec: 60s waiting screen
28
+ const DEFAULT_TTL_MS = 10 * 60 * 1000; // spec: hold the code 10 min on host drop
29
+
30
+ // Standard OpenSSH SHA256 fingerprint over the raw public-key blob.
31
+ function fingerprint(keyData) {
32
+ return 'SHA256:' + createHash('sha256').update(keyData).digest('base64').replace(/=+$/, '');
33
+ }
34
+
35
+ // Copy — kept plain and friendly (spec frames). CRLF for raw terminals.
36
+ const COPY = {
37
+ connecting: (host, code) => `connecting to ${host}'s room ${code}…\r\n`,
38
+ namePrompt: 'pick a name: ',
39
+ passPrompt: 'room password: ',
40
+ badPass: '\r\nwrong password. \u{1F44B}\r\n',
41
+ knocking: '\u{1F6AA} knocking…\r\n',
42
+ denied: "\r\nthe host didn't let you in this time. no hard feelings \u{1F44B}\r\n",
43
+ timeout: "\r\nno answer — the host isn't available right now. try again later \u{1F44B}\r\n",
44
+ banned: '\r\nyou\'ve been removed from this room.\r\n',
45
+ kicked: '\r\nyou were removed from the room by the host.\r\n',
46
+ noRoom: (code) => `no room "${code}" — it may have ended or the code is wrong. \u{1F44B}\r\n`,
47
+ lockout: 'too many attempts — try again in a minute. \u{1F44B}\r\n',
48
+ ended: '\r\nthe session has ended. \u{1F44B}\r\n',
49
+ // Honest now that reclaim exists (spec failure table: guests see "host
50
+ // reconnecting…"; the relay holds the room for the TTL grace window).
51
+ hostGone: '\r\nhost reconnecting…\r\n',
52
+ };
53
+
54
+ function safeWrite(stream, data) {
55
+ try {
56
+ stream?.write(data);
57
+ } catch {
58
+ /* stream already gone */
59
+ }
60
+ }
61
+ function safeEnd(obj) {
62
+ try {
63
+ obj?.end();
64
+ } catch {
65
+ /* already closed */
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Start the relay. Resolves once it is listening.
71
+ *
72
+ * @param {object} opts
73
+ * @param {number} [opts.port] listen port (0 ⇒ ephemeral; spec prod: 22 & 443)
74
+ * @param {number} [opts.webPort] when set, also open the browser door (http + ws) on this port
75
+ * @param {string} [opts.host] bind address (default 127.0.0.1)
76
+ * @param {string} [opts.hostKeyPath] path to the ssh host private key
77
+ * @param {string|Buffer} [opts.hostKey] host private key inline (alternative to hostKeyPath)
78
+ * @param {string} [opts.hostName] name shown to guests ("<hostName>'s room …")
79
+ * @param {number} [opts.knockTimeoutMs] waiting-screen timeout
80
+ * @param {number} [opts.ttlMs] host-drop grace before the room closes
81
+ * @param {object} [opts.registry] extra options forwarded to createRegistry (tests)
82
+ * @returns {Promise<{close():void, port:number, address:object}>}
83
+ */
84
+ export function startRelay(opts = {}) {
85
+ const {
86
+ port = 0,
87
+ webPort,
88
+ host = '127.0.0.1',
89
+ hostKeyPath,
90
+ hostKey,
91
+ hostName = 'the host',
92
+ knockTimeoutMs = DEFAULT_KNOCK_TIMEOUT_MS,
93
+ ttlMs = DEFAULT_TTL_MS,
94
+ // The public browser-facing base URL of a DEPLOYED relay (e.g.
95
+ // "https://claude-share.fly.dev"). Sent to the host in the room grant so its
96
+ // printed/copied links carry the real https origin instead of host:webPort.
97
+ publicUrl,
98
+ // Relay-wide room ceiling. Room creation is unauthenticated (any ssh client
99
+ // can HELLO), so a public relay needs a lid on how many rooms strangers can
100
+ // pile up. Far above any real usage; a flood just gets its connection closed.
101
+ maxRooms = 50,
102
+ // Room-creation credential. When set, a HELLO must carry a matching `secret`
103
+ // or it is REFUSED — strangers can't create rooms at all. RECLAIM stays
104
+ // ungated on purpose: it is already bound to the creating host's key
105
+ // fingerprint, and must keep working mid-session if the secret rotates.
106
+ roomSecret,
107
+ registry: registryOpts = {},
108
+ } = opts;
109
+
110
+ const hostKeyData = hostKey ?? (hostKeyPath ? readFileSync(hostKeyPath) : undefined);
111
+ if (!hostKeyData) throw new Error('startRelay: hostKey or hostKeyPath is required');
112
+
113
+ const registry = createRegistry({ ttlMs, ...registryOpts });
114
+
115
+ // Live routing state, kept out of the pure registry. code -> room.
116
+ // guests: Map<id, rec> admitted, mirroring
117
+ // pending: Map<id, rec> knocking, awaiting admit/deny
118
+ // seen: Map<fp, name> fingerprints seen this room (for the `seen` field)
119
+ const live = new Map();
120
+ const conns = new Set(); // every open ssh connection, for a clean shutdown
121
+ let webDoor = null; // the browser door (http + ws), started below when opts.webPort is set
122
+
123
+ function findRec(room, id) {
124
+ return room.guests.get(id) ?? room.pending.get(id);
125
+ }
126
+ function allRecs(room) {
127
+ return [...room.guests.values(), ...room.pending.values()];
128
+ }
129
+
130
+ // A guest connection is finished (kick, leave, timeout, room close, wifi drop).
131
+ function endGuest(rec) {
132
+ safeEnd(rec.stream);
133
+ safeEnd(rec.conn);
134
+ }
135
+
136
+ function onGuestGone(rec) {
137
+ if (rec.gone) return;
138
+ rec.gone = true;
139
+ clearTimeout(rec.knockTimer);
140
+ const room = live.get(rec.code);
141
+ if (room) {
142
+ room.guests.delete(rec.id);
143
+ room.pending.delete(rec.id);
144
+ }
145
+ registry.removeGuest(rec.code, rec.id);
146
+ // Tell the host so it can free the seat / clear the stale knock.
147
+ if (room?.hostPresent && rec.announced) safeWrite(room.hostStream, encode({ t: TYPES.LEFT, id: rec.id }));
148
+ }
149
+
150
+ // Tear a whole room down: notify + disconnect everyone, drop registry state.
151
+ function closeRoom(code, msg) {
152
+ const room = live.get(code);
153
+ if (!room) return;
154
+ clearTimeout(room.hostTimer);
155
+ for (const rec of allRecs(room)) {
156
+ rec.gone = true; // suppress LEFT spam during teardown
157
+ clearTimeout(rec.knockTimer);
158
+ if (msg) safeWrite(rec.stream, msg);
159
+ endGuest(rec);
160
+ }
161
+ safeEnd(room.hostStream);
162
+ safeEnd(room.hostConn);
163
+ registry.close(code);
164
+ live.delete(code);
165
+ }
166
+
167
+ // The host's control connection closed. `conn` is the connection that closed;
168
+ // if it is no longer the room's host connection, a newer one has already
169
+ // reclaimed the room (wifi-drop race — the old socket closes late), so ignore.
170
+ function onHostGone(code, conn) {
171
+ const room = live.get(code);
172
+ if (!room || !room.hostPresent) return;
173
+ if (conn && room.hostConn !== conn) return; // superseded by a reclaim; not really gone
174
+ room.hostPresent = false;
175
+ registry.hostDropped(code); // registry starts its own grace timer
176
+ for (const rec of allRecs(room)) safeWrite(rec.stream, COPY.hostGone);
177
+ // Guests wait, visibly (spec). If the host reclaims with its key before the
178
+ // grace window elapses, RECLAIM cancels this timer; otherwise the room ends.
179
+ room.hostTimer = setTimeout(() => closeRoom(code, COPY.ended), ttlMs);
180
+ room.hostTimer.unref?.();
181
+ }
182
+
183
+ // ---- host protocol channel ------------------------------------------------
184
+
185
+ function setupHostChannel(conn, stream, hostFp) {
186
+ const dec = new Decoder();
187
+ let code = null; // this host owns exactly one room, set on `hello`/`reclaim`
188
+
189
+ const handle = (msg) => {
190
+ if (!validate(msg)) return; // drop anything malformed (spec: fail closed)
191
+ switch (msg.t) {
192
+ case TYPES.HELLO: {
193
+ if (code) return; // one room per host connection
194
+ if (roomSecret && !secretsMatch(msg.secret, roomSecret)) {
195
+ // Machine-readable refusal so the CLI can say "bad secret" instead
196
+ // of mistaking this for a dead relay and reconnect-looping.
197
+ safeWrite(stream, encode({ t: TYPES.REFUSED, reason: 'secret' }));
198
+ safeEnd(stream);
199
+ safeEnd(conn);
200
+ return;
201
+ }
202
+ if (live.size >= maxRooms) {
203
+ // Full: refuse by closing. The CLI's reconnect loop backs off; real
204
+ // rooms expire on their 10-min TTL, so capacity frees itself.
205
+ safeEnd(stream);
206
+ safeEnd(conn);
207
+ return;
208
+ }
209
+ const room = registry.create();
210
+ code = room.code;
211
+ live.set(code, {
212
+ code,
213
+ hostConn: conn,
214
+ hostStream: stream,
215
+ hostPresent: true,
216
+ hostFp, // gates reclaim: only this key may take the room back
217
+ // Optional join password (set by the host in HELLO). Both doors
218
+ // pre-gate every guest against it BEFORE the knock reaches the host;
219
+ // knock/admit stays the final gate behind it. Survives reclaim (the
220
+ // room object persists across a host drop).
221
+ pass: typeof msg.pass === 'string' && msg.pass.length ? msg.pass : null,
222
+ guests: new Map(),
223
+ pending: new Map(),
224
+ seen: new Map(),
225
+ hostTimer: null,
226
+ });
227
+ safeWrite(stream, encode({ t: TYPES.ROOM, code, ...(publicUrl ? { webUrl: publicUrl } : {}) }));
228
+ return;
229
+ }
230
+ case TYPES.RECLAIM: {
231
+ if (code) return; // this connection already owns a room
232
+ const target = msg.code;
233
+ const room = live.get(target);
234
+ // registry.get honors TTL expiry; hostFp must match the original host
235
+ // key so a code-guesser cannot hijack the room brain (spec §trust).
236
+ if (!room || !registry.get(target) || !hostFp || room.hostFp !== hostFp) {
237
+ safeWrite(stream, encode({ t: TYPES.GONE, code: target }));
238
+ return;
239
+ }
240
+ // Reattach this connection as the room's host. Newest wins: bump any
241
+ // still-open prior host socket (the drop may not be detected yet).
242
+ const oldConn = room.hostConn;
243
+ const oldStream = room.hostStream;
244
+ room.hostConn = conn;
245
+ room.hostStream = stream;
246
+ room.hostPresent = true;
247
+ clearTimeout(room.hostTimer); // cancel the pending end-room timer
248
+ room.hostTimer = null;
249
+ registry.hostReturned(target); // cancel the registry grace timer too
250
+ code = target;
251
+ if (oldConn && oldConn !== conn) {
252
+ safeEnd(oldStream);
253
+ safeEnd(oldConn);
254
+ }
255
+ safeWrite(stream, encode({ t: TYPES.ROOM, code, ...(publicUrl ? { webUrl: publicUrl } : {}) }));
256
+ // Re-sync every still-present guest's terminal size so the brain can
257
+ // re-clamp the shared view (sizes may have changed during the outage).
258
+ for (const rec of room.guests.values()) {
259
+ safeWrite(stream, encode({ t: TYPES.RESIZE, id: rec.id, cols: rec.cols, rows: rec.rows }));
260
+ }
261
+ return;
262
+ }
263
+ case TYPES.ADMIT: {
264
+ const room = live.get(code);
265
+ const rec = room && room.pending.get(msg.id);
266
+ if (!rec) return; // unknown / already resolved knock
267
+ clearTimeout(rec.knockTimer);
268
+ // Registry enforces cap + ban; a race that fails here reads as a deny.
269
+ const ok = registry.addGuest(code, rec.id, { name: rec.name, fp: rec.fp ?? undefined, role: 'prompter' });
270
+ if (!ok) {
271
+ room.pending.delete(rec.id);
272
+ safeWrite(rec.stream, COPY.denied);
273
+ endGuest(rec);
274
+ return;
275
+ }
276
+ room.pending.delete(rec.id);
277
+ room.guests.set(rec.id, rec);
278
+ rec.phase = 'live';
279
+ // A web participant has no terminal knock screen to clear, so it gets an
280
+ // explicit 'joined' text frame — its cue to switch from the knock view to
281
+ // the live session (screen bytes + state follow). ssh guests need none.
282
+ if (rec.kind === 'web') rec.sendText(JSON.stringify({ t: TYPES.JOINED, id: rec.id }));
283
+ // No "you're live" seam here: the host's join context card (sent next, via
284
+ // TO) already ends with one. Writing it here too printed the separator
285
+ // twice around the card (finding 4) — the card owns the single seam now.
286
+ safeWrite(stream, encode({ t: TYPES.JOINED, id: rec.id }));
287
+ // Hand the host the guest's terminal size so it can clamp the shared view.
288
+ safeWrite(stream, encode({ t: TYPES.RESIZE, id: rec.id, cols: rec.cols, rows: rec.rows }));
289
+ return;
290
+ }
291
+ case TYPES.DENY: {
292
+ const room = live.get(code);
293
+ const rec = room && findRec(room, msg.id);
294
+ if (!rec) return;
295
+ clearTimeout(rec.knockTimer);
296
+ room.pending.delete(rec.id);
297
+ room.guests.delete(rec.id);
298
+ // A web rec needs a machine-readable reason — terminal copy would land as
299
+ // mirror bytes and the silent close then read as "host offline" (wrong).
300
+ if (rec.kind === 'web' && rec.sendText) rec.sendText(JSON.stringify({ t: 'error', reason: 'denied' }));
301
+ else safeWrite(rec.stream, COPY.denied);
302
+ endGuest(rec);
303
+ return;
304
+ }
305
+ case TYPES.SCREEN: {
306
+ const room = live.get(code);
307
+ if (!room) return;
308
+ const buf = Buffer.from(msg.data, 'base64');
309
+ for (const rec of room.guests.values()) if (rec.phase === 'live') safeWrite(rec.stream, buf);
310
+ return;
311
+ }
312
+ case TYPES.TO: {
313
+ const room = live.get(code);
314
+ const rec = room && room.guests.get(msg.id);
315
+ if (rec) safeWrite(rec.stream, Buffer.from(msg.data, 'base64'));
316
+ return;
317
+ }
318
+ case TYPES.STATE: {
319
+ // The overlay snapshot is for browser views only (ssh guests render raw
320
+ // screen bytes). Fan it out to every live web participant as a text frame.
321
+ const room = live.get(code);
322
+ if (!room) return;
323
+ const line = JSON.stringify(msg);
324
+ for (const rec of room.guests.values()) {
325
+ if (rec.kind === 'web' && rec.phase === 'live') rec.sendText(line);
326
+ }
327
+ return;
328
+ }
329
+ case TYPES.DROP: {
330
+ const room = live.get(code);
331
+ const rec = room && findRec(room, msg.id);
332
+ if (!rec) return;
333
+ clearTimeout(rec.knockTimer);
334
+ if (msg.ban && rec.fp) registry.ban(code, rec.fp); // evicts + blocklists the fp
335
+ room.guests.delete(rec.id);
336
+ room.pending.delete(rec.id);
337
+ rec.gone = true; // host already knows; don't echo a LEFT back
338
+ // A web rec needs a machine-readable reason (the client shows a removed
339
+ // panel and hides the mirror); terminal copy is for ssh guests only.
340
+ if (rec.kind === 'web' && rec.sendText) rec.sendText(JSON.stringify({ t: 'error', reason: 'kicked' }));
341
+ else safeWrite(rec.stream, COPY.kicked);
342
+ endGuest(rec);
343
+ return;
344
+ }
345
+ case TYPES.END: {
346
+ if (code) closeRoom(code, COPY.ended);
347
+ safeEnd(stream);
348
+ safeEnd(conn);
349
+ return;
350
+ }
351
+ }
352
+ };
353
+
354
+ stream.on('data', (chunk) => {
355
+ for (const msg of dec.push(chunk)) handle(msg);
356
+ });
357
+ const drop = () => {
358
+ if (code) onHostGone(code, conn);
359
+ };
360
+ stream.on('close', drop);
361
+ conn.on('close', drop);
362
+ }
363
+
364
+ // ---- guest terminal channel ----------------------------------------------
365
+
366
+ function startGuestFlow(conn, stream, code, ident, ip, ptyInfo) {
367
+ const room = live.get(code);
368
+ // registry.get is the source of truth for existence (honors TTL expiry).
369
+ if (!room || !registry.get(code)) {
370
+ safeWrite(stream, COPY.noRoom(code));
371
+ safeEnd(stream);
372
+ safeEnd(conn);
373
+ return;
374
+ }
375
+
376
+ safeWrite(stream, COPY.connecting(hostName, code));
377
+
378
+ if (!room.hostPresent) {
379
+ safeWrite(stream, COPY.hostGone);
380
+ safeEnd(stream);
381
+ safeEnd(conn);
382
+ return;
383
+ }
384
+
385
+ const fp = ident.fp; // null for keyless
386
+ if (fp && registry.get(code).banned.has(fp)) {
387
+ safeWrite(stream, COPY.banned);
388
+ safeEnd(stream);
389
+ safeEnd(conn);
390
+ return;
391
+ }
392
+
393
+ if (!registry.tryKnock(code, ip)) {
394
+ safeWrite(stream, COPY.lockout);
395
+ safeEnd(stream);
396
+ safeEnd(conn);
397
+ return;
398
+ }
399
+
400
+ const rec = {
401
+ id: randomUUID(),
402
+ code,
403
+ conn,
404
+ stream,
405
+ fp,
406
+ name: null,
407
+ phase: room.pass ? 'pass' : 'name',
408
+ nameBuf: '',
409
+ passBuf: '',
410
+ cols: ptyInfo?.cols ?? 80, // spec floor; refined by window-change
411
+ rows: ptyInfo?.rows ?? 24,
412
+ knockTimer: null,
413
+ announced: false,
414
+ gone: false,
415
+ };
416
+ room.pending.set(rec.id, rec);
417
+
418
+ // A passworded room challenges EVERY guest first — known keys included (a
419
+ // remembered name is a convenience, not a credential). Open rooms flow as before.
420
+ if (room.pass) {
421
+ safeWrite(stream, COPY.passPrompt);
422
+ } else {
423
+ afterPass(room, rec);
424
+ }
425
+
426
+ stream.on('data', (chunk) => {
427
+ if (rec.phase === 'pass') feedPass(room, rec, chunk);
428
+ else if (rec.phase === 'name') feedName(room, rec, chunk);
429
+ else if (rec.phase === 'live') safeWrite(room.hostStream, encode({ t: TYPES.KEY, id: rec.id, data: chunk.toString('base64') }));
430
+ // 'knocking': input ignored until admitted
431
+ });
432
+
433
+ const gone = () => onGuestGone(rec);
434
+ stream.on('close', gone);
435
+ conn.on('close', gone);
436
+ }
437
+
438
+ // The post-password continuation: a known key skips the name prompt and
439
+ // carries its prior name in `seen`; everyone else picks a name.
440
+ function afterPass(room, rec) {
441
+ const priorName = rec.fp ? room.seen.get(rec.fp) : undefined;
442
+ if (priorName !== undefined) {
443
+ rec.name = priorName;
444
+ sendKnock(room, rec, priorName); // seen = the prior name
445
+ } else {
446
+ rec.phase = 'name';
447
+ safeWrite(rec.stream, COPY.namePrompt);
448
+ }
449
+ }
450
+
451
+ // Line editor for the password prompt: masked echo, backspace, Enter. A wrong
452
+ // guess ends the connection — retrying costs a fresh connection, and each one
453
+ // burns a tryKnock slot, so brute force hits the per-ip lockout fast.
454
+ function feedPass(room, rec, chunk) {
455
+ for (const byte of chunk) {
456
+ if (byte === 0x0d || byte === 0x0a) {
457
+ if (secretsMatch(rec.passBuf, room.pass)) {
458
+ rec.passBuf = '';
459
+ safeWrite(rec.stream, '\r\n');
460
+ afterPass(room, rec);
461
+ } else {
462
+ safeWrite(rec.stream, COPY.badPass);
463
+ endGuest(rec);
464
+ }
465
+ return;
466
+ }
467
+ if (byte === 0x7f || byte === 0x08) {
468
+ if (rec.passBuf.length) {
469
+ rec.passBuf = rec.passBuf.slice(0, -1);
470
+ safeWrite(rec.stream, '\b \b');
471
+ }
472
+ continue;
473
+ }
474
+ if (byte >= 0x20 && byte < 0x7f) {
475
+ rec.passBuf += String.fromCharCode(byte);
476
+ safeWrite(rec.stream, '•'); // masked echo (raw pty, no local echo)
477
+ }
478
+ }
479
+ }
480
+
481
+ // Minimal line editor for the name prompt (echo, backspace, Enter).
482
+ function feedName(room, rec, chunk) {
483
+ for (const byte of chunk) {
484
+ if (byte === 0x0d || byte === 0x0a) {
485
+ finalizeName(room, rec);
486
+ return;
487
+ }
488
+ if (byte === 0x7f || byte === 0x08) {
489
+ if (rec.nameBuf.length) {
490
+ rec.nameBuf = rec.nameBuf.slice(0, -1);
491
+ safeWrite(rec.stream, '\b \b');
492
+ }
493
+ continue;
494
+ }
495
+ if (byte >= 0x20 && byte < 0x7f) {
496
+ rec.nameBuf += String.fromCharCode(byte);
497
+ safeWrite(rec.stream, String.fromCharCode(byte)); // echo (raw pty, no local echo)
498
+ }
499
+ }
500
+ }
501
+
502
+ function finalizeName(room, rec) {
503
+ // feedName already dropped non-printable bytes as it echoed; sanitizeName is the
504
+ // shared trim/cap/fallback (and the single source of truth both doors run).
505
+ const name = sanitizeName(rec.nameBuf);
506
+ rec.name = name;
507
+ if (rec.fp) room.seen.set(rec.fp, name); // remember real keys only
508
+ safeWrite(rec.stream, '\r\n');
509
+ sendKnock(room, rec, null); // first sighting ⇒ seen=null
510
+ }
511
+
512
+ function sendKnock(room, rec, seen) {
513
+ rec.phase = 'knocking';
514
+ rec.announced = true;
515
+ safeWrite(rec.stream, COPY.knocking);
516
+ safeWrite(room.hostStream, encode({ t: TYPES.KNOCK, id: rec.id, name: rec.name, fp: rec.fp ?? '', seen }));
517
+ rec.knockTimer = setTimeout(() => {
518
+ if (rec.gone) return;
519
+ room.pending.delete(rec.id);
520
+ safeWrite(rec.stream, COPY.timeout);
521
+ endGuest(rec);
522
+ }, knockTimeoutMs);
523
+ rec.knockTimer.unref?.();
524
+ }
525
+
526
+ // ---- connection + auth ----------------------------------------------------
527
+
528
+ const server = new Server({ hostKeys: [hostKeyData] }, (conn, info) => {
529
+ conns.add(conn);
530
+ const ip = info?.ip ?? 'unknown';
531
+ const ident = { username: null, fp: null }; // filled on successful auth
532
+
533
+ conn.on('authentication', (ctx) => {
534
+ switch (ctx.method) {
535
+ case 'publickey': {
536
+ const fp = fingerprint(ctx.key.data);
537
+ // Signature phase: prove possession before trusting the fingerprint.
538
+ if (ctx.signature) {
539
+ let key;
540
+ try {
541
+ key = parseKey(`${ctx.key.algo} ${ctx.key.data.toString('base64')}`);
542
+ } catch {
543
+ return ctx.reject();
544
+ }
545
+ if (key instanceof Error || key.verify(ctx.blob, ctx.signature, ctx.hashAlgo) !== true) {
546
+ return ctx.reject();
547
+ }
548
+ }
549
+ // Query phase (no signature) or verified: this key is acceptable.
550
+ ident.username = ctx.username;
551
+ ident.fp = fp;
552
+ return ctx.accept();
553
+ }
554
+ case 'keyboard-interactive': {
555
+ // Keyless fallback → session-only identity, no fingerprint (spec §identity).
556
+ ident.username = ctx.username;
557
+ ident.fp = null;
558
+ return ctx.accept();
559
+ }
560
+ default:
561
+ // Reject `none` and passwords; advertise the methods we actually take.
562
+ // (spike finding: rejecting `none` forces stock clients onto publickey.)
563
+ return ctx.reject(['publickey', 'keyboard-interactive']);
564
+ }
565
+ });
566
+
567
+ conn.on('ready', () => {
568
+ const isHost = ident.username === 'host';
569
+ conn.on('session', (accept) => {
570
+ const session = accept();
571
+ let ptyInfo = null;
572
+ let started = false;
573
+
574
+ session.on('pty', (a, r, i) => {
575
+ ptyInfo = i;
576
+ if (typeof a === 'function') a();
577
+ });
578
+ session.on('window-change', (a, r, i) => {
579
+ if (typeof a === 'function') a();
580
+ // Route a live guest's resize to the host.
581
+ const room = ident._code && live.get(ident._code);
582
+ const rec = room && room.guests.get(ident._rid);
583
+ if (rec) {
584
+ rec.cols = i.cols;
585
+ rec.rows = i.rows;
586
+ safeWrite(room.hostStream, encode({ t: TYPES.RESIZE, id: rec.id, cols: i.cols, rows: i.rows }));
587
+ }
588
+ });
589
+
590
+ const begin = (accept) => {
591
+ const stream = typeof accept === 'function' ? accept() : accept;
592
+ if (started) return;
593
+ started = true;
594
+ if (isHost) {
595
+ setupHostChannel(conn, stream, ident.fp);
596
+ } else {
597
+ // Stash ids so window-change can find this guest's record.
598
+ const before = new Set(live.get(ident.username)?.pending.keys() ?? []);
599
+ startGuestFlow(conn, stream, ident.username, ident, ip, ptyInfo);
600
+ const room = live.get(ident.username);
601
+ if (room) {
602
+ for (const id of room.pending.keys()) if (!before.has(id)) ident._rid = id;
603
+ ident._code = ident.username;
604
+ }
605
+ }
606
+ };
607
+ session.on('shell', begin);
608
+ session.on('exec', (accept) => begin(accept));
609
+ });
610
+ });
611
+
612
+ conn.on('close', () => conns.delete(conn));
613
+ conn.on('error', () => {}); // client resets are routine; never crash the relay
614
+ });
615
+
616
+ function close() {
617
+ for (const code of [...live.keys()]) closeRoom(code, COPY.ended);
618
+ for (const conn of [...conns]) safeEnd(conn);
619
+ server.close();
620
+ webDoor?.close();
621
+ }
622
+
623
+ return new Promise((resolve, reject) => {
624
+ server.once('error', reject);
625
+ server.listen(port, host, function () {
626
+ server.removeListener('error', reject);
627
+ server.on('error', () => {}); // post-listen errors shouldn't crash the process
628
+ const address = this.address();
629
+ const done = (web) => {
630
+ webDoor = web;
631
+ resolve({
632
+ close,
633
+ port: address.port,
634
+ address,
635
+ webPort: web ? web.port : null,
636
+ webAddress: web ? web.address : null,
637
+ });
638
+ };
639
+ // The browser door shares this relay's live rooms + registry, so web
640
+ // participants are real room members subject to the same knock/ban/cap.
641
+ if (webPort == null) return done(null);
642
+ startWebDoor({ port: webPort, host, live, registry, knockTimeoutMs, onGuestGone, safeWrite }).then(done, (err) => {
643
+ try {
644
+ close();
645
+ } catch {
646
+ /* nothing to unwind */
647
+ }
648
+ reject(err);
649
+ });
650
+ });
651
+ });
652
+ }