superpowers-mcp 6.2.2 → 6.2.3

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.
@@ -2,12 +2,24 @@ const crypto = require('crypto');
2
2
  const http = require('http');
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const net = require('net');
5
6
 
6
7
  // ========== WebSocket Protocol (RFC 6455) ==========
7
8
 
8
9
  const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A };
9
10
  const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
10
11
  const MAX_FRAME_PAYLOAD_BYTES = 10 * 1024 * 1024;
12
+ // Bound concurrent WebSocket clients (each holds a frame buffer up to
13
+ // MAX_FRAME_PAYLOAD_BYTES) so an authenticated local client can't exhaust
14
+ // memory. Generous: covers many browser tabs plus scripted clients.
15
+ const MAX_WS_CLIENTS = 16;
16
+ const WS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
17
+ const WS_FRAME_TIMEOUT_MS = 5000;
18
+ // Screens are agent-generated HTML; cap how much of one we'll read/serve.
19
+ const MAX_SCREEN_BYTES = 20 * 1024 * 1024;
20
+ // Cap the per-session events log (dropped oldest when exceeded).
21
+ const MAX_EVENTS_FILE_BYTES = 1024 * 1024;
22
+ const MAX_LOG_EVENT_BYTES = 4096;
11
23
 
12
24
  function computeAcceptKey(clientKey) {
13
25
  return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64');
@@ -66,6 +78,12 @@ function decodeFrame(buffer) {
66
78
  throw new Error('WebSocket frame payload exceeds maximum allowed size');
67
79
  }
68
80
 
81
+ // RFC 6455 §5.5: control frames (opcodes 0x8-0xA) must have payloads
82
+ // of at most 125 bytes. Enforce it so a PING can't be used to amplify.
83
+ if (opcode >= 0x8 && payloadLen > 125) {
84
+ throw new Error('WebSocket control frame payload exceeds 125 bytes');
85
+ }
86
+
69
87
  const maskOffset = offset;
70
88
  const dataOffset = offset + 4;
71
89
  const totalLen = dataOffset + payloadLen;
@@ -84,10 +102,13 @@ function decodeFrame(buffer) {
84
102
 
85
103
  const PORT_FILE = process.env.BRAINSTORM_PORT_FILE || null;
86
104
  const randomPort = () => 49152 + Math.floor(Math.random() * 16383);
87
- // Prefer an explicit port, else the port this session last bound (so a restart
88
- // reuses it and an already-open browser tab reconnects), else a random high port.
105
+ // Prefer an explicit port, else the port this session last bound, else a random
106
+ // high port. Authentication is intentionally rotated on every server start.
89
107
  function preferredPort() {
90
- if (process.env.BRAINSTORM_PORT) return Number(process.env.BRAINSTORM_PORT);
108
+ if (process.env.BRAINSTORM_PORT) {
109
+ const p = Number(process.env.BRAINSTORM_PORT);
110
+ if (Number.isInteger(p) && p > 1023 && p < 65536) return p;
111
+ }
91
112
  if (PORT_FILE) {
92
113
  try {
93
114
  const p = Number(fs.readFileSync(PORT_FILE, 'utf-8').trim());
@@ -97,8 +118,21 @@ function preferredPort() {
97
118
  return randomPort();
98
119
  }
99
120
  let PORT = preferredPort();
121
+ function isLoopbackHost(value) {
122
+ const host = String(value).trim().replace(/^\[|\]$/g, '').toLowerCase();
123
+ return host === 'localhost' || host === '::1' || (net.isIP(host) === 4 && host.startsWith('127.'));
124
+ }
125
+
100
126
  const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
127
+ if (!isLoopbackHost(HOST)) {
128
+ console.error('Refusing insecure non-loopback HTTP bind; use a TLS reverse proxy or tunnel to 127.0.0.1');
129
+ process.exit(1);
130
+ }
101
131
  const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
132
+ if (!isLoopbackHost(URL_HOST)) {
133
+ console.error('BRAINSTORM_URL_HOST must be a loopback hostname or address');
134
+ process.exit(1);
135
+ }
102
136
  const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
103
137
  const CONTENT_DIR = path.join(SESSION_DIR, 'content');
104
138
  const STATE_DIR = path.join(SESSION_DIR, 'state');
@@ -112,42 +146,26 @@ const TELEMETRY_DISABLE_ENV_VARS = [
112
146
  const SUPERPOWERS_TELEMETRY_DISABLED = TELEMETRY_DISABLE_ENV_VARS.some(name => isTruthyEnv(process.env[name]));
113
147
  let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null;
114
148
 
115
- // Per-session secret key. The companion is reachable by any local browser tab
116
- // and, when bound to a non-loopback host, by any host that can route to it.
117
- // The key authenticates the real client uniformly across loopback, tunnel, and
118
- // remote binds — and defeats DNS rebinding — where a Host/Origin allowlist
119
- // cannot. It rides the served URL as ?key= and is mirrored into a cookie on
120
- // first load so same-origin subresources and the WebSocket carry it for free.
121
- // Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the
122
- // same key and an already-open tab's cookie still validates.
123
- const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null;
149
+ // Each server invocation gets a fresh 256-bit secret. An explicitly supplied
150
+ // BRAINSTORM_TOKEN is retained for controlled integrations, but launchers never
151
+ // persist it across logical sessions.
124
152
  function generateToken() {
125
153
  return crypto.randomBytes(32).toString('hex');
126
154
  }
127
155
 
128
- function chmodOwnerOnly(file) {
129
- try { fs.chmodSync(file, 0o600); } catch (e) { /* best effort */ }
130
- }
131
-
132
156
  function initialToken() {
133
157
  if (process.env.BRAINSTORM_TOKEN) {
134
- return { value: process.env.BRAINSTORM_TOKEN, source: 'env' };
135
- }
136
- if (TOKEN_FILE) {
137
- try {
138
- const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim();
139
- if (/^[0-9a-f]{32,}$/i.test(t)) {
140
- chmodOwnerOnly(TOKEN_FILE);
141
- return { value: t, source: 'file' };
142
- }
143
- } catch (e) { /* no prior token recorded */ }
158
+ const t = String(process.env.BRAINSTORM_TOKEN).trim();
159
+ if (/^[0-9a-f]{32,}$/i.test(t)) {
160
+ return { value: t, source: 'env' };
161
+ }
144
162
  }
145
163
  return { value: generateToken(), source: 'generated' };
146
164
  }
147
165
 
148
166
  const tokenInfo = initialToken();
149
- let TOKEN = tokenInfo.value;
150
- let tokenSource = tokenInfo.source;
167
+ const TOKEN = tokenInfo.value;
168
+ const tokenSource = tokenInfo.source;
151
169
  let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen
152
170
 
153
171
  const MIME_TYPES = {
@@ -185,23 +203,11 @@ h1 { color: #333; } p { color: #666; } code { background: #f0f0f0; padding: 0.1e
185
203
  <p>This page needs the full URL your coding agent gave you, including the
186
204
  <code>?key=&hellip;</code> part. Copy the complete URL and open it again.</p></body></html>`;
187
205
 
188
- function bootstrapPage(key) {
189
- const jsonKey = JSON.stringify(String(key));
190
- return `<!DOCTYPE html>
191
- <html>
192
- <head><meta charset="utf-8"><title>Opening Brainstorm Companion</title></head>
193
- <body>
194
- <script>
195
- try { sessionStorage.setItem('brainstorm-session-key', ${jsonKey}); } catch (e) {}
196
- location.replace('/');
197
- </script>
198
- </body>
199
- </html>`;
200
- }
201
-
202
206
  const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
203
207
  const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
204
- const helperInjection = '<script>\n' + helperScript + '\n</script>';
208
+ function helperInjection(nonce) {
209
+ return '<script nonce="' + nonce + '">\n' + helperScript + '\n</script>';
210
+ }
205
211
 
206
212
  // ========== Helper Functions ==========
207
213
 
@@ -264,17 +270,219 @@ function wrapInFrame(content) {
264
270
  return renderBranding(frameTemplate).replace('<!-- CONTENT -->', content);
265
271
  }
266
272
 
273
+ function ensurePrivateDirectory(directory) {
274
+ try {
275
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
276
+ const stat = fs.lstatSync(directory);
277
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return false;
278
+ fs.chmodSync(directory, 0o700);
279
+ return true;
280
+ } catch (e) {
281
+ return false;
282
+ }
283
+ }
284
+
285
+ function openPrivateAppendFile(filePath) {
286
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
287
+ let before = null;
288
+ try {
289
+ before = fs.lstatSync(filePath);
290
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1) return null;
291
+ } catch (e) {
292
+ if (e.code !== 'ENOENT') return null;
293
+ }
294
+
295
+ let fd = null;
296
+ const appendFlags = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | noFollow;
297
+ try {
298
+ if (!before) {
299
+ try {
300
+ fd = fs.openSync(filePath, appendFlags | fs.constants.O_EXCL, 0o600);
301
+ } catch (e) {
302
+ if (e.code !== 'EEXIST') throw e;
303
+ }
304
+ }
305
+ if (fd === null) {
306
+ before = fs.lstatSync(filePath);
307
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1) return null;
308
+ fd = fs.openSync(filePath, appendFlags, 0o600);
309
+ }
310
+ const actual = fs.fstatSync(fd);
311
+ if (!actual.isFile() || actual.nlink !== 1 || (before && !sameFileIdentity(before, actual))) {
312
+ fs.closeSync(fd);
313
+ return null;
314
+ }
315
+ fs.fchmodSync(fd, 0o600);
316
+ return fd;
317
+ } catch (e) {
318
+ if (fd !== null) {
319
+ try { fs.closeSync(fd); } catch (closeErr) { /* already closed */ }
320
+ }
321
+ return null;
322
+ }
323
+ }
324
+
325
+ function writePrivateFile(filePath, data) {
326
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
327
+ let before = null;
328
+ try {
329
+ before = fs.lstatSync(filePath);
330
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1) return false;
331
+ } catch (e) {
332
+ if (e.code !== 'ENOENT') return false;
333
+ }
334
+
335
+ let fd = null;
336
+ const writeFlags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | noFollow;
337
+ try {
338
+ if (!before) {
339
+ try {
340
+ fd = fs.openSync(filePath, writeFlags | fs.constants.O_EXCL, 0o600);
341
+ } catch (e) {
342
+ if (e.code !== 'EEXIST') throw e;
343
+ }
344
+ }
345
+ if (fd === null) {
346
+ before = fs.lstatSync(filePath);
347
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1) return false;
348
+ fd = fs.openSync(filePath, writeFlags, 0o600);
349
+ }
350
+ const stat = fs.fstatSync(fd);
351
+ if (!stat.isFile() || stat.nlink !== 1 || (before && !sameFileIdentity(before, stat))) return false;
352
+ fs.fchmodSync(fd, 0o600);
353
+ fs.writeFileSync(fd, data);
354
+ return true;
355
+ } catch (e) {
356
+ return false;
357
+ } finally {
358
+ if (fd !== null) {
359
+ try { fs.closeSync(fd); } catch (e) { /* already closed */ }
360
+ }
361
+ }
362
+ }
363
+
364
+ // Best-effort: return the newest screen file, or null when the content dir is
365
+ // missing/empty or a file vanished between readdir and stat (the agent may be
366
+ // writing screens concurrently). Callers must not let a throw escape the
367
+ // request handler — an uncaught ENOENT there would crash the whole server.
267
368
  function getNewestScreen() {
268
- const files = fs.readdirSync(CONTENT_DIR)
269
- .filter(f => !f.startsWith('.') && f.endsWith('.html'))
270
- .map(f => {
271
- const fp = path.join(CONTENT_DIR, f);
272
- if (!isRegularFileInsideContentDir(fp)) return null;
273
- return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
274
- })
275
- .filter(Boolean)
276
- .sort((a, b) => b.mtime - a.mtime);
277
- return files.length > 0 ? files[0].path : null;
369
+ try {
370
+ const files = fs.readdirSync(CONTENT_DIR)
371
+ .filter(f => !f.startsWith('.') && f.endsWith('.html'))
372
+ .map(f => {
373
+ const fp = path.join(CONTENT_DIR, f);
374
+ if (!isRegularFileInsideContentDir(fp)) return null;
375
+ try {
376
+ const st = fs.statSync(fp);
377
+ if (!st.isFile() || st.size > MAX_SCREEN_BYTES) return null;
378
+ return { path: fp, mtime: st.mtime.getTime() };
379
+ } catch (e) {
380
+ return null; // deleted between readdir and stat
381
+ }
382
+ })
383
+ .filter(Boolean)
384
+ .sort((a, b) => b.mtime - a.mtime);
385
+ return files.length > 0 ? files[0].path : null;
386
+ } catch (e) {
387
+ console.error('Failed to list screens:', e.message);
388
+ return null;
389
+ }
390
+ }
391
+
392
+ // Make sure the content dir exists (it may have been deleted at runtime) and
393
+ // the watcher is (re-)established so new screens keep triggering reloads.
394
+ function ensureContentDir() {
395
+ if (!ensurePrivateDirectory(CONTENT_DIR)) {
396
+ console.error('Failed to recreate or validate content dir');
397
+ return false;
398
+ }
399
+ ensureContentWatcher();
400
+ return true;
401
+ }
402
+
403
+ // Resolve a content file and capture its identity before opening it. The
404
+ // second resolution plus fd-level identity check closes the check/open race:
405
+ // if a parent directory or file is swapped between checks, the opened fd no
406
+ // longer matches the expected device/inode and the read is rejected.
407
+ function resolveContentFile(filePath) {
408
+ const contentDirStat = fs.lstatSync(CONTENT_DIR);
409
+ if (contentDirStat.isSymbolicLink() || !contentDirStat.isDirectory()) return null;
410
+
411
+ const realContentDir = fs.realpathSync(CONTENT_DIR);
412
+ const pathStat = fs.lstatSync(filePath);
413
+ if (pathStat.isSymbolicLink() || !pathStat.isFile() || pathStat.nlink !== 1) return null;
414
+
415
+ const realFilePath = fs.realpathSync(filePath);
416
+ const relative = path.relative(realContentDir, realFilePath);
417
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
418
+
419
+ const stat = fs.statSync(realFilePath);
420
+ if (!stat.isFile() || stat.nlink !== 1) return null;
421
+ return { realFilePath, stat };
422
+ }
423
+
424
+ function sameFileIdentity(a, b) {
425
+ return a.dev === b.dev && a.ino === b.ino;
426
+ }
427
+
428
+ // Read a file from CONTENT_DIR defensively: open with O_NOFOLLOW (POSIX) and
429
+ // fstat the open fd so a concurrent writer can't swap the file for a symlink
430
+ // or an oversized file between our identity check and the read. Returns null
431
+ // on any failure — callers must treat null as "not found" and MUST NOT call
432
+ // writeHead again after a 200 was already sent (that throws
433
+ // ERR_HTTP_HEADERS_SENT and crashes the process).
434
+ function readContentFile(filePath, maxBytes) {
435
+ let fd = null;
436
+ try {
437
+ const expected = resolveContentFile(filePath);
438
+ if (!expected || expected.stat.size > maxBytes) return null;
439
+
440
+ const confirmed = resolveContentFile(filePath);
441
+ if (
442
+ !confirmed ||
443
+ expected.realFilePath !== confirmed.realFilePath ||
444
+ !sameFileIdentity(expected.stat, confirmed.stat)
445
+ ) return null;
446
+
447
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
448
+ const st = fs.fstatSync(fd);
449
+ if (!st.isFile() || st.nlink !== 1 || st.size > maxBytes || !sameFileIdentity(st, confirmed.stat)) return null;
450
+
451
+ const chunks = [];
452
+ const chunkSize = 64 * 1024;
453
+ let total = 0;
454
+ while (total <= maxBytes) {
455
+ const buffer = Buffer.allocUnsafe(Math.min(chunkSize, maxBytes + 1 - total));
456
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
457
+ if (bytesRead === 0) break;
458
+ total += bytesRead;
459
+ chunks.push(buffer.subarray(0, bytesRead));
460
+ if (total > maxBytes) return null;
461
+ }
462
+
463
+ const after = fs.fstatSync(fd);
464
+ if (!after.isFile() || after.nlink !== 1 || after.size > maxBytes || !sameFileIdentity(after, confirmed.stat)) return null;
465
+ return Buffer.concat(chunks, total);
466
+ } catch (e) {
467
+ return null;
468
+ } finally {
469
+ if (fd !== null) {
470
+ try { fs.closeSync(fd); } catch (e) { /* already closed */ }
471
+ }
472
+ }
473
+ }
474
+
475
+ function serveScreen() {
476
+ ensureContentDir();
477
+ const screenFile = getNewestScreen();
478
+ if (!screenFile) return waitingPage();
479
+ const raw = readContentFile(screenFile, MAX_SCREEN_BYTES);
480
+ if (raw === null) {
481
+ console.error('Failed to read screen:', screenFile);
482
+ return waitingPage();
483
+ }
484
+ const html = raw.toString('utf-8');
485
+ return isFullDocument(html) ? html : wrapInFrame(html);
278
486
  }
279
487
 
280
488
  function urlHostForHttp(host) {
@@ -302,18 +510,11 @@ function browserLauncherForPlatform(url, {
302
510
  }
303
511
 
304
512
  function isRegularFileInsideContentDir(filePath) {
305
- let stat, realContentDir, realFilePath;
306
513
  try {
307
- stat = fs.lstatSync(filePath);
308
- if (stat.isSymbolicLink()) return false;
309
- if (!stat.isFile()) return false;
310
- if (stat.nlink !== 1) return false;
311
- realContentDir = fs.realpathSync(CONTENT_DIR);
312
- realFilePath = fs.realpathSync(filePath);
514
+ return resolveContentFile(filePath) !== null;
313
515
  } catch (e) {
314
516
  return false;
315
517
  }
316
- return realFilePath.startsWith(realContentDir + path.sep);
317
518
  }
318
519
 
319
520
  // ========== Authentication ==========
@@ -363,12 +564,14 @@ function queryKey(url) {
363
564
  return new URLSearchParams(url.slice(q + 1)).get('key');
364
565
  }
365
566
 
366
- function securityHeaders(headers = {}) {
567
+ function securityHeaders(headers = {}, scriptNonce = null) {
568
+ const scriptSource = scriptNonce ? "'nonce-" + scriptNonce + "'" : "'none'";
367
569
  return {
368
570
  'Referrer-Policy': 'no-referrer',
369
571
  'Cache-Control': 'no-store',
370
572
  'X-Frame-Options': 'DENY',
371
- 'Content-Security-Policy': "frame-ancestors 'none'",
573
+ 'X-Content-Type-Options': 'nosniff',
574
+ 'Content-Security-Policy': "default-src 'none'; script-src " + scriptSource + "; style-src 'unsafe-inline'; img-src 'self' https://primeradiant.com data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'none'; object-src 'none'; form-action 'none'",
372
575
  'Cross-Origin-Resource-Policy': 'same-origin',
373
576
  ...headers
374
577
  };
@@ -401,21 +604,20 @@ function handleRequest(req, res) {
401
604
  const pathname = pathnameOf(req.url);
402
605
  const keyFromQuery = queryKey(req.url);
403
606
  if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) {
404
- res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
405
- res.end(bootstrapPage(TOKEN));
607
+ res.writeHead(303, securityHeaders({ Location: '/' }));
608
+ res.end();
406
609
  } else if (req.method === 'GET' && pathname === '/') {
407
- const screenFile = getNewestScreen();
408
- let html = screenFile
409
- ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
410
- : waitingPage();
610
+ let html = serveScreen();
611
+ const nonce = crypto.randomBytes(16).toString('base64');
612
+ const injection = helperInjection(nonce);
411
613
 
412
614
  if (html.includes('</body>')) {
413
- html = html.replace('</body>', helperInjection + '\n</body>');
615
+ html = html.replace('</body>', injection + '\n</body>');
414
616
  } else {
415
- html += helperInjection;
617
+ html += injection;
416
618
  }
417
619
 
418
- res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
620
+ res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }, nonce));
419
621
  res.end(html);
420
622
  } else if (req.method === 'GET' && pathname.startsWith('/files/')) {
421
623
  const fileName = path.basename(pathname.slice(7));
@@ -427,10 +629,20 @@ function handleRequest(req, res) {
427
629
  res.end('Not found');
428
630
  return;
429
631
  }
632
+ // Read FIRST, headers after: if the file vanished between the identity
633
+ // check and the read, writeHead(200) was never sent and the 404 below is
634
+ // legal. (Writing 200, then throwing and writing 404 in a catch would
635
+ // itself throw ERR_HTTP_HEADERS_SENT and crash the process.)
636
+ const data = readContentFile(filePath, MAX_SCREEN_BYTES);
637
+ if (data === null) {
638
+ res.writeHead(404, securityHeaders());
639
+ res.end('Not found');
640
+ return;
641
+ }
430
642
  const ext = path.extname(filePath).toLowerCase();
431
643
  const contentType = MIME_TYPES[ext] || 'application/octet-stream';
432
644
  res.writeHead(200, securityHeaders({ 'Content-Type': contentType }));
433
- res.end(fs.readFileSync(filePath));
645
+ res.end(data);
434
646
  } else {
435
647
  res.writeHead(404, securityHeaders());
436
648
  res.end('Not found');
@@ -441,12 +653,42 @@ function handleRequest(req, res) {
441
653
 
442
654
  const clients = new Set();
443
655
 
656
+ // RFC 6455 handshake validation: only accept genuine WebSocket upgrade
657
+ // requests, so an authenticated-but-non-WS client can't enter the frame
658
+ // parser (and so a cross-protocol request can't piggyback on the socket).
659
+ function isWebSocketUpgradeRequest(req) {
660
+ const upgrade = String(req.headers.upgrade || '').toLowerCase();
661
+ const connection = String(req.headers.connection || '').toLowerCase()
662
+ .split(',').map(s => s.trim());
663
+ const version = String(req.headers['sec-websocket-version'] || '');
664
+ const key = String(req.headers['sec-websocket-key'] || '');
665
+ // 16 random bytes, base64-encoded (22 chars + '==' padding).
666
+ const validKey = /^[A-Za-z0-9+/]{22}==$/.test(key);
667
+ return upgrade === 'websocket' &&
668
+ connection.includes('upgrade') &&
669
+ version === '13' &&
670
+ validKey;
671
+ }
672
+
444
673
  function handleUpgrade(req, socket) {
445
674
  if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; }
675
+ if (!isWebSocketUpgradeRequest(req)) { socket.destroy(); return; }
676
+ // The companion's WebSocket lives at '/' only (helper.js connects there).
677
+ if (pathnameOf(req.url) !== '/') { socket.destroy(); return; }
446
678
 
447
679
  const key = req.headers['sec-websocket-key'];
448
680
  if (!key) { socket.destroy(); return; }
449
681
 
682
+ // Keep the cap, but evict the oldest connection so a client cannot reserve
683
+ // every slot indefinitely and prevent a fresh browser from connecting.
684
+ if (clients.size >= MAX_WS_CLIENTS) {
685
+ const oldest = clients.values().next().value;
686
+ if (oldest) {
687
+ clients.delete(oldest);
688
+ try { oldest.destroy(); } catch (e) { /* already gone */ }
689
+ }
690
+ }
691
+
450
692
  const accept = computeAcceptKey(key);
451
693
  socket.write(
452
694
  'HTTP/1.1 101 Switching Protocols\r\n' +
@@ -456,48 +698,122 @@ function handleUpgrade(req, socket) {
456
698
  );
457
699
 
458
700
  let buffer = Buffer.alloc(0);
701
+ let closed = false;
702
+ let partialFrameTimer = null;
459
703
  clients.add(socket);
460
704
 
705
+ const clearPartialFrameTimer = () => {
706
+ if (partialFrameTimer !== null) {
707
+ clearTimeout(partialFrameTimer);
708
+ partialFrameTimer = null;
709
+ }
710
+ };
711
+ const armPartialFrameTimer = () => {
712
+ if (partialFrameTimer !== null) return;
713
+ partialFrameTimer = setTimeout(() => {
714
+ partialFrameTimer = null;
715
+ closed = true;
716
+ clients.delete(socket);
717
+ socket.destroy();
718
+ }, WS_FRAME_TIMEOUT_MS);
719
+ partialFrameTimer.unref();
720
+ };
721
+ const closeSocket = (code = 1000) => {
722
+ if (closed) return;
723
+ closed = true;
724
+ clients.delete(socket);
725
+ clearPartialFrameTimer();
726
+ const closeBuf = Buffer.alloc(code === 1000 ? 0 : 2);
727
+ if (closeBuf.length) closeBuf.writeUInt16BE(code);
728
+ try { socket.end(encodeFrame(OPCODES.CLOSE, closeBuf)); } catch (e) { /* best effort */ }
729
+ socket.pause();
730
+ socket.destroySoon();
731
+ };
732
+
733
+ socket.setTimeout(WS_IDLE_TIMEOUT_MS, () => closeSocket(1001));
461
734
  socket.on('data', (chunk) => {
735
+ // RFC 6455 §5.5.1: after sending CLOSE we must not process further
736
+ // frames — a peer could otherwise keep writing events (and extending
737
+ // its activity influence) in the window before destruction.
738
+ if (closed) return;
462
739
  buffer = Buffer.concat([buffer, chunk]);
740
+ if (buffer.length > MAX_FRAME_PAYLOAD_BYTES + 14) {
741
+ closeSocket(1009);
742
+ return;
743
+ }
463
744
  while (buffer.length > 0) {
464
745
  let result;
465
746
  try {
466
747
  result = decodeFrame(buffer);
467
748
  } catch (e) {
468
- socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
469
- clients.delete(socket);
749
+ closeSocket(1002);
750
+ return;
751
+ }
752
+ if (!result) {
753
+ armPartialFrameTimer();
470
754
  return;
471
755
  }
472
- if (!result) break;
473
756
  buffer = buffer.slice(result.bytesConsumed);
757
+ clearPartialFrameTimer();
474
758
 
475
759
  switch (result.opcode) {
476
760
  case OPCODES.TEXT:
477
761
  handleMessage(result.payload.toString());
478
762
  break;
479
763
  case OPCODES.CLOSE:
480
- socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
481
- clients.delete(socket);
764
+ closeSocket();
482
765
  return;
483
766
  case OPCODES.PING:
484
767
  socket.write(encodeFrame(OPCODES.PONG, result.payload));
485
768
  break;
486
769
  case OPCODES.PONG:
487
770
  break;
488
- default: {
489
- const closeBuf = Buffer.alloc(2);
490
- closeBuf.writeUInt16BE(1003);
491
- socket.end(encodeFrame(OPCODES.CLOSE, closeBuf));
492
- clients.delete(socket);
771
+ default:
772
+ closeSocket(1003);
493
773
  return;
494
- }
495
774
  }
496
775
  }
497
776
  });
498
777
 
499
- socket.on('close', () => clients.delete(socket));
500
- socket.on('error', () => clients.delete(socket));
778
+ socket.on('close', () => {
779
+ clearPartialFrameTimer();
780
+ clients.delete(socket);
781
+ });
782
+ socket.on('error', () => {
783
+ clearPartialFrameTimer();
784
+ clients.delete(socket);
785
+ });
786
+ }
787
+
788
+ function appendEvent(event) {
789
+ const eventsFile = path.join(STATE_DIR, 'events');
790
+ const serialized = JSON.stringify(event);
791
+ if (typeof serialized !== 'string') return;
792
+ const line = serialized + '\n';
793
+ const lineBytes = Buffer.byteLength(line, 'utf8');
794
+ if (lineBytes > MAX_EVENTS_FILE_BYTES) {
795
+ console.error('User event exceeds events file size limit');
796
+ return;
797
+ }
798
+
799
+ const fd = openPrivateAppendFile(eventsFile);
800
+ if (fd === null) {
801
+ console.error('Failed to append user event: events path is not a private regular file');
802
+ return;
803
+ }
804
+ try {
805
+ const current = fs.fstatSync(fd);
806
+ if (current.size + lineBytes > MAX_EVENTS_FILE_BYTES) {
807
+ fs.ftruncateSync(fd, 0);
808
+ }
809
+ fs.writeSync(fd, line);
810
+ } catch (e) {
811
+ // STATE_DIR may have been deleted at runtime — never let a user event
812
+ // crash the server.
813
+ console.error('Failed to append user event:', e.message);
814
+ } finally {
815
+ try { fs.closeSync(fd); } catch (e) { /* already closed */ }
816
+ }
501
817
  }
502
818
 
503
819
  function handleMessage(text) {
@@ -509,10 +825,15 @@ function handleMessage(text) {
509
825
  return;
510
826
  }
511
827
  touchActivity();
512
- console.log(JSON.stringify({ source: 'user-event', ...event }));
828
+ // Spread first so 'source' always wins: a client-supplied "source" field
829
+ // must not be able to spoof the event origin in the log.
830
+ const logLine = JSON.stringify({ ...event, source: 'user-event' });
831
+ const logOutput = Buffer.byteLength(logLine, 'utf8') <= MAX_LOG_EVENT_BYTES
832
+ ? logLine
833
+ : JSON.stringify({ source: 'user-event', truncated: true });
834
+ console.log(logOutput);
513
835
  if (event && event.choice) {
514
- const eventsFile = path.join(STATE_DIR, 'events');
515
- fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
836
+ appendEvent(event);
516
837
  }
517
838
  }
518
839
 
@@ -571,58 +892,134 @@ function touchActivity() {
571
892
  // ========== File Watching ==========
572
893
 
573
894
  const debounceTimers = new Map();
895
+ let watcher = null;
896
+ let watcherRetryTimer = null;
897
+ let knownFiles = new Set();
898
+ let contentDirIno = null;
899
+
900
+ // Track known files to distinguish new screens from updates.
901
+ // macOS fs.watch reports 'rename' for both new files and overwrites,
902
+ // so we can't rely on eventType alone.
903
+ function rescanKnownFiles() {
904
+ try {
905
+ knownFiles = new Set(
906
+ fs.readdirSync(CONTENT_DIR).filter(f => !f.startsWith('.') && f.endsWith('.html'))
907
+ );
908
+ } catch (e) {
909
+ knownFiles = new Set();
910
+ }
911
+ }
912
+
913
+ function currentContentIno() {
914
+ try {
915
+ return fs.statSync(CONTENT_DIR).ino;
916
+ } catch (e) {
917
+ return null;
918
+ }
919
+ }
920
+
921
+ function onContentEvent(eventType, filename) {
922
+ // The watched directory itself was deleted. On Linux, inotify reports
923
+ // IN_DELETE_SELF/IN_IGNORED as a plain 'rename' event carrying the dir's
924
+ // OWN basename and NO error — so the watcher stays non-null while being
925
+ // dead. Tear it down so ensureContentWatcher can re-arm it once the dir is
926
+ // recreated. Guard with an inode check: a LATE delete event from an old,
927
+ // already-replaced watcher can arrive after the new watcher is live, and
928
+ // we must not kill the healthy new watcher.
929
+ if (filename === path.basename(CONTENT_DIR)) {
930
+ const ino = currentContentIno();
931
+ if (ino === null || ino !== contentDirIno) {
932
+ stopWatcher();
933
+ }
934
+ return;
935
+ }
936
+ if (!filename || filename.startsWith('.') || !filename.endsWith('.html')) return;
937
+
938
+ if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));
939
+ debounceTimers.set(filename, setTimeout(() => {
940
+ debounceTimers.delete(filename);
941
+ const filePath = path.join(CONTENT_DIR, filename);
942
+
943
+ if (!fs.existsSync(filePath)) return; // file was deleted
944
+ touchActivity();
945
+
946
+ if (!knownFiles.has(filename)) {
947
+ knownFiles.add(filename);
948
+ const eventsFile = path.join(STATE_DIR, 'events');
949
+ try { fs.unlinkSync(eventsFile); } catch (e) { /* missing or raced */ }
950
+ console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
951
+ maybeOpenBrowser();
952
+ } else {
953
+ console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
954
+ }
955
+
956
+ broadcast({ type: 'reload' });
957
+ }, 100));
958
+ }
959
+
960
+ // (Re-)establish the content-dir watcher. The watcher dies when the content
961
+ // dir is deleted (inotify watches the inode; FSEvents the path), so it must
962
+ // be re-created after the dir comes back — otherwise new screens would never
963
+ // trigger reloads. Two detection paths:
964
+ // 1. onContentEvent sees the dir's own basename → stopWatcher() already ran;
965
+ // 2. the dir was replaced without us noticing (inode mismatch vs the inode
966
+ // we started watching) → re-arm here.
967
+ function ensureContentWatcher() {
968
+ if (watcher) {
969
+ const ino = currentContentIno();
970
+ if (ino === null || ino === contentDirIno) return;
971
+ stopWatcher(); // watched dir was deleted and recreated; watch the new one
972
+ }
973
+ if (!fs.existsSync(CONTENT_DIR)) return;
974
+ try {
975
+ rescanKnownFiles();
976
+ contentDirIno = currentContentIno();
977
+ watcher = fs.watch(CONTENT_DIR, onContentEvent);
978
+ watcher.on('error', (err) => {
979
+ console.error('fs.watch error:', err.message);
980
+ watcher = null;
981
+ if (watcherRetryTimer) clearTimeout(watcherRetryTimer);
982
+ watcherRetryTimer = setTimeout(() => {
983
+ watcherRetryTimer = null;
984
+ ensureContentWatcher();
985
+ }, 1000);
986
+ });
987
+ } catch (e) {
988
+ watcher = null;
989
+ }
990
+ }
991
+
992
+ function stopWatcher() {
993
+ if (watcherRetryTimer) { clearTimeout(watcherRetryTimer); watcherRetryTimer = null; }
994
+ if (watcher) {
995
+ try { watcher.close(); } catch (e) { /* already closed */ }
996
+ watcher = null;
997
+ }
998
+ contentDirIno = null;
999
+ }
574
1000
 
575
1001
  // ========== Server Startup ==========
576
1002
 
577
1003
  function startServer() {
578
- if (!fs.existsSync(CONTENT_DIR)) fs.mkdirSync(CONTENT_DIR, { recursive: true });
579
- if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
580
-
581
- // Track known files to distinguish new screens from updates.
582
- // macOS fs.watch reports 'rename' for both new files and overwrites,
583
- // so we can't rely on eventType alone.
584
- const knownFiles = new Set(
585
- fs.readdirSync(CONTENT_DIR).filter(f => !f.startsWith('.') && f.endsWith('.html'))
586
- );
1004
+ if (!ensurePrivateDirectory(SESSION_DIR) ||
1005
+ !ensurePrivateDirectory(CONTENT_DIR) ||
1006
+ !ensurePrivateDirectory(STATE_DIR)) {
1007
+ throw new Error('Brainstorm session directories must be private, real directories');
1008
+ }
587
1009
 
588
1010
  const server = http.createServer(handleRequest);
589
1011
  server.on('upgrade', handleUpgrade);
590
-
591
- const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => {
592
- if (!filename || filename.startsWith('.') || !filename.endsWith('.html')) return;
593
-
594
- if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));
595
- debounceTimers.set(filename, setTimeout(() => {
596
- debounceTimers.delete(filename);
597
- const filePath = path.join(CONTENT_DIR, filename);
598
-
599
- if (!fs.existsSync(filePath)) return; // file was deleted
600
- touchActivity();
601
-
602
- if (!knownFiles.has(filename)) {
603
- knownFiles.add(filename);
604
- const eventsFile = path.join(STATE_DIR, 'events');
605
- if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
606
- console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
607
- maybeOpenBrowser();
608
- } else {
609
- console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
610
- }
611
-
612
- broadcast({ type: 'reload' });
613
- }, 100));
614
- });
615
- watcher.on('error', (err) => console.error('fs.watch error:', err.message));
1012
+ ensureContentWatcher();
616
1013
 
617
1014
  function shutdown(reason) {
618
1015
  console.log(JSON.stringify({ type: 'server-stopped', reason }));
619
1016
  const infoFile = path.join(STATE_DIR, 'server-info');
620
- if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);
621
- fs.writeFileSync(
1017
+ try { fs.unlinkSync(infoFile); } catch (e) { /* already gone or raced */ }
1018
+ writePrivateFile(
622
1019
  path.join(STATE_DIR, 'server-stopped'),
623
1020
  JSON.stringify({ reason, timestamp: Date.now() }) + '\n'
624
1021
  );
625
- watcher.close();
1022
+ stopWatcher();
626
1023
  clearInterval(lifecycleCheck);
627
1024
  // Close any upgraded WebSocket sockets so server.close() can complete and
628
1025
  // the process actually exits instead of lingering on an open connection.
@@ -630,6 +1027,9 @@ function startServer() {
630
1027
  try { socket.destroy(); } catch (e) { /* already gone */ }
631
1028
  }
632
1029
  server.close(() => process.exit(0));
1030
+ // Safety net: an in-flight HTTP connection (e.g. a slow /files/ download)
1031
+ // must not keep a shutting-down server alive indefinitely.
1032
+ setTimeout(() => process.exit(0), 5000).unref();
633
1033
  }
634
1034
 
635
1035
  function ownerAlive() {
@@ -641,6 +1041,7 @@ function startServer() {
641
1041
  const lifecycleCheck = setInterval(() => {
642
1042
  if (!ownerAlive()) shutdown('owner process exited');
643
1043
  else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout');
1044
+ else ensureContentWatcher(); // self-heal the watcher if the dir was recreated
644
1045
  }, LIFECYCLE_CHECK_MS);
645
1046
  lifecycleCheck.unref();
646
1047
 
@@ -666,17 +1067,11 @@ function startServer() {
666
1067
  // one after an EADDRINUSE fallback) so it can't collide with another server's
667
1068
  // cookie in the shared localhost jar.
668
1069
  COOKIE_NAME = 'brainstorm-key-' + PORT;
669
- // Record the bound port AND token so the next restart of this session reuses
670
- // them — but ONLY when we got our preferred port. On a fallback we bound a
671
- // *different* port because someone else holds the preferred one; persisting
672
- // would overwrite the shared files and strand that other session's open tab.
1070
+ // Record the bound port only when we got our preferred port. Authentication
1071
+ // is deliberately not persisted, so a new logical session gets a new key.
673
1072
  if (PORT_FILE && !triedFallback) {
674
- try { fs.writeFileSync(PORT_FILE, String(PORT)); } catch (e) { /* best effort */ }
675
- if (TOKEN_FILE) {
676
- try {
677
- fs.writeFileSync(TOKEN_FILE, TOKEN, { mode: 0o600 });
678
- chmodOwnerOnly(TOKEN_FILE);
679
- } catch (e) { /* best effort */ }
1073
+ if (!writePrivateFile(PORT_FILE, String(PORT))) {
1074
+ console.error('Failed to write private port file');
680
1075
  }
681
1076
  }
682
1077
  const info = JSON.stringify({
@@ -686,7 +1081,9 @@ function startServer() {
686
1081
  });
687
1082
  console.log(info);
688
1083
  // server-info embeds the key — keep it owner-only.
689
- fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n', { mode: 0o600 });
1084
+ if (!writePrivateFile(path.join(STATE_DIR, 'server-info'), info + '\n')) {
1085
+ throw new Error('Failed to write private server-info');
1086
+ }
690
1087
  }
691
1088
 
692
1089
  server.on('error', (err) => {
@@ -697,10 +1094,6 @@ function startServer() {
697
1094
  }
698
1095
  triedFallback = true;
699
1096
  PORT = randomPort();
700
- if (tokenSource === 'file') {
701
- TOKEN = generateToken();
702
- tokenSource = 'generated-fallback';
703
- }
704
1097
  server.listen(PORT, HOST, onListen);
705
1098
  } else {
706
1099
  console.error('Server failed to bind:', err.message);