diff --git a/CHANGELOG.md b/CHANGELOG.md index e0998614..fefacd48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [0.18.1.0] - 2026-04-16 +## [0.18.2.0] - 2026-04-16 ### Added - **Windows cookie import.** `/setup-browser-cookies` now works on Windows. Point it at Chrome, Edge, Brave, or Chromium, pick a profile, and gstack will pull your real browser cookies into the headless session. Handles AES-256-GCM (Chrome 80+), DPAPI key unwrap via PowerShell, and falls back to a headless CDP session for v20 App-Bound Encryption on Chrome 127+. Windows users can now do authenticated QA testing with `/qa` and `/design-review` for the first time. @@ -14,10 +14,24 @@ ### For contributors - Community wave lands 6 PRs: #993 (byliu-labs), #994 (joelgreen), #996 (voidborne-d), #864 (cathrynlavery), #982 (breakneo), #892 (msr-hickory). -- SIGTERM handling is now mode-aware. In normal mode the server ignores SIGTERM so Claude Code's sandbox doesn't tear it down mid-session. In headed mode (`/open-gstack-browser`) and tunnel mode (`/pair-agent`) SIGTERM still triggers a clean shutdown — those modes skip idle cleanup, so without the mode gate orphan daemons would accumulate forever. Same logic applies to the parent-PID watchdog. Inline comments document the resolution order. +- SIGTERM handling is now mode-aware. In normal mode the server ignores SIGTERM so Claude Code's sandbox doesn't tear it down mid-session. In headed mode (`/open-gstack-browser`) and tunnel mode (`/pair-agent`) SIGTERM still triggers a clean shutdown — those modes skip idle cleanup, so without the mode gate orphan daemons would accumulate forever. Note that v0.18.1.0 also disables the parent-PID watchdog when `BROWSE_HEADED=1`, so headed mode is doubly protected. Inline comments document the resolution order. - Windows v20 App-Bound Encryption CDP fallback now logs the Chrome version on entry and has an inline comment documenting the debug-port security posture (127.0.0.1-only, random port in [9222, 9321] for collision avoidance, always killed in finally). - New regression test `test/openclaw-native-skills.test.ts` pins OpenClaw skill frontmatter to `name` + `description` only — catches version/metadata drift at PR time. +## [0.18.1.0] - 2026-04-16 + +### Fixed +- **`/open-gstack-browser` actually stays open now.** If you ran `/open-gstack-browser` or `$B connect` and your browser vanished roughly 15 seconds later, this was why: a watchdog inside the browse server was polling the CLI process that spawned it, and when the CLI exited (which it does, immediately, right after launching the browser), the watchdog said "orphan!" and killed everything. The fix disables that watchdog for headed mode, both in the CLI (always set `BROWSE_PARENT_PID=0` for headed launches) and in the server (skip the watchdog entirely when `BROWSE_HEADED=1`). Two layers of defense in case a future launcher forgets to pass the env var. Thanks to @rocke2020 (#1020), @sanghyuk-seo-nexcube (#1018), @rodbland2021 (#1012), and @jbetala7 (#986) for independently diagnosing this and sending in clean, well-documented fixes. +- **Closing the headed browser window now cleans up properly.** Before this release, clicking the X on the GStack Browser window skipped the server's cleanup routine and exited the process directly. That left behind stale sidebar-agent processes polling a dead server, unsaved chat session state, leftover Chromium profile locks (which cause "profile in use" errors on the next `$B connect`), and a stale `browse.json` state file. Now the disconnect handler routes through the full `shutdown()` path first, cleans everything, and then exits with code 2 (which still distinguishes user-close from crash). +- **CI/Claude Code Bash calls can now share a persistent headless server.** The headless spawn path used to hardcode the CLI's own PID as the watchdog target, ignoring `BROWSE_PARENT_PID=0` even if you set it in your environment. Now `BROWSE_PARENT_PID=0 $B goto https://...` keeps the server alive across short-lived CLI invocations, which is what multi-step workflows (CI matrices, Claude Code's Bash tool, cookie picker flows) actually want. +- **`SIGTERM` / `SIGINT` shutdown now exits with code 0 instead of 1.** Regression caught during /ship's adversarial review: when `shutdown()` started accepting an `exitCode` argument, Node's signal listeners silently passed the signal name (`'SIGTERM'`) as the exit code, which got coerced to `NaN` and used `1`. Wrapped the listeners so they call `shutdown()` with no args. Your `Ctrl+C` now exits clean again. + +### For contributors +- `test/relink.test.ts` no longer flakes under parallel test load. The 23 tests in that file each shell out to `gstack-config` + `gstack-relink` (bash subprocess work), and under `bun test` with other suites running, each test drifted ~200ms past Bun's 5s default. Wrapped `test` to default the per-test timeout to 15s with `Object.assign` preserving `.only`/`.skip`/`.each` sub-APIs. +- `BrowserManager` gained an `onDisconnect` callback (wired by `server.ts` to `shutdown(2)`), replacing the direct `process.exit(2)` in the disconnect handler. The callback is wrapped with try/catch + Promise rejection handling so a rejecting cleanup path still exits the process instead of leaving a live server attached to a dead browser. +- `shutdown()` now accepts an optional `exitCode: number = 0` parameter, used by the disconnect path (exit 2) and the signal path (default 0). Same cleanup code, two call sites, distinct exit codes. +- `BROWSE_PARENT_PID` parsing in `cli.ts` now matches `server.ts`: `parseInt` instead of strict string equality, so `BROWSE_PARENT_PID=0\n` (common from shell `export`) is honored. + ## [0.18.0.1] - 2026-04-16 ### Fixed diff --git a/TODOS.md b/TODOS.md index 0e3ac932..7bb06d01 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,5 +1,19 @@ # TODOS +## Browse + +### Scope sidebar-agent kill to session PID, not `pkill -f sidebar-agent\.ts` + +**What:** `shutdown()` in `browse/src/server.ts:1193` uses `pkill -f sidebar-agent\.ts` to kill the sidebar-agent daemon, which matches every sidebar-agent on the machine, not just the one this server spawned. Replace with PID tracking: store the sidebar-agent PID when `cli.ts` spawns it (via state file or env), then `process.kill(pid, 'SIGTERM')` in `shutdown()`. + +**Why:** A user running two Conductor worktrees (or any multi-session setup), each with its own `$B connect`, closes one browser window ... and the other worktree's sidebar-agent gets killed too. The blast radius was there before, but the v0.18.1.0 disconnect-cleanup fix makes it more reachable: every user-close now runs the full `shutdown()` path, whereas before user-close bypassed it. + +**Context:** Surfaced by /ship's adversarial review on v0.18.1.0. Pre-existing code, not introduced by the fix. Fix requires propagating the sidebar-agent PID from `cli.ts` spawn site (~line 885) into the server's state file so `shutdown()` can target just this session's agent. Related: `browse/src/cli.ts` spawns with `Bun.spawn(...).unref()` and already captures `agentProc.pid`. + +**Effort:** S (human: ~2h / CC: ~15min) +**Priority:** P2 +**Depends on:** None + ## Sidebar Security ### ML Prompt Injection Classifier diff --git a/VERSION b/VERSION index 72ad141a..51534b8f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.18.1.0 +0.18.2.0 diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 63d78358..6b9242da 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -72,6 +72,12 @@ export class BrowserManager { private connectionMode: 'launched' | 'headed' = 'launched'; private intentionalDisconnect = false; + // Called when the headed browser disconnects without intentional teardown + // (user closed the window). Wired up by server.ts to run full cleanup + // (sidebar-agent, state file, profile locks) before exiting with code 2. + // Returns void or a Promise; rejections are caught and fall back to exit(2). + public onDisconnect: (() => void | Promise) | null = null; + getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; } // ─── Watch Mode Methods ───────────────────────────────── @@ -467,13 +473,32 @@ export class BrowserManager { await this.newTab(); } - // Browser disconnect handler — exit code 2 distinguishes from crashes (1) + // Browser disconnect handler — exit code 2 distinguishes from crashes (1). + // Calls onDisconnect() to trigger full shutdown (kill sidebar-agent, save + // session, clean profile locks + state file) before exit. Falls back to + // direct process.exit(2) if no callback is wired up, or if the callback + // throws/rejects — never leave the process running with a dead browser. if (this.browser) { this.browser.on('disconnected', () => { if (this.intentionalDisconnect) return; console.error('[browse] Real browser disconnected (user closed or crashed).'); console.error('[browse] Run `$B connect` to reconnect.'); - process.exit(2); + if (!this.onDisconnect) { + process.exit(2); + return; + } + try { + const result = this.onDisconnect(); + if (result && typeof (result as Promise).catch === 'function') { + (result as Promise).catch((err) => { + console.error('[browse] onDisconnect rejected:', err); + process.exit(2); + }); + } + } catch (err) { + console.error('[browse] onDisconnect threw:', err); + process.exit(2); + } }); } diff --git a/browse/src/cli.ts b/browse/src/cli.ts index ae287515..eb58cd7d 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -210,12 +210,20 @@ async function startServer(extraEnv?: Record): Promise): Promise { // server can become an orphan — keeping chrome-headless-shell alive and // causing console-window flicker on Windows. Poll the parent PID every 15s // and self-terminate if it is gone. +// +// Headed mode (BROWSE_HEADED=1 or BROWSE_PARENT_PID=0): The user controls +// the browser window lifecycle. The CLI exits immediately after connect, +// so the watchdog would kill the server prematurely. Disabled in both cases +// as defense-in-depth — the CLI sets PID=0 for headed mode, and the server +// also checks BROWSE_HEADED in case a future launcher forgets. +// Cleanup happens via browser disconnect event or $B disconnect. const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10); -if (BROWSE_PARENT_PID > 0) { +// Outer gate: if the spawner explicitly marks this as headed (env var set at +// launch time), skip registering the watchdog entirely. Cheaper than entering +// the closure every 15s. The CLI's connect path sets BROWSE_HEADED=1 + PID=0, +// so this branch is the normal path for /open-gstack-browser. +const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1'; +if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) { let parentGone = false; setInterval(() => { try { @@ -786,6 +798,10 @@ if (BROWSE_PARENT_PID > 0) { } } }, 15_000); +} else if (IS_HEADED_WATCHDOG) { + console.log('[browse] Parent-process watchdog disabled (headed mode)'); +} else if (BROWSE_PARENT_PID === 0) { + console.log('[browse] Parent-process watchdog disabled (BROWSE_PARENT_PID=0)'); } // ─── Command Sets (from commands.ts — single source of truth) ─── @@ -812,6 +828,10 @@ function emitInspectorEvent(event: any): void { // ─── Server ──────────────────────────────────────────────────── const browserManager = new BrowserManager(); +// When the user closes the headed browser window, run full cleanup +// (kill sidebar-agent, save session, remove profile locks, delete state file) +// before exiting with code 2. Exit code 2 distinguishes user-close from crashes (1). +browserManager.onDisconnect = () => shutdown(2); let isShuttingDown = false; // Test if a port is available by binding and immediately releasing. @@ -1199,7 +1219,7 @@ async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise shutdown()); // SIGTERM behavior depends on mode: // - Normal (headless) mode: Claude Code's Bash sandbox fires SIGTERM when the // parent shell exits between tool invocations. Ignoring it keeps the server @@ -1253,6 +1279,8 @@ process.on('SIGINT', shutdown); // - Headed / tunnel mode: idle timeout doesn't apply in these modes. Respect // SIGTERM so external tooling (systemd, supervisord, CI) can shut cleanly // without waiting forever. Ctrl+C and /stop still work either way. +// - Active cookie picker: never tear down mid-import regardless of mode — +// would strand the picker UI with "Failed to fetch." process.on('SIGTERM', () => { if (hasActivePicker()) { console.log('[browse] Received SIGTERM but cookie picker is active, ignoring to avoid stranding the picker UI'); diff --git a/browse/test/watchdog.test.ts b/browse/test/watchdog.test.ts new file mode 100644 index 00000000..1a6fd9af --- /dev/null +++ b/browse/test/watchdog.test.ts @@ -0,0 +1,147 @@ +import { describe, test, expect, afterEach } from 'bun:test'; +import { spawn, type Subprocess } from 'bun'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; + +// End-to-end regression tests for the parent-process watchdog in server.ts. +// Proves three invariants that the v0.18.1.0 fix depends on: +// +// 1. BROWSE_PARENT_PID=0 disables the watchdog (opt-in used by CI and pair-agent). +// 2. BROWSE_HEADED=1 disables the watchdog (server-side defense-in-depth). +// 3. Default headless mode still kills the server when its parent dies +// (the original orphan-prevention must keep working). +// +// Each test spawns the real server.ts, not a mock. Tests 1 and 2 verify the +// code path via stdout log line (fast). Test 3 waits for the watchdog's 15s +// poll cycle to actually fire (slow — ~25s). + +const ROOT = path.resolve(import.meta.dir, '..'); +const SERVER_SCRIPT = path.join(ROOT, 'src', 'server.ts'); + +let tmpDir: string; +let serverProc: Subprocess | null = null; +let parentProc: Subprocess | null = null; + +afterEach(async () => { + // Kill any survivors so subsequent tests get a clean slate. + try { parentProc?.kill('SIGKILL'); } catch {} + try { serverProc?.kill('SIGKILL'); } catch {} + // Give processes a moment to exit before tmpDir cleanup. + await Bun.sleep(100); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} + parentProc = null; + serverProc = null; +}); + +function spawnServer(env: Record, port: number): Subprocess { + const stateFile = path.join(tmpDir, 'browse-state.json'); + return spawn(['bun', 'run', SERVER_SCRIPT], { + env: { + ...process.env, + BROWSE_STATE_FILE: stateFile, + BROWSE_PORT: String(port), + ...env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); // signal 0 = existence check, no signal sent + return true; + } catch { + return false; + } +} + +// Read stdout until we see the expected marker or timeout. Returns the captured +// text. Used to verify the watchdog code path ran as expected at startup. +async function readStdoutUntil( + proc: Subprocess, + marker: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + const decoder = new TextDecoder(); + let captured = ''; + const reader = (proc.stdout as ReadableStream).getReader(); + try { + while (Date.now() < deadline) { + const readPromise = reader.read(); + const timed = Bun.sleep(Math.max(0, deadline - Date.now())); + const result = await Promise.race([readPromise, timed.then(() => null)]); + if (!result || result.done) break; + captured += decoder.decode(result.value); + if (captured.includes(marker)) return captured; + } + } finally { + try { reader.releaseLock(); } catch {} + } + return captured; +} + +describe('parent-process watchdog (v0.18.1.0)', () => { + test('BROWSE_PARENT_PID=0 disables the watchdog', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-pid0-')); + serverProc = spawnServer({ BROWSE_PARENT_PID: '0' }, 34901); + + const out = await readStdoutUntil( + serverProc, + 'Parent-process watchdog disabled (BROWSE_PARENT_PID=0)', + 5000, + ); + expect(out).toContain('Parent-process watchdog disabled (BROWSE_PARENT_PID=0)'); + // Control: the "parent exited, shutting down" line must NOT appear — + // that would mean the watchdog ran after we said to skip it. + expect(out).not.toContain('Parent process'); + }, 15_000); + + test('BROWSE_HEADED=1 disables the watchdog (server-side guard)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-headed-')); + // Pass a bogus parent PID to prove BROWSE_HEADED takes precedence. + // If the server-side guard regresses, the watchdog would try to poll + // this PID and eventually fire on the "dead parent." + serverProc = spawnServer( + { BROWSE_HEADED: '1', BROWSE_PARENT_PID: '999999' }, + 34902, + ); + + const out = await readStdoutUntil( + serverProc, + 'Parent-process watchdog disabled (headed mode)', + 5000, + ); + expect(out).toContain('Parent-process watchdog disabled (headed mode)'); + expect(out).not.toContain('Parent process 999999 exited'); + }, 15_000); + + test('default headless mode: watchdog fires when parent dies', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-default-')); + + // Spawn a real, short-lived "parent" that the watchdog will poll. + parentProc = spawn(['sleep', '60'], { stdio: ['ignore', 'ignore', 'ignore'] }); + const parentPid = parentProc.pid!; + + // Default headless: no BROWSE_HEADED, real parent PID — watchdog active. + serverProc = spawnServer({ BROWSE_PARENT_PID: String(parentPid) }, 34903); + const serverPid = serverProc.pid!; + + // Give the server a moment to start and register the watchdog interval. + await Bun.sleep(2000); + expect(isProcessAlive(serverPid)).toBe(true); + + // Kill the parent. The watchdog polls every 15s, so first tick after + // parent death lands within ~15s, plus shutdown() cleanup time. + parentProc.kill('SIGKILL'); + + // Poll for up to 25s for the server to exit. + const deadline = Date.now() + 25_000; + while (Date.now() < deadline) { + if (!isProcessAlive(serverPid)) break; + await Bun.sleep(500); + } + expect(isProcessAlive(serverPid)).toBe(false); + }, 45_000); +}); diff --git a/package.json b/package.json index 68edadf1..6bd3facb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "0.18.1.0", + "version": "0.18.2.0", "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/relink.test.ts b/test/relink.test.ts index d0c48f19..e5cd5206 100644 --- a/test/relink.test.ts +++ b/test/relink.test.ts @@ -1,9 +1,19 @@ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { describe, test as _bunTest, expect, beforeEach, afterEach } from 'bun:test'; import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +// Every test in this file shells out to gstack-config + gstack-relink (bash scripts +// invoking subprocess work). Under parallel bun test load, subprocess spawn contends +// with other suites and each test can drift ~200ms past the 5s default. Bump to 15s. +// Object.assign preserves test.only / test.skip / test.each / test.todo sub-APIs. +const test = Object.assign( + ((name: any, fn: any, timeout?: number) => + _bunTest(name, fn, timeout ?? 15_000)) as typeof _bunTest, + _bunTest, +); + const ROOT = path.resolve(import.meta.dir, '..'); const BIN = path.join(ROOT, 'bin');