fix(scripts): extract cross-platform openBrowser helper with structured launch result (#3180)

scripts/plan-canvas.js and scripts/control-pane.js both define their own
openBrowser helpers for launching the user's default browser. The two
implementations have diverged:

- plan-canvas.js dispatches across darwin/win32/linux but its
  try/spawn try/catch does not catch async child errors (ENOENT/EACCES
  on hosts without the launcher command). Returns a bare true/false.
- control-pane.js is darwin-only and silently returns early on
  Windows/Linux, so the two scripts behave inconsistently across
  platforms.

When the launcher command is missing (e.g. headless CI without xdg-open)
the JSON output still says 'browser: opened', lying to the agent.

Changes:
- scripts/lib/platform-launch.js: shared openBrowser helper that
  dispatches per platform, wires child 'error' so ENOENT/EACCES
  propagate, and returns {opened, reason} instead of a bare boolean.
- scripts/plan-canvas.js: drops its local helper, imports the shared
  one, and adds browserReason to its JSON output.
- scripts/control-pane.js: replaces its darwin-only branch with the
  shared helper and logs the structured reason on failure.
- tests/lib/platform-launch.test.js: 7 node:test cases covering
  opener-command selection, invalid-URL guard, structured-result
  shape, and a smoke run for the actual spawn.

Verification: node --test tests/lib/platform-launch.test.js → 7/7 pass.

Co-authored-by: auyua9 <auyua9@users.noreply.github.com>
This commit is contained in:
auyua9
2026-09-20 14:26:52 -04:00
committed by GitHub
co-authored by auyua9
parent 95448ad81d
commit 9ac593b55c
4 changed files with 152 additions and 25 deletions
+5 -16
View File
@@ -20,14 +20,13 @@
const fs = require('fs');
const http = require('http');
const path = require('path');
const { spawn } = require('child_process');
const {
canonicalizeArtifactPath,
createSessionStore,
resolveStateDir,
sessionKeyFor
} = require('./lib/plan-canvas/sessions');
const { openBrowser } = require('./lib/platform-launch');
const {
DEFAULT_HOST,
createPlanCanvasServer,
@@ -194,19 +193,7 @@ async function ensureServer({ stateDir, port }) {
throw new Error(`plan-canvas server did not become healthy on port ${port}; check ${path.join(stateDir, 'server.log')}`);
}
function openBrowser(url) {
const platform = process.platform;
const [cmd, args] =
platform === 'darwin' ? ['open', [url]]
: platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
: ['xdg-open', [url]];
try {
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
return true;
} catch {
return false;
}
}
function output(payload) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
@@ -232,11 +219,13 @@ async function cmdOpen(file, args, { stateDir, port }) {
if (res.statusCode === 409) return res.body;
if (res.statusCode !== 200) throw new Error(res.body.error || `open failed (HTTP ${res.statusCode})`);
const url = `http://${DEFAULT_HOST}:${port}${res.body.url}`;
const launched = args.includes('--no-open') ? false : openBrowser(url);
const launchResult = args.includes('--no-open') ? { opened: false, reason: 'no-open-flag' } : openBrowser(url);
const launched = launchResult.opened;
return {
status: 'open',
url,
browser: launched ? 'opened' : 'not opened',
browserReason: launchResult.reason,
next_step:
'Run `ecc-plan-canvas await <file>` and leave it running; it returns when the human sends feedback, a verdict, or ends the session.'
};