浏览代码

feat(brainstorm-server): opt-in auto-open of the browser on the first screen

When the user approves the visual companion, open their browser automatically the
first time a screen is actually ready to show — rather than at startup (just the
waiting page) or making them open the URL by hand.

Opt-in and gated on approval: off unless BRAINSTORM_OPEN is set (start-server.sh
--open, which the agent passes only after the user agrees to use the companion).
Even then it fires once, and is skipped if a browser is already connected, on a
non-loopback/remote bind, or when headless. Launcher is the platform default
(open / xdg-open / WSL cmd.exe) or BRAINSTORM_OPEN_CMD; best-effort, never fatal.

lifecycle.test.js: opens once on the first screen when approved; does NOT open
without approval.

Closes #755
Refs #759
Jesse Vincent 3 月之前
父节点
当前提交
463dfb7fd4

+ 22 - 0
skills/brainstorming/scripts/server.cjs

@@ -267,6 +267,27 @@ function broadcast(msg) {
   }
 }
 
+// Best-effort: open the user's browser the first time a screen is actually ready
+// to show. Skips when disabled, on a non-loopback (remote) bind, or when a
+// browser is already connected. Override the launcher with BRAINSTORM_OPEN_CMD.
+let browserOpened = false;
+function maybeOpenBrowser() {
+  if (browserOpened) return;
+  browserOpened = true;
+  if (!process.env.BRAINSTORM_OPEN) return; // opt-in: only after the user approves the companion
+  if (HOST !== '127.0.0.1' && HOST !== 'localhost') return;
+  if (clients.size > 0) return; // the user already opened it
+  const url = 'http://' + URL_HOST + ':' + PORT;
+  let cmd = process.env.BRAINSTORM_OPEN_CMD;
+  if (!cmd) {
+    if (process.platform === 'darwin') cmd = 'open';
+    else if (/microsoft/i.test(require('os').release())) cmd = 'cmd.exe /c start ""'; // WSL → Windows browser
+    else if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) cmd = 'xdg-open';
+    else return; // headless: nothing to open
+  }
+  try { require('child_process').exec(cmd + ' ' + JSON.stringify(url), () => {}); } catch (e) { /* best effort */ }
+}
+
 // ========== Activity Tracking ==========
 
 // Idle timeout: shut down after this long with no activity. Default 4 hours;
@@ -323,6 +344,7 @@ function startServer() {
         const eventsFile = path.join(STATE_DIR, 'events');
         if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
         console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
+        maybeOpenBrowser();
       } else {
         console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
       }

+ 6 - 0
skills/brainstorming/scripts/start-server.sh

@@ -12,6 +12,8 @@
 #                         Use 0.0.0.0 in remote/containerized environments.
 #   --url-host <host>     Hostname shown in returned URL JSON.
 #   --idle-timeout-minutes <n>  Shut down after n minutes idle (default 240 = 4h).
+#   --open                Auto-open the browser on the first screen (use only
+#                         after the user approves the visual companion).
 #   --foreground          Run server in the current terminal (no backgrounding).
 #   --background          Force background mode (overrides Codex auto-foreground).
 
@@ -42,6 +44,10 @@ while [[ $# -gt 0 ]]; do
       IDLE_TIMEOUT_MINUTES="$2"
       shift 2
       ;;
+    --open)
+      export BRAINSTORM_OPEN=1
+      shift
+      ;;
     --foreground|--no-daemon)
       FOREGROUND="true"
       shift

+ 39 - 0
tests/brainstorm-server/lifecycle.test.js

@@ -124,6 +124,45 @@ async function runTests() {
     assert(portB >= 49152, 'should fall back to a random high port');
   });
 
+  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');
+    const openCmd = `sh -c 'echo "$0" >> ${marker}'`; // capture the launch instead of opening a browser
+    const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3417, BRAINSTORM_DIR: dir, BRAINSTORM_OPEN: '1', BRAINSTORM_OPEN_CMD: openCmd, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
+    let out = ''; srv.stdout.on('data', d => out += d.toString());
+    for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
+
+    // First screen, with no browser connected -> should auto-open.
+    fs.writeFileSync(path.join(dir, 'content', 'first.html'), '<h2>First</h2>');
+    await sleep(700);
+    // Second screen -> must NOT open again.
+    fs.writeFileSync(path.join(dir, 'content', 'second.html'), '<h2>Second</h2>');
+    await sleep(700);
+
+    srv.kill(); await sleep(100);
+    const lines = fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8').trim().split('\n').filter(Boolean) : [];
+    fs.rmSync(dir, { recursive: true, force: true });
+
+    assert.strictEqual(lines.length, 1, 'should open exactly once');
+    assert(lines[0].includes('3417'), `should open the server URL, got: ${lines[0]}`);
+  });
+
+  await test('does NOT auto-open unless approved (BRAINSTORM_OPEN unset)', async () => {
+    const dir = fs.mkdtempSync('/tmp/bs-open-');
+    const marker = path.join(dir, 'opened.log');
+    const openCmd = `sh -c 'echo "$0" >> ${marker}'`;
+    // BRAINSTORM_OPEN intentionally NOT set — auto-open must stay off.
+    const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3418, BRAINSTORM_DIR: dir, BRAINSTORM_OPEN_CMD: openCmd, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
+    let out = ''; srv.stdout.on('data', d => out += d.toString());
+    for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
+    fs.writeFileSync(path.join(dir, 'content', 'first.html'), '<h2>First</h2>');
+    await sleep(700);
+    srv.kill(); await sleep(100);
+    const opened = fs.existsSync(marker);
+    fs.rmSync(dir, { recursive: true, force: true });
+    assert(!opened, 'must not open the browser without explicit approval');
+  });
+
   console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
   if (failed > 0) process.exit(1);
 }