From 073b0f8ae652f5fa1080a47f0631abde8237e3f6 Mon Sep 17 00:00:00 2001 From: garrytan <19957+garrytan@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:07:16 +0000 Subject: [PATCH] fix(browse): isolate headless lifecycle from shared profiles --- CHANGELOG.md | 6 + VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- browse/src/cli.ts | 19 +- browse/src/server.ts | 8 +- .../test/chromium-profile-isolation.test.ts | 218 ++++++++++++++++++ browse/test/server-factory.test.ts | 9 +- package.json | 2 +- 8 files changed, 252 insertions(+), 14 deletions(-) create mode 100644 browse/test/chromium-profile-isolation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f799e2717..7224a8cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.87.2.0] - 2026-09-15 + +### Fixed + +- Starting or stopping a headless browse daemon no longer kills another project's headed browser or removes its shared profile locks. Headed launches still clear stale profile locks, and headless cleanup still reaps Chromium children by their recorded identity. (#2817) + ## [1.87.0.0] - 2026-09-11 **`/cso` now distinguishes verified vulnerabilities from hypotheses and coverage gaps, and qualified comprehensive audits can produce replayable repair bundles without changing your working branch.** diff --git a/VERSION b/VERSION index 2ea29d262..30478137d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.87.0.0 +1.87.2.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index c17d43942..f81c28aca 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.87.0.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.87.2.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/browse/src/cli.ts b/browse/src/cli.ts index c5b875755..e36728189 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -515,8 +515,10 @@ async function startServer(extraEnv?: Record): Promise { + let scratch: string; + let profile: string; + let stateFile: string; + let env: Record; + let children: ReturnType[]; + let daemonPid: number | undefined; + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-profile-isolation-')); + profile = path.join(scratch, '.gstack', 'chromium-profile'); + stateFile = path.join(scratch, 'project-b', '.gstack', 'browse.json'); + fs.mkdirSync(profile, { recursive: true }); + fs.mkdirSync(path.dirname(stateFile), { recursive: true }); + env = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !/^(BROWSE_|GSTACK_|CHROMIUM_PROFILE$|CLAUDE_PLUGIN_DATA$)/.test(key)) { + env[key] = value; + } + } + Object.assign(env, { + HOME: scratch, + PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join( + process.env.XDG_CACHE_HOME || path.join(os.homedir(), process.platform === 'darwin' ? 'Library/Caches' : '.cache'), + 'ms-playwright', + ), + GSTACK_HOME: path.join(scratch, '.gstack'), + GSTACK_SECURITY_OFF: '1', + BROWSE_STATE_FILE: stateFile, + BROWSE_PORT: '0', + BROWSE_PARENT_PID: '0', + BROWSE_START_TIMEOUT: '30000', + }); + children = []; + daemonPid = undefined; + }); + + afterEach(async () => { + if (fs.existsSync(stateFile)) { + daemonPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid; + } + if (daemonPid) safeKill(-daemonPid, 'SIGKILL'); + for (const child of children) child.kill('SIGKILL'); + await Promise.all(children.map(child => child.exited)); + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + async function spawnChromiumHolder() { + const script = path.join(scratch, `chromium-holder-${children.length}.ts`); + fs.writeFileSync(script, 'console.log("ready"); await Bun.sleep(60000);'); + const child = Bun.spawn([process.execPath, script], { + env, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'ignore', + }); + children.push(child); + const reader = child.stdout.getReader(); + const ready = await reader.read(); + reader.releaseLock(); + expect(new TextDecoder().decode(ready.value)).toContain('ready'); + return child; + } + + function seedLocks(pid: number) { + fs.symlinkSync(`${os.hostname()}-${pid}`, path.join(profile, LOCKS[0])); + fs.symlinkSync('socket-target', path.join(profile, LOCKS[1])); + fs.symlinkSync('cookie-target', path.join(profile, LOCKS[2])); + } + + function expectLocksIntact(pid: number) { + expect(isProcessAlive(pid)).toBe(true); + expect(fs.readlinkSync(path.join(profile, LOCKS[0]))).toBe(`${os.hostname()}-${pid}`); + expect(fs.readlinkSync(path.join(profile, LOCKS[1]))).toBe('socket-target'); + expect(fs.readlinkSync(path.join(profile, LOCKS[2]))).toBe('cookie-target'); + } + + async function runCli(args: string[]) { + const child = Bun.spawn([process.execPath, CLI, ...args], { + cwd: path.join(scratch, 'project-b'), + env, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }); + children.push(child); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + if (fs.existsSync(stateFile)) daemonPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid; + const log = path.join(path.dirname(stateFile), 'browse-daemon.log'); + expect(code, stdout + stderr + (fs.existsSync(log) ? fs.readFileSync(log, 'utf-8') : '')).toBe(0); + } + + test('headless startup leaves another project\'s live headed lock holder and locks alone', async () => { + const holder = await spawnChromiumHolder(); + seedLocks(holder.pid); + + await runCli(['status']); + + expect(daemonPid).toBeGreaterThan(0); + expect(isProcessAlive(daemonPid!)).toBe(true); + expectLocksIntact(holder.pid); + }, 60_000); + + test('headless startup still reaps its own recorded orphan without touching the headed profile', async () => { + const holder = await spawnChromiumHolder(); + const orphan = await spawnChromiumHolder(); + seedLocks(holder.pid); + fs.writeFileSync(stateFile, JSON.stringify({ + pid: 999_999_999, + port: 0, + token: 'test-token', + mode: 'launched', + chromiumPid: orphan.pid, + chromiumStartTime: readPidStartTime(orphan.pid), + })); + + await runCli(['status']); + + expect(isProcessAlive(orphan.pid)).toBe(false); + expectLocksIntact(holder.pid); + }, 60_000); + + for (const mode of ['flag', 'environment'] as const) { + test(`headed startup via ${mode} still reaps a profile orphan and removes stale locks`, async () => { + const orphan = await spawnChromiumHolder(); + seedLocks(orphan.pid); + env.BROWSE_HEADED = mode === 'environment' ? '1' : '0'; + const serverScript = path.join(scratch, 'stub-server.ts'); + fs.writeFileSync(serverScript, ` + import * as fs from 'node:fs'; + const server = Bun.serve({ + hostname: '127.0.0.1', port: 0, + fetch: () => Response.json({ status: 'healthy' }), + }); + fs.writeFileSync(process.env.BROWSE_STATE_FILE!, JSON.stringify({ + pid: process.pid, port: server.port, token: 'test-token', + mode: process.env.BROWSE_HEADED === '1' ? 'headed' : 'launched', + })); + `); + env.BROWSE_SERVER_SCRIPT = serverScript; + + await runCli(mode === 'flag' ? ['--headed', 'status'] : ['status']); + + expect(JSON.parse(fs.readFileSync(stateFile, 'utf-8')).mode).toBe('headed'); + expect(isProcessAlive(orphan.pid)).toBe(false); + expect(fs.readdirSync(profile)).not.toContain(LOCKS[0]); + expect(fs.readdirSync(profile)).not.toContain(LOCKS[1]); + expect(fs.readdirSync(profile)).not.toContain(LOCKS[2]); + }, 60_000); + } + + for (const args of [['stop'], ['--force-restart', 'stop'], ['disconnect']]) { + test(`headless ${args.join(' ')} preserves another project's headed profile`, async () => { + await runCli(['status']); + if (args[0] === 'disconnect') { + const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + state.configHash = 'proxy-only-config'; + fs.writeFileSync(stateFile, JSON.stringify(state)); + } + const holder = await spawnChromiumHolder(); + seedLocks(holder.pid); + + await runCli(args); + const deadline = Date.now() + 10000; + while (fs.existsSync(stateFile) && Date.now() < deadline) await Bun.sleep(50); + + expect(fs.existsSync(stateFile)).toBe(false); + expectLocksIntact(holder.pid); + }, 60_000); + } + + for (const mode of ['launched', 'headed'] as const) { + test(`${mode} factory shutdown scopes profile cleanup to the active browser mode`, async () => { + const holder = await spawnChromiumHolder(); + seedLocks(holder.pid); + const child = Bun.spawn([process.execPath, '-e', ` + import { buildFetchHandler } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))}; + import { resolveConfig } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/config.ts'))}; + const handle = buildFetchHandler({ + authToken: 'profile-isolation-test-token', + browsePort: 0, + idleTimeoutMs: 1800000, + config: resolveConfig(), + ownsTerminalAgent: false, + browserManager: { + getConnectionMode: () => ${JSON.stringify(mode)}, + isWatching: () => false, + close: async () => {}, + }, + }); + await handle.shutdown(); + `], { env, stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' }); + children.push(child); + const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]); + + expect(code, stderr).toBe(0); + if (mode === 'headed') { + for (const lock of LOCKS) expect(fs.readdirSync(profile)).not.toContain(lock); + } else { + expectLocksIntact(holder.pid); + } + }, 60_000); + } +}); diff --git a/browse/test/server-factory.test.ts b/browse/test/server-factory.test.ts index b6c1dc44d..bed2a70c8 100644 --- a/browse/test/server-factory.test.ts +++ b/browse/test/server-factory.test.ts @@ -531,7 +531,7 @@ describe('idle timer + onDisconnect dual-instance fix', () => { test('lifecycle handlers (idleCheckTick + parent watchdog + SIGTERM) read activeBrowserManager, not module-level browserManager', () => { // Static guard against a future refactor reintroducing a stale read. - // The 3 lifecycle sites this plan fixed all call getConnectionMode via + // The 4 lifecycle sites all call getConnectionMode via // the indirection. Other module-level browserManager reads inside // handleCommandInternalImpl (informational mode reporting in response // payloads) are out of scope and intentionally untouched. @@ -540,7 +540,10 @@ describe('idle timer + onDisconnect dual-instance fix', () => { expect(factoryStart).toBeGreaterThan(0); const moduleLevel = src.slice(0, factoryStart); const activeCount = (moduleLevel.match(/activeBrowserManager\.getConnectionMode\(\)/g) || []).length; - // Edit 2 (idleCheckTick), Edit 3 (parent watchdog), Edit 6 (SIGTERM). - expect(activeCount).toBe(3); + // idleCheckTick, parent watchdog, SIGTERM, and emergencyCleanup. + expect(activeCount).toBe(4); + const emergencyStart = src.indexOf('function emergencyCleanup()'); + expect(emergencyStart).toBeGreaterThan(0); + expect(src.slice(emergencyStart, factoryStart)).toContain("activeBrowserManager.getConnectionMode() === 'headed'"); }); }); diff --git a/package.json b/package.json index fcaa2b436..5dd2f40e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.87.0", + "version": "1.87.2", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module",