From 80341768013892b2bf5e355efb944eff95461036 Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Wed, 10 Jun 2026 18:39:37 -0700 Subject: [PATCH] Isolate companion fallback tokens --- skills/brainstorming/scripts/server.cjs | 28 +++++- tests/brainstorm-server/lifecycle.test.js | 110 ++++++++++++++++++++++ 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/skills/brainstorming/scripts/server.cjs b/skills/brainstorming/scripts/server.cjs index d2535f87..f1a3b1ac 100644 --- a/skills/brainstorming/scripts/server.cjs +++ b/skills/brainstorming/scripts/server.cjs @@ -113,16 +113,26 @@ let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_ // Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the // same key and an already-open tab's cookie still validates. const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null; -const TOKEN = (() => { - if (process.env.BRAINSTORM_TOKEN) return process.env.BRAINSTORM_TOKEN; +function generateToken() { + return crypto.randomBytes(32).toString('hex'); +} + +function initialToken() { + if (process.env.BRAINSTORM_TOKEN) { + return { value: process.env.BRAINSTORM_TOKEN, source: 'env' }; + } if (TOKEN_FILE) { try { const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim(); - if (/^[0-9a-f]{32,}$/i.test(t)) return t; + if (/^[0-9a-f]{32,}$/i.test(t)) return { value: t, source: 'file' }; } catch (e) { /* no prior token recorded */ } } - return crypto.randomBytes(32).toString('hex'); -})(); + return { value: generateToken(), source: 'generated' }; +} + +const tokenInfo = initialToken(); +let TOKEN = tokenInfo.value; +let tokenSource = tokenInfo.source; let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen const MIME_TYPES = { @@ -594,8 +604,16 @@ function startServer() { server.on('error', (err) => { if (err.code === 'EADDRINUSE' && !triedFallback) { + if (tokenSource === 'env') { + console.error('Server failed to bind: preferred port is in use and BRAINSTORM_TOKEN is set; refusing fallback with explicit token'); + process.exit(1); + } triedFallback = true; PORT = randomPort(); + if (tokenSource === 'file') { + TOKEN = generateToken(); + tokenSource = 'generated-fallback'; + } server.listen(PORT, HOST, onListen); } else { console.error('Server failed to bind:', err.message); diff --git a/tests/brainstorm-server/lifecycle.test.js b/tests/brainstorm-server/lifecycle.test.js index 95f7bab4..ef0d2fcd 100644 --- a/tests/brainstorm-server/lifecycle.test.js +++ b/tests/brainstorm-server/lifecycle.test.js @@ -66,6 +66,18 @@ function openCaptureCommand(dir, marker) { return `node ${JSON.stringify(scriptPath)} ${JSON.stringify(markerPath)}`; } +function httpStatus(port, key) { + return new Promise(resolve => { + const pathWithKey = key ? '/?key=' + encodeURIComponent(key) : '/'; + require('http') + .get({ hostname: '127.0.0.1', port, path: pathWithKey }, res => { + res.resume(); + resolve(res.statusCode); + }) + .on('error', () => resolve(0)); + }); +} + async function runTests() { let passed = 0, failed = 0; async function test(name, fn) { @@ -246,6 +258,104 @@ async function runTests() { assert.strictEqual(persisted, '3415', 'fallback must not overwrite .last-port'); }); + await test('fallback with persisted token generates a fresh unpersisted key', async () => { + const dir = fs.mkdtempSync('/tmp/bs-port-'); + const portFile = path.join(dir, '.last-port'); + const tokenFile = path.join(dir, '.last-token'); + const preferredToken = 'abababababababababababababababab'; + let a = null, b = null; + + try { + a = spawn('node', [SERVER], { + env: { + ...process.env, + BRAINSTORM_DIR: path.join(dir, 'a'), + BRAINSTORM_PORT: 3422, + BRAINSTORM_TOKEN: preferredToken, + BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 + } + }); + let outA = ''; a.stdout.on('data', d => outA += d.toString()); + for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50); + assert(outA.includes('server-started'), 'preferred-port server should start'); + + fs.writeFileSync(portFile, '3422'); + fs.writeFileSync(tokenFile, preferredToken, { mode: 0o600 }); + + b = spawn('node', [SERVER], { + env: { + ...process.env, + BRAINSTORM_DIR: path.join(dir, 'b'), + BRAINSTORM_PORT_FILE: portFile, + BRAINSTORM_TOKEN_FILE: tokenFile, + BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 + } + }); + let outB = ''; b.stdout.on('data', d => outB += d.toString()); + for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50); + const infoB = firstServerStarted(outB); + const fallbackKey = new URL(infoB.url).searchParams.get('key'); + const persistedAfter = fs.readFileSync(tokenFile, 'utf8').trim(); + const originalStatus = await httpStatus(3422, fallbackKey); + + assert.notStrictEqual(infoB.port, 3422, 'fallback should use a different port'); + assert.notStrictEqual(fallbackKey, preferredToken, 'fallback must not reuse persisted key'); + assert.strictEqual(persistedAfter, preferredToken, 'fallback must not overwrite .last-token'); + assert.strictEqual(originalStatus, 403, 'fallback key must not authenticate to original server'); + } finally { + await killAndWait(a); + await killAndWait(b); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + await test('fallback with explicit BRAINSTORM_TOKEN fails closed', async () => { + const dir = fs.mkdtempSync('/tmp/bs-port-'); + const portFile = path.join(dir, '.last-port'); + const explicitToken = 'cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd'; + let a = null, b = null; + + try { + a = spawn('node', [SERVER], { + env: { + ...process.env, + BRAINSTORM_DIR: path.join(dir, 'a'), + BRAINSTORM_PORT: 3423, + BRAINSTORM_TOKEN: explicitToken, + BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 + } + }); + let outA = ''; a.stdout.on('data', d => outA += d.toString()); + for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50); + assert(outA.includes('server-started'), 'preferred-port server should start'); + + fs.writeFileSync(portFile, '3423'); + b = spawn('node', [SERVER], { + env: { + ...process.env, + BRAINSTORM_DIR: path.join(dir, 'b'), + BRAINSTORM_PORT_FILE: portFile, + BRAINSTORM_TOKEN: explicitToken, + BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 + } + }); + let outB = ''; let errB = ''; + b.stdout.on('data', d => outB += d.toString()); + b.stderr.on('data', d => errB += d.toString()); + for (let i = 0; i < 60 && !outB.includes('server-started') && b.exitCode === null; i++) await sleep(50); + const exited = await waitForExit(b, 1500); + + assert(exited, 'explicit-token fallback process should exit'); + assert.notStrictEqual(b.exitCode, 0, 'explicit-token fallback should fail non-zero'); + assert(!outB.includes('server-started'), 'explicit-token fallback must not start on a random port'); + assert(/BRAINSTORM_TOKEN/.test(errB), `stderr should explain explicit token fallback refusal, got: ${errB}`); + } finally { + await killAndWait(a); + await killAndWait(b); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + await test('auto-opens the browser once, on the first screen', async () => { const dir = fs.mkdtempSync('/tmp/bs-open-'); const marker = path.join(dir, 'opened.log');