superpowers-mcp 6.2.2 → 6.2.4

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;
@@ -83,11 +101,22 @@ function decodeFrame(buffer) {
83
101
  // ========== Configuration ==========
84
102
 
85
103
  const PORT_FILE = process.env.BRAINSTORM_PORT_FILE || null;
104
+ // Per-session secret key. The companion is reachable by any local browser tab.
105
+ // The key authenticates the client, rides the served URL as ?key=, and is
106
+ // mirrored into a HttpOnly cookie on first load so the WebSocket and /files/*
107
+ // subresources authenticate for free. When BRAINSTORM_TOKEN_FILE is set (the
108
+ // persistent --project-dir launcher mode), the key is persisted alongside the
109
+ // port so a restart reuses it and an already-open browser tab's cookie keeps
110
+ // validating. Without a token file the key is rotated on every server start.
111
+ const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null;
86
112
  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.
113
+ // Prefer an explicit port, else the port this session last bound, else a random
114
+ // high port.
89
115
  function preferredPort() {
90
- if (process.env.BRAINSTORM_PORT) return Number(process.env.BRAINSTORM_PORT);
116
+ if (process.env.BRAINSTORM_PORT) {
117
+ const p = Number(process.env.BRAINSTORM_PORT);
118
+ if (Number.isInteger(p) && p > 1023 && p < 65536) return p;
119
+ }
91
120
  if (PORT_FILE) {
92
121
  try {
93
122
  const p = Number(fs.readFileSync(PORT_FILE, 'utf-8').trim());
@@ -97,13 +126,29 @@ function preferredPort() {
97
126
  return randomPort();
98
127
  }
99
128
  let PORT = preferredPort();
129
+ function isLoopbackHost(value) {
130
+ const host = String(value).trim().replace(/^\[|\]$/g, '').toLowerCase();
131
+ return host === 'localhost' || host === '::1' || (net.isIP(host) === 4 && host.startsWith('127.'));
132
+ }
133
+
100
134
  const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
135
+ if (!isLoopbackHost(HOST)) {
136
+ console.error('Refusing insecure non-loopback HTTP bind; use a TLS reverse proxy or tunnel to 127.0.0.1');
137
+ process.exit(1);
138
+ }
101
139
  const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
140
+ if (!isLoopbackHost(URL_HOST)) {
141
+ console.error('BRAINSTORM_URL_HOST must be a loopback hostname or address');
142
+ process.exit(1);
143
+ }
102
144
  const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
103
145
  const CONTENT_DIR = path.join(SESSION_DIR, 'content');
104
146
  const STATE_DIR = path.join(SESSION_DIR, 'state');
105
147
  const SUPERPOWERS_VERSION = readSuperpowersVersion();
106
- const SUPERPOWERS_BRAND_IMAGE_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png';
148
+ // The brand mark is served locally from /brand-logo.svg so the page never
149
+ // contacts a third-party host; the SVG payload is generated inline below.
150
+ const SUPERPOWERS_BRAND_IMAGE_URL = '/brand-logo.svg';
151
+ const BRAND_LOGO_SVG = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 24' role='img' aria-label='Superpowers'><g fill='#fff'><path d='M11 21h-1l1-7H7.5c-.58 0-.57-.32-.38-.66.19-.34.05-.08.07-.12C8.48 10.94 10.42 7.54 13 3h1l-1 7h3.5c.49 0 .56.33.47.51l-.07.15C12.96 17.55 11 21 11 21z' transform='translate(0 2.5) scale(0.62)'/><text x='24' y='17.5' font-family='system-ui, sans-serif' font-size='15' font-weight='700' letter-spacing='2.5'>SUPERPOWERS</text></g></svg>";
107
152
  const TELEMETRY_DISABLE_ENV_VARS = [
108
153
  'SUPERPOWERS_DISABLE_TELEMETRY',
109
154
  'DISABLE_TELEMETRY',
@@ -112,42 +157,73 @@ const TELEMETRY_DISABLE_ENV_VARS = [
112
157
  const SUPERPOWERS_TELEMETRY_DISABLED = TELEMETRY_DISABLE_ENV_VARS.some(name => isTruthyEnv(process.env[name]));
113
158
  let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null;
114
159
 
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;
160
+ // A 256-bit secret. An explicitly supplied BRAINSTORM_TOKEN is retained for
161
+ // controlled integrations. In persistent launcher mode (BRAINSTORM_TOKEN_FILE)
162
+ // the key is reused across restarts so already-open tabs stay authenticated;
163
+ // otherwise each server invocation gets a fresh key. Persistence is best effort:
164
+ // a write failure must never prevent the server from starting.
124
165
  function generateToken() {
125
166
  return crypto.randomBytes(32).toString('hex');
126
167
  }
127
168
 
128
- function chmodOwnerOnly(file) {
129
- try { fs.chmodSync(file, 0o600); } catch (e) { /* best effort */ }
169
+ // Mirror of writePrivateFile for the read path: the token file must be a
170
+ // regular, single-link, non-symlink file. The fd is opened with O_NOFOLLOW and
171
+ // its identity re-checked, and it is tightened to 0600 via the fd — never a
172
+ // path-based chmod, which would follow a symlink to an attacker-chosen target.
173
+ // Returns the file content or null on any violation/error.
174
+ function readPrivateFile(filePath) {
175
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
176
+ try {
177
+ const before = fs.lstatSync(filePath);
178
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1) return null;
179
+ const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
180
+ try {
181
+ const stat = fs.fstatSync(fd);
182
+ if (!stat.isFile() || stat.nlink !== 1 || !sameFileIdentity(before, stat)) return null;
183
+ fs.fchmodSync(fd, 0o600);
184
+ return fs.readFileSync(fd, 'utf-8');
185
+ } finally {
186
+ fs.closeSync(fd);
187
+ }
188
+ } catch (e) {
189
+ return null;
190
+ }
130
191
  }
131
192
 
132
193
  function initialToken() {
133
194
  if (process.env.BRAINSTORM_TOKEN) {
134
- return { value: process.env.BRAINSTORM_TOKEN, source: 'env' };
195
+ const t = String(process.env.BRAINSTORM_TOKEN).trim();
196
+ if (/^[0-9a-f]{32,}$/i.test(t)) {
197
+ // A file path is not touched when the env token wins: the env value is a
198
+ // transient override for controlled integrations, and persisting it would
199
+ // turn a one-off secret into a durable one. Note the interaction: a later
200
+ // launch without the env var falls back to whatever .last-token holds.
201
+ return { value: t, source: 'env' };
202
+ }
135
203
  }
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 */ }
204
+ if (TOKEN_FILE && path.isAbsolute(TOKEN_FILE)) {
205
+ // No prior token recorded (or the file fails the regular-file checks below)
206
+ // — either way, fall through to generating a fresh key.
207
+ const t = readPrivateFile(TOKEN_FILE);
208
+ if (t !== null && /^[0-9a-f]{32,}$/i.test(t.trim())) {
209
+ return { value: t.trim(), source: 'file' };
210
+ }
211
+ }
212
+ const generated = { value: generateToken(), source: 'generated' };
213
+ if (TOKEN_FILE && path.isAbsolute(TOKEN_FILE)) {
214
+ // Persist the key so a restart reuses it; best effort only — a write
215
+ // failure must never prevent the server from starting, but it silently
216
+ // degrades "restart keeps the tab alive" into per-start rotation.
217
+ if (!writePrivateFile(TOKEN_FILE, generated.value + '\n')) {
218
+ console.error('Failed to write private token file:', TOKEN_FILE);
219
+ }
144
220
  }
145
- return { value: generateToken(), source: 'generated' };
221
+ return generated;
146
222
  }
147
223
 
148
224
  const tokenInfo = initialToken();
149
- let TOKEN = tokenInfo.value;
150
- let tokenSource = tokenInfo.source;
225
+ const TOKEN = tokenInfo.value;
226
+ const tokenSource = tokenInfo.source;
151
227
  let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen
152
228
 
153
229
  const MIME_TYPES = {
@@ -185,23 +261,11 @@ h1 { color: #333; } p { color: #666; } code { background: #f0f0f0; padding: 0.1e
185
261
  <p>This page needs the full URL your coding agent gave you, including the
186
262
  <code>?key=&hellip;</code> part. Copy the complete URL and open it again.</p></body></html>`;
187
263
 
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
264
  const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
203
265
  const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
204
- const helperInjection = '<script>\n' + helperScript + '\n</script>';
266
+ function helperInjection(nonce) {
267
+ return '<script nonce="' + nonce + '">\n' + helperScript + '\n</script>';
268
+ }
205
269
 
206
270
  // ========== Helper Functions ==========
207
271
 
@@ -246,7 +310,7 @@ function brandMarkup() {
246
310
  : 'Superpowers v' + version;
247
311
  const logo = SUPERPOWERS_TELEMETRY_DISABLED
248
312
  ? ''
249
- : '<img class="brand-logo" src="' + SUPERPOWERS_BRAND_IMAGE_URL + '?v=' + encodeURIComponent(SUPERPOWERS_VERSION) + '" alt="Prime Radiant" referrerpolicy="no-referrer" decoding="async">';
313
+ : '<img class="brand-logo" src="' + SUPERPOWERS_BRAND_IMAGE_URL + '" alt="Prime Radiant" referrerpolicy="no-referrer" decoding="async">';
250
314
 
251
315
  return '<div class="brand"><a href="https://github.com/obra/superpowers">' + logo + '<span class="brand-copy">' + text + '</span></a></div>';
252
316
  }
@@ -264,17 +328,219 @@ function wrapInFrame(content) {
264
328
  return renderBranding(frameTemplate).replace('<!-- CONTENT -->', content);
265
329
  }
266
330
 
331
+ function ensurePrivateDirectory(directory) {
332
+ try {
333
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
334
+ const stat = fs.lstatSync(directory);
335
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return false;
336
+ fs.chmodSync(directory, 0o700);
337
+ return true;
338
+ } catch (e) {
339
+ return false;
340
+ }
341
+ }
342
+
343
+ function openPrivateAppendFile(filePath) {
344
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
345
+ let before = null;
346
+ try {
347
+ before = fs.lstatSync(filePath);
348
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1) return null;
349
+ } catch (e) {
350
+ if (e.code !== 'ENOENT') return null;
351
+ }
352
+
353
+ let fd = null;
354
+ const appendFlags = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | noFollow;
355
+ try {
356
+ if (!before) {
357
+ try {
358
+ fd = fs.openSync(filePath, appendFlags | fs.constants.O_EXCL, 0o600);
359
+ } catch (e) {
360
+ if (e.code !== 'EEXIST') throw e;
361
+ }
362
+ }
363
+ if (fd === null) {
364
+ before = fs.lstatSync(filePath);
365
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1) return null;
366
+ fd = fs.openSync(filePath, appendFlags, 0o600);
367
+ }
368
+ const actual = fs.fstatSync(fd);
369
+ if (!actual.isFile() || actual.nlink !== 1 || (before && !sameFileIdentity(before, actual))) {
370
+ fs.closeSync(fd);
371
+ return null;
372
+ }
373
+ fs.fchmodSync(fd, 0o600);
374
+ return fd;
375
+ } catch (e) {
376
+ if (fd !== null) {
377
+ try { fs.closeSync(fd); } catch (closeErr) { /* already closed */ }
378
+ }
379
+ return null;
380
+ }
381
+ }
382
+
383
+ function writePrivateFile(filePath, data) {
384
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
385
+ let before = null;
386
+ try {
387
+ before = fs.lstatSync(filePath);
388
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1) return false;
389
+ } catch (e) {
390
+ if (e.code !== 'ENOENT') return false;
391
+ }
392
+
393
+ let fd = null;
394
+ const writeFlags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | noFollow;
395
+ try {
396
+ if (!before) {
397
+ try {
398
+ fd = fs.openSync(filePath, writeFlags | fs.constants.O_EXCL, 0o600);
399
+ } catch (e) {
400
+ if (e.code !== 'EEXIST') throw e;
401
+ }
402
+ }
403
+ if (fd === null) {
404
+ before = fs.lstatSync(filePath);
405
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1) return false;
406
+ fd = fs.openSync(filePath, writeFlags, 0o600);
407
+ }
408
+ const stat = fs.fstatSync(fd);
409
+ if (!stat.isFile() || stat.nlink !== 1 || (before && !sameFileIdentity(before, stat))) return false;
410
+ fs.fchmodSync(fd, 0o600);
411
+ fs.writeFileSync(fd, data);
412
+ return true;
413
+ } catch (e) {
414
+ return false;
415
+ } finally {
416
+ if (fd !== null) {
417
+ try { fs.closeSync(fd); } catch (e) { /* already closed */ }
418
+ }
419
+ }
420
+ }
421
+
422
+ // Best-effort: return the newest screen file, or null when the content dir is
423
+ // missing/empty or a file vanished between readdir and stat (the agent may be
424
+ // writing screens concurrently). Callers must not let a throw escape the
425
+ // request handler — an uncaught ENOENT there would crash the whole server.
267
426
  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;
427
+ try {
428
+ const files = fs.readdirSync(CONTENT_DIR)
429
+ .filter(f => !f.startsWith('.') && f.endsWith('.html'))
430
+ .map(f => {
431
+ const fp = path.join(CONTENT_DIR, f);
432
+ if (!isRegularFileInsideContentDir(fp)) return null;
433
+ try {
434
+ const st = fs.statSync(fp);
435
+ if (!st.isFile() || st.size > MAX_SCREEN_BYTES) return null;
436
+ return { path: fp, mtime: st.mtime.getTime() };
437
+ } catch (e) {
438
+ return null; // deleted between readdir and stat
439
+ }
440
+ })
441
+ .filter(Boolean)
442
+ .sort((a, b) => b.mtime - a.mtime);
443
+ return files.length > 0 ? files[0].path : null;
444
+ } catch (e) {
445
+ console.error('Failed to list screens:', e.message);
446
+ return null;
447
+ }
448
+ }
449
+
450
+ // Make sure the content dir exists (it may have been deleted at runtime) and
451
+ // the watcher is (re-)established so new screens keep triggering reloads.
452
+ function ensureContentDir() {
453
+ if (!ensurePrivateDirectory(CONTENT_DIR)) {
454
+ console.error('Failed to recreate or validate content dir');
455
+ return false;
456
+ }
457
+ ensureContentWatcher();
458
+ return true;
459
+ }
460
+
461
+ // Resolve a content file and capture its identity before opening it. The
462
+ // second resolution plus fd-level identity check closes the check/open race:
463
+ // if a parent directory or file is swapped between checks, the opened fd no
464
+ // longer matches the expected device/inode and the read is rejected.
465
+ function resolveContentFile(filePath) {
466
+ const contentDirStat = fs.lstatSync(CONTENT_DIR);
467
+ if (contentDirStat.isSymbolicLink() || !contentDirStat.isDirectory()) return null;
468
+
469
+ const realContentDir = fs.realpathSync(CONTENT_DIR);
470
+ const pathStat = fs.lstatSync(filePath);
471
+ if (pathStat.isSymbolicLink() || !pathStat.isFile() || pathStat.nlink !== 1) return null;
472
+
473
+ const realFilePath = fs.realpathSync(filePath);
474
+ const relative = path.relative(realContentDir, realFilePath);
475
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
476
+
477
+ const stat = fs.statSync(realFilePath);
478
+ if (!stat.isFile() || stat.nlink !== 1) return null;
479
+ return { realFilePath, stat };
480
+ }
481
+
482
+ function sameFileIdentity(a, b) {
483
+ return a.dev === b.dev && a.ino === b.ino;
484
+ }
485
+
486
+ // Read a file from CONTENT_DIR defensively: open with O_NOFOLLOW (POSIX) and
487
+ // fstat the open fd so a concurrent writer can't swap the file for a symlink
488
+ // or an oversized file between our identity check and the read. Returns null
489
+ // on any failure — callers must treat null as "not found" and MUST NOT call
490
+ // writeHead again after a 200 was already sent (that throws
491
+ // ERR_HTTP_HEADERS_SENT and crashes the process).
492
+ function readContentFile(filePath, maxBytes) {
493
+ let fd = null;
494
+ try {
495
+ const expected = resolveContentFile(filePath);
496
+ if (!expected || expected.stat.size > maxBytes) return null;
497
+
498
+ const confirmed = resolveContentFile(filePath);
499
+ if (
500
+ !confirmed ||
501
+ expected.realFilePath !== confirmed.realFilePath ||
502
+ !sameFileIdentity(expected.stat, confirmed.stat)
503
+ ) return null;
504
+
505
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
506
+ const st = fs.fstatSync(fd);
507
+ if (!st.isFile() || st.nlink !== 1 || st.size > maxBytes || !sameFileIdentity(st, confirmed.stat)) return null;
508
+
509
+ const chunks = [];
510
+ const chunkSize = 64 * 1024;
511
+ let total = 0;
512
+ while (total <= maxBytes) {
513
+ const buffer = Buffer.allocUnsafe(Math.min(chunkSize, maxBytes + 1 - total));
514
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
515
+ if (bytesRead === 0) break;
516
+ total += bytesRead;
517
+ chunks.push(buffer.subarray(0, bytesRead));
518
+ if (total > maxBytes) return null;
519
+ }
520
+
521
+ const after = fs.fstatSync(fd);
522
+ if (!after.isFile() || after.nlink !== 1 || after.size > maxBytes || !sameFileIdentity(after, confirmed.stat)) return null;
523
+ return Buffer.concat(chunks, total);
524
+ } catch (e) {
525
+ return null;
526
+ } finally {
527
+ if (fd !== null) {
528
+ try { fs.closeSync(fd); } catch (e) { /* already closed */ }
529
+ }
530
+ }
531
+ }
532
+
533
+ function serveScreen() {
534
+ ensureContentDir();
535
+ const screenFile = getNewestScreen();
536
+ if (!screenFile) return waitingPage();
537
+ const raw = readContentFile(screenFile, MAX_SCREEN_BYTES);
538
+ if (raw === null) {
539
+ console.error('Failed to read screen:', screenFile);
540
+ return waitingPage();
541
+ }
542
+ const html = raw.toString('utf-8');
543
+ return isFullDocument(html) ? html : wrapInFrame(html);
278
544
  }
279
545
 
280
546
  function urlHostForHttp(host) {
@@ -302,18 +568,11 @@ function browserLauncherForPlatform(url, {
302
568
  }
303
569
 
304
570
  function isRegularFileInsideContentDir(filePath) {
305
- let stat, realContentDir, realFilePath;
306
571
  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);
572
+ return resolveContentFile(filePath) !== null;
313
573
  } catch (e) {
314
574
  return false;
315
575
  }
316
- return realFilePath.startsWith(realContentDir + path.sep);
317
576
  }
318
577
 
319
578
  // ========== Authentication ==========
@@ -363,12 +622,14 @@ function queryKey(url) {
363
622
  return new URLSearchParams(url.slice(q + 1)).get('key');
364
623
  }
365
624
 
366
- function securityHeaders(headers = {}) {
625
+ function securityHeaders(headers = {}, scriptNonce = null) {
626
+ const scriptSource = scriptNonce ? "'nonce-" + scriptNonce + "'" : "'none'";
367
627
  return {
368
628
  'Referrer-Policy': 'no-referrer',
369
629
  'Cache-Control': 'no-store',
370
630
  'X-Frame-Options': 'DENY',
371
- 'Content-Security-Policy': "frame-ancestors 'none'",
631
+ 'X-Content-Type-Options': 'nosniff',
632
+ 'Content-Security-Policy': "default-src 'none'; script-src " + scriptSource + "; style-src 'unsafe-inline'; img-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'none'; object-src 'none'; form-action 'none'",
372
633
  'Cross-Origin-Resource-Policy': 'same-origin',
373
634
  ...headers
374
635
  };
@@ -401,22 +662,25 @@ function handleRequest(req, res) {
401
662
  const pathname = pathnameOf(req.url);
402
663
  const keyFromQuery = queryKey(req.url);
403
664
  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));
665
+ res.writeHead(303, securityHeaders({ Location: '/' }));
666
+ res.end();
406
667
  } 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();
668
+ let html = serveScreen();
669
+ const nonce = crypto.randomBytes(16).toString('base64');
670
+ const injection = helperInjection(nonce);
411
671
 
412
672
  if (html.includes('</body>')) {
413
- html = html.replace('</body>', helperInjection + '\n</body>');
673
+ html = html.replace('</body>', injection + '\n</body>');
414
674
  } else {
415
- html += helperInjection;
675
+ html += injection;
416
676
  }
417
677
 
418
- res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
678
+ res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }, nonce));
419
679
  res.end(html);
680
+ } else if (req.method === 'GET' && pathname === '/brand-logo.svg') {
681
+ // Locally-generated brand mark; same-origin so img-src 'self' suffices.
682
+ res.writeHead(200, securityHeaders({ 'Content-Type': 'image/svg+xml; charset=utf-8' }));
683
+ res.end(BRAND_LOGO_SVG);
420
684
  } else if (req.method === 'GET' && pathname.startsWith('/files/')) {
421
685
  const fileName = path.basename(pathname.slice(7));
422
686
  const filePath = path.join(CONTENT_DIR, fileName);
@@ -427,10 +691,20 @@ function handleRequest(req, res) {
427
691
  res.end('Not found');
428
692
  return;
429
693
  }
694
+ // Read FIRST, headers after: if the file vanished between the identity
695
+ // check and the read, writeHead(200) was never sent and the 404 below is
696
+ // legal. (Writing 200, then throwing and writing 404 in a catch would
697
+ // itself throw ERR_HTTP_HEADERS_SENT and crash the process.)
698
+ const data = readContentFile(filePath, MAX_SCREEN_BYTES);
699
+ if (data === null) {
700
+ res.writeHead(404, securityHeaders());
701
+ res.end('Not found');
702
+ return;
703
+ }
430
704
  const ext = path.extname(filePath).toLowerCase();
431
705
  const contentType = MIME_TYPES[ext] || 'application/octet-stream';
432
706
  res.writeHead(200, securityHeaders({ 'Content-Type': contentType }));
433
- res.end(fs.readFileSync(filePath));
707
+ res.end(data);
434
708
  } else {
435
709
  res.writeHead(404, securityHeaders());
436
710
  res.end('Not found');
@@ -441,12 +715,42 @@ function handleRequest(req, res) {
441
715
 
442
716
  const clients = new Set();
443
717
 
718
+ // RFC 6455 handshake validation: only accept genuine WebSocket upgrade
719
+ // requests, so an authenticated-but-non-WS client can't enter the frame
720
+ // parser (and so a cross-protocol request can't piggyback on the socket).
721
+ function isWebSocketUpgradeRequest(req) {
722
+ const upgrade = String(req.headers.upgrade || '').toLowerCase();
723
+ const connection = String(req.headers.connection || '').toLowerCase()
724
+ .split(',').map(s => s.trim());
725
+ const version = String(req.headers['sec-websocket-version'] || '');
726
+ const key = String(req.headers['sec-websocket-key'] || '');
727
+ // 16 random bytes, base64-encoded (22 chars + '==' padding).
728
+ const validKey = /^[A-Za-z0-9+/]{22}==$/.test(key);
729
+ return upgrade === 'websocket' &&
730
+ connection.includes('upgrade') &&
731
+ version === '13' &&
732
+ validKey;
733
+ }
734
+
444
735
  function handleUpgrade(req, socket) {
445
736
  if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; }
737
+ if (!isWebSocketUpgradeRequest(req)) { socket.destroy(); return; }
738
+ // The companion's WebSocket lives at '/' only (helper.js connects there).
739
+ if (pathnameOf(req.url) !== '/') { socket.destroy(); return; }
446
740
 
447
741
  const key = req.headers['sec-websocket-key'];
448
742
  if (!key) { socket.destroy(); return; }
449
743
 
744
+ // Keep the cap, but evict the oldest connection so a client cannot reserve
745
+ // every slot indefinitely and prevent a fresh browser from connecting.
746
+ if (clients.size >= MAX_WS_CLIENTS) {
747
+ const oldest = clients.values().next().value;
748
+ if (oldest) {
749
+ clients.delete(oldest);
750
+ try { oldest.destroy(); } catch (e) { /* already gone */ }
751
+ }
752
+ }
753
+
450
754
  const accept = computeAcceptKey(key);
451
755
  socket.write(
452
756
  'HTTP/1.1 101 Switching Protocols\r\n' +
@@ -456,48 +760,122 @@ function handleUpgrade(req, socket) {
456
760
  );
457
761
 
458
762
  let buffer = Buffer.alloc(0);
763
+ let closed = false;
764
+ let partialFrameTimer = null;
459
765
  clients.add(socket);
460
766
 
767
+ const clearPartialFrameTimer = () => {
768
+ if (partialFrameTimer !== null) {
769
+ clearTimeout(partialFrameTimer);
770
+ partialFrameTimer = null;
771
+ }
772
+ };
773
+ const armPartialFrameTimer = () => {
774
+ if (partialFrameTimer !== null) return;
775
+ partialFrameTimer = setTimeout(() => {
776
+ partialFrameTimer = null;
777
+ closed = true;
778
+ clients.delete(socket);
779
+ socket.destroy();
780
+ }, WS_FRAME_TIMEOUT_MS);
781
+ partialFrameTimer.unref();
782
+ };
783
+ const closeSocket = (code = 1000) => {
784
+ if (closed) return;
785
+ closed = true;
786
+ clients.delete(socket);
787
+ clearPartialFrameTimer();
788
+ const closeBuf = Buffer.alloc(code === 1000 ? 0 : 2);
789
+ if (closeBuf.length) closeBuf.writeUInt16BE(code);
790
+ try { socket.end(encodeFrame(OPCODES.CLOSE, closeBuf)); } catch (e) { /* best effort */ }
791
+ socket.pause();
792
+ socket.destroySoon();
793
+ };
794
+
795
+ socket.setTimeout(WS_IDLE_TIMEOUT_MS, () => closeSocket(1001));
461
796
  socket.on('data', (chunk) => {
797
+ // RFC 6455 §5.5.1: after sending CLOSE we must not process further
798
+ // frames — a peer could otherwise keep writing events (and extending
799
+ // its activity influence) in the window before destruction.
800
+ if (closed) return;
462
801
  buffer = Buffer.concat([buffer, chunk]);
802
+ if (buffer.length > MAX_FRAME_PAYLOAD_BYTES + 14) {
803
+ closeSocket(1009);
804
+ return;
805
+ }
463
806
  while (buffer.length > 0) {
464
807
  let result;
465
808
  try {
466
809
  result = decodeFrame(buffer);
467
810
  } catch (e) {
468
- socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
469
- clients.delete(socket);
811
+ closeSocket(1002);
812
+ return;
813
+ }
814
+ if (!result) {
815
+ armPartialFrameTimer();
470
816
  return;
471
817
  }
472
- if (!result) break;
473
818
  buffer = buffer.slice(result.bytesConsumed);
819
+ clearPartialFrameTimer();
474
820
 
475
821
  switch (result.opcode) {
476
822
  case OPCODES.TEXT:
477
823
  handleMessage(result.payload.toString());
478
824
  break;
479
825
  case OPCODES.CLOSE:
480
- socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
481
- clients.delete(socket);
826
+ closeSocket();
482
827
  return;
483
828
  case OPCODES.PING:
484
829
  socket.write(encodeFrame(OPCODES.PONG, result.payload));
485
830
  break;
486
831
  case OPCODES.PONG:
487
832
  break;
488
- default: {
489
- const closeBuf = Buffer.alloc(2);
490
- closeBuf.writeUInt16BE(1003);
491
- socket.end(encodeFrame(OPCODES.CLOSE, closeBuf));
492
- clients.delete(socket);
833
+ default:
834
+ closeSocket(1003);
493
835
  return;
494
- }
495
836
  }
496
837
  }
497
838
  });
498
839
 
499
- socket.on('close', () => clients.delete(socket));
500
- socket.on('error', () => clients.delete(socket));
840
+ socket.on('close', () => {
841
+ clearPartialFrameTimer();
842
+ clients.delete(socket);
843
+ });
844
+ socket.on('error', () => {
845
+ clearPartialFrameTimer();
846
+ clients.delete(socket);
847
+ });
848
+ }
849
+
850
+ function appendEvent(event) {
851
+ const eventsFile = path.join(STATE_DIR, 'events');
852
+ const serialized = JSON.stringify(event);
853
+ if (typeof serialized !== 'string') return;
854
+ const line = serialized + '\n';
855
+ const lineBytes = Buffer.byteLength(line, 'utf8');
856
+ if (lineBytes > MAX_EVENTS_FILE_BYTES) {
857
+ console.error('User event exceeds events file size limit');
858
+ return;
859
+ }
860
+
861
+ const fd = openPrivateAppendFile(eventsFile);
862
+ if (fd === null) {
863
+ console.error('Failed to append user event: events path is not a private regular file');
864
+ return;
865
+ }
866
+ try {
867
+ const current = fs.fstatSync(fd);
868
+ if (current.size + lineBytes > MAX_EVENTS_FILE_BYTES) {
869
+ fs.ftruncateSync(fd, 0);
870
+ }
871
+ fs.writeSync(fd, line);
872
+ } catch (e) {
873
+ // STATE_DIR may have been deleted at runtime — never let a user event
874
+ // crash the server.
875
+ console.error('Failed to append user event:', e.message);
876
+ } finally {
877
+ try { fs.closeSync(fd); } catch (e) { /* already closed */ }
878
+ }
501
879
  }
502
880
 
503
881
  function handleMessage(text) {
@@ -509,10 +887,15 @@ function handleMessage(text) {
509
887
  return;
510
888
  }
511
889
  touchActivity();
512
- console.log(JSON.stringify({ source: 'user-event', ...event }));
890
+ // Spread first so 'source' always wins: a client-supplied "source" field
891
+ // must not be able to spoof the event origin in the log.
892
+ const logLine = JSON.stringify({ ...event, source: 'user-event' });
893
+ const logOutput = Buffer.byteLength(logLine, 'utf8') <= MAX_LOG_EVENT_BYTES
894
+ ? logLine
895
+ : JSON.stringify({ source: 'user-event', truncated: true });
896
+ console.log(logOutput);
513
897
  if (event && event.choice) {
514
- const eventsFile = path.join(STATE_DIR, 'events');
515
- fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
898
+ appendEvent(event);
516
899
  }
517
900
  }
518
901
 
@@ -571,58 +954,134 @@ function touchActivity() {
571
954
  // ========== File Watching ==========
572
955
 
573
956
  const debounceTimers = new Map();
957
+ let watcher = null;
958
+ let watcherRetryTimer = null;
959
+ let knownFiles = new Set();
960
+ let contentDirIno = null;
961
+
962
+ // Track known files to distinguish new screens from updates.
963
+ // macOS fs.watch reports 'rename' for both new files and overwrites,
964
+ // so we can't rely on eventType alone.
965
+ function rescanKnownFiles() {
966
+ try {
967
+ knownFiles = new Set(
968
+ fs.readdirSync(CONTENT_DIR).filter(f => !f.startsWith('.') && f.endsWith('.html'))
969
+ );
970
+ } catch (e) {
971
+ knownFiles = new Set();
972
+ }
973
+ }
974
+
975
+ function currentContentIno() {
976
+ try {
977
+ return fs.statSync(CONTENT_DIR).ino;
978
+ } catch (e) {
979
+ return null;
980
+ }
981
+ }
982
+
983
+ function onContentEvent(eventType, filename) {
984
+ // The watched directory itself was deleted. On Linux, inotify reports
985
+ // IN_DELETE_SELF/IN_IGNORED as a plain 'rename' event carrying the dir's
986
+ // OWN basename and NO error — so the watcher stays non-null while being
987
+ // dead. Tear it down so ensureContentWatcher can re-arm it once the dir is
988
+ // recreated. Guard with an inode check: a LATE delete event from an old,
989
+ // already-replaced watcher can arrive after the new watcher is live, and
990
+ // we must not kill the healthy new watcher.
991
+ if (filename === path.basename(CONTENT_DIR)) {
992
+ const ino = currentContentIno();
993
+ if (ino === null || ino !== contentDirIno) {
994
+ stopWatcher();
995
+ }
996
+ return;
997
+ }
998
+ if (!filename || filename.startsWith('.') || !filename.endsWith('.html')) return;
999
+
1000
+ if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));
1001
+ debounceTimers.set(filename, setTimeout(() => {
1002
+ debounceTimers.delete(filename);
1003
+ const filePath = path.join(CONTENT_DIR, filename);
1004
+
1005
+ if (!fs.existsSync(filePath)) return; // file was deleted
1006
+ touchActivity();
1007
+
1008
+ if (!knownFiles.has(filename)) {
1009
+ knownFiles.add(filename);
1010
+ const eventsFile = path.join(STATE_DIR, 'events');
1011
+ try { fs.unlinkSync(eventsFile); } catch (e) { /* missing or raced */ }
1012
+ console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
1013
+ maybeOpenBrowser();
1014
+ } else {
1015
+ console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
1016
+ }
1017
+
1018
+ broadcast({ type: 'reload' });
1019
+ }, 100));
1020
+ }
1021
+
1022
+ // (Re-)establish the content-dir watcher. The watcher dies when the content
1023
+ // dir is deleted (inotify watches the inode; FSEvents the path), so it must
1024
+ // be re-created after the dir comes back — otherwise new screens would never
1025
+ // trigger reloads. Two detection paths:
1026
+ // 1. onContentEvent sees the dir's own basename → stopWatcher() already ran;
1027
+ // 2. the dir was replaced without us noticing (inode mismatch vs the inode
1028
+ // we started watching) → re-arm here.
1029
+ function ensureContentWatcher() {
1030
+ if (watcher) {
1031
+ const ino = currentContentIno();
1032
+ if (ino === null || ino === contentDirIno) return;
1033
+ stopWatcher(); // watched dir was deleted and recreated; watch the new one
1034
+ }
1035
+ if (!fs.existsSync(CONTENT_DIR)) return;
1036
+ try {
1037
+ rescanKnownFiles();
1038
+ contentDirIno = currentContentIno();
1039
+ watcher = fs.watch(CONTENT_DIR, onContentEvent);
1040
+ watcher.on('error', (err) => {
1041
+ console.error('fs.watch error:', err.message);
1042
+ watcher = null;
1043
+ if (watcherRetryTimer) clearTimeout(watcherRetryTimer);
1044
+ watcherRetryTimer = setTimeout(() => {
1045
+ watcherRetryTimer = null;
1046
+ ensureContentWatcher();
1047
+ }, 1000);
1048
+ });
1049
+ } catch (e) {
1050
+ watcher = null;
1051
+ }
1052
+ }
1053
+
1054
+ function stopWatcher() {
1055
+ if (watcherRetryTimer) { clearTimeout(watcherRetryTimer); watcherRetryTimer = null; }
1056
+ if (watcher) {
1057
+ try { watcher.close(); } catch (e) { /* already closed */ }
1058
+ watcher = null;
1059
+ }
1060
+ contentDirIno = null;
1061
+ }
574
1062
 
575
1063
  // ========== Server Startup ==========
576
1064
 
577
1065
  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
- );
1066
+ if (!ensurePrivateDirectory(SESSION_DIR) ||
1067
+ !ensurePrivateDirectory(CONTENT_DIR) ||
1068
+ !ensurePrivateDirectory(STATE_DIR)) {
1069
+ throw new Error('Brainstorm session directories must be private, real directories');
1070
+ }
587
1071
 
588
1072
  const server = http.createServer(handleRequest);
589
1073
  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));
1074
+ ensureContentWatcher();
616
1075
 
617
1076
  function shutdown(reason) {
618
1077
  console.log(JSON.stringify({ type: 'server-stopped', reason }));
619
1078
  const infoFile = path.join(STATE_DIR, 'server-info');
620
- if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);
621
- fs.writeFileSync(
1079
+ try { fs.unlinkSync(infoFile); } catch (e) { /* already gone or raced */ }
1080
+ writePrivateFile(
622
1081
  path.join(STATE_DIR, 'server-stopped'),
623
1082
  JSON.stringify({ reason, timestamp: Date.now() }) + '\n'
624
1083
  );
625
- watcher.close();
1084
+ stopWatcher();
626
1085
  clearInterval(lifecycleCheck);
627
1086
  // Close any upgraded WebSocket sockets so server.close() can complete and
628
1087
  // the process actually exits instead of lingering on an open connection.
@@ -630,6 +1089,9 @@ function startServer() {
630
1089
  try { socket.destroy(); } catch (e) { /* already gone */ }
631
1090
  }
632
1091
  server.close(() => process.exit(0));
1092
+ // Safety net: an in-flight HTTP connection (e.g. a slow /files/ download)
1093
+ // must not keep a shutting-down server alive indefinitely.
1094
+ setTimeout(() => process.exit(0), 5000).unref();
633
1095
  }
634
1096
 
635
1097
  function ownerAlive() {
@@ -641,6 +1103,7 @@ function startServer() {
641
1103
  const lifecycleCheck = setInterval(() => {
642
1104
  if (!ownerAlive()) shutdown('owner process exited');
643
1105
  else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout');
1106
+ else ensureContentWatcher(); // self-heal the watcher if the dir was recreated
644
1107
  }, LIFECYCLE_CHECK_MS);
645
1108
  lifecycleCheck.unref();
646
1109
 
@@ -666,17 +1129,14 @@ function startServer() {
666
1129
  // one after an EADDRINUSE fallback) so it can't collide with another server's
667
1130
  // cookie in the shared localhost jar.
668
1131
  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.
1132
+ // Record the bound port only when we got our preferred port, so a stale
1133
+ // port file from a crashed fallback can't pin a future session to a port
1134
+ // another server owns. The session key, by contrast, is persisted whenever
1135
+ // BRAINSTORM_TOKEN_FILE is set (see initialToken) — the pair is what keeps
1136
+ // an already-open tab's cookie valid across a restart.
673
1137
  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 */ }
1138
+ if (!writePrivateFile(PORT_FILE, String(PORT))) {
1139
+ console.error('Failed to write private port file');
680
1140
  }
681
1141
  }
682
1142
  const info = JSON.stringify({
@@ -686,7 +1146,9 @@ function startServer() {
686
1146
  });
687
1147
  console.log(info);
688
1148
  // server-info embeds the key — keep it owner-only.
689
- fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n', { mode: 0o600 });
1149
+ if (!writePrivateFile(path.join(STATE_DIR, 'server-info'), info + '\n')) {
1150
+ throw new Error('Failed to write private server-info');
1151
+ }
690
1152
  }
691
1153
 
692
1154
  server.on('error', (err) => {
@@ -697,10 +1159,6 @@ function startServer() {
697
1159
  }
698
1160
  triedFallback = true;
699
1161
  PORT = randomPort();
700
- if (tokenSource === 'file') {
701
- TOKEN = generateToken();
702
- tokenSource = 'generated-fallback';
703
- }
704
1162
  server.listen(PORT, HOST, onListen);
705
1163
  } else {
706
1164
  console.error('Server failed to bind:', err.message);