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.
- package/README.ja.md +25 -2
- package/README.ko.md +25 -2
- package/README.md +20 -2
- package/README.zh-TW.md +20 -3
- package/out/server.js +17 -17
- package/package.json +6 -3
- package/skills/brainstorming/scripts/helper.js +8 -15
- package/skills/brainstorming/scripts/server.cjs +535 -142
- package/skills/brainstorming/scripts/start-server.ps1 +92 -11
- package/skills/brainstorming/scripts/start-server.sh +104 -16
- package/skills/brainstorming/scripts/stop-server.ps1 +33 -2
- package/skills/brainstorming/scripts/stop-server.sh +36 -4
- package/skills/brainstorming/visual-companion.md +16 -22
|
@@ -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
|
|
88
|
-
//
|
|
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)
|
|
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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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=…</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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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-
|
|
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(
|
|
405
|
-
res.end(
|
|
607
|
+
res.writeHead(303, securityHeaders({ Location: '/' }));
|
|
608
|
+
res.end();
|
|
406
609
|
} else if (req.method === 'GET' && pathname === '/') {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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>',
|
|
615
|
+
html = html.replace('</body>', injection + '\n</body>');
|
|
414
616
|
} else {
|
|
415
|
-
html +=
|
|
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(
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', () =>
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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
|
|
670
|
-
//
|
|
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
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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);
|