Skip to content

ENG-97 — Per-session isolated browser for the shared gstack browse daemon

Status: Design (no daemon changes shipped in this PR) Issue: ENG-97 · Interim mitigation: PR #14063 (scripts/dev/browse-lock.ps1) Scope of this doc: explain how the daemon resolves its single browser today, why concurrent sessions collide, and choose an isolation approach. It deliberately does not modify the running daemon — the daemon is shared infra and a bad change strands every browse-test session on the dev VM.

All src/*.ts references below are in the gstack install at ~/.claude/skills/gstack/browse/ (the compiled artifact that actually runs is ~/.claude/skills/gstack/browse/dist/server-node.mjs, built from that src/).


1. Problem

The dev VM runs one gstack browse daemon: a single server-node.mjs process controlling one headless Chromium with one active tab. It has no per-session isolation. When two agent/dev sessions drive it at the same time, their commands interleave on the same tab — navigations get hijacked, screenshots come back blank or show the other session's page, and steps fail with no active page.

Real impact: this blocked ~3 of 5 ENG-87 fundraising/event QA captures for hours on 2026-06-15 — a parallel mobile-portal QA session held the daemon continuously and ~1h40 of retries couldn't get a clean window. The interim fix (PR #14063) is an advisory auto-expiring lock that serializes access; it does not give true parallelism and only works if every session honours it.

This doc is about the durable fix: give each session its own browser so two sessions can drive simultaneously with zero contention.


2. How the daemon resolves its single Chromium / tab today

The flow has three layers: a CLI that finds-or-spawns the daemon, the daemon process that owns one Chromium, and a BrowserManager that tracks tabs behind a single "active tab" cursor.

2.1 CLI → daemon resolution is keyed by project dir, not by session

resolveConfig() (src/config.ts:53) picks where the daemon's state file lives:

  1. BROWSE_STATE_FILE env, if set — derive everything from it; else
  2. git rev-parse --show-toplevel<gitRoot>/.gstack/browse.json; else
  3. process.cwd() fallback.

So the state file is keyed by the git toplevel. Two sessions in the same checkout resolve to the same .gstack/browse.json.

ensureServer() (src/cli.ts:404) reads that state file (readState(), src/cli.ts:109) into a ServerState ({ pid, port, token, mode, configHash, … }, src/cli.ts:92). If the recorded port answers GET /health as healthy (isServerHealthy, src/cli.ts:124) it reuses that daemon. Otherwise it takes an exclusive <stateFile>.lock (acquireServerLock, src/cli.ts:379) and spawns a new daemon, passing BROWSE_STATE_FILE to the child (src/cli.ts:341-345).

2.2 The daemon picks a port and owns one Chromium

In the daemon (src/server.ts):

  • Port: findPort() (src/server.ts:879) returns BROWSE_PORT if set (src/server.ts:142), otherwise a random port in 10000–60000. It binds 127.0.0.1 and writes the chosen port back into the state file so the CLI can find it.
  • Browser: a single BrowserManager (src/browser-manager.ts:149) launches one Chromium — headless chromium.launch() (src/browser-manager.ts:372) or headed chromium.launchPersistentContext() (src/browser-manager.ts:557).
  • Profile / user-data-dir: resolveChromiumProfile() (src/config.ts:176): CHROMIUM_PROFILE env if set, else <GSTACK_HOME|~/.gstack>/chromium-profile. The default profile path is shared across every daemon on the machine.

2.3 One global "active tab" cursor

BrowserManager tracks pages: Map<number, Page> and a single activeTabId (src/browser-manager.ts:157-159). Almost every verb resolves its target through getActiveSession() (src/browser-manager.ts:956), which returns the TabSession for activeTabId or throws No active page. Use "browse goto <url>" first.

activeTabId is a single mutable pointer with no session scoping: - newTab() sets activeTabId to the new tab (src/browser-manager.ts:790-793). - switchTab() / syncActiveTabByUrl() move it (src/browser-manager.ts:832, :848) — the latter runs on every /sidebar-tabs poll, so a manual tab switch in the browser repoints it within ~2s. - closeTab() repoints it to whatever tab remains (src/browser-manager.ts:821-828).

2.4 Isolation seams that already exist (key finding)

The daemon was already extended for pair-agent isolation, and three env seams already let an embedder run an isolated instance without any daemon code change:

Seam Where Effect
BROWSE_PORT src/server.ts:142, :879 Pin the daemon to a chosen port instead of random.
BROWSE_STATE_FILE src/config.ts:60 Key the state file (hence the whole find-or-spawn) anywhere, not just the git root.
CHROMIUM_PROFILE src/config.ts:176 Give the Chromium its own user-data-dir. Already used by gbrowser's gbd for per-workspace profiles.
BROWSE_TAB + tab ownership src/browse-client.ts:142, src/browser-manager.ts:177 (tabOwnership), :926 (checkTabAccess) Pin a client to one tab id and scope writes by clientId within one browser.

Implication: ENG-97's "preferred approach — check whether the daemon already supports a port/instance/profile selector" is largely already true. The selectors exist. The missing piece is a launcher that derives a per-session key and sets these envs — not new daemon internals. That reframes the work from "patch shared infra" to "add an opt-in resolver around it," which is exactly the low-blast-radius shape this issue wants.


3. The concurrency failure mode

Two sessions, same checkout (the common dev-VM case):

  1. Both run git rev-parse --show-toplevel → same <gitRoot>/.gstack/browse.json → both resolve to the same daemon (§2.1).
  2. That daemon has one activeTabId (§2.3). Session B's goto/newtab moves the global cursor; session A's next screenshot/snapshot then captures B's page — or, if B closed the tab A was on, A hits No active page.
  3. There is no per-session context, no per-session cursor, no lease — the active-tab pointer is shared global state.

Two failure modes even if you try to run a second daemon by hand: - Profile collision: the default CHROMIUM_PROFILE is shared (§2.2). A second Chromium on the same user-data-dir hits Chromium's ProcessSingleton lock. (The cleanSingletonLocks guard at src/config.ts:201 and the headed-handoff machinery exist precisely because this profile is contended.) - State-file collision: without a distinct BROWSE_STATE_FILE, the second spawn's health-check finds the first daemon and just reuses it — you never get a second browser.

The interim browse-lock.ps1 (PR #14063) sidesteps all of this by letting only one session drive at a time. Correct, but serial, and advisory (a session that ignores the lock still collides).


4. Options

Three shapes, cheapest-isolation to strongest-isolation. All three are enabled by the seams in §2.4; they differ in where the isolation boundary sits and how much daemon code must change.

Option A — Per-session daemon instance (separate browser per session)

A launcher derives a stable session key (Claude session id / Conductor workspace / worktree path) and sets, before invoking browse:

  • BROWSE_PORT = a deterministic port from hash(key) into a reserved band (with a small linear-probe on collision),
  • CHROMIUM_PROFILE = ~/.gstack/profiles/<key> (its own user-data-dir),
  • BROWSE_STATE_FILE = ~/.gstack/sessions/<key>/browse.json (its own find-or-spawn).

Each session gets a fully independent Chromium + cookie jar + tab set. No daemon code changes — it composes existing env seams.

  • Pros: true parallelism, zero contention; strongest isolation (separate process and cookies/storage, so no org-bleed between sessions); mirrors how gbrowser's gbd already does per-workspace profiles; nothing to PR upstream to ship the core behaviour.
  • Cons: N Chromium processes ≈ N × few-hundred-MB RAM on the dev VM; needs lifecycle/GC for idle daemons and stale profiles/<key> dirs; needs port-band allocation + collision handling; login/cookie state is no longer shared across sessions, so a session that relied on an already-logged-in shared profile must import cookies itself (/setup-browser-cookies).

Option B — Per-session browser context inside one daemon

Keep one daemon and one Chromium, but give each session its own Playwright BrowserContext (own cookie jar/storage) and its own active-tab cursor, scoped by clientId.

  • Pros: one Chromium process → much lower RAM than Option A; BrowserContexts are cheap; still gives true cookie/storage isolation between sessions.
  • Cons: requires real daemon changes to shared infra — today there is one global activeTabId and one context (§2.3); this needs a per-client context map + per-client active-tab + threading clientId through every command's target resolution. The tabOwnership/checkTabAccess plumbing is a start but does not yet scope the active-tab cursor or the context. One daemon crash takes down all sessions (shared blast radius). Should be PR'd upstream, raising lead time and review surface — exactly the "don't rebuild shared infra under time pressure" risk this issue flags.

Option C — Tab-pool with leases (one daemon, one context)

Keep one browser and one context; a session leases a tab (gets a tabId), all its commands pin to that tab via BROWSE_TAB/clientId, and the lease auto-expires.

  • Pros: smallest change — BROWSE_TAB pinning and tabOwnership already exist (§2.4); one Chromium → lowest RAM.
  • Cons: tabs in one context share cookies/localStorage/sessionStoragenot true isolation. Two sessions QA-ing different orgs would bleed into each other, which is the exact org-bleed bug class we already fight (see project_browse_digital_card_org_bleed). Doesn't satisfy the ticket's "own browser instance." Still one crash = all sessions down.

Comparison

A: per-session daemon B: per-session context C: tab-pool + leases
True parallelism ✅ (serial within a tab)
Process isolation ❌ (shared crash) ❌ (shared crash)
Cookie/storage isolation ❌ (bleed)
Daemon code change none large small
Upstream PR needed no yes small
RAM cost high (N browsers) low lowest
Lifecycle/GC work yes (daemons + profiles) moderate low

5. Recommendation

Adopt Option A (per-session daemon) as the durable fix, and keep browse-lock.ps1 as the documented fallback for the shared-instance case.

Rationale: - It is the only option that delivers true isolation across all three boundaries (process, profile, cookies) — and cookie isolation matters because our QA frequently switches orgs, and shared-context options reintroduce org-bleed. - It needs no change to the shared daemon, which is the whole constraint of this issue: it leans entirely on BROWSE_PORT / CHROMIUM_PROFILE / BROWSE_STATE_FILE, seams that already ship (one of them already used in production by gbrowser). The deliverable is a launcher/resolver + lifecycle/GC + docs, all opt-in. - It matches the ticket's "extend, don't rebuild" and its "check whether a selector already exists" step — the selectors do exist (§2.4), so the upstream PR the ticket anticipated may be unnecessary for the core behaviour (at most a small ergonomics flag).

Keep browse-lock.ps1 for the cases where Option A is the wrong trade: a RAM-constrained box, or sessions that intentionally share one logged-in profile. It becomes the "shared-instance" mode rather than the default.

Route heavy/parallel QA to CI. Per the ticket, the browse-smoke matrix on r2/r3/r4 already runs one isolated browser per job. For large parallel capture runs, dispatching to CI sidesteps the dev-VM entirely; Option A is for the interactive dev-VM case.

Phase 2 (only if dev-VM RAM becomes the constraint): revisit Option B (per-session context) as a memory-efficient successor and PR it upstream. Option A's launcher key (the session id) is the same key Option B would scope contexts by, so Option A is not throwaway — it's the front half of B.

Lifecycle details to nail down before implementing Option A

  • Session key source: prefer the Conductor workspace / worktree path (stable across reconnects) over a per-invocation id. Fall back to git toplevel so today's behaviour is the degenerate single-session case.
  • Port band: reserve a contiguous range (e.g. 39000–39099) distinct from the random 10000–60000 default to make collisions and cleanup easy to reason about.
  • GC: reap a session daemon after an idle TTL (no command for N minutes) and delete its profiles/<key> dir; reuse the existing health-check + killServer paths.
  • Cookie bootstrap: document that an isolated session starts with a clean profile and must run /setup-browser-cookies (or a per-session cookie import) if it needs an authenticated start.

6. Prototype (flag-gated, NOT wired into the default path)

A companion prototype ships in this PR at scripts/dev/browse-session.ps1:

  • Activated only when BROWSE_ISOLATED=1 is set; otherwise it is a byte-for-byte pass-through to the normal browse CLI and the existing shared-daemon path is untouched.
  • When active, it resolves the session key (GSTACK_SESSION_KEY > CLAUDE_SESSION_ID > CONDUCTOR_WORKSPACE_ID > worktree path > git toplevel), picks a port in the reserved 39000–39099 band (deterministic start, linear-probed for a free slot, then persisted per session so re-invocations reuse the same daemon), and exports BROWSE_PORT / CHROMIUM_PROFILE (~/.gstack/profiles/<slug>) / BROWSE_STATE_FILE (~/.gstack/sessions/<slug>/browse.json) before exec'ing browse. The <slug> carries a short hash of the full key so long worktree paths don't collide when truncated.
  • It is explicitly a proof-of-concept for the §5 lifecycle decisions, not a default and not invoked by any skill or other script. The shared daemon and server-node.mjs are not modified. Idle-daemon GC and stale-profile reaping are intentionally left for the production launcher.

This keeps the design pass safe: nothing here can strand an existing browse-test session, because the default resolution path is byte-for-byte unchanged unless a session opts in.


7. Acceptance mapping (ENG-97)

Acceptance criterion How this design meets it
Two concurrent sessions drive a browser without hijacking Option A: each session gets its own daemon/port/profile → no shared activeTabId, no shared user-data-dir. Verify by running two capture flows in parallel under BROWSE_ISOLATED=1.
Approach documented in .agents/context/local-iis.md Follow-up: add a short pointer there once Option A's launcher lands; this doc is the canonical design.
browse-lock.ps1 retired or kept as fallback Kept as the documented shared-instance fallback (§5).

8. References

  • Interim advisory lock: PR #14063 (scripts/dev/browse-lock.ps1)
  • Daemon source: ~/.claude/skills/gstack/browse/src/ (config.ts, server.ts, browser-manager.ts, cli.ts, browse-client.ts)
  • Related: ENG-87 (QA contention writeup), ENG-188 (admin SPA prime failure)