2026-03-11 13:11:29 -07:00
/ * *
* Integration tests for the brainstorm server .
*
* Tests the full server behavior : HTTP serving , WebSocket communication ,
* file watching , and the brainstorming workflow .
*
* Uses the ` ws ` npm package as a test client ( test - only dependency ,
* not shipped to end users ) .
* /
2026-03-06 12:55:18 -08:00
const { spawn } = require ( 'child_process' ) ;
const http = require ( 'http' ) ;
const WebSocket = require ( 'ws' ) ;
const fs = require ( 'fs' ) ;
const path = require ( 'path' ) ;
const assert = require ( 'assert' ) ;
2026-03-17 19:44:46 +05:30
const SERVER _PATH = path . join ( _ _dirname , '../../skills/brainstorming/scripts/server.cjs' ) ;
2026-03-06 12:55:18 -08:00
const TEST _PORT = 3334 ;
const TEST _DIR = '/tmp/brainstorm-test' ;
2026-03-24 11:07:59 -07:00
const CONTENT _DIR = path . join ( TEST _DIR , 'content' ) ;
const STATE _DIR = path . join ( TEST _DIR , 'state' ) ;
feat(brainstorm-server): gate every endpoint behind a per-session key
The companion server is reachable by any local browser tab (default loopback
bind) and by any host that can route to it (remote --host bind). It served
screens, files, and accepted event-injecting WebSocket connections with no
authentication, so a malicious browser tab or a direct remote client could read
brainstorm content or inject events that the agent reads as the user's input
(prompt injection into a live session).
Generate a per-session secret token, carry it in the served URL as ?key=, and
mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so
same-origin subresources and the WebSocket handshake authenticate automatically.
Every HTTP request and WebSocket upgrade now requires a valid key (query or
cookie, constant-time compared); unauthenticated requests get a friendly 403
explaining they need the full URL. A secret authenticates the client uniformly
across loopback, tunnel, and remote binds and defeats DNS rebinding, which a
Host/Origin allowlist cannot.
Also guard handleMessage against a null JSON payload that crashed the process.
Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus
cookie bootstrap and the null-payload guard; server.test.js threads the key;
ws-protocol.test.js + auth.test.js wired into npm test.
Closes #1014
Refs #1110, #1553, #1504
2026-06-09 12:22:53 -07:00
// Fixed session key so the test client can authenticate (see auth.test.js for
// the security behavior itself; here we just need authorized requests).
const TOKEN = 'testtoken-server-0123456789abcdef' ;
2026-03-06 12:55:18 -08:00
function cleanup ( ) {
if ( fs . existsSync ( TEST _DIR ) ) {
fs . rmSync ( TEST _DIR , { recursive : true } ) ;
}
}
async function sleep ( ms ) {
return new Promise ( resolve => setTimeout ( resolve , ms ) ) ;
}
async function fetch ( url ) {
return new Promise ( ( resolve , reject ) => {
2026-06-10 14:58:16 -07:00
const headers = { Cookie : ` brainstorm-key- ${ TEST _PORT } = ${ TOKEN } ` } ;
http . get ( url , { headers } , ( res ) => {
2026-03-06 12:55:18 -08:00
let data = '' ;
res . on ( 'data' , chunk => data += chunk ) ;
2026-03-11 13:11:29 -07:00
res . on ( 'end' , ( ) => resolve ( {
status : res . statusCode ,
headers : res . headers ,
body : data
} ) ) ;
2026-03-06 12:55:18 -08:00
} ) . on ( 'error' , reject ) ;
} ) ;
}
function startServer ( ) {
return spawn ( 'node' , [ SERVER _PATH ] , {
feat(brainstorm-server): gate every endpoint behind a per-session key
The companion server is reachable by any local browser tab (default loopback
bind) and by any host that can route to it (remote --host bind). It served
screens, files, and accepted event-injecting WebSocket connections with no
authentication, so a malicious browser tab or a direct remote client could read
brainstorm content or inject events that the agent reads as the user's input
(prompt injection into a live session).
Generate a per-session secret token, carry it in the served URL as ?key=, and
mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so
same-origin subresources and the WebSocket handshake authenticate automatically.
Every HTTP request and WebSocket upgrade now requires a valid key (query or
cookie, constant-time compared); unauthenticated requests get a friendly 403
explaining they need the full URL. A secret authenticates the client uniformly
across loopback, tunnel, and remote binds and defeats DNS rebinding, which a
Host/Origin allowlist cannot.
Also guard handleMessage against a null JSON payload that crashed the process.
Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus
cookie bootstrap and the null-payload guard; server.test.js threads the key;
ws-protocol.test.js + auth.test.js wired into npm test.
Closes #1014
Refs #1110, #1553, #1504
2026-06-09 12:22:53 -07:00
env : { ... process . env , BRAINSTORM _PORT : TEST _PORT , BRAINSTORM _DIR : TEST _DIR , BRAINSTORM _TOKEN : TOKEN }
2026-03-06 12:55:18 -08:00
} ) ;
}
2026-03-11 13:11:29 -07:00
async function waitForServer ( server ) {
let stdout = '' ;
let stderr = '' ;
return new Promise ( ( resolve , reject ) => {
server . stdout . on ( 'data' , ( data ) => {
stdout += data . toString ( ) ;
if ( stdout . includes ( 'server-started' ) ) {
resolve ( { stdout , stderr , getStdout : ( ) => stdout } ) ;
}
} ) ;
server . stderr . on ( 'data' , ( data ) => { stderr += data . toString ( ) ; } ) ;
server . on ( 'error' , reject ) ;
setTimeout ( ( ) => reject ( new Error ( ` Server didn't start. stderr: ${ stderr } ` ) ) , 5000 ) ;
} ) ;
}
2026-06-10 18:25:03 -07:00
class SkipTest extends Error {
constructor ( message ) {
super ( message ) ;
this . skip = true ;
}
}
function skip ( message ) {
throw new SkipTest ( message ) ;
}
function serverStartedMessage ( out ) {
const line = out . trim ( ) . split ( '\n' ) . find ( l => l . includes ( 'server-started' ) ) ;
assert ( line , 'server-started JSON should be present' ) ;
return JSON . parse ( line ) ;
}
function assertStartedOnExpectedPort ( out ) {
const msg = serverStartedMessage ( out ) ;
assert . strictEqual (
msg . port ,
TEST _PORT ,
` server.test.js expected fixed port ${ TEST _PORT } , got ${ msg . port } ; fixed-port tests must not run through fallback `
) ;
return msg ;
}
function ensureSymlinkWorks ( target , link ) {
try {
fs . symlinkSync ( target , link ) ;
fs . unlinkSync ( link ) ;
} catch ( e ) {
try { fs . unlinkSync ( link ) ; } catch ( ignore ) { }
skip ( ` symlink creation unavailable on this host: ${ e . message } ` ) ;
}
}
2026-03-06 12:55:18 -08:00
async function runTests ( ) {
cleanup ( ) ;
const server = startServer ( ) ;
2026-03-11 13:11:29 -07:00
let stdoutAccum = '' ;
server . stdout . on ( 'data' , ( data ) => { stdoutAccum += data . toString ( ) ; } ) ;
2026-03-06 12:55:18 -08:00
2026-06-10 18:33:38 -07:00
let initialStdout = '' ;
2026-03-11 13:11:29 -07:00
let passed = 0 ;
let failed = 0 ;
2026-06-10 18:25:03 -07:00
let skipped = 0 ;
2026-03-06 12:55:18 -08:00
2026-03-11 13:11:29 -07:00
function test ( name , fn ) {
return fn ( ) . then ( ( ) => {
console . log ( ` PASS: ${ name } ` ) ;
passed ++ ;
} ) . catch ( e => {
2026-06-10 18:25:03 -07:00
if ( e . skip ) {
console . log ( ` SKIP: ${ name } ` ) ;
console . log ( ` ${ e . message } ` ) ;
skipped ++ ;
return ;
}
2026-03-11 13:11:29 -07:00
console . log ( ` FAIL: ${ name } ` ) ;
console . log ( ` ${ e . message } ` ) ;
failed ++ ;
} ) ;
2026-03-06 12:55:18 -08:00
}
try {
2026-06-10 18:33:38 -07:00
const { stdout } = await waitForServer ( server ) ;
initialStdout = stdout ;
assertStartedOnExpectedPort ( initialStdout ) ;
2026-03-11 13:11:29 -07:00
// ========== Server Startup ==========
console . log ( '\n--- Server Startup ---' ) ;
await test ( 'outputs server-started JSON on startup' , ( ) => {
2026-06-10 18:25:03 -07:00
const msg = serverStartedMessage ( initialStdout ) ;
2026-03-11 13:11:29 -07:00
assert . strictEqual ( msg . type , 'server-started' ) ;
assert . strictEqual ( msg . port , TEST _PORT ) ;
assert ( msg . url , 'Should include URL' ) ;
assert ( msg . screen _dir , 'Should include screen_dir' ) ;
return Promise . resolve ( ) ;
} ) ;
2026-03-24 11:07:59 -07:00
await test ( 'writes server-info to state/' , ( ) => {
const infoPath = path . join ( STATE _DIR , 'server-info' ) ;
assert ( fs . existsSync ( infoPath ) , 'state/server-info should exist' ) ;
2026-03-11 13:11:29 -07:00
const info = JSON . parse ( fs . readFileSync ( infoPath , 'utf-8' ) . trim ( ) ) ;
assert . strictEqual ( info . type , 'server-started' ) ;
assert . strictEqual ( info . port , TEST _PORT ) ;
2026-03-24 11:07:59 -07:00
assert . strictEqual ( info . screen _dir , CONTENT _DIR , 'screen_dir should point to content/' ) ;
assert . strictEqual ( info . state _dir , STATE _DIR , 'state_dir should point to state/' ) ;
2026-03-11 13:11:29 -07:00
return Promise . resolve ( ) ;
} ) ;
// ========== HTTP Serving ==========
console . log ( '\n--- HTTP Serving ---' ) ;
await test ( 'serves waiting page when no screens exist' , async ( ) => {
const res = await fetch ( ` http://localhost: ${ TEST _PORT } / ` ) ;
assert . strictEqual ( res . status , 200 ) ;
2026-03-24 11:07:59 -07:00
assert ( res . body . includes ( 'Waiting for the agent' ) , 'Should show waiting message' ) ;
2026-03-11 13:11:29 -07:00
} ) ;
await test ( 'injects helper.js into waiting page' , async ( ) => {
const res = await fetch ( ` http://localhost: ${ TEST _PORT } / ` ) ;
assert ( res . body . includes ( 'WebSocket' ) , 'Should have helper.js injected' ) ;
assert ( res . body . includes ( 'toggleSelect' ) , 'Should have toggleSelect from helper' ) ;
assert ( res . body . includes ( 'brainstorm' ) , 'Should have brainstorm API from helper' ) ;
} ) ;
await test ( 'returns Content-Type text/html' , async ( ) => {
const res = await fetch ( ` http://localhost: ${ TEST _PORT } / ` ) ;
assert ( res . headers [ 'content-type' ] . includes ( 'text/html' ) , 'Should be text/html' ) ;
} ) ;
await test ( 'serves full HTML documents as-is (not wrapped)' , async ( ) => {
const fullDoc = '<!DOCTYPE html>\n<html><head><title>Custom</title></head><body><h1>Custom Page</h1></body></html>' ;
2026-03-24 11:07:59 -07:00
fs . writeFileSync ( path . join ( CONTENT _DIR , 'full-doc.html' ) , fullDoc ) ;
2026-03-11 13:11:29 -07:00
await sleep ( 300 ) ;
const res = await fetch ( ` http://localhost: ${ TEST _PORT } / ` ) ;
assert ( res . body . includes ( '<h1>Custom Page</h1>' ) , 'Should contain original content' ) ;
assert ( res . body . includes ( 'WebSocket' ) , 'Should still inject helper.js' ) ;
2026-06-15 16:32:55 -07:00
assert ( ! res . body . includes ( '<div class="header">' ) , 'Should NOT wrap in frame template' ) ;
2026-03-11 13:11:29 -07:00
} ) ;
await test ( 'wraps content fragments in frame template' , async ( ) => {
const fragment = '<h2>Pick a layout</h2>\n<div class="options"><div class="option" data-choice="a"><div class="letter">A</div></div></div>' ;
2026-03-24 11:07:59 -07:00
fs . writeFileSync ( path . join ( CONTENT _DIR , 'fragment.html' ) , fragment ) ;
2026-03-11 13:11:29 -07:00
await sleep ( 300 ) ;
const res = await fetch ( ` http://localhost: ${ TEST _PORT } / ` ) ;
2026-06-15 16:32:55 -07:00
assert ( res . body . includes ( '<div class="header">' ) , 'Fragment should get header chrome' ) ;
2026-03-11 13:11:29 -07:00
assert ( ! res . body . includes ( '<!-- CONTENT -->' ) , 'Placeholder should be replaced' ) ;
assert ( res . body . includes ( 'Pick a layout' ) , 'Fragment content should be present' ) ;
assert ( res . body . includes ( 'data-choice="a"' ) , 'Fragment interactive elements intact' ) ;
} ) ;
await test ( 'serves newest file by mtime' , async ( ) => {
2026-03-24 11:07:59 -07:00
fs . writeFileSync ( path . join ( CONTENT _DIR , 'older.html' ) , '<h2>Older</h2>' ) ;
2026-03-11 13:11:29 -07:00
await sleep ( 100 ) ;
2026-03-24 11:07:59 -07:00
fs . writeFileSync ( path . join ( CONTENT _DIR , 'newer.html' ) , '<h2>Newer</h2>' ) ;
2026-03-11 13:11:29 -07:00
await sleep ( 300 ) ;
const res = await fetch ( ` http://localhost: ${ TEST _PORT } / ` ) ;
assert ( res . body . includes ( 'Newer' ) , 'Should serve newest file' ) ;
} ) ;
await test ( 'ignores non-html files for serving' , async ( ) => {
// Write a newer non-HTML file — should still serve newest .html
2026-03-24 11:07:59 -07:00
fs . writeFileSync ( path . join ( CONTENT _DIR , 'data.json' ) , '{"not": "html"}' ) ;
2026-03-11 13:11:29 -07:00
await sleep ( 300 ) ;
const res = await fetch ( ` http://localhost: ${ TEST _PORT } / ` ) ;
assert ( res . body . includes ( 'Newer' ) , 'Should still serve newest HTML' ) ;
assert ( ! res . body . includes ( '"not"' ) , 'Should not serve JSON' ) ;
} ) ;
2026-06-09 14:53:48 -07:00
await test ( 'ignores macOS resource-fork dotfiles (._*.html) when serving' , async ( ) => {
// On macOS/ExFAT/SMB, the OS writes ._name.html sidecar files holding
// binary metadata. They end with .html but must never be served as a screen.
fs . writeFileSync ( path . join ( CONTENT _DIR , 'real-screen.html' ) , '<h2>Real Screen Content</h2>' ) ;
await sleep ( 100 ) ;
fs . writeFileSync ( path . join ( CONTENT _DIR , '._real-screen.html' ) , 'Mac OS X resource fork garbage' ) ;
await sleep ( 300 ) ;
const res = await fetch ( ` http://localhost: ${ TEST _PORT } / ` ) ;
assert ( res . body . includes ( 'Real Screen Content' ) , 'should serve the real screen, not the newer ._ sidecar' ) ;
assert ( ! res . body . includes ( 'resource fork garbage' ) , 'must not serve ._*.html dotfile content' ) ;
} ) ;
await test ( 'does not serve dotfiles via /files/' , async ( ) => {
fs . writeFileSync ( path . join ( CONTENT _DIR , '._secret.html' ) , 'dotfile body should not be served' ) ;
const res = await fetch ( ` http://localhost: ${ TEST _PORT } /files/._secret.html ` ) ;
assert . strictEqual ( res . status , 404 , '/files/ must 404 on dotfiles' ) ;
} ) ;
2026-06-09 19:13:52 -07:00
await test ( 'GET /files/ (empty name) returns 404 and does not crash the server' , async ( ) => {
const res = await fetch ( ` http://localhost: ${ TEST _PORT } /files/ ` ) ;
assert . strictEqual ( res . status , 404 , '/files/ (the content dir) must 404, not EISDIR-crash' ) ;
// The server must still be alive afterward.
const alive = await fetch ( ` http://localhost: ${ TEST _PORT } / ` ) ;
assert . strictEqual ( alive . status , 200 , 'server must survive a /files/ request' ) ;
} ) ;
2026-06-10 14:58:16 -07:00
await test ( 'does not serve symlinks that escape content dir via /files/' , async ( ) => {
const target = path . join ( STATE _DIR , 'server-info' ) ;
const link = path . join ( CONTENT _DIR , 'linked-server-info.txt' ) ;
try { fs . unlinkSync ( link ) ; } catch ( e ) { }
2026-06-10 18:25:03 -07:00
ensureSymlinkWorks ( target , link ) ;
2026-06-10 14:58:16 -07:00
fs . symlinkSync ( target , link ) ;
const res = await fetch ( ` http://localhost: ${ TEST _PORT } /files/linked-server-info.txt ` ) ;
assert . strictEqual ( res . status , 404 , 'symlink to state/server-info must not be served' ) ;
assert ( ! res . body . includes ( 'server-started' ) , 'response must not include server-info body' ) ;
} ) ;
await test ( 'does not serve hard links to files outside content dir via /files/' , async ( ) => {
const target = path . join ( STATE _DIR , 'server-info' ) ;
const link = path . join ( CONTENT _DIR , 'hard-linked-server-info.txt' ) ;
try { fs . unlinkSync ( link ) ; } catch ( e ) { }
fs . linkSync ( target , link ) ;
const res = await fetch ( ` http://localhost: ${ TEST _PORT } /files/hard-linked-server-info.txt ` ) ;
assert . strictEqual ( res . status , 404 , 'hard link to state/server-info must not be served' ) ;
assert ( ! res . body . includes ( 'server-started' ) , 'response must not include server-info body' ) ;
} ) ;
2026-06-10 18:25:03 -07:00
await test ( 'does not serve symlinks that escape content dir via root screen selection' , async ( ) => {
const target = path . join ( STATE _DIR , 'server-info' ) ;
const link = path . join ( CONTENT _DIR , 'root-linked-server-info.html' ) ;
try { fs . unlinkSync ( link ) ; } catch ( e ) { }
ensureSymlinkWorks ( target , link ) ;
fs . symlinkSync ( target , link ) ;
const future = new Date ( Date . now ( ) + 2000 ) ;
fs . utimesSync ( target , future , future ) ;
await sleep ( 300 ) ;
const res = await fetch ( ` http://localhost: ${ TEST _PORT } / ` ) ;
assert . strictEqual ( res . status , 200 ) ;
assert ( ! res . body . includes ( '"type":"server-started"' ) , 'root screen must not serve state/server-info through a symlink' ) ;
assert ( ! res . body . includes ( '"state_dir"' ) , 'root screen must not include server-info body' ) ;
} ) ;
await test ( 'does not serve hard links that escape content dir via root screen selection' , async ( ) => {
const target = path . join ( STATE _DIR , 'server-info' ) ;
const link = path . join ( CONTENT _DIR , 'root-hard-linked-server-info.html' ) ;
try { fs . unlinkSync ( link ) ; } catch ( e ) { }
try {
fs . linkSync ( target , link ) ;
} catch ( e ) {
skip ( ` hardlink creation unavailable on this host: ${ e . message } ` ) ;
}
const linkStat = fs . lstatSync ( link ) ;
if ( linkStat . nlink <= 1 ) {
skip ( ` hardlink nlink did not expose multiple links: ${ linkStat . nlink } ` ) ;
}
const future = new Date ( Date . now ( ) + 3000 ) ;
fs . utimesSync ( target , future , future ) ;
await sleep ( 300 ) ;
const res = await fetch ( ` http://localhost: ${ TEST _PORT } / ` ) ;
assert . strictEqual ( res . status , 200 ) ;
assert ( ! res . body . includes ( '"type":"server-started"' ) , 'root screen must not serve state/server-info through a hardlink' ) ;
assert ( ! res . body . includes ( '"state_dir"' ) , 'root screen must not include server-info body' ) ;
} ) ;
2026-03-11 13:11:29 -07:00
await test ( 'returns 404 for non-root paths' , async ( ) => {
const res = await fetch ( ` http://localhost: ${ TEST _PORT } /other ` ) ;
assert . strictEqual ( res . status , 404 ) ;
} ) ;
// ========== WebSocket Communication ==========
console . log ( '\n--- WebSocket Communication ---' ) ;
await test ( 'accepts WebSocket upgrade on /' , async ( ) => {
feat(brainstorm-server): gate every endpoint behind a per-session key
The companion server is reachable by any local browser tab (default loopback
bind) and by any host that can route to it (remote --host bind). It served
screens, files, and accepted event-injecting WebSocket connections with no
authentication, so a malicious browser tab or a direct remote client could read
brainstorm content or inject events that the agent reads as the user's input
(prompt injection into a live session).
Generate a per-session secret token, carry it in the served URL as ?key=, and
mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so
same-origin subresources and the WebSocket handshake authenticate automatically.
Every HTTP request and WebSocket upgrade now requires a valid key (query or
cookie, constant-time compared); unauthenticated requests get a friendly 403
explaining they need the full URL. A secret authenticates the client uniformly
across loopback, tunnel, and remote binds and defeats DNS rebinding, which a
Host/Origin allowlist cannot.
Also guard handleMessage against a null JSON payload that crashed the process.
Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus
cookie bootstrap and the null-payload guard; server.test.js threads the key;
ws-protocol.test.js + auth.test.js wired into npm test.
Closes #1014
Refs #1110, #1553, #1504
2026-06-09 12:22:53 -07:00
const ws = new WebSocket ( ` ws://localhost: ${ TEST _PORT } /?key= ${ TOKEN } ` ) ;
2026-03-11 13:11:29 -07:00
await new Promise ( ( resolve , reject ) => {
ws . on ( 'open' , resolve ) ;
ws . on ( 'error' , reject ) ;
} ) ;
ws . close ( ) ;
} ) ;
await test ( 'relays user events to stdout with source field' , async ( ) => {
stdoutAccum = '' ;
feat(brainstorm-server): gate every endpoint behind a per-session key
The companion server is reachable by any local browser tab (default loopback
bind) and by any host that can route to it (remote --host bind). It served
screens, files, and accepted event-injecting WebSocket connections with no
authentication, so a malicious browser tab or a direct remote client could read
brainstorm content or inject events that the agent reads as the user's input
(prompt injection into a live session).
Generate a per-session secret token, carry it in the served URL as ?key=, and
mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so
same-origin subresources and the WebSocket handshake authenticate automatically.
Every HTTP request and WebSocket upgrade now requires a valid key (query or
cookie, constant-time compared); unauthenticated requests get a friendly 403
explaining they need the full URL. A secret authenticates the client uniformly
across loopback, tunnel, and remote binds and defeats DNS rebinding, which a
Host/Origin allowlist cannot.
Also guard handleMessage against a null JSON payload that crashed the process.
Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus
cookie bootstrap and the null-payload guard; server.test.js threads the key;
ws-protocol.test.js + auth.test.js wired into npm test.
Closes #1014
Refs #1110, #1553, #1504
2026-06-09 12:22:53 -07:00
const ws = new WebSocket ( ` ws://localhost: ${ TEST _PORT } /?key= ${ TOKEN } ` ) ;
2026-03-11 13:11:29 -07:00
await new Promise ( resolve => ws . on ( 'open' , resolve ) ) ;
ws . send ( JSON . stringify ( { type : 'click' , text : 'Test Button' } ) ) ;
await sleep ( 300 ) ;
assert ( stdoutAccum . includes ( '"source":"user-event"' ) , 'Should tag with source' ) ;
assert ( stdoutAccum . includes ( 'Test Button' ) , 'Should include event data' ) ;
ws . close ( ) ;
} ) ;
2026-03-24 11:07:59 -07:00
await test ( 'writes choice events to state/events' , async ( ) => {
2026-03-11 13:11:29 -07:00
// Clean up events from prior tests
2026-03-24 11:07:59 -07:00
const eventsFile = path . join ( STATE _DIR , 'events' ) ;
2026-03-11 13:11:29 -07:00
if ( fs . existsSync ( eventsFile ) ) fs . unlinkSync ( eventsFile ) ;
feat(brainstorm-server): gate every endpoint behind a per-session key
The companion server is reachable by any local browser tab (default loopback
bind) and by any host that can route to it (remote --host bind). It served
screens, files, and accepted event-injecting WebSocket connections with no
authentication, so a malicious browser tab or a direct remote client could read
brainstorm content or inject events that the agent reads as the user's input
(prompt injection into a live session).
Generate a per-session secret token, carry it in the served URL as ?key=, and
mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so
same-origin subresources and the WebSocket handshake authenticate automatically.
Every HTTP request and WebSocket upgrade now requires a valid key (query or
cookie, constant-time compared); unauthenticated requests get a friendly 403
explaining they need the full URL. A secret authenticates the client uniformly
across loopback, tunnel, and remote binds and defeats DNS rebinding, which a
Host/Origin allowlist cannot.
Also guard handleMessage against a null JSON payload that crashed the process.
Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus
cookie bootstrap and the null-payload guard; server.test.js threads the key;
ws-protocol.test.js + auth.test.js wired into npm test.
Closes #1014
Refs #1110, #1553, #1504
2026-06-09 12:22:53 -07:00
const ws = new WebSocket ( ` ws://localhost: ${ TEST _PORT } /?key= ${ TOKEN } ` ) ;
2026-03-11 13:11:29 -07:00
await new Promise ( resolve => ws . on ( 'open' , resolve ) ) ;
ws . send ( JSON . stringify ( { type : 'click' , choice : 'b' , text : 'Option B' } ) ) ;
await sleep ( 300 ) ;
assert ( fs . existsSync ( eventsFile ) , '.events should exist' ) ;
const lines = fs . readFileSync ( eventsFile , 'utf-8' ) . trim ( ) . split ( '\n' ) ;
const event = JSON . parse ( lines [ lines . length - 1 ] ) ;
assert . strictEqual ( event . choice , 'b' ) ;
assert . strictEqual ( event . text , 'Option B' ) ;
ws . close ( ) ;
} ) ;
2026-03-24 11:07:59 -07:00
await test ( 'does NOT write non-choice events to state/events' , async ( ) => {
const eventsFile = path . join ( STATE _DIR , 'events' ) ;
2026-03-11 13:11:29 -07:00
if ( fs . existsSync ( eventsFile ) ) fs . unlinkSync ( eventsFile ) ;
feat(brainstorm-server): gate every endpoint behind a per-session key
The companion server is reachable by any local browser tab (default loopback
bind) and by any host that can route to it (remote --host bind). It served
screens, files, and accepted event-injecting WebSocket connections with no
authentication, so a malicious browser tab or a direct remote client could read
brainstorm content or inject events that the agent reads as the user's input
(prompt injection into a live session).
Generate a per-session secret token, carry it in the served URL as ?key=, and
mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so
same-origin subresources and the WebSocket handshake authenticate automatically.
Every HTTP request and WebSocket upgrade now requires a valid key (query or
cookie, constant-time compared); unauthenticated requests get a friendly 403
explaining they need the full URL. A secret authenticates the client uniformly
across loopback, tunnel, and remote binds and defeats DNS rebinding, which a
Host/Origin allowlist cannot.
Also guard handleMessage against a null JSON payload that crashed the process.
Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus
cookie bootstrap and the null-payload guard; server.test.js threads the key;
ws-protocol.test.js + auth.test.js wired into npm test.
Closes #1014
Refs #1110, #1553, #1504
2026-06-09 12:22:53 -07:00
const ws = new WebSocket ( ` ws://localhost: ${ TEST _PORT } /?key= ${ TOKEN } ` ) ;
2026-03-11 13:11:29 -07:00
await new Promise ( resolve => ws . on ( 'open' , resolve ) ) ;
ws . send ( JSON . stringify ( { type : 'hover' , text : 'Something' } ) ) ;
await sleep ( 300 ) ;
// Non-choice events should not create .events file
assert ( ! fs . existsSync ( eventsFile ) , '.events should not exist for non-choice events' ) ;
ws . close ( ) ;
} ) ;
await test ( 'handles multiple concurrent WebSocket clients' , async ( ) => {
feat(brainstorm-server): gate every endpoint behind a per-session key
The companion server is reachable by any local browser tab (default loopback
bind) and by any host that can route to it (remote --host bind). It served
screens, files, and accepted event-injecting WebSocket connections with no
authentication, so a malicious browser tab or a direct remote client could read
brainstorm content or inject events that the agent reads as the user's input
(prompt injection into a live session).
Generate a per-session secret token, carry it in the served URL as ?key=, and
mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so
same-origin subresources and the WebSocket handshake authenticate automatically.
Every HTTP request and WebSocket upgrade now requires a valid key (query or
cookie, constant-time compared); unauthenticated requests get a friendly 403
explaining they need the full URL. A secret authenticates the client uniformly
across loopback, tunnel, and remote binds and defeats DNS rebinding, which a
Host/Origin allowlist cannot.
Also guard handleMessage against a null JSON payload that crashed the process.
Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus
cookie bootstrap and the null-payload guard; server.test.js threads the key;
ws-protocol.test.js + auth.test.js wired into npm test.
Closes #1014
Refs #1110, #1553, #1504
2026-06-09 12:22:53 -07:00
const ws1 = new WebSocket ( ` ws://localhost: ${ TEST _PORT } /?key= ${ TOKEN } ` ) ;
const ws2 = new WebSocket ( ` ws://localhost: ${ TEST _PORT } /?key= ${ TOKEN } ` ) ;
2026-03-11 13:11:29 -07:00
await Promise . all ( [
new Promise ( resolve => ws1 . on ( 'open' , resolve ) ) ,
new Promise ( resolve => ws2 . on ( 'open' , resolve ) )
] ) ;
let ws1Reload = false ;
let ws2Reload = false ;
ws1 . on ( 'message' , ( data ) => {
if ( JSON . parse ( data . toString ( ) ) . type === 'reload' ) ws1Reload = true ;
} ) ;
ws2 . on ( 'message' , ( data ) => {
if ( JSON . parse ( data . toString ( ) ) . type === 'reload' ) ws2Reload = true ;
} ) ;
2026-03-24 11:07:59 -07:00
fs . writeFileSync ( path . join ( CONTENT _DIR , 'multi-client.html' ) , '<h2>Multi</h2>' ) ;
2026-03-11 13:11:29 -07:00
await sleep ( 500 ) ;
assert ( ws1Reload , 'Client 1 should receive reload' ) ;
assert ( ws2Reload , 'Client 2 should receive reload' ) ;
ws1 . close ( ) ;
ws2 . close ( ) ;
} ) ;
await test ( 'cleans up closed clients from broadcast list' , async ( ) => {
feat(brainstorm-server): gate every endpoint behind a per-session key
The companion server is reachable by any local browser tab (default loopback
bind) and by any host that can route to it (remote --host bind). It served
screens, files, and accepted event-injecting WebSocket connections with no
authentication, so a malicious browser tab or a direct remote client could read
brainstorm content or inject events that the agent reads as the user's input
(prompt injection into a live session).
Generate a per-session secret token, carry it in the served URL as ?key=, and
mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so
same-origin subresources and the WebSocket handshake authenticate automatically.
Every HTTP request and WebSocket upgrade now requires a valid key (query or
cookie, constant-time compared); unauthenticated requests get a friendly 403
explaining they need the full URL. A secret authenticates the client uniformly
across loopback, tunnel, and remote binds and defeats DNS rebinding, which a
Host/Origin allowlist cannot.
Also guard handleMessage against a null JSON payload that crashed the process.
Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus
cookie bootstrap and the null-payload guard; server.test.js threads the key;
ws-protocol.test.js + auth.test.js wired into npm test.
Closes #1014
Refs #1110, #1553, #1504
2026-06-09 12:22:53 -07:00
const ws1 = new WebSocket ( ` ws://localhost: ${ TEST _PORT } /?key= ${ TOKEN } ` ) ;
2026-03-11 13:11:29 -07:00
await new Promise ( resolve => ws1 . on ( 'open' , resolve ) ) ;
ws1 . close ( ) ;
await sleep ( 100 ) ;
// This should not throw even though ws1 is closed
2026-03-24 11:07:59 -07:00
fs . writeFileSync ( path . join ( CONTENT _DIR , 'after-close.html' ) , '<h2>After</h2>' ) ;
2026-03-11 13:11:29 -07:00
await sleep ( 300 ) ;
// If we got here without error, the test passes
} ) ;
await test ( 'handles malformed JSON from client gracefully' , async ( ) => {
feat(brainstorm-server): gate every endpoint behind a per-session key
The companion server is reachable by any local browser tab (default loopback
bind) and by any host that can route to it (remote --host bind). It served
screens, files, and accepted event-injecting WebSocket connections with no
authentication, so a malicious browser tab or a direct remote client could read
brainstorm content or inject events that the agent reads as the user's input
(prompt injection into a live session).
Generate a per-session secret token, carry it in the served URL as ?key=, and
mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so
same-origin subresources and the WebSocket handshake authenticate automatically.
Every HTTP request and WebSocket upgrade now requires a valid key (query or
cookie, constant-time compared); unauthenticated requests get a friendly 403
explaining they need the full URL. A secret authenticates the client uniformly
across loopback, tunnel, and remote binds and defeats DNS rebinding, which a
Host/Origin allowlist cannot.
Also guard handleMessage against a null JSON payload that crashed the process.
Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus
cookie bootstrap and the null-payload guard; server.test.js threads the key;
ws-protocol.test.js + auth.test.js wired into npm test.
Closes #1014
Refs #1110, #1553, #1504
2026-06-09 12:22:53 -07:00
const ws = new WebSocket ( ` ws://localhost: ${ TEST _PORT } /?key= ${ TOKEN } ` ) ;
2026-03-11 13:11:29 -07:00
await new Promise ( resolve => ws . on ( 'open' , resolve ) ) ;
// Send invalid JSON — server should not crash
ws . send ( 'not json at all {{{' ) ;
await sleep ( 300 ) ;
// Verify server is still responsive
const res = await fetch ( ` http://localhost: ${ TEST _PORT } / ` ) ;
assert . strictEqual ( res . status , 200 , 'Server should still be running' ) ;
ws . close ( ) ;
} ) ;
// ========== File Watching ==========
console . log ( '\n--- File Watching ---' ) ;
await test ( 'sends reload on new .html file' , async ( ) => {
feat(brainstorm-server): gate every endpoint behind a per-session key
The companion server is reachable by any local browser tab (default loopback
bind) and by any host that can route to it (remote --host bind). It served
screens, files, and accepted event-injecting WebSocket connections with no
authentication, so a malicious browser tab or a direct remote client could read
brainstorm content or inject events that the agent reads as the user's input
(prompt injection into a live session).
Generate a per-session secret token, carry it in the served URL as ?key=, and
mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so
same-origin subresources and the WebSocket handshake authenticate automatically.
Every HTTP request and WebSocket upgrade now requires a valid key (query or
cookie, constant-time compared); unauthenticated requests get a friendly 403
explaining they need the full URL. A secret authenticates the client uniformly
across loopback, tunnel, and remote binds and defeats DNS rebinding, which a
Host/Origin allowlist cannot.
Also guard handleMessage against a null JSON payload that crashed the process.
Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus
cookie bootstrap and the null-payload guard; server.test.js threads the key;
ws-protocol.test.js + auth.test.js wired into npm test.
Closes #1014
Refs #1110, #1553, #1504
2026-06-09 12:22:53 -07:00
const ws = new WebSocket ( ` ws://localhost: ${ TEST _PORT } /?key= ${ TOKEN } ` ) ;
2026-03-11 13:11:29 -07:00
await new Promise ( resolve => ws . on ( 'open' , resolve ) ) ;
let gotReload = false ;
ws . on ( 'message' , ( data ) => {
if ( JSON . parse ( data . toString ( ) ) . type === 'reload' ) gotReload = true ;
} ) ;
2026-03-24 11:07:59 -07:00
fs . writeFileSync ( path . join ( CONTENT _DIR , 'watch-new.html' ) , '<h2>New</h2>' ) ;
2026-03-11 13:11:29 -07:00
await sleep ( 500 ) ;
assert ( gotReload , 'Should send reload on new file' ) ;
ws . close ( ) ;
} ) ;
await test ( 'sends reload on .html file change' , async ( ) => {
2026-03-24 11:07:59 -07:00
const filePath = path . join ( CONTENT _DIR , 'watch-change.html' ) ;
2026-03-11 13:11:29 -07:00
fs . writeFileSync ( filePath , '<h2>Original</h2>' ) ;
await sleep ( 500 ) ;
feat(brainstorm-server): gate every endpoint behind a per-session key
The companion server is reachable by any local browser tab (default loopback
bind) and by any host that can route to it (remote --host bind). It served
screens, files, and accepted event-injecting WebSocket connections with no
authentication, so a malicious browser tab or a direct remote client could read
brainstorm content or inject events that the agent reads as the user's input
(prompt injection into a live session).
Generate a per-session secret token, carry it in the served URL as ?key=, and
mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so
same-origin subresources and the WebSocket handshake authenticate automatically.
Every HTTP request and WebSocket upgrade now requires a valid key (query or
cookie, constant-time compared); unauthenticated requests get a friendly 403
explaining they need the full URL. A secret authenticates the client uniformly
across loopback, tunnel, and remote binds and defeats DNS rebinding, which a
Host/Origin allowlist cannot.
Also guard handleMessage against a null JSON payload that crashed the process.
Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus
cookie bootstrap and the null-payload guard; server.test.js threads the key;
ws-protocol.test.js + auth.test.js wired into npm test.
Closes #1014
Refs #1110, #1553, #1504
2026-06-09 12:22:53 -07:00
const ws = new WebSocket ( ` ws://localhost: ${ TEST _PORT } /?key= ${ TOKEN } ` ) ;
2026-03-11 13:11:29 -07:00
await new Promise ( resolve => ws . on ( 'open' , resolve ) ) ;
let gotReload = false ;
ws . on ( 'message' , ( data ) => {
if ( JSON . parse ( data . toString ( ) ) . type === 'reload' ) gotReload = true ;
} ) ;
fs . writeFileSync ( filePath , '<h2>Modified</h2>' ) ;
await sleep ( 500 ) ;
assert ( gotReload , 'Should send reload on file change' ) ;
ws . close ( ) ;
} ) ;
await test ( 'does NOT send reload for non-.html files' , async ( ) => {
feat(brainstorm-server): gate every endpoint behind a per-session key
The companion server is reachable by any local browser tab (default loopback
bind) and by any host that can route to it (remote --host bind). It served
screens, files, and accepted event-injecting WebSocket connections with no
authentication, so a malicious browser tab or a direct remote client could read
brainstorm content or inject events that the agent reads as the user's input
(prompt injection into a live session).
Generate a per-session secret token, carry it in the served URL as ?key=, and
mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so
same-origin subresources and the WebSocket handshake authenticate automatically.
Every HTTP request and WebSocket upgrade now requires a valid key (query or
cookie, constant-time compared); unauthenticated requests get a friendly 403
explaining they need the full URL. A secret authenticates the client uniformly
across loopback, tunnel, and remote binds and defeats DNS rebinding, which a
Host/Origin allowlist cannot.
Also guard handleMessage against a null JSON payload that crashed the process.
Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus
cookie bootstrap and the null-payload guard; server.test.js threads the key;
ws-protocol.test.js + auth.test.js wired into npm test.
Closes #1014
Refs #1110, #1553, #1504
2026-06-09 12:22:53 -07:00
const ws = new WebSocket ( ` ws://localhost: ${ TEST _PORT } /?key= ${ TOKEN } ` ) ;
2026-03-11 13:11:29 -07:00
await new Promise ( resolve => ws . on ( 'open' , resolve ) ) ;
let gotReload = false ;
ws . on ( 'message' , ( data ) => {
if ( JSON . parse ( data . toString ( ) ) . type === 'reload' ) gotReload = true ;
} ) ;
2026-03-24 11:07:59 -07:00
fs . writeFileSync ( path . join ( CONTENT _DIR , 'data.txt' ) , 'not html' ) ;
2026-03-11 13:11:29 -07:00
await sleep ( 500 ) ;
assert ( ! gotReload , 'Should NOT reload for non-HTML files' ) ;
ws . close ( ) ;
} ) ;
2026-06-09 14:53:48 -07:00
await test ( 'does NOT send reload for ._*.html resource-fork dotfiles' , async ( ) => {
2026-06-09 18:33:00 -07:00
const ws = new WebSocket ( ` ws://localhost: ${ TEST _PORT } /?key= ${ TOKEN } ` ) ;
2026-06-09 14:53:48 -07:00
await new Promise ( resolve => ws . on ( 'open' , resolve ) ) ;
let gotReload = false ;
ws . on ( 'message' , ( data ) => {
if ( JSON . parse ( data . toString ( ) ) . type === 'reload' ) gotReload = true ;
} ) ;
fs . writeFileSync ( path . join ( CONTENT _DIR , '._sidecar.html' ) , 'resource fork' ) ;
await sleep ( 500 ) ;
assert ( ! gotReload , 'a ._ dotfile appearing must not trigger a reload' ) ;
ws . close ( ) ;
} ) ;
2026-03-24 11:07:59 -07:00
await test ( 'clears state/events on new screen' , async ( ) => {
// Create an events file
const eventsFile = path . join ( STATE _DIR , 'events' ) ;
2026-03-11 13:11:29 -07:00
fs . writeFileSync ( eventsFile , '{"choice":"a"}\n' ) ;
assert ( fs . existsSync ( eventsFile ) ) ;
2026-03-24 11:07:59 -07:00
fs . writeFileSync ( path . join ( CONTENT _DIR , 'clear-events.html' ) , '<h2>New screen</h2>' ) ;
2026-03-11 13:11:29 -07:00
await sleep ( 500 ) ;
2026-03-24 11:07:59 -07:00
assert ( ! fs . existsSync ( eventsFile ) , 'state/events should be cleared on new screen' ) ;
2026-03-11 13:11:29 -07:00
} ) ;
await test ( 'logs screen-added on new file' , async ( ) => {
stdoutAccum = '' ;
2026-03-24 11:07:59 -07:00
fs . writeFileSync ( path . join ( CONTENT _DIR , 'log-test.html' ) , '<h2>Log</h2>' ) ;
2026-03-11 13:11:29 -07:00
await sleep ( 500 ) ;
assert ( stdoutAccum . includes ( 'screen-added' ) , 'Should log screen-added' ) ;
} ) ;
await test ( 'logs screen-updated on file change' , async ( ) => {
2026-03-24 11:07:59 -07:00
const filePath = path . join ( CONTENT _DIR , 'log-update.html' ) ;
2026-03-11 13:11:29 -07:00
fs . writeFileSync ( filePath , '<h2>V1</h2>' ) ;
await sleep ( 500 ) ;
stdoutAccum = '' ;
fs . writeFileSync ( filePath , '<h2>V2</h2>' ) ;
await sleep ( 500 ) ;
assert ( stdoutAccum . includes ( 'screen-updated' ) , 'Should log screen-updated' ) ;
} ) ;
// ========== Helper.js Content ==========
console . log ( '\n--- Helper.js Verification ---' ) ;
await test ( 'helper.js defines required APIs' , ( ) => {
const helperContent = fs . readFileSync (
path . join ( _ _dirname , '../../skills/brainstorming/scripts/helper.js' ) , 'utf-8'
) ;
assert ( helperContent . includes ( 'toggleSelect' ) , 'Should define toggleSelect' ) ;
assert ( helperContent . includes ( 'sendEvent' ) , 'Should define sendEvent' ) ;
assert ( helperContent . includes ( 'selectedChoice' ) , 'Should track selectedChoice' ) ;
assert ( helperContent . includes ( 'brainstorm' ) , 'Should expose brainstorm API' ) ;
return Promise . resolve ( ) ;
} ) ;
// ========== Frame Template ==========
console . log ( '\n--- Frame Template Verification ---' ) ;
await test ( 'frame template has required structure' , ( ) => {
const template = fs . readFileSync (
path . join ( _ _dirname , '../../skills/brainstorming/scripts/frame-template.html' ) , 'utf-8'
) ;
2026-06-15 16:32:55 -07:00
assert ( template . includes ( '<div class="header">' ) , 'Should have top header markup' ) ;
assert ( ! template . includes ( 'indicator-bar' ) , 'Should not have footer chrome' ) ;
assert ( ! template . includes ( 'indicator-text' ) , 'Header should not render selection indicator text' ) ;
assert ( template . includes ( '<!-- BRANDING -->' ) , 'Should have branding placeholder' ) ;
assert ( template . includes ( '<div class="status">Connecting…</div>' ) , 'Header should include connection status' ) ;
assert ( template . includes ( 'grid-template-columns: minmax(0, 1fr) auto;' ) , 'Header should let brand text shrink before the status column' ) ;
assert ( template . includes ( 'padding: 0.5rem 1.5rem;' ) , 'Header should keep equal left and right edge padding' ) ;
assert ( template . includes ( '.header .brand { justify-self: start; width: 100%; font-size: 0.75rem; line-height: 1; }' ) , 'Header brand should align left, fill its grid track, and match header text size' ) ;
assert ( template . includes ( '.header .status { grid-column: 2; line-height: 1; }' ) , 'Header status should sit in the right column' ) ;
assert ( ! template . includes ( '<div></div>' ) , 'Header should not use an empty spacer before branding' ) ;
2026-03-11 13:11:29 -07:00
assert ( template . includes ( '<!-- CONTENT -->' ) , 'Should have content placeholder' ) ;
Phase D: cross-runtime tweaks (visual-companion, executing-plans, test)
Misc platform/runtime statements and adjacencies that don't fit the
prose, config-ref, README-ordering, or tool-vocabulary buckets:
- visual-companion frame template: rename CSS/HTML id #claude-content
→ #frame-content. The id is purely styling — nothing external
references it. The brainstorm-server test that asserted the old
string is updated in lockstep.
- visual-companion launch instructions: add a Copilot CLI section
alongside Claude Code, Codex, and Gemini CLI; combine the Claude
Code (macOS / Linux) and (Windows) sections so heading style
matches the other (non-OS-qualified) platforms.
- visual-companion: "Use Write tool" → "Use your file-creation tool"
for the cat/heredoc warning. The prohibition is what's load-
bearing, not the tool name.
- executing-plans/SKILL.md: list all subagent-capable runtimes
(Claude Code, Codex CLI, Codex App, Copilot CLI, Gemini CLI) and
point at the per-platform tool refs as the source of truth.
- executing-plans/SKILL.md: relative path "using-superpowers/
references/" → "../using-superpowers/references/" to resolve
correctly from the executing-plans/ directory.
No bundled spec doc here — Phase D was scope-extension work that
took place across rounds, with no standalone spec authored.
2026-05-05 18:26:01 -07:00
assert ( template . includes ( 'frame-content' ) , 'Should have content container' ) ;
2026-03-11 13:11:29 -07:00
return Promise . resolve ( ) ;
} ) ;
// ========== Summary ==========
2026-06-10 18:25:03 -07:00
console . log ( ` \n --- Results: ${ passed } passed, ${ failed } failed, ${ skipped } skipped --- ` ) ;
2026-03-11 13:11:29 -07:00
if ( failed > 0 ) process . exit ( 1 ) ;
2026-03-06 12:55:18 -08:00
} finally {
server . kill ( ) ;
2026-03-11 13:11:29 -07:00
await sleep ( 100 ) ;
2026-03-06 12:55:18 -08:00
cleanup ( ) ;
}
}
runTests ( ) . catch ( err => {
console . error ( 'Test failed:' , err ) ;
process . exit ( 1 ) ;
} ) ;