diff --git a/BROWSER.md b/BROWSER.md index bb511bb14..4477f178f 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -1606,7 +1606,7 @@ the global `~/.gstack/browser-skills/foo/` only inside project-a. | `BROWSE_TUNNEL` | 0 | Activate the dual-listener tunnel architecture (requires `NGROK_AUTHTOKEN`) | | `BROWSE_TUNNEL_LOCAL_ONLY` | 0 | Test-only — bind both listeners locally without ngrok | | `CHROMIUM_PROFILE` | unset | Explicit headed Chromium profile directory (used by gbrowser's gbd per-workspace); honored by headed launch and profile-lock cleanup, not used by headless sessions | -| `GSTACK_DISABLE_GPU` | unset | Set to `off` to skip the macOS headless GPU-taming flag set (applied by default on Darwin to stop runaway GPU-process spin) | +| `GSTACK_DISABLE_GPU` | unset | Set to `off` to skip the macOS headless GPU-taming flag set (applied by default on Darwin to stop runaway GPU-process spin). That flag set also disables WebGL: `getContext('webgl')`/`'webgl2'` return `null`, so WebGL pages (three.js, Mapbox GL, sigma.js) show their fallback. Set `off` to QA WebGL or canvas-heavy apps. | | `GSTACK_BROWSE_MAX_HTML_BYTES` | 52428800 (50MB) | `load-html` size cap | | `GSTACK_SECURITY_OFF` | unset | Emergency kill switch — disable ML classifier | | `GSTACK_STEALTH` | unset | Set to `extended` (also accepts `1`/`true`) to layer six aggressive patches (WebGL spoof, faked plugins, mediaDevices) on top of Layer C. Actively lies; can break sites. | diff --git a/browse/src/cli.ts b/browse/src/cli.ts index e9e209296..2a37ec1ff 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -811,8 +811,14 @@ async function ensureServer(flags?: GlobalFlags): Promise { // hint. No silent restart — that would drop tab state, cookies, and // logged-in sessions without warning. if (desiredHash && state.configHash && state.configHash !== desiredHash) { - console.error(`[browse] existing daemon has different config (proxy/headed mismatch).`); - console.error(`[browse] run 'browse disconnect' first to apply --proxy/--headed.`); + // #3030: a caller that passed no flags never asked to "apply" any. + if (flags?.proxyUrl || flags?.headed) { + console.error(`[browse] existing daemon has different config (proxy/headed mismatch).`); + console.error(`[browse] run 'browse disconnect' first to apply --proxy/--headed.`); + } else { + console.error(`[browse] a browse daemon for this project is running with --headed/--proxy (started by another session).`); + console.error(`[browse] pass the same flags to use it, or run 'browse disconnect' to start a plain one.`); + } process.exit(1); } // Same path: existing daemon is plain (no flags) but caller passes diff --git a/browse/test/cli-config-mismatch-message.test.ts b/browse/test/cli-config-mismatch-message.test.ts new file mode 100644 index 000000000..3e8dff90c --- /dev/null +++ b/browse/test/cli-config-mismatch-message.test.ts @@ -0,0 +1,53 @@ +/** + * #3030: when another session started the project's daemon with --headed or + * --proxy, a plain call is refused (by design: a silent restart would drop + * tabs and logins). The refusal must not tell a caller who passed no flags + * to "apply --proxy/--headed". + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const CLI = path.resolve(import.meta.dir, '../src/cli.ts'); + +describe('#3030: config-mismatch refusal names who started the daemon', () => { + let scratch: string; + let daemon: ReturnType; + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-mismatch-')); + daemon = Bun.serve({ + hostname: '127.0.0.1', port: 0, + fetch: (req) => new URL(req.url).pathname === '/health' ? Response.json({ status: 'healthy' }) : new Response('unexpected', { status: 500 }), + }); + }); + afterEach(() => { try { daemon.stop(true); } catch {} fs.rmSync(scratch, { recursive: true, force: true }); }); + + async function run(args: string[]) { + const stateFile = path.join(scratch, 'browse.json'); + fs.writeFileSync(stateFile, JSON.stringify({ pid: process.pid, port: daemon.port, token: 't', mode: 'headed', configHash: 'started-by-another-session' })); + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) if (value !== undefined && !/^(BROWSE_|GSTACK_)/.test(key)) env[key] = value; + Object.assign(env, { HOME: scratch, GSTACK_HOME: path.join(scratch, '.gstack'), BROWSE_STATE_FILE: stateFile }); + const cli = Bun.spawn([process.execPath, CLI, ...args], { cwd: scratch, env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }); + const timer = setTimeout(() => cli.kill('SIGKILL'), 30_000); + const [code, out, err] = await Promise.all([cli.exited, new Response(cli.stdout).text(), new Response(cli.stderr).text()]); + clearTimeout(timer); + return { code, out: out + err }; + } + + test('a plain call is told another session started it, and both remedies', async () => { + const r = await run(['text']); + expect(r.code).toBe(1); + expect(r.out).toContain('running with --headed/--proxy (started by another session)'); + expect(r.out).toContain("pass the same flags to use it, or run 'browse disconnect'"); + expect(r.out).not.toContain('to apply --proxy/--headed'); + }, 45_000); + + test('a call that passed a different flag keeps the apply-via-disconnect hint', async () => { + const r = await run(['--proxy', 'http://127.0.0.1:9', 'text']); + expect(r.code).toBe(1); + expect(r.out).toContain("run 'browse disconnect' first to apply --proxy/--headed"); + }, 45_000); +});