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.
- package/README.ja.md +33 -2
- package/README.ko.md +33 -2
- package/README.md +28 -2
- package/README.zh-TW.md +28 -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 +600 -142
- package/skills/brainstorming/scripts/start-server.ps1 +99 -9
- package/skills/brainstorming/scripts/start-server.sh +104 -15
- package/skills/brainstorming/scripts/stop-server.ps1 +33 -2
- package/skills/brainstorming/scripts/stop-server.sh +36 -4
- package/skills/brainstorming/visual-companion.md +19 -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;
|
|
@@ -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
|
|
88
|
-
//
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
221
|
+
return generated;
|
|
146
222
|
}
|
|
147
223
|
|
|
148
224
|
const tokenInfo = initialToken();
|
|
149
|
-
|
|
150
|
-
|
|
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=…</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
|
-
|
|
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 + '
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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-
|
|
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(
|
|
405
|
-
res.end(
|
|
665
|
+
res.writeHead(303, securityHeaders({ Location: '/' }));
|
|
666
|
+
res.end();
|
|
406
667
|
} else if (req.method === 'GET' && pathname === '/') {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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>',
|
|
673
|
+
html = html.replace('</body>', injection + '\n</body>');
|
|
414
674
|
} else {
|
|
415
|
-
html +=
|
|
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(
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', () =>
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
);
|
|
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
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
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
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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);
|