diff --git a/BROWSER.md b/BROWSER.md index 92ee065e9..4df487ad3 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -452,15 +452,22 @@ for the full design + decision trail. daemon (tabs, cookies, and logins are lost). `browse stop` against a daemon that already died is success: the desired end state holds, so it cleans the stale state file instead of booting a daemon just to stop it — - and reaps the headless Chromium child recorded in that state file if one - survived. The reap verifies the recorded start time AND a Chromium-looking - cmdline before sending any signal, so a recycled PID is never killed. + and attempts to reap a surviving headless Chromium child when the state + file contains its recorded identity. The reap checks the start time AND a + Chromium-looking cmdline before sending any signal. Production identity + capture remains unfixed: the existing Playwright `Browser.process()` + assumption does not supply that record. Tests with supplied identities + verify cleanup, not real-launch identity capture. ### Multi-workspace isolation Each project root (detected via `git rev-parse --show-toplevel`) gets its -own daemon, port, state file, cookies, and logs. No cross-workspace -collisions. State at `/.gstack/browse.json`. +own daemon, port, state file, and logs. Headless sessions have separate +cookie stores; headed sessions still share the default Chromium profile. +Headless startup, stop, disconnect, and shutdown leave that profile's locks +and their holder alone. Headed launches retain stale-lock cleanup, and +headed-versus-headed arbitration is unchanged. State lives at +`/.gstack/browse.json`. | Workspace | State file | Port | |-----------|-----------|------| @@ -1510,8 +1517,10 @@ No protocol. No schema. No connection management. ## Multi-workspace Each project root (detected via `git rev-parse --show-toplevel`) gets its -own daemon, port, state file, cookies, and logs. No cross-workspace -collisions. +own daemon, port, state file, and logs. Headless sessions have separate +cookie stores and leave the shared headed profile alone; two headed +sessions still share the default profile. See [Multi-workspace isolation](#multi-workspace-isolation) +for the cleanup boundary. | Workspace | State file | Port | |-----------|-----------|------| @@ -1537,7 +1546,7 @@ the global `~/.gstack/browser-skills/foo/` only inside project-a. | `BROWSE_HEADLESS_SKIP` | 0 | Skip Chromium launch entirely (test harness only) | | `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 Chromium profile directory (used by gbrowser's gbd per-workspace); honored by both launch and profile-lock cleanup | +| `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_BROWSE_MAX_HTML_BYTES` | 52428800 (50MB) | `load-html` size cap | | `GSTACK_SECURITY_OFF` | unset | Emergency kill switch — disable ML classifier | diff --git a/CHANGELOG.md b/CHANGELOG.md index f08dca342..e2390dc02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## [1.87.2.0] - 2026-09-15 + +**Headless commands stop closing your logged-in browser.** +**Other projects leave it alone.** + +Keep a headed GStack Browser open while another project uses headless browse commands. Starting, stopping, or disconnecting that project's headless daemon no longer kills the process holding your headed profile or removes its locks. The shared-profile cleanup now runs only for headed sessions, which actually use that profile. Headed startup keeps its stale-lock recovery. + +### The three numbers that matter + +Source: `bun test browse/test/chromium-profile-isolation.test.ts`, running the same nine-case regression suite against unchanged main and this release. Its headless CLI cases pair a real daemon with a controlled live process holding another project's profile locks. The headed startup controls use a stub daemon. + +| Metric | Before | After | Δ | +|---|---|---|---| +| Isolation suite cases passing | 3 of 9 | 9 of 9 | +6 | +| Isolation suite cases failing | 6 of 9 | 0 of 9 | -6 | +| Headed startup controls passing | 2 of 2 | 2 of 2 | Unchanged | + +The six previously failing cases now pass without deleting the foreign locks or killing their holder. Both explicit `--headed` and `BROWSE_HEADED=1` still clear stale locks before launch. + +This release separates headless cleanup from the shared headed profile. It does not give two headed sessions separate profiles, and it does not fix production Chromium process-identity capture. Tests that supply a recorded process identity verify its cleanup behavior; they are not evidence that a real launch records that identity. + +### What this means for multi-project work + +You can leave one browser open for work that needs your logins while another project starts or stops its own headless daemon. That removes the cross-project cleanup path behind the disappearing-window report, without claiming a solution for two headed browsers sharing one profile. Upgrade gstack and keep using your existing headed connection. + +### Itemized changes + +#### Fixed + +- Headless daemon startup, stop, disconnect, and crash cleanup leave another project's headed browser and shared profile locks alone. Headed launches retain stale-lock cleanup. (#2817) + +#### For contributors + +- Added nine regression cases for shared-profile isolation, including cleanup with a supplied recorded process identity. Production identity capture is not validated by those fixtures. +- Extended only the outer deadline of the native Windows USERPROFILE integration test to 120 seconds. Its assertions and subprocess timeouts are unchanged. + ## [1.87.1.0] - 2026-09-15 **Two vulnerable dependencies are fixed.** diff --git a/VERSION b/VERSION index e273ee6b0..30478137d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.87.1.0 +1.87.2.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 2c19a329a..f81c28aca 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.87.1.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 9c5316105..9de176de5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.87.1", + "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", diff --git a/test/cso-windows-launcher.test.ts b/test/cso-windows-launcher.test.ts index efb92ec6c..77299e65d 100644 --- a/test/cso-windows-launcher.test.ts +++ b/test/cso-windows-launcher.test.ts @@ -300,7 +300,7 @@ describe('CSO native Windows build contract', () => { const actual=path.join(ROOT,'bin','gstack-cso-launcher.exe'),env={...process.env,HOME:'',GSTACK_HOME:'',CLAUDE_PLUGIN_ROOT:'',CLAUDE_PLUGIN_DATA:'',USERPROFILE:profile,PATH:temporary,NODE_OPTIONS:'--require=hostile'}; const doctor=spawnSync(actual,['doctor','--repo',repository],{cwd:repository,encoding:'utf8',env,timeout:30_000});expect(doctor.status).toBe(0);expect(JSON.parse(doctor.stdout).downloads).toBe(false); const started=spawnSync(actual,['start','--repo',repository,'--offline'],{cwd:repository,encoding:'utf8',env,timeout:30_000});expectSuccessfulProcess(started,'gstack-cso start');expect(JSON.parse(started.stdout).schemaVersion).toBe(3);expect(fs.existsSync(path.join(profile,'.gstack','security','cso'))).toBe(true); - }); + }, 120_000); test('the actual helper rejects source mutation during snapshot capture without certifying a report', async () => { const repository=path.join(temporary,'racing repository'),profile=path.join(temporary,'race profile'),padding=path.join(repository,'padding'),target=path.join(repository,'zzzz-race-target.js');