From 4f3d347f4c41ba8d784cea7062fdf2ecb192b4c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:20:29 +0000 Subject: [PATCH 01/15] fix(ci): repair workflow so tests actually run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Set up Node.js' step combined 'uses' with 'run: npm test', which is invalid workflow syntax — GitHub rejected the workflow and no CI step ever executed. Split the steps, drop the unused Python setup, and run the suite on a Node 20 + 22 matrix (20 is the supported floor; 22 exercises live streaming and CDP attach through the global WebSocket client). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- .github/workflows/ci.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95d2129..0cc7da2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,19 +12,21 @@ jobs: validate: runs-on: ubuntu-latest timeout-minutes: 10 + strategy: + matrix: + # 20 is the supported floor; 22 exercises live streaming and CDP + # attach mode (global WebSocket client). + node-version: [20, 22] steps: - name: Check out repository # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - name: Set up Node.js # v4.0.3 uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b with: - node-version: 20 + node-version: ${{ matrix.node-version }} + - name: Run tests run: npm test - name: Validate shell syntax run: bash -n scripts/*.sh From 52e93ffbae62a2c21d3a67d3dc440d021392177b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:20:59 +0000 Subject: [PATCH 02/15] fix(renderer): separate backend arbitration from render mode; add observe-only and target cycling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this.mode carried two unrelated values: the backend decision (attach vs agent-browser) at construction, then the render mode (kitty/symbols/text) once run() assigned pickRenderMode's result. The second assignment erased the first, so a pane started with HERDR_BROWSER_CDP_URL or a cdp-url config never entered the attach tick path — it sat in agent-browser mode waiting for a session — and a runtime 'a'-key attach clobbered the render mode, breaking kitty PNG and text rendering for the rest of the pane's life. Tests missed it because they drive tick() without run(). Backend checks now read this.backend; this.mode is render-only and null until run() picks it. The header shows the endpoint host:port (never the capability token path) instead of the meaningless session name while attached. Also removes a merge artifact from 294cade where navigate()'s networkBaselinePending=true fix (34d1559) was immediately undone by the pre-fix line the merge kept — a busy-guarded baseline read would replay the whole failure log on the next poll. On top of the split, two planned follow-ups from the attach-mode plan land: 'o' toggles observe-only (clicks, wheel, keys, prompts, and Cmd+click handoffs are dropped at the pane, so watching a live automation run cannot perturb it — the toggle itself and pane-view keys stay reachable), and 't' wires the already-implemented cycleTarget() backend to a key so the pane can move between page targets, with a banner when there is only one. Regression tests cover the split, the baseline flag, and both keys. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- bin/renderer.mjs | 107 +++++++++++++++++++++++----- tests/renderer.test.mjs | 152 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 238 insertions(+), 21 deletions(-) diff --git a/bin/renderer.mjs b/bin/renderer.mjs index 6410ea6..f39f770 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -562,15 +562,20 @@ export class Renderer { // deliberate act than an ambient agent-browser session, so it wins — and // it wins deterministically at start, never by racing discovery. this.cdpEndpoint = this.resolveCdpEndpoint(env); - this.mode = this.cdpEndpoint ? "attach" : "agent-browser"; + // Backend (attach vs agent-browser) is not the render mode: run() assigns + // this.mode from pickRenderMode (kitty/symbols/text) after the terminal + // probe. Sharing one field made that assignment erase the attach decision, + // so a configured endpoint never actually attached once run() started. + this.backend = this.cdpEndpoint ? "attach" : "agent-browser"; + this.mode = null; // render mode; run() picks it after the kitty probe this.browser = - this.mode === "attach" + this.backend === "attach" ? makeCdpBrowser(this.cdpEndpoint) : makeBrowser(this.session, this.bin); // Attach mode observes a browser someone else owns: ownership is never // claimed, so the quit path can never close a stranger's session. - this.ownershipEnabled = this.mode !== "attach"; - this.backendName = this.mode === "attach" ? "browser endpoint" : "agent-browser"; + this.ownershipEnabled = this.backend !== "attach"; + this.backendName = this.backend === "attach" ? "browser endpoint" : "agent-browser"; const onPath = (cmd) => spawnSync("sh", ["-c", `command -v ${cmd}`], { timeout: 5000 }).status === 0; @@ -593,6 +598,10 @@ export class Renderer { this.banner = ""; this.attached = false; this.selfCreated = false; + // Observe-only: pane input (clicks, wheel, navigation, typing) is + // dropped instead of forwarded, so watching a live automation run + // cannot blur the field it is typing into or dismiss what it awaits. + this.observeOnly = false; this.promptState = null; this.paintQueue = Promise.resolve(); this.paintErrors = 0; @@ -768,10 +777,10 @@ export class Renderer { this.header(); return; } - if (this.mode === "agent-browser" && this.live) this.dropLive(); + if (this.backend === "agent-browser" && this.live) this.dropLive(); this.stopNetworkTimer(); this.cdpEndpoint = endpoint; - this.mode = "attach"; + this.backend = "attach"; this.ownershipEnabled = false; this.backendName = "browser endpoint"; this.selfCreated = false; // never inherit ownership across a switch @@ -835,7 +844,7 @@ export class Renderer { // The tick-time read stays as the fallback for platforms where fs.watch // misses events (some network filesystems). startNavigateWatch() { - if (this.mode !== "attach" || this.navigateWatcher) return; + if (this.backend !== "attach" || this.navigateWatcher) return; this.navigateFile = path.join( this.stateDir, `navigate-${safeWsId(this.env.HERDR_WORKSPACE_ID)}`, @@ -848,7 +857,14 @@ export class Renderer { } catch { return; // nothing pending } - if (url) this.userAction(() => this.browser.open(url)); + if (!url) return; + // A Cmd+click handoff is page-affecting input like any other; the + // URL is consumed (file already unlinked) but not forwarded. + if (this.observeOnly) { + this.noteObserveBlocked(); + return; + } + this.userAction(() => this.browser.open(url)); }; this.consumeNavigateFile = consume; try { @@ -949,7 +965,7 @@ export class Renderer { // Hidden tabs and DevTools screencast contention both present as a frozen // frame with no error. One restart attempt, last frame stays on screen. checkFrameStaleness(now = Date.now()) { - if (this.mode !== "attach" || !this.attached || !this.lastFrameAt) return; + if (this.backend !== "attach" || !this.attached || !this.lastFrameAt) return; if (now - this.lastFrameAt < 10_000 || this.staleHandled) return; this.staleHandled = true; this.banner = "frame stale (tab hidden or contended)"; @@ -1025,8 +1041,16 @@ export class Renderer { this.lastHeaderSig = null; // force a repaint once we fit again return; } + // Attach mode shows the endpoint (host:port only — the path is a + // capability token); agent-browser mode shows the session name that + // the quick-start instructions tell the user to copy from here. + const source = + this.backend === "attach" + ? `attach:${redactWsUrl(this.cdpEndpoint)}` + : `session:${this.session}`; + const observe = this.observeOnly ? " observe-only" : ""; const line1 = truncate( - ` herdr-browser session:${this.session} mode:${this.mode}`, + ` herdr-browser ${source} mode:${this.mode ?? "-"}${observe}`, cols, ); const blankHint = @@ -1055,8 +1079,11 @@ export class Renderer { `${ESC}[${bottomRow};1H${truncate(text, cols)}${ESC}[K`, ); } else { - const help = - " u:url click:page i:type b/f:back-fwd r:reload j/k:scroll q:quit"; + const help = this.observeOnly + ? " observe-only: input is not forwarded o:enable-input q:quit" + : this.backend === "attach" + ? " u:url click:page i:type b/f:back-fwd r:reload j/k:scroll t:target o:observe q:quit" + : " u:url click:page i:type b/f:back-fwd r:reload j/k:scroll o:observe q:quit"; process.stdout.write( `${ESC}[${bottomRow};1H${ESC}[2m${truncate(help, cols)}${ESC}[K${ESC}[0m`, ); @@ -1185,7 +1212,7 @@ export class Renderer { // Attach mode is event-driven: the tick only (re)connects, watches // liveness, and notices a stalled screencast. Frames and console // entries arrive over the CDP session, not from polling. - if (this.mode === "attach") { + if (this.backend === "attach") { if (!this.attached) { if (Date.now() < this.streamCooldownUntil) return; this.streamCooldownUntil = Date.now() + 5_000; @@ -1416,6 +1443,10 @@ export class Renderer { onMouse(mouse) { if (!mouse || mouse.release) return; + if (this.observeOnly && this.attached) { + this.noteObserveBlocked(); + return; + } // Wheel reports (64=up, 65=down) scroll the page; presses only. if (mouse.button === 64 || mouse.button === 65) { if (this.attached) { @@ -1429,6 +1460,23 @@ export class Renderer { this.userAction(() => this.clickAt(mouse.col, mouse.row)); } + // Observe-only is a pane-side latch, deliberately not a backend call: + // nothing about the observed browser changes, input simply stops here. + toggleObserveOnly() { + this.observeOnly = !this.observeOnly; + if (!this.observeOnly && this.banner.startsWith("observe-only")) + this.banner = ""; + this.lastHeaderSig = null; // the observe marker lives in line 1 + this.header(); + this.renderBottom(); + } + + noteObserveBlocked() { + if (this.banner.startsWith("observe-only")) return; + this.banner = "observe-only — input is not forwarded (o re-enables)"; + this.header(); + } + onKey(ch) { // A prompt owns the keyboard; clicks while typing must not drive the // page behind the prompt. @@ -1436,6 +1484,18 @@ export class Renderer { this.promptInput(ch); return; } + // The toggle itself must stay reachable while observe-only is on. + if (ch === "o") { + this.toggleObserveOnly(); + return; + } + if ( + this.observeOnly && + ["u", "i", "b", "f", "r", "j", "k", " "].includes(ch) + ) { + this.noteObserveBlocked(); + return; + } if (!this.attached && !["u", "q", "\x03"].includes(ch)) return; switch (ch) { case "u": @@ -1447,6 +1507,22 @@ export class Renderer { case "a": this.openPrompt("attach to endpoint: ", (v) => this.attachTo(v)); break; + // Cycle the pinned page target (attach mode, R6). View-only motion: + // it moves the pane's screencast, never focus or page state, so it + // stays allowed under observe-only. + case "t": + if (this.backend !== "attach") break; + this.userAction(async () => { + const moved = await this.browser.cycleTarget?.(); + if (!moved) { + this.banner = "no other page targets"; + this.header(); + } else if (this.banner === "no other page targets") { + this.banner = ""; + this.header(); + } + }); + break; case "i": this.openPrompt("type: ", (v) => this.browser.type(v)); break; @@ -1750,7 +1826,6 @@ export class Renderer { // poll is in flight, the busy guard skips this read, and clearing // the flag here would let the next poll replay the whole log. this.networkBaselinePending = true; - this.networkBaselinePending = false; this.attached = true; // pollNetwork requires it; the session exists await this.pollNetwork(true); } else { @@ -1782,7 +1857,7 @@ export class Renderer { if (!pt) return; // Attach-mode frames are scaled by maxWidth and the observed browser's // DPR, so frame pixels are not page pixels — rescale before dispatch. - const target = this.mode === "attach" ? this.cdpPagePoint(pt, dims) : pt; + const target = this.backend === "attach" ? this.cdpPagePoint(pt, dims) : pt; await this.browser.click(target.x, target.y); } @@ -1804,7 +1879,7 @@ export class Renderer { /* already closed */ } this.live = null; - if (this.mode === "attach") { + if (this.backend === "attach") { // Stop our screencast and drop the socket. Never a target close, // never an agent-browser subprocess — we did not create any of this. try { diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index 5cf9f3f..1463665 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -1973,7 +1973,7 @@ const fakeCdpBackend = (over = {}) => { test("attach mode: CDP endpoint wins over agent-browser and disables owning paths", () => { const r = attachRenderer(); - assert.equal(r.mode, "attach"); + assert.equal(r.backend, "attach"); assert.equal(r.ownershipEnabled, false); assert.equal(r.backendName, "browser endpoint"); // The duck-type omissions are the contract: no viewport fitting, no @@ -1982,7 +1982,7 @@ test("attach mode: CDP endpoint wins over agent-browser and disables owning path assert.equal(typeof r.browser.network, "undefined"); assert.equal(typeof r.browser.streamEnable, "undefined"); const plain = mkRenderer(); - assert.equal(plain.mode, "agent-browser"); + assert.equal(plain.backend, "agent-browser"); assert.equal(plain.ownershipEnabled, true); }); @@ -2148,7 +2148,7 @@ test("attach mode: config-dir cdp-url is a valid endpoint source", () => { const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "hb-cfg-cdp-")); fs.writeFileSync(path.join(cfg, "cdp-url"), "http://127.0.0.1:9333\n"); const r = mkRenderer({ HERDR_PLUGIN_CONFIG_DIR: cfg }); - assert.equal(r.mode, "attach"); + assert.equal(r.backend, "attach"); assert.equal(r.cdpEndpoint, "http://127.0.0.1:9333"); // Env wins over the file. const r2 = mkRenderer({ @@ -2162,7 +2162,7 @@ test("attach prompt refuses navigation-shaped input and keeps u for URLs", async const r = quiet(mkRenderer()); await r.attachTo("localhost:9222"); assert.match(r.banner, /not an endpoint/); - assert.equal(r.mode, "agent-browser", "bad input must not switch modes"); + assert.equal(r.backend, "agent-browser", "bad input must not switch modes"); }); test("attach switch resets reconciliation state and drops ownership", async () => { @@ -2172,7 +2172,7 @@ test("attach switch resets reconciliation state and drops ownership", async () = r.lastHash = "deadbeef"; r.browser = { ...r.browser, sessionExists: async () => false }; await r.attachTo("http://127.0.0.1:9222"); - assert.equal(r.mode, "attach"); + assert.equal(r.backend, "attach"); assert.equal(r.ownershipEnabled, false); assert.equal(r.selfCreated, false, "ownership never survives a backend switch"); assert.deepEqual(r.consoleState, { count: 0, tail: [] }); @@ -2196,3 +2196,145 @@ test("attach mode: a Cmd+click handoff file navigates the attached target", asyn "click navigates the attached target, not an agent-browser session", ); }); + +// --- Wave 5: backend/render-mode split, observe-only, target cycling --- + +test("backend split: render-mode pick does not erase a configured attach backend", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + // run() assigns the render mode after the kitty probe; the attach decision + // must survive it or a configured endpoint never attaches in the real pane. + r.mode = "symbols"; + await r.tick(); + assert.equal(r.backend, "attach"); + assert.ok(r.browser.calls.includes("connect"), "tick still takes the attach path"); + assert.equal(r.mode, "symbols", "render mode is untouched by attaching"); +}); + +test("backend split: runtime attach switch keeps the render mode", async () => { + const r = quiet(mkRenderer()); + r.mode = "kitty"; + r.browser = { ...r.browser, sessionExists: async () => false }; + await r.attachTo("http://127.0.0.1:9222"); + assert.equal(r.backend, "attach"); + assert.equal(r.mode, "kitty", "a-key attach must not clobber kitty rendering"); +}); + +test("navigate: baseline stays pending when the busy guard skips the read", async () => { + const r = quiet(mkRenderer()); + const calls = []; + r.browser = { + sessionExists: async () => true, + open: async (u) => calls.push(`open:${u}`), + network: async () => { + calls.push("network"); + return []; + }, + }; + r.networkPollBusy = true; // a live-timer poll is in flight + await r.navigate("https://localhost:3000/"); + assert.ok(!calls.includes("network"), "busy guard skipped the baseline read"); + assert.equal( + r.networkBaselinePending, + true, + "flag must stay pending or the next poll replays the whole failure log", + ); +}); + +test("t cycles the pinned page target in attach mode only", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + let cycles = 0; + r.browser.cycleTarget = async () => { + cycles++; + return true; + }; + await r.tick(); + r.onKey("t"); + await flush(); + assert.equal(cycles, 1); + + const plain = quiet(mkRenderer()); + plain.attached = true; + let plainCycles = 0; + plain.browser = { ...plain.browser, cycleTarget: async () => plainCycles++ }; + plain.onKey("t"); + await flush(); + assert.equal(plainCycles, 0, "agent-browser backend has no target cycling"); +}); + +test("t with a single page target reports instead of failing silently", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + r.browser.cycleTarget = async () => false; + await r.tick(); + r.onKey("t"); + await flush(); + assert.equal(r.banner, "no other page targets"); +}); + +test("observe-only: o toggles, page-affecting keys and clicks are dropped", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + r.browser.reload = async () => r.browser.calls.push("reload"); + r.browser.scroll = async () => r.browser.calls.push("scroll"); + r.browser.type = async () => r.browser.calls.push("type"); + await r.tick(); + r.onKey("o"); + assert.equal(r.observeOnly, true); + for (const ch of ["r", "j", "k", " ", "b", "f"]) r.onKey(ch); + r.onKey("i"); // must not even open the type prompt + assert.equal(r.promptState, null); + r.onMouse({ button: 0, col: 10, row: 5, release: false }); + r.onMouse({ button: 65, col: 10, row: 5, release: false }); + await flush(); + assert.deepEqual( + r.browser.calls.filter((c) => + /^(reload|scroll|type|click)/.test(c), + ), + [], + "no input reaches the observed browser while observe-only is on", + ); + assert.match(r.banner, /observe-only/); + r.onKey("o"); + assert.equal(r.observeOnly, false); + assert.equal(r.banner, "", "the observe banner clears with the toggle"); + r.onKey("r"); + await flush(); + assert.ok(r.browser.calls.includes("reload"), "input works again after re-enable"); +}); + +test("observe-only: u prompt is blocked; a Cmd+click handoff is consumed, not forwarded", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + r.browser.open = async (u) => r.browser.calls.push(`open:${u}`); + await r.tick(); + r.onKey("o"); + r.onKey("u"); + assert.equal(r.promptState, null, "navigation prompt must not open"); + fs.writeFileSync( + path.join(r.stateDir, `navigate-${safeWsId(r.env.HERDR_WORKSPACE_ID)}`), + "http://localhost:3000/x\n", + ); + r.consumeNavigateFile(); + await flush(); + assert.ok( + !r.browser.calls.some((c) => c.startsWith("open:")), + "handoff navigation is not forwarded under observe-only", + ); +}); + +test("observe-only: t (view-only) and q remain available; header shows the state", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + let cycles = 0; + r.browser.cycleTarget = async () => { + cycles++; + return true; + }; + await r.tick(); + r.onKey("o"); + r.onKey("t"); + await flush(); + assert.equal(cycles, 1, "cycling the pane's own view stays allowed"); +}); From 5836e53a2e757e2d2213c81ec95c0c60e8dcd1df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:21:25 +0000 Subject: [PATCH 03/15] docs: repair merge-garbled failure-feed paragraph; document t, o, and the attach header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failed-request paragraph kept both sides of the 294cade merge — the pre-fix and post-fix wording of the dedupe-window sentence ran into each other mid-sentence. Restore the 34d1559 wording, which matches the code (a repeat paints once and stays collapsed until quiet for 60 seconds). Document the new t (cycle page targets) and o (observe-only) keys and the endpoint host:port header shown while attached. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- README.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4eb6ded..e092842 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,8 @@ Use these controls to drive the shared session directly: | --- | --- | | `u` | Open the address prompt; `https://` is assumed when omitted | | `a` | Attach to a CDP endpoint (`http://host:port` or `ws://…`) | +| `t` | Attach mode: cycle the pane between the browser's page targets (tabs) | +| `o` | Toggle observe-only: pane input is dropped instead of forwarded | | Click the screenshot | Send real Chrome mouse move/down/up events at that page coordinate | | `i` | Type into the currently focused page element | | `b` / `f` | Navigate backward / forward | @@ -212,10 +214,7 @@ seconds without a status). Only xhr, fetch, and document requests are watched images, stylesheets, and held-open streams (SSE, WebSocket) stay out. Failures from before the pane attached are intentionally not replayed, a repeating identical failure paints once and stays collapsed until it has been quiet for -60 seconds, and on very long -from before the pane attached are intentionally not replayed, repeated -identical failures are collapsed within a 60-second window, and on very long -sessions the feed turns itself off with a one-time note once the daemon's +60 seconds, and on very long sessions the feed turns itself off with a one-time note once the daemon's request log outgrows the pane's read buffer. ## Attach to any CDP browser @@ -236,7 +235,15 @@ printf 'http://127.0.0.1:9222\n' > "$(herdr plugin config-dir structupath.browse Press `a` in the pane to attach at runtime. `u` still means "navigate" — the keys are separate because `localhost:9222` is a valid destination as well as a -valid endpoint. +valid endpoint. While attached, the pane header shows the endpoint's +`host:port` instead of a session name, `t` cycles between the browser's page +targets when your automation has more than one tab open, and `o` toggles +**observe-only**: every pane click, wheel event, keystroke, and navigation — +including Cmd/Ctrl+click link handoffs — is dropped at the pane instead of +forwarded, so watching a live run cannot blur the field your automation is +typing into or dismiss the element it is waiting on. Observe-only is a +pane-side latch; nothing about the observed browser changes when you toggle +it, and it works in agent-browser mode too. Launcher recipes: Playwright `chromium.launch({args:['--remote-debugging-port=9222']})`, Puppeteer the same `args`, Browser Use its `chrome_remote_debugging_port` option. From 98f42ff638a4706e9ae8ef553ea41ec1985bebd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:27:02 +0000 Subject: [PATCH 04/15] feat(launch): press l to launch a local Chromium the pane owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pane no longer requires any pre-existing engine: l finds a local Chromium (HERDR_BROWSER_CHROMIUM / chromium config file, then PATH names and the macOS app bundles), starts it headless with --remote-debugging-port=0 and a per-workspace profile under the plugin state dir, reads the bound port from DevToolsActivePort, and attaches through the normal attach path. HERDR_BROWSER_LAUNCH_HEADED=1 launches a visible window. Root (containers, CI) adds --no-sandbox, since Chrome refuses to start as root without it. Ownership is the deliberate difference from plain attach: the pane spawned this browser, so quitting the pane — or attaching to a different endpoint — kills it instead of leaking a headless Chrome. The endpoint guarantees are unchanged: loopback port, capability token never displayed. Also fixes the unattached key gate, which swallowed 'a' — the documented attach key was unreachable exactly when attaching is the answer (no session yet, or a dead endpoint). 'a' and 'l' now pass the gate. Verified end to end against a real Chromium: launch, attach, navigate, screencast frames, screenshot, console + network-failure feed, and child kill on cleanup. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- README.md | 34 +++++++- bin/renderer.mjs | 170 +++++++++++++++++++++++++++++++++++++++- tests/renderer.test.mjs | 83 ++++++++++++++++++++ 3 files changed, 282 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e092842..a2ad41d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ Conductor). - **Shared agent sessions** — one isolated browser session per Herdr workspace. - **Attach to any CDP browser** — observe a Playwright, Puppeteer, or Browser Use run (or any Chrome started with `--remote-debugging-port`) without owning it. +- **Zero-setup launch** — press `l` and the pane launches a local Chromium of + its own and attaches to it; no agent-browser install required. +- **Observe-only mode** — press `o` and pane input stops being forwarded, so + watching a live automation run can never perturb it. - **Live push streaming** — frames, URL/title changes, console messages, and page errors arrive over WebSocket, with transparent polling fallback. - **Failed network requests** — 4xx/5xx and no-response xhr/fetch/document @@ -40,7 +44,8 @@ Conductor). | --- | --- | --- | | Herdr | `>= 0.7.0` | Tested with Herdr 0.7.4 | | Node.js | `>= 20` | Node 22+ enables live WebSocket streaming and CDP attach mode | -| agent-browser | Required | Tested with agent-browser 0.33.x; failed-request reporting needs the `network requests` command | +| agent-browser | Optional | Required for shared agent sessions; tested with agent-browser 0.33.x; failed-request reporting needs the `network requests` command | +| Chromium/Chrome | Optional | Any Chromium-based browser enables launch mode (`l`) and attach mode | | chafa | Optional | ANSI rendering and streamed JPEGs in Kitty mode | | carbonyl | Optional | Only required for the separate interactive Browse action | @@ -140,6 +145,7 @@ Use these controls to drive the shared session directly: | --- | --- | | `u` | Open the address prompt; `https://` is assumed when omitted | | `a` | Attach to a CDP endpoint (`http://host:port` or `ws://…`) | +| `l` | Launch a local Chromium the pane owns and attach to it | | `t` | Attach mode: cycle the pane between the browser's page targets (tabs) | | `o` | Toggle observe-only: pane input is dropped instead of forwarded | | Click the screenshot | Send real Chrome mouse move/down/up events at that page coordinate | @@ -276,6 +282,27 @@ report it the way the agent-browser polling feed's timeout heuristic does. Attach mode needs Node 22 or newer (for the built-in WebSocket client); the pane says so plainly on older Node and keeps working in agent-browser mode. +## Launch a browser from the pane + +Press `l` and the pane launches a local Chromium with a loopback DevTools +port and attaches to it — no agent-browser, no configuration. This is the +zero-setup path: open the pane, press `l`, press `u`, browse. + +The launcher looks for `HERDR_BROWSER_CHROMIUM` (or the `chromium` config +file), then probes `chromium`, `chromium-browser`, `google-chrome`, +`google-chrome-stable`, `chrome`, and the macOS Chrome/Chromium app bundles. +The browser starts headless with a fresh ephemeral DevTools port +(`--remote-debugging-port=0`, read back from `DevToolsActivePort`) and a +per-workspace profile under the plugin state directory, so cookies and +localStorage survive relaunches. Set `HERDR_BROWSER_LAUNCH_HEADED=1` to get +a visible browser window instead. + +Unlike plain attach mode, the pane owns what it launches: quitting the pane +— or attaching to a different endpoint — kills the launched browser rather +than leaking a headless Chrome. Every attach-mode guarantee about the +*endpoint* still holds: the DevTools port binds to loopback, and the +capability token is never displayed. + ## Session model By default, each Herdr workspace uses: @@ -347,6 +374,8 @@ Plugin config files contain one value on their first line: | `session` | Session name | `herdr-ws-` | Watch a different agent-browser session | | `run-id` | Valid run ID | Generated | Correlate a recording bundle with an external run | | `render` | `kitty`, `symbols`, `text` | Automatic probe | Force a rendering mode | +| `cdp-url` | `http://host:port` or `ws://…` | None | Attach to this CDP endpoint at startup | +| `chromium` | Path to a browser binary | Probed | Browser used by launch mode (`l`) | Equivalent environment controls: @@ -355,6 +384,9 @@ Equivalent environment controls: | `HERDR_BROWSER_SESSION` | Workspace session | Override the watched session | | `HERDR_BROWSER_RUN_ID` | Config or generated ID | Select the recording run ID | | `HERDR_BROWSER_RENDER` | Automatic probe | Override the rendering mode | +| `HERDR_BROWSER_CDP_URL` | None | Attach to this CDP endpoint at startup | +| `HERDR_BROWSER_CHROMIUM` | Probed | Browser binary used by launch mode | +| `HERDR_BROWSER_LAUNCH_HEADED` | Unset | `1` launches a visible window instead of headless | | `HERDR_BROWSER_INTERVAL_MS` | `1000` | Polling interval; clamped to safe bounds | | `AGENT_BROWSER_IDLE_TIMEOUT_MS` | `1800000` | Idle timeout for plugin-created browser daemons | diff --git a/bin/renderer.mjs b/bin/renderer.mjs index f39f770..4fe0aad 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -2,7 +2,7 @@ // herdr-browser pane renderer: an attached view of an agent-browser session. // It stays passive until explicit pane input, never clears the console buffer, // and closes only sessions that its own successful navigation created. -import { execFile, spawnSync } from "node:child_process"; +import { execFile, spawn, spawnSync } from "node:child_process"; import { promisify } from "node:util"; import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; @@ -97,6 +97,26 @@ export function consoleTail(entries, n = 8) { return entries.slice(-n).map((e) => e.text); } +// Locate a launchable Chromium for launch mode (the l key). An explicit +// choice (env, then config file) is trusted as-is — it may name a binary +// that is not on PATH; probing covers the common names plus the macOS app +// bundles that never appear on PATH. +export function findChromium(env, configDirValue, probe) { + const explicit = env.HERDR_BROWSER_CHROMIUM || configDirValue; + if (explicit) return explicit; + const candidates = [ + "chromium", + "chromium-browser", + "google-chrome", + "google-chrome-stable", + "chrome", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ]; + for (const c of candidates) if (probe(c)) return c; + return null; +} + // Render-mode precedence: explicit config > kitty probe > symbols > text. // probeResponse is the raw bytes the terminal answered to a kitty graphics // query; empty/undefined means no answer (not supported). Kitty mode emits @@ -640,6 +660,12 @@ export class Renderer { this.lastFrameAt = 0; this.staleHandled = false; this.loopbackWarned = false; + // Launch mode (see launchChromium): the one browser the pane owns the + // lifecycle of, because it spawned it. Quit must kill it — a leaked + // headless Chrome has no other owner to collect it. + this.launchedChild = null; + this.launchedEndpoint = null; + this.launchingChromium = false; this.kittyAnon = false; // chafa emitted anonymous kitty placements this.lastImageDims = null; this.lastViewportRequest = ""; @@ -778,6 +804,17 @@ export class Renderer { return; } if (this.backend === "agent-browser" && this.live) this.dropLive(); + // Pointing the pane away from a browser it launched abandons it; kill + // it now rather than leak a headless Chrome with no remaining owner. + if (this.launchedChild && endpoint !== this.launchedEndpoint) { + try { + this.launchedChild.kill(); + } catch { + /* already gone */ + } + this.launchedChild = null; + this.launchedEndpoint = null; + } this.stopNetworkTimer(); this.cdpEndpoint = endpoint; this.backend = "attach"; @@ -793,6 +830,114 @@ export class Renderer { await this.tick(); } + // Launch mode: start a local Chromium with a loopback DevTools port and + // attach to it through the normal attach path. Unlike plain attach, the + // pane owns this browser's lifecycle — it spawned it — so quit (or + // attaching elsewhere) kills it instead of leaking a headless Chrome. + // Port 0 + DevToolsActivePort avoids picking a port and racing for it: + // Chrome binds an ephemeral port and writes it to the profile root. + async launchChromium() { + if (this.launchingChromium) return; + if (this.launchedChild && this.launchedChild.exitCode === null) { + // Still running (the pane may have attached elsewhere meanwhile in + // a way that kept it): just point back at it. + if (this.launchedEndpoint) this.userAction(() => this.attachTo(this.launchedEndpoint)); + return; + } + this.launchingChromium = true; + try { + // Probe with the renderer's own env so the pane and the launched + // child resolve binaries from the same PATH. + const bin = findChromium(this.env, this.configValue("chromium"), (c) => + spawnSync("sh", ["-c", 'command -v -- "$1"', "sh", c], { + timeout: 5000, + env: this.env, + }).status === 0, + ); + if (!bin) { + this.banner = + "no Chromium found — set HERDR_BROWSER_CHROMIUM to a browser binary"; + this.header(); + return; + } + const profile = path.join( + this.stateDir, + `chromium-profile-${safeWsId(this.env.HERDR_WORKSPACE_ID)}`, + ); + fs.mkdirSync(profile, { recursive: true, mode: 0o700 }); + const portFile = path.join(profile, "DevToolsActivePort"); + try { + fs.unlinkSync(portFile); // a stale port must never win the wait below + } catch { + /* none */ + } + const headed = /^(1|true|yes)$/i.test( + String(this.env.HERDR_BROWSER_LAUNCH_HEADED ?? ""), + ); + const args = [ + "--remote-debugging-port=0", + `--user-data-dir=${profile}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + ...(headed ? [] : ["--headless=new"]), + // Chrome refuses to start as root without this; root (containers, + // CI) already has no user boundary for the sandbox to defend. + ...(process.getuid?.() === 0 ? ["--no-sandbox"] : []), + "about:blank", + ]; + this.banner = "launching Chromium…"; + this.header(); + let child; + try { + child = spawn(bin, args, { stdio: "ignore" }); + } catch (err) { + this.banner = `cannot launch ${bin}: ${sanitizeText(err?.message ?? "spawn failed")}`; + this.header(); + return; + } + const port = await this.waitForDevToolsPort(portFile, child); + if (!port) { + try { + child.kill(); + } catch { + /* already dead */ + } + this.banner = `${bin} did not expose a DevTools port — is it Chromium-based?`; + this.header(); + return; + } + this.launchedChild = child; + this.launchedEndpoint = `http://127.0.0.1:${port}`; + child.once("exit", () => { + if (this.launchedChild === child) this.launchedChild = null; + }); + this.userAction(() => this.attachTo(this.launchedEndpoint)); + } finally { + this.launchingChromium = false; + } + } + + // DevToolsActivePort appears in the profile root once the port is bound: + // line 1 is the port, line 2 the browser target path (a capability token + // we deliberately do not read — discovery re-derives it). + async waitForDevToolsPort(portFile, child, timeoutMs = 15_000) { + const until = Date.now() + timeoutMs; + while (Date.now() < until) { + if (child.exitCode !== null) return null; // died during startup + try { + const port = Number( + fs.readFileSync(portFile, "utf8").split("\n")[0].trim(), + ); + if (Number.isInteger(port) && port > 0 && port <= 65535) return port; + } catch { + /* not written yet */ + } + await new Promise((r) => setTimeout(r, 200)); + } + return null; + } + // Attach: connect, wire the event bridge, and take the R9 baseline. All // failures land in a banner — a bad endpoint must never crash the pane. async attachCdp() { @@ -1083,7 +1228,7 @@ export class Renderer { ? " observe-only: input is not forwarded o:enable-input q:quit" : this.backend === "attach" ? " u:url click:page i:type b/f:back-fwd r:reload j/k:scroll t:target o:observe q:quit" - : " u:url click:page i:type b/f:back-fwd r:reload j/k:scroll o:observe q:quit"; + : " u:url click:page i:type b/f:back-fwd r:reload j/k:scroll l:launch o:observe q:quit"; process.stdout.write( `${ESC}[${bottomRow};1H${ESC}[2m${truncate(help, cols)}${ESC}[K${ESC}[0m`, ); @@ -1248,7 +1393,7 @@ export class Renderer { // advice below can never fix it, so say what's actually wrong. this.banner = this.agentBrowser ? `waiting for session "${this.session}" — Cmd+click a localhost link or have your agent use --session ${this.session}` - : "agent-browser is not installed — npm install -g agent-browser && agent-browser install"; + : "agent-browser is not installed — press l to launch a local Chromium, or: npm install -g agent-browser"; this.header(); return; } @@ -1496,7 +1641,9 @@ export class Renderer { this.noteObserveBlocked(); return; } - if (!this.attached && !["u", "q", "\x03"].includes(ch)) return; + // a and l must stay reachable while unattached — no session yet and a + // dead endpoint are exactly when attaching or launching is the answer. + if (!this.attached && !["u", "a", "l", "q", "\x03"].includes(ch)) return; switch (ch) { case "u": this.openPrompt("URL: ", (v) => this.navigate(v)); @@ -1507,6 +1654,11 @@ export class Renderer { case "a": this.openPrompt("attach to endpoint: ", (v) => this.attachTo(v)); break; + // Deliberately unqueued: the DevTools-port wait can take seconds and + // must not stall the paint queue; only the final attach is enqueued. + case "l": + this.launchChromium(); + break; // Cycle the pinned page target (attach mode, R6). View-only motion: // it moves the pane's screencast, never focus or page state, so it // stays allowed under observe-only. @@ -1879,6 +2031,16 @@ export class Renderer { /* already closed */ } this.live = null; + if (this.launchedChild) { + // The pane spawned this browser; quitting must not leak it. SIGTERM + // lets Chrome flush its profile — its own exit handles the rest. + try { + this.launchedChild.kill(); + } catch { + /* already gone */ + } + this.launchedChild = null; + } if (this.backend === "attach") { // Stop our screencast and drop the socket. Never a target close, // never an agent-browser subprocess — we did not create any of this. diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index 1463665..4069fbf 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -27,6 +27,7 @@ import { newNetworkState, diffNetworkFailures, formatNetworkFailure, + findChromium, } from "../bin/renderer.mjs"; const repoRoot = path.resolve( @@ -2338,3 +2339,85 @@ test("observe-only: t (view-only) and q remain available; header shows the state await flush(); assert.equal(cycles, 1, "cycling the pane's own view stays allowed"); }); + +// --- Wave 5: Chromium launch mode --- + +test("findChromium: explicit env wins, then config, then first probed candidate", () => { + assert.equal( + findChromium({ HERDR_BROWSER_CHROMIUM: "/opt/my-chrome" }, "cfg", () => true), + "/opt/my-chrome", + ); + assert.equal(findChromium({}, "/cfg/chrome", () => true), "/cfg/chrome"); + assert.equal( + findChromium({}, undefined, (c) => c === "google-chrome"), + "google-chrome", + ); + assert.equal(findChromium({}, undefined, () => false), null); +}); + +const fakeChromiumScript = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hb-chrome-")); + const bin = path.join(dir, "fake-chromium"); + fs.writeFileSync( + bin, + `#!/bin/sh +d="" +for a in "$@"; do case "$a" in --user-data-dir=*) d="\${a#--user-data-dir=}";; esac; done +printf '9876\\n/devtools/browser/fake-guid\\n' > "$d/DevToolsActivePort" +exec sleep 30 +`, + { mode: 0o755 }, + ); + return bin; +}; + +test("launch mode: l spawns the configured chromium, waits for the port, attaches, owns the child", async () => { + const bin = fakeChromiumScript(); + const r = quiet(mkRenderer({ HERDR_BROWSER_CHROMIUM: bin })); + const attachedTo = []; + r.attachTo = async (ep) => attachedTo.push(ep); + r.attached = false; + r.onKey("l"); // reachable while unattached + // The port wait polls every 200ms; give the fake time to write the file. + for (let i = 0; i < 50 && !attachedTo.length; i++) + await new Promise((res) => setTimeout(res, 100)); + assert.deepEqual(attachedTo, ["http://127.0.0.1:9876"]); + assert.ok(r.launchedChild, "the pane records the child it owns"); + const pid = r.launchedChild.pid; + r.cleanup(); + assert.equal(r.launchedChild, null); + await new Promise((res) => setTimeout(res, 300)); + assert.throws( + () => process.kill(pid, 0), + "quit must kill the browser the pane launched", + ); +}); + +test("launch mode: attaching to a different endpoint kills the launched browser", async () => { + const r = quiet(mkRenderer()); + const child = spawnSync("sh", ["-c", "echo"], {}); // placeholder shape + let killed = 0; + r.launchedChild = { kill: () => killed++, exitCode: null }; + r.launchedEndpoint = "http://127.0.0.1:9876"; + r.browser = { ...r.browser, sessionExists: async () => false }; + await r.attachTo("http://127.0.0.1:9333"); + assert.equal(killed, 1, "abandoning a launched browser must not leak it"); + assert.equal(r.launchedChild, null); + void child; +}); + +test("launch mode: no chromium found reports instead of failing silently", async () => { + const r = quiet(mkRenderer({ PATH: "/nonexistent" })); + // Probe uses the real PATH via sh; force emptiness through env PATH. + r.env.PATH = "/nonexistent"; + await r.launchChromium(); + assert.match(r.banner, /no Chromium found/); +}); + +test("a opens the attach prompt while unattached", () => { + const r = quiet(mkRenderer()); + r.attached = false; + r.onKey("a"); + assert.ok(r.promptState, "attach prompt must be reachable with no session"); + assert.match(r.promptState.label, /attach/); +}); From e605efb194ee3f655927633fda8659166a45775e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:30:21 +0000 Subject: [PATCH 05/15] feat: harden launch/attach edges; release 0.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launchChromium refuses before spawning on Node < 22 — attaching to the result needs the global WebSocket client, and a browser the pane can never attach to would idle until quit. Firefox endpoints now fail with a named banner ('browser has no CDP screencast') instead of a raw protocol error, closing a deferred item from the attach plan. A real-browser integration test drives launch mode end to end — spawn, DevToolsActivePort, attach, navigate, screencast frame, console feed, child kill — and skips cleanly where no Chromium or WebSocket client exists, so the Node 20 CI leg and engine-less machines stay green. Version 0.7.0; plan doc records the wave. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- bin/cdp.mjs | 12 +- bin/renderer.mjs | 9 ++ ...-001-feat-launch-mode-observe-only-plan.md | 73 +++++++++++++ herdr-plugin.toml | 2 +- package.json | 2 +- tests/launch.integration.test.mjs | 103 ++++++++++++++++++ tests/manifest.test.mjs | 4 +- tests/renderer.test.mjs | 14 ++- 8 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 docs/plans/2026-08-23-001-feat-launch-mode-observe-only-plan.md create mode 100644 tests/launch.integration.test.mjs diff --git a/bin/cdp.mjs b/bin/cdp.mjs index 8b71b12..78f5487 100644 --- a/bin/cdp.mjs +++ b/bin/cdp.mjs @@ -316,7 +316,17 @@ export function makeCdpBrowser(endpointInput, opts = {}) { } catch { /* older engines: page-level feed only */ } - await startScreencast(); + try { + await startScreencast(); + } catch (err) { + // Firefox's CDP subset has no Page.startScreencast: name the reason + // instead of surfacing a raw protocol error nobody can act on. + if (/wasn't found|not found|not supported|unknown method/i.test(err?.message ?? "")) + throw new Error( + "browser has no CDP screencast (Firefox?) — attach needs a Chromium-based browser", + ); + throw err; + } }; const onCdpEvent = (m) => { diff --git a/bin/renderer.mjs b/bin/renderer.mjs index 4fe0aad..e9b47b1 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -844,6 +844,15 @@ export class Renderer { if (this.launchedEndpoint) this.userAction(() => this.attachTo(this.launchedEndpoint)); return; } + // Refuse before spawning: attaching to the result needs the Node 22 + // WebSocket client, and a browser we can never attach to would just + // idle until quit. + if (!cdpSupported()) { + this.banner = + "launch mode needs Node 22+ (global WebSocket) — pane is idle"; + this.header(); + return; + } this.launchingChromium = true; try { // Probe with the renderer's own env so the pane and the launched diff --git a/docs/plans/2026-08-23-001-feat-launch-mode-observe-only-plan.md b/docs/plans/2026-08-23-001-feat-launch-mode-observe-only-plan.md new file mode 100644 index 0000000..9db59e4 --- /dev/null +++ b/docs/plans/2026-08-23-001-feat-launch-mode-observe-only-plan.md @@ -0,0 +1,73 @@ +--- +title: "feat: Chromium launch mode, observe-only input, and the backend/render-mode split" +type: feat +date: 2026-08-23 +--- + +# feat: Chromium launch mode, observe-only input, and the backend/render-mode split + +## Summary + +Three changes that close the gap between the pane's design and what actually +ran, then remove its last external dependency. First, the backend decision +(attach vs agent-browser) moves out of `this.mode`, which `run()` was +overwriting with the render mode — the configured-endpoint attach path was +unreachable in the real binary. Second, two follow-ups deferred by the CDP +attach plan land: the `t` key drives the already-implemented `cycleTarget()`, +and `o` toggles observe-only, dropping all pane input at the pane so watching +a live automation run cannot perturb it. Third, launch mode: `l` starts a +local Chromium the pane owns and attaches to it, making the pane usable with +zero pre-existing engines. + +## Requirements + +**Backend split** + +- B1. `this.backend` carries `attach`/`agent-browser`; `this.mode` is + render-only (`kitty`/`symbols`/`text`, `null` until `run()` probes). Every + former backend check on `this.mode` reads `this.backend`. +- B2. The header shows `attach:` (never the capability-token path) + while attached, and the session name otherwise — the quick start tells + users to copy it from there. +- B3. A regression test exercises `tick()` *after* a render-mode assignment, + the combination the old tests never covered. + +**Observe-only (o)** + +- O1. While on, clicks, wheel events, page-affecting keys (`u i b f r j k` + space), and Cmd+click navigate handoffs are dropped at the pane; a banner + says why and the help line shows the state. Nothing is sent to the + observed browser when toggling — it is a pane-side latch. +- O2. The toggle itself, pane-view keys (`t`), `a`, `l`, and `q` stay + reachable. Works in both backends. + +**Target cycling (t)** + +- T1. `t` calls the attach backend's `cycleTarget()`; a single-target + browser reports "no other page targets" instead of doing nothing. + +**Launch mode (l)** + +- L1. `l` finds a browser (`HERDR_BROWSER_CHROMIUM` env / `chromium` config + first, then PATH names and macOS app bundles), spawns it with + `--remote-debugging-port=0` and a per-workspace profile under plugin + state, reads the bound port from `DevToolsActivePort`, and attaches + through the normal attach path. Headless by default; + `HERDR_BROWSER_LAUNCH_HEADED=1` for a window; `--no-sandbox` only as root. +- L2. Ownership is the deliberate difference from plain attach: quit or + attaching elsewhere kills the launched browser — never leak a headless + Chrome. Refuse before spawning on Node < 22. +- L3. The unattached key gate admits `a` and `l` (it swallowed `a`, making + the documented attach key unreachable exactly when it was the answer). +- L4. A real-browser integration test (skipped where no Chromium or Node < + 22) drives launch → attach → navigate → frame → console feed → child kill. + +## Also fixed en route + +- The 294cade merge kept both sides of the 34d1559 fix in `navigate()` + (baseline flag cleared immediately) and both drafts of the README's + failure-feed paragraph; both restored to the fixed versions. +- CI's Node setup step illegally combined `uses` with `run`, so no CI step + had ever executed; the workflow now runs tests on a Node 20/22 matrix. +- Firefox endpoints fail `Page.startScreencast` with a named banner instead + of a raw protocol error (deferred item from the attach plan). diff --git a/herdr-plugin.toml b/herdr-plugin.toml index a6b30ec..39d987f 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "structupath.browser" name = "Browser" -version = "0.6.0" +version = "0.7.0" min_herdr_version = "0.7.0" description = "Driveable browser pane: live screenshots, console output, and localhost link handling via agent-browser" platforms = ["macos", "linux"] diff --git a/package.json b/package.json index fdd1261..4291b57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "herdr-browser", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module", "engines": { "node": ">=20" }, diff --git a/tests/launch.integration.test.mjs b/tests/launch.integration.test.mjs new file mode 100644 index 0000000..e282908 --- /dev/null +++ b/tests/launch.integration.test.mjs @@ -0,0 +1,103 @@ +// Real-browser integration: launch mode end to end against an installed +// Chromium. Skips where the run cannot work — no Chromium on the machine, or +// no WebSocket client (Node < 22) — so the suite stays green everywhere while +// CI with a browser exercises the true path: spawn, DevToolsActivePort, +// attach, navigate, screencast frame, console feed, child kill. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Renderer, findChromium } from "../bin/renderer.mjs"; + +const probe = (c) => + spawnSync("sh", ["-c", 'command -v -- "$1"', "sh", c], { timeout: 5000 }) + .status === 0; +const chromium = findChromium(process.env, undefined, probe); +const skip = + typeof WebSocket !== "function" + ? "needs Node 22+ (global WebSocket)" + : !chromium + ? "no Chromium installed" + : false; + +const until = async (cond, ms) => { + const end = Date.now() + ms; + while (Date.now() < end) { + if (cond()) return true; + await new Promise((r) => setTimeout(r, 200)); + } + return cond(); +}; + +test("launch mode drives a real Chromium end to end", { skip }, async () => { + const r = new Renderer({ + HERDR_BROWSER_SESSION: "hb-launch-int", + HERDR_PLUGIN_STATE_DIR: fs.mkdtempSync(path.join(os.tmpdir(), "hb-int-")), + HERDR_BROWSER_CHROMIUM: chromium, + HOME: os.homedir(), + PATH: process.env.PATH, + }); + r.header = () => {}; + r.renderConsole = () => {}; + r.renderBottom = () => {}; + r.renderImage = async () => {}; + r.mode = "symbols"; // what run() would have picked; the backend must survive it + + let frames = 0; + const orig = r.onCdpMessage.bind(r); + r.onCdpMessage = (m) => { + if (m.type === "frame") frames++; + orig(m); + }; + + let pid; + try { + await r.launchChromium(); + assert.ok( + await until(() => r.attached, 30_000), + `pane should attach to the launched browser (banner: ${r.banner})`, + ); + pid = r.launchedChild?.pid; + assert.ok(pid, "the pane records the child it owns"); + assert.equal(r.backend, "attach"); + assert.equal(r.mode, "symbols", "render mode survives the launch"); + + await r.browser.open("data:text/html,hb-intok"); + assert.ok( + await until(() => /hb-int|data:text\/html/.test(r.lastUrl), 10_000), + `navigation should surface in the header state (url: ${r.lastUrl})`, + ); + assert.ok( + await until(() => frames > 0, 10_000), + "at least one screencast frame arrives", + ); + + await r.browser.open( + "data:text/html,", + ); + assert.ok( + await until( + () => r.consoleLines.some((l) => l.includes("hb-int-boom")), + 10_000, + ), + "page console output reaches the pane feed", + ); + } finally { + r.cleanup(); + } + if (pid) { + assert.ok( + await until(() => { + try { + process.kill(pid, 0); + return false; + } catch { + return true; + } + }, 5_000), + "quit must kill the browser the pane launched", + ); + } +}); diff --git a/tests/manifest.test.mjs b/tests/manifest.test.mjs index d26611a..70dd475 100644 --- a/tests/manifest.test.mjs +++ b/tests/manifest.test.mjs @@ -57,8 +57,8 @@ test("release version and existing action IDs remain stable", () => { path.join(root, "herdr-plugin.toml"), "utf8", ); - assert.equal(packageJson.version, "0.6.0"); - assert.match(manifest, /^version = "0\.6\.0"$/m); + assert.equal(packageJson.version, "0.7.0"); + assert.match(manifest, /^version = "0\.7\.0"$/m); assert.deepEqual( [...manifest.matchAll(/^id = "([^"]+)"$/gm)] .slice(1, 6) diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index 4069fbf..d71bb84 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -2371,7 +2371,17 @@ exec sleep 30 return bin; }; -test("launch mode: l spawns the configured chromium, waits for the port, attaches, owns the child", async () => { +// launchChromium refuses on Node < 22 before doing anything else, so the +// behavioral tests below only make sense where attach itself is possible. +const canCdp = typeof WebSocket === "function"; + +test("launch mode: refuses on Node without a WebSocket client", { skip: canCdp }, async () => { + const r = quiet(mkRenderer()); + await r.launchChromium(); + assert.match(r.banner, /needs Node 22/); +}); + +test("launch mode: l spawns the configured chromium, waits for the port, attaches, owns the child", { skip: !canCdp }, async () => { const bin = fakeChromiumScript(); const r = quiet(mkRenderer({ HERDR_BROWSER_CHROMIUM: bin })); const attachedTo = []; @@ -2406,7 +2416,7 @@ test("launch mode: attaching to a different endpoint kills the launched browser" void child; }); -test("launch mode: no chromium found reports instead of failing silently", async () => { +test("launch mode: no chromium found reports instead of failing silently", { skip: !canCdp }, async () => { const r = quiet(mkRenderer({ PATH: "/nonexistent" })); // Probe uses the real PATH via sh; force emptiness through env PATH. r.env.PATH = "/nonexistent"; From 186e140a3cb120d1cb8e213d7ca4f580a5559918 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:35:28 +0000 Subject: [PATCH 06/15] fix: launch-mode spawn errors banner instead of crashing; recording refuses in attach mode Three findings from an adversarial re-read plus the recording gap: - spawn() reports a missing or non-executable Chromium (a typo'd HERDR_BROWSER_CHROMIUM) as an async 'error' event; without a listener it became an uncaughtException that took the whole pane down. The launcher now listens, aborts the port wait, and banners the real reason. - --headless=new was removed in newer Chromes; plain --headless selects new headless on 112+ and old headless (which also serves DevTools and screencast) before that. - record.mjs hardcoded plugin version 0.6.0, which the 0.7.0 bump missed. Now single-sourced from package.json; the Start/Stop manifest identity check still fails closed across an upgrade. Recording captures the workspace's agent-browser session; in CDP attach mode there is none, and record-start would have spawned and recorded a fresh, unrelated headless browser. record.sh now resolves the endpoint the same way open.sh does and refuses with an explanation, in both the env-var and config-file cases. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- README.md | 5 +++++ bin/record.mjs | 10 +++++++++- bin/renderer.mjs | 21 +++++++++++++++++---- scripts/record.sh | 12 ++++++++++++ tests/launchers.test.mjs | 17 +++++++++++++++++ tests/renderer.test.mjs | 9 +++++++++ 6 files changed, 69 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a2ad41d..55997e4 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,11 @@ echo "my-agent-session" \ ## Recording +Recording captures the workspace's **agent-browser session**. In CDP attach +mode there is no such session, so the record actions refuse with an +explanation instead of silently recording a fresh, unrelated headless +browser — record from the automation client that owns the browser instead. + Start and stop recording through the existing recording actions. Each new capture is a run-scoped observation bundle: diff --git a/bin/record.mjs b/bin/record.mjs index 7fe167c..9d0672d 100644 --- a/bin/record.mjs +++ b/bin/record.mjs @@ -7,7 +7,15 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const PLUGIN_ID = "structupath.browser"; -const PLUGIN_VERSION = "0.6.0"; +// Single-sourced from package.json so a release bump cannot miss it. The +// Start/Stop manifest check still compares versions strictly: a recording +// that crosses an upgrade fails closed rather than completing ambiguously. +const PLUGIN_VERSION = JSON.parse( + fs.readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), + "utf8", + ), +).version; const SCHEMA_VERSION = 1; const RUN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const WORKSPACE_ID_RE = /^[A-Za-z0-9_-]+$/; diff --git a/bin/renderer.mjs b/bin/renderer.mjs index e9b47b1..c8d9fb8 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -889,7 +889,10 @@ export class Renderer { "--no-first-run", "--no-default-browser-check", "--disable-background-networking", - ...(headed ? [] : ["--headless=new"]), + // Plain --headless: new headless on 112+, old headless before it + // (both support DevTools and screencast); =new was removed in + // newer Chromes and would eventually break launches. + ...(headed ? [] : ["--headless"]), // Chrome refuses to start as root without this; root (containers, // CI) already has no user boundary for the sandbox to defend. ...(process.getuid?.() === 0 ? ["--no-sandbox"] : []), @@ -898,6 +901,10 @@ export class Renderer { this.banner = "launching Chromium…"; this.header(); let child; + // spawn reports a missing or non-executable binary as an async + // 'error' event, not a throw; without a listener that event is an + // uncaughtException that takes the whole pane down. + const spawnFailed = { err: null }; try { child = spawn(bin, args, { stdio: "ignore" }); } catch (err) { @@ -905,14 +912,19 @@ export class Renderer { this.header(); return; } - const port = await this.waitForDevToolsPort(portFile, child); + child.once("error", (err) => { + spawnFailed.err = err; + }); + const port = await this.waitForDevToolsPort(portFile, child, spawnFailed); if (!port) { try { child.kill(); } catch { /* already dead */ } - this.banner = `${bin} did not expose a DevTools port — is it Chromium-based?`; + this.banner = spawnFailed.err + ? `cannot launch ${bin}: ${sanitizeText(spawnFailed.err.message ?? "spawn failed")}` + : `${bin} did not expose a DevTools port — is it Chromium-based?`; this.header(); return; } @@ -930,10 +942,11 @@ export class Renderer { // DevToolsActivePort appears in the profile root once the port is bound: // line 1 is the port, line 2 the browser target path (a capability token // we deliberately do not read — discovery re-derives it). - async waitForDevToolsPort(portFile, child, timeoutMs = 15_000) { + async waitForDevToolsPort(portFile, child, spawnFailed = null, timeoutMs = 15_000) { const until = Date.now() + timeoutMs; while (Date.now() < until) { if (child.exitCode !== null) return null; // died during startup + if (spawnFailed?.err) return null; // binary missing/not executable try { const port = Number( fs.readFileSync(portFile, "utf8").split("\n")[0].trim(), diff --git a/scripts/record.sh b/scripts/record.sh index fb4b840..f6ea2c0 100755 --- a/scripts/record.sh +++ b/scripts/record.sh @@ -14,6 +14,18 @@ start | stop) ;; ;; esac +# Recording captures the workspace's agent-browser session. A workspace +# configured for CDP attach mode has no such session — starting one here +# would record a fresh, unrelated headless browser, not the observed one. +cdp_endpoint="${HERDR_BROWSER_CDP_URL:-}" +if [ -z "$cdp_endpoint" ] && [ -n "${HERDR_PLUGIN_CONFIG_DIR:-}" ] && [ -f "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" ]; then + cdp_endpoint="$(head -n1 "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" | tr -d '[:space:][:cntrl:]')" +fi +if [ -n "$cdp_endpoint" ]; then + echo "herdr-browser: recording captures agent-browser sessions, but this workspace is configured for CDP attach mode (cdp-url). Record from the automation client that owns the browser, or remove the cdp-url configuration to record an agent-browser session." >&2 + exit 1 +fi + require_agent_browser if ! command -v node >/dev/null 2>&1; then echo "herdr-browser: node is required." >&2 diff --git a/tests/launchers.test.mjs b/tests/launchers.test.mjs index 33060f8..d272cfb 100644 --- a/tests/launchers.test.mjs +++ b/tests/launchers.test.mjs @@ -580,3 +580,20 @@ test("a live holder is never stolen from, even after the wait budget", { ); assert.equal(fs.existsSync(lock), false, "lock released after holder exit"); }); + +test("record refuses in attach mode instead of recording an unrelated browser", () => { + const env = freshEnv({ HERDR_BROWSER_CDP_URL: "http://127.0.0.1:9222" }); + const r = runScript("record.sh", ["start"], env); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /attach mode/); + // The config-file source must reach the same verdict as the env var. + const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "hb-rec-cfg-")); + fs.writeFileSync(path.join(cfg, "cdp-url"), "http://127.0.0.1:9222\n"); + const r2 = runScript( + "record.sh", + ["start"], + freshEnv({ HERDR_PLUGIN_CONFIG_DIR: cfg }), + ); + assert.notEqual(r2.status, 0); + assert.match(r2.stderr, /attach mode/); +}); diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index d71bb84..36e6849 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -2431,3 +2431,12 @@ test("a opens the attach prompt while unattached", () => { assert.ok(r.promptState, "attach prompt must be reachable with no session"); assert.match(r.promptState.label, /attach/); }); + +test("launch mode: a bad binary path banners instead of crashing the pane", { skip: !canCdp }, async () => { + const r = quiet( + mkRenderer({ HERDR_BROWSER_CHROMIUM: "/nonexistent/definitely-not-chrome" }), + ); + await r.launchChromium(); + assert.match(r.banner, /cannot launch/); + assert.equal(r.launchedChild, null); +}); From 1dfe362d446ac87dbb28923f31bb74d5c699c187 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:38:15 +0000 Subject: [PATCH 07/15] test: skip attach-behavior tests where attach itself is impossible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attachCdp refuses on Node < 22 (no WebSocket client) before touching the backend, so every attach-mode behavioral test that drives tick() failed on the CI matrix's Node 20 leg — a gap the broken workflow had always hidden. Those tests now skip under the same condition production refuses under, verified with node --no-experimental-websocket: 0 failures, 18 skips. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- tests/renderer.test.mjs | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index 36e6849..bd64bc1 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -72,6 +72,9 @@ const quiet = (r) => { const flush = async () => { for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r)); }; +// attachCdp (and launchChromium) refuse on Node < 22 before touching the +// backend, so attach-behavior tests only make sense where attach is possible. +const canCdp = typeof WebSocket === "function"; test("reconcile: first poll returns everything", () => { const r = reconcileConsole({ count: 0, tail: [] }, e(["a", "b"])); @@ -1987,7 +1990,7 @@ test("attach mode: CDP endpoint wins over agent-browser and disables owning path assert.equal(plain.ownershipEnabled, true); }); -test("attach mode: tick connects, then only watches liveness", async () => { +test("attach mode: tick connects, then only watches liveness", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); await r.tick(); @@ -1998,7 +2001,7 @@ test("attach mode: tick connects, then only watches liveness", async () => { assert.deepEqual(r.browser.calls, ["connect"], "no polling while attached"); }); -test("attach mode: frames paint and ack with the integer id after the paint settles", async () => { +test("attach mode: frames paint and ack with the integer id after the paint settles", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); let renders = 0; @@ -2034,7 +2037,7 @@ test("attach mode: clicks scale from frame pixels to page pixels per frame", asy }); }); -test("attach mode: dead endpoint detaches; raw ws endpoints do not retry", async () => { +test("attach mode: dead endpoint detaches; raw ws endpoints do not retry", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend({ alive: false }); await r.tick(); @@ -2052,7 +2055,7 @@ test("attach mode: dead endpoint detaches; raw ws endpoints do not retry", async assert.equal(raw.streamCooldownUntil, Number.MAX_SAFE_INTEGER, "no retry loop"); }); -test("attach mode: reattach to a different browser resets state with a marker", async () => { +test("attach mode: reattach to a different browser resets state with a marker", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); await r.tick(); @@ -2069,7 +2072,7 @@ test("attach mode: reattach to a different browser resets state with a marker", assert.equal(r.lastHash, "", "frame state reset"); }); -test("attach mode: stale frames banner once and trigger one restart", async () => { +test("attach mode: stale frames banner once and trigger one restart", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); await r.tick(); @@ -2110,7 +2113,7 @@ test("attach mode: Node without global WebSocket banners instead of crashing", a } }); -test("attach mode: endpoint tokens never reach the banner", async () => { +test("attach mode: endpoint tokens never reach the banner", { skip: !canCdp }, async () => { const r = attachRenderer({ HERDR_BROWSER_CDP_URL: "ws://127.0.0.1:9222/devtools/browser/SECRET-TOKEN", }); @@ -2181,7 +2184,7 @@ test("attach switch resets reconciliation state and drops ownership", async () = assert.ok(r.consoleLines.some((l) => /switched to attach mode/.test(l))); }); -test("attach mode: a Cmd+click handoff file navigates the attached target", async () => { +test("attach mode: a Cmd+click handoff file navigates the attached target", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); r.browser.open = async (u) => r.browser.calls.push(`open:${u}`); @@ -2200,7 +2203,7 @@ test("attach mode: a Cmd+click handoff file navigates the attached target", asyn // --- Wave 5: backend/render-mode split, observe-only, target cycling --- -test("backend split: render-mode pick does not erase a configured attach backend", async () => { +test("backend split: render-mode pick does not erase a configured attach backend", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); // run() assigns the render mode after the kitty probe; the attach decision @@ -2242,7 +2245,7 @@ test("navigate: baseline stays pending when the busy guard skips the read", asyn ); }); -test("t cycles the pinned page target in attach mode only", async () => { +test("t cycles the pinned page target in attach mode only", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); let cycles = 0; @@ -2264,7 +2267,7 @@ test("t cycles the pinned page target in attach mode only", async () => { assert.equal(plainCycles, 0, "agent-browser backend has no target cycling"); }); -test("t with a single page target reports instead of failing silently", async () => { +test("t with a single page target reports instead of failing silently", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); r.browser.cycleTarget = async () => false; @@ -2274,7 +2277,7 @@ test("t with a single page target reports instead of failing silently", async () assert.equal(r.banner, "no other page targets"); }); -test("observe-only: o toggles, page-affecting keys and clicks are dropped", async () => { +test("observe-only: o toggles, page-affecting keys and clicks are dropped", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); r.browser.reload = async () => r.browser.calls.push("reload"); @@ -2305,7 +2308,7 @@ test("observe-only: o toggles, page-affecting keys and clicks are dropped", asyn assert.ok(r.browser.calls.includes("reload"), "input works again after re-enable"); }); -test("observe-only: u prompt is blocked; a Cmd+click handoff is consumed, not forwarded", async () => { +test("observe-only: u prompt is blocked; a Cmd+click handoff is consumed, not forwarded", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); r.browser.open = async (u) => r.browser.calls.push(`open:${u}`); @@ -2325,7 +2328,7 @@ test("observe-only: u prompt is blocked; a Cmd+click handoff is consumed, not fo ); }); -test("observe-only: t (view-only) and q remain available; header shows the state", async () => { +test("observe-only: t (view-only) and q remain available; header shows the state", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); let cycles = 0; @@ -2371,10 +2374,6 @@ exec sleep 30 return bin; }; -// launchChromium refuses on Node < 22 before doing anything else, so the -// behavioral tests below only make sense where attach itself is possible. -const canCdp = typeof WebSocket === "function"; - test("launch mode: refuses on Node without a WebSocket client", { skip: canCdp }, async () => { const r = quiet(mkRenderer()); await r.launchChromium(); From 12244a6f14d2eaff0a9bc8cf2bf4aab27022950b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:44:18 +0000 Subject: [PATCH 08/15] feat: config-first observe-only and launch; prefer google-chrome in probing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HERDR_BROWSER_OBSERVE=1 (or the observe config file) starts the pane observe-only for watch-the-agent workflows; o still toggles. HERDR_BROWSER_LAUNCH=1 (or the launch config file) makes the first unattached tick launch a local Chromium instead of waiting for an agent-browser session — one attempt, banner on failure, and a configured cdp-url endpoint still wins. An in-flight launch now also holds the banner against the waiting-for-session tick. Probe order change from CI evidence: 'chromium' on Ubuntu is often a snap wrapper whose confinement cannot read a profile directory outside $HOME — it never writes DevToolsActivePort and the launch times out (exactly what the integration test caught on the runner). google-chrome, nearly always a real binary where present, is probed first. Adds a CI badge and documents the new knobs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- README.md | 16 ++++++++++++- bin/renderer.mjs | 40 ++++++++++++++++++++++++++----- tests/renderer.test.mjs | 53 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 55997e4..078f94b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # herdr-browser +[![CI](https://github.com/StructuPath/herdr-browser/actions/workflows/ci.yml/badge.svg)](https://github.com/StructuPath/herdr-browser/actions/workflows/ci.yml) + A drivable browser pane for [Herdr](https://herdr.dev), built around [agent-browser](https://github.com/vercel-labs/agent-browser). @@ -249,7 +251,9 @@ including Cmd/Ctrl+click link handoffs — is dropped at the pane instead of forwarded, so watching a live run cannot blur the field your automation is typing into or dismiss the element it is waiting on. Observe-only is a pane-side latch; nothing about the observed browser changes when you toggle -it, and it works in agent-browser mode too. +it, and it works in agent-browser mode too. Set `HERDR_BROWSER_OBSERVE=1` +(or the `observe` config file) to start the pane that way for +watch-the-agent workflows. Launcher recipes: Playwright `chromium.launch({args:['--remote-debugging-port=9222']})`, Puppeteer the same `args`, Browser Use its `chrome_remote_debugging_port` option. @@ -303,6 +307,12 @@ than leaking a headless Chrome. Every attach-mode guarantee about the *endpoint* still holds: the DevTools port binds to loopback, and the capability token is never displayed. +For a workspace that should always work this way, set +`HERDR_BROWSER_LAUNCH=1` (or write `1` to the `launch` config file): the +pane launches its Chromium on open, no keypress needed. A configured +`cdp-url` endpoint still wins, and the launch is attempted once — if it +fails, the banner says why and the keys take over. + ## Session model By default, each Herdr workspace uses: @@ -381,6 +391,8 @@ Plugin config files contain one value on their first line: | `render` | `kitty`, `symbols`, `text` | Automatic probe | Force a rendering mode | | `cdp-url` | `http://host:port` or `ws://…` | None | Attach to this CDP endpoint at startup | | `chromium` | Path to a browser binary | Probed | Browser used by launch mode (`l`) | +| `launch` | `1`/`true`/`yes`/`on` | Off | Launch a Chromium on open instead of waiting for a session | +| `observe` | `1`/`true`/`yes`/`on` | Off | Start observe-only; `o` still toggles | Equivalent environment controls: @@ -392,6 +404,8 @@ Equivalent environment controls: | `HERDR_BROWSER_CDP_URL` | None | Attach to this CDP endpoint at startup | | `HERDR_BROWSER_CHROMIUM` | Probed | Browser binary used by launch mode | | `HERDR_BROWSER_LAUNCH_HEADED` | Unset | `1` launches a visible window instead of headless | +| `HERDR_BROWSER_LAUNCH` | Unset | `1` launches a Chromium on open instead of waiting for a session | +| `HERDR_BROWSER_OBSERVE` | Unset | `1` starts the pane observe-only | | `HERDR_BROWSER_INTERVAL_MS` | `1000` | Polling interval; clamped to safe bounds | | `AGENT_BROWSER_IDLE_TIMEOUT_MS` | `1800000` | Idle timeout for plugin-created browser daemons | diff --git a/bin/renderer.mjs b/bin/renderer.mjs index c8d9fb8..f7b0303 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -97,6 +97,11 @@ export function consoleTail(entries, n = 8) { return entries.slice(-n).map((e) => e.text); } +// Boolean knobs accept the common spellings; anything else is off. +export function truthyConfig(value) { + return /^(1|true|yes|on)$/i.test(String(value ?? "").trim()); +} + // Locate a launchable Chromium for launch mode (the l key). An explicit // choice (env, then config file) is trusted as-is — it may name a binary // that is not on PATH; probing covers the common names plus the macOS app @@ -104,11 +109,15 @@ export function consoleTail(entries, n = 8) { export function findChromium(env, configDirValue, probe) { const explicit = env.HERDR_BROWSER_CHROMIUM || configDirValue; if (explicit) return explicit; + // google-chrome first: where it exists it is nearly always a real + // binary, while "chromium" on Ubuntu is often a snap wrapper whose + // confinement cannot read a profile directory outside $HOME — it then + // never writes DevToolsActivePort and the launch times out. const candidates = [ - "chromium", - "chromium-browser", "google-chrome", "google-chrome-stable", + "chromium", + "chromium-browser", "chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", @@ -621,7 +630,17 @@ export class Renderer { // Observe-only: pane input (clicks, wheel, navigation, typing) is // dropped instead of forwarded, so watching a live automation run // cannot blur the field it is typing into or dismiss what it awaits. - this.observeOnly = false; + // Configurable at start for watch-the-agent workflows; o still toggles. + this.observeOnly = truthyConfig( + env.HERDR_BROWSER_OBSERVE ?? this.configValue("observe"), + ); + // Launch-first workspaces: the first unattached tick launches a local + // Chromium instead of waiting for an agent-browser session. One + // attempt — a failed launch banners and leaves the keys in charge. + this.launchConfigured = + !this.cdpEndpoint && + truthyConfig(env.HERDR_BROWSER_LAUNCH ?? this.configValue("launch")); + this.launchAttempted = false; this.promptState = null; this.paintQueue = Promise.resolve(); this.paintErrors = 0; @@ -880,9 +899,7 @@ export class Renderer { } catch { /* none */ } - const headed = /^(1|true|yes)$/i.test( - String(this.env.HERDR_BROWSER_LAUNCH_HEADED ?? ""), - ); + const headed = truthyConfig(this.env.HERDR_BROWSER_LAUNCH_HEADED); const args = [ "--remote-debugging-port=0", `--user-data-dir=${profile}`, @@ -1410,6 +1427,17 @@ export class Renderer { // exists — created by an agent, a link click, or a URL-bearing open — // only run the non-creating existence check and wait. if (!this.attached) { + // A launch-first workspace starts its own browser instead of + // waiting. Once, from the tick so run()'s terminal setup is done; + // on failure the banner stands and the keys are back in charge. + if (this.launchConfigured && !this.launchAttempted) { + this.launchAttempted = true; + this.launchChromium(); + return; + } + // An in-flight launch (config or the l key) owns the banner; the + // waiting-for-session advice below would overwrite it mid-wait. + if (this.launchingChromium) return; if (!(await this.browser.sessionExists())) { // A missing binary is not 'session not started yet' — the waiting // advice below can never fix it, so say what's actually wrong. diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index bd64bc1..f2cc575 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -28,6 +28,7 @@ import { diffNetworkFailures, formatNetworkFailure, findChromium, + truthyConfig, } from "../bin/renderer.mjs"; const repoRoot = path.resolve( @@ -2439,3 +2440,55 @@ test("launch mode: a bad binary path banners instead of crashing the pane", { sk assert.match(r.banner, /cannot launch/); assert.equal(r.launchedChild, null); }); + +// --- Wave 6: config-first observe-only and launch --- + +test("truthyConfig accepts common spellings only", () => { + for (const v of ["1", "true", "YES", "on ", " True"]) assert.equal(truthyConfig(v), true, v); + for (const v of ["0", "false", "", undefined, null, "2", "enabled"]) assert.equal(truthyConfig(v), false, String(v)); +}); + +test("HERDR_BROWSER_OBSERVE starts the pane observe-only; o still toggles", () => { + const r = quiet(mkRenderer({ HERDR_BROWSER_OBSERVE: "1" })); + assert.equal(r.observeOnly, true); + r.attached = true; + let reloads = 0; + r.browser = { ...r.browser, reload: async () => reloads++ }; + r.onKey("r"); + assert.equal(reloads, 0, "input starts blocked"); + r.onKey("o"); + assert.equal(r.observeOnly, false); +}); + +test("observe config file is a valid source", () => { + const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "hb-cfg-obs-")); + fs.writeFileSync(path.join(cfg, "observe"), "true\n"); + const r = mkRenderer({ HERDR_PLUGIN_CONFIG_DIR: cfg }); + assert.equal(r.observeOnly, true); +}); + +test("launch-first workspace launches once from the first unattached tick", { skip: !canCdp }, async () => { + const bin = fakeChromiumScript(); + const r = quiet( + mkRenderer({ HERDR_BROWSER_LAUNCH: "1", HERDR_BROWSER_CHROMIUM: bin }), + ); + assert.equal(r.launchConfigured, true); + const attachedTo = []; + r.attachTo = async (ep) => attachedTo.push(ep); + r.browser = { ...r.browser, sessionExists: async () => false }; + await r.tick(); // triggers the launch instead of waiting for a session + assert.equal(r.launchAttempted, true); + for (let i = 0; i < 50 && !attachedTo.length; i++) + await new Promise((res) => setTimeout(res, 100)); + assert.deepEqual(attachedTo, ["http://127.0.0.1:9876"]); + r.cleanup(); +}); + +test("a configured cdp endpoint wins over launch-first", () => { + const r = mkRenderer({ + HERDR_BROWSER_LAUNCH: "1", + HERDR_BROWSER_CDP_URL: "http://127.0.0.1:9222", + }); + assert.equal(r.backend, "attach"); + assert.equal(r.launchConfigured, false); +}); From 4b06696c3fb2c282da2626f5e98d9fcf13defed6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:46:09 +0000 Subject: [PATCH 09/15] polish: launch discontinuity note, plugin description, config trust note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console discontinuity line now says '— launched Chromium —' when the pane launched the browser itself (attachTo grows an optional note). The plugin manifest description mentions all three backends. The security section states plainly that chromium/cdp-url config files and their env equivalents are trusted local configuration. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- README.md | 3 +++ bin/renderer.mjs | 13 ++++++++++--- herdr-plugin.toml | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 078f94b..bd7af67 100644 --- a/README.md +++ b/README.md @@ -422,6 +422,9 @@ Environment variables take precedence over config files. - WebM recordings are intentionally retained under the plugin state directory. - Browser sessions are a trusted local boundary: any local process that knows a session name can drive it, including authenticated pages. +- The `chromium`/`cdp-url` configuration files and their environment + equivalents are trusted local configuration: whoever can write them chooses + which binary launch mode executes and which endpoint the pane dials. If an agent browses sensitive or authenticated content, that content is visible in the pane and briefly present in its cached frame. Treat screen sharing and diff --git a/bin/renderer.mjs b/bin/renderer.mjs index f7b0303..fbfe5f0 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -814,7 +814,7 @@ export class Renderer { // Switch this pane to attach mode at runtime. Everything the old backend // reconciled against is meaningless afterwards, so state resets and the // console carries one discontinuity line. - async attachTo(value) { + async attachTo(value, { note } = {}) { const endpoint = String(value ?? "").trim(); if (!/^(wss?|https?):\/\//i.test(endpoint)) { this.banner = @@ -844,7 +844,10 @@ export class Renderer { this.attached = false; this.cdpGuid = null; this.resetBackendState(); - this.pushConsole([{ text: "— switched to attach mode —", type: "log" }], false); + this.pushConsole( + [{ text: note ?? "— switched to attach mode —", type: "log" }], + false, + ); this.streamCooldownUntil = 0; await this.tick(); } @@ -950,7 +953,11 @@ export class Renderer { child.once("exit", () => { if (this.launchedChild === child) this.launchedChild = null; }); - this.userAction(() => this.attachTo(this.launchedEndpoint)); + this.userAction(() => + this.attachTo(this.launchedEndpoint, { + note: "— launched Chromium —", + }), + ); } finally { this.launchingChromium = false; } diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 39d987f..9b38136 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -2,7 +2,7 @@ id = "structupath.browser" name = "Browser" version = "0.7.0" min_herdr_version = "0.7.0" -description = "Driveable browser pane: live screenshots, console output, and localhost link handling via agent-browser" +description = "Driveable browser pane: live screenshots, console output, localhost links; drives agent-browser sessions, attaches to any CDP browser, or launches its own Chromium" platforms = ["macos", "linux"] [[actions]] From 3d13e8b6b77a03ebe12f25d23690c68013708ec1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:48:49 +0000 Subject: [PATCH 10/15] fix(launch): a crashed launched browser banners l-to-relaunch, never redials its dead port The launched Chromium's DevTools port dies with it, so on its exit the attach retry loop would dial a corpse every cooldown while the banner claimed to be retrying something recoverable. The exit handler (when the pane is still pointed at the launched endpoint) now detaches, latches the retry cooldown, and says what actually helps. Pane-initiated kills are unaffected: cleanup and attach-elsewhere clear launchedChild before the exit event fires, and a fresh l or a resets the latch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- bin/renderer.mjs | 13 +++++++++- ...-001-feat-launch-mode-observe-only-plan.md | 7 ++++++ tests/renderer.test.mjs | 25 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/bin/renderer.mjs b/bin/renderer.mjs index fbfe5f0..eb20be6 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -951,7 +951,18 @@ export class Renderer { this.launchedChild = child; this.launchedEndpoint = `http://127.0.0.1:${port}`; child.once("exit", () => { - if (this.launchedChild === child) this.launchedChild = null; + if (this.launchedChild !== child) return; + this.launchedChild = null; + // A dead launched browser cannot be re-discovered — its port died + // with it. Retrying would dial a corpse every cooldown; say what + // actually helps instead. (Quit-path kills land here too, but the + // pane is tearing down then and nobody sees the banner.) + if (this.cdpEndpoint === this.launchedEndpoint) { + this.attached = false; + this.streamCooldownUntil = Number.MAX_SAFE_INTEGER; + this.banner = "launched Chromium exited — press l to relaunch"; + this.header(); + } }); this.userAction(() => this.attachTo(this.launchedEndpoint, { diff --git a/docs/plans/2026-08-23-001-feat-launch-mode-observe-only-plan.md b/docs/plans/2026-08-23-001-feat-launch-mode-observe-only-plan.md index 9db59e4..c4268a9 100644 --- a/docs/plans/2026-08-23-001-feat-launch-mode-observe-only-plan.md +++ b/docs/plans/2026-08-23-001-feat-launch-mode-observe-only-plan.md @@ -64,6 +64,13 @@ zero pre-existing engines. ## Also fixed en route +- Config-first knobs landed with the wave: `HERDR_BROWSER_OBSERVE`/`observe` + starts the pane observe-only, `HERDR_BROWSER_LAUNCH`/`launch` launches on + the first unattached tick (a configured `cdp-url` still wins). CI evidence + reordered the probe list — Ubuntu's `chromium` is often a snap wrapper + that cannot read a profile outside `$HOME`, so `google-chrome` is tried + first. A crashed launched browser banners "press l to relaunch" instead + of redialing its dead port. - The 294cade merge kept both sides of the 34d1559 fix in `navigate()` (baseline flag cleared immediately) and both drafts of the README's failure-feed paragraph; both restored to the fixed versions. diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index f2cc575..3e0853b 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -2492,3 +2492,28 @@ test("a configured cdp endpoint wins over launch-first", () => { assert.equal(r.backend, "attach"); assert.equal(r.launchConfigured, false); }); + +test("a crashed launched Chromium banners l-to-relaunch instead of redialing a dead port", { skip: !canCdp }, async () => { + const bin = fakeChromiumScript(); + const r = quiet(mkRenderer({ HERDR_BROWSER_CHROMIUM: bin })); + r.attachTo = async (ep) => { + r.cdpEndpoint = ep; // what the real attachTo records + r.attached = true; + }; + await r.launchChromium(); + for (let i = 0; i < 50 && !r.attached; i++) + await new Promise((res) => setTimeout(res, 100)); + const child = r.launchedChild; + assert.ok(child); + child.kill("SIGKILL"); // crash, not a pane-initiated quit + for (let i = 0; i < 50 && r.launchedChild; i++) + await new Promise((res) => setTimeout(res, 100)); + assert.equal(r.launchedChild, null); + assert.equal(r.attached, false); + assert.match(r.banner, /press l to relaunch/); + assert.equal( + r.streamCooldownUntil, + Number.MAX_SAFE_INTEGER, + "no rediscovery loop against a port that died with the browser", + ); +}); From 547042b8b6a8ef36d27d18a6c6cb64265ff364e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 07:06:21 +0000 Subject: [PATCH 11/15] fix: teach the launchers about launch/observe modes; close review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A high-effort review of the branch surfaced seven findings; all are fixed: - open.sh's backend arbitration only knew cdp-url, so in a launch-first workspace a Cmd+click spawned an invisible, unrelated agent-browser session instead of navigating the pane's launched Chromium. Arbitration now lives in lib.sh (cdp_endpoint_configured / launch_configured / observe_configured, in lockstep with the renderer's knobs) and both pane-owned modes take the handoff-file path. - open.sh also required agent-browser before the arbitration branch, so an attach/launch workspace could not even open the pane without it. The requirement moved onto the branch that invokes agent-browser. - record.sh's refusal now covers launch mode, which equally has no agent-browser session to record. - An observe-configured workspace's open action refuses link navigation: in agent-browser mode the daemon is navigated outside the pane, so the pane-side latch alone could not keep the promise. README scopes the runtime toggle's caveat honestly. - Launch-failure banners are held for 30s against the waiting-for-session tick that repainted over them within a second. - A Chromium crash between the DevToolsActivePort read and the queued attach now latches and banners; the queued attach notices the corpse. Both death checks also handle signal deaths (exitCode stays null, signalCode is set) — SIGKILL previously evaded them. - Observe/launch env knobs use || like cdp-url, so a set-but-empty env var falls through to the config file instead of silently disabling it. - README's probe-order sentence matches the google-chrome-first code. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- README.md | 23 ++++++++----- bin/renderer.mjs | 72 ++++++++++++++++++++++++++-------------- scripts/lib.sh | 41 +++++++++++++++++++++++ scripts/open.sh | 32 +++++++++++------- scripts/record.sh | 13 +++----- tests/launchers.test.mjs | 58 ++++++++++++++++++++++++++++++-- tests/renderer.test.mjs | 34 +++++++++++++++++++ 7 files changed, 217 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index bd7af67..3b57e65 100644 --- a/README.md +++ b/README.md @@ -247,13 +247,16 @@ valid endpoint. While attached, the pane header shows the endpoint's `host:port` instead of a session name, `t` cycles between the browser's page targets when your automation has more than one tab open, and `o` toggles **observe-only**: every pane click, wheel event, keystroke, and navigation — -including Cmd/Ctrl+click link handoffs — is dropped at the pane instead of -forwarded, so watching a live run cannot blur the field your automation is -typing into or dismiss the element it is waiting on. Observe-only is a -pane-side latch; nothing about the observed browser changes when you toggle -it, and it works in agent-browser mode too. Set `HERDR_BROWSER_OBSERVE=1` -(or the `observe` config file) to start the pane that way for -watch-the-agent workflows. +including Cmd/Ctrl+click link handoffs in attach mode — is dropped at the +pane instead of forwarded, so watching a live run cannot blur the field your +automation is typing into or dismiss the element it is waiting on. +Observe-only is a pane-side latch; nothing about the observed browser +changes when you toggle it, and pane input is guarded in agent-browser mode +too. One caveat for the runtime toggle: in agent-browser mode a Cmd+click +navigates the session daemon directly, outside the pane. Set +`HERDR_BROWSER_OBSERVE=1` (or the `observe` config file) for +watch-the-agent workspaces — the pane starts observe-only *and* the open +action itself refuses link navigation, closing that gap in both modes. Launcher recipes: Playwright `chromium.launch({args:['--remote-debugging-port=9222']})`, Puppeteer the same `args`, Browser Use its `chrome_remote_debugging_port` option. @@ -293,8 +296,10 @@ port and attaches to it — no agent-browser, no configuration. This is the zero-setup path: open the pane, press `l`, press `u`, browse. The launcher looks for `HERDR_BROWSER_CHROMIUM` (or the `chromium` config -file), then probes `chromium`, `chromium-browser`, `google-chrome`, -`google-chrome-stable`, `chrome`, and the macOS Chrome/Chromium app bundles. +file), then probes `google-chrome`, `google-chrome-stable`, `chromium`, +`chromium-browser`, `chrome`, and the macOS Chrome/Chromium app bundles — +`google-chrome` first because Ubuntu's `chromium` is often a snap wrapper +whose confinement cannot use a profile outside `$HOME`. The browser starts headless with a fresh ephemeral DevTools port (`--remote-debugging-port=0`, read back from `DevToolsActivePort`) and a per-workspace profile under the plugin state directory, so cookies and diff --git a/bin/renderer.mjs b/bin/renderer.mjs index eb20be6..a401f5f 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -631,15 +631,18 @@ export class Renderer { // dropped instead of forwarded, so watching a live automation run // cannot blur the field it is typing into or dismiss what it awaits. // Configurable at start for watch-the-agent workflows; o still toggles. + // || not ??: a set-but-empty env var falls through to the config file, + // matching resolveCdpEndpoint — the knob families must agree on what + // an empty environment value means. this.observeOnly = truthyConfig( - env.HERDR_BROWSER_OBSERVE ?? this.configValue("observe"), + env.HERDR_BROWSER_OBSERVE || this.configValue("observe"), ); // Launch-first workspaces: the first unattached tick launches a local // Chromium instead of waiting for an agent-browser session. One // attempt — a failed launch banners and leaves the keys in charge. this.launchConfigured = !this.cdpEndpoint && - truthyConfig(env.HERDR_BROWSER_LAUNCH ?? this.configValue("launch")); + truthyConfig(env.HERDR_BROWSER_LAUNCH || this.configValue("launch")); this.launchAttempted = false; this.promptState = null; this.paintQueue = Promise.resolve(); @@ -685,6 +688,9 @@ export class Renderer { this.launchedChild = null; this.launchedEndpoint = null; this.launchingChromium = false; + // A launch failure explains itself for this long before the generic + // waiting-for-session banner may paint over it (see tick). + this.bannerHoldUntil = 0; this.kittyAnon = false; // chafa emitted anonymous kitty placements this.lastImageDims = null; this.lastViewportRequest = ""; @@ -870,9 +876,7 @@ export class Renderer { // WebSocket client, and a browser we can never attach to would just // idle until quit. if (!cdpSupported()) { - this.banner = - "launch mode needs Node 22+ (global WebSocket) — pane is idle"; - this.header(); + this.holdBanner("launch mode needs Node 22+ (global WebSocket) — pane is idle"); return; } this.launchingChromium = true; @@ -886,9 +890,9 @@ export class Renderer { }).status === 0, ); if (!bin) { - this.banner = - "no Chromium found — set HERDR_BROWSER_CHROMIUM to a browser binary"; - this.header(); + this.holdBanner( + "no Chromium found — set HERDR_BROWSER_CHROMIUM to a browser binary", + ); return; } const profile = path.join( @@ -928,8 +932,9 @@ export class Renderer { try { child = spawn(bin, args, { stdio: "ignore" }); } catch (err) { - this.banner = `cannot launch ${bin}: ${sanitizeText(err?.message ?? "spawn failed")}`; - this.header(); + this.holdBanner( + `cannot launch ${bin}: ${sanitizeText(err?.message ?? "spawn failed")}`, + ); return; } child.once("error", (err) => { @@ -942,33 +947,38 @@ export class Renderer { } catch { /* already dead */ } - this.banner = spawnFailed.err - ? `cannot launch ${bin}: ${sanitizeText(spawnFailed.err.message ?? "spawn failed")}` - : `${bin} did not expose a DevTools port — is it Chromium-based?`; - this.header(); + this.holdBanner( + spawnFailed.err + ? `cannot launch ${bin}: ${sanitizeText(spawnFailed.err.message ?? "spawn failed")}` + : `${bin} did not expose a DevTools port — is it Chromium-based?`, + ); return; } this.launchedChild = child; this.launchedEndpoint = `http://127.0.0.1:${port}`; + const ep = this.launchedEndpoint; child.once("exit", () => { if (this.launchedChild !== child) return; this.launchedChild = null; // A dead launched browser cannot be re-discovered — its port died // with it. Retrying would dial a corpse every cooldown; say what - // actually helps instead. (Quit-path kills land here too, but the - // pane is tearing down then and nobody sees the banner.) - if (this.cdpEndpoint === this.launchedEndpoint) { + // actually helps instead. The launchedEndpoint comparison also + // catches a crash before the queued attach ran (cdpEndpoint not + // yet switched). Quit-path kills never get here: cleanup and + // attach-elsewhere clear launchedChild before the event fires. + if (this.cdpEndpoint === ep || this.launchedEndpoint === ep) { this.attached = false; this.streamCooldownUntil = Number.MAX_SAFE_INTEGER; - this.banner = "launched Chromium exited — press l to relaunch"; - this.header(); + this.holdBanner("launched Chromium exited — press l to relaunch"); } }); - this.userAction(() => - this.attachTo(this.launchedEndpoint, { - note: "— launched Chromium —", - }), - ); + this.userAction(() => { + // Died between the port read and this queued attach: dialing the + // corpse would re-arm the retry loop the exit handler just + // latched. Signal deaths leave exitCode null — check both. + if (child.exitCode !== null || child.signalCode !== null) return; + return this.attachTo(ep, { note: "— launched Chromium —" }); + }); } finally { this.launchingChromium = false; } @@ -980,7 +990,8 @@ export class Renderer { async waitForDevToolsPort(portFile, child, spawnFailed = null, timeoutMs = 15_000) { const until = Date.now() + timeoutMs; while (Date.now() < until) { - if (child.exitCode !== null) return null; // died during startup + // Signal deaths leave exitCode null and set signalCode — check both. + if (child.exitCode !== null || child.signalCode !== null) return null; if (spawnFailed?.err) return null; // binary missing/not executable try { const port = Number( @@ -1457,6 +1468,9 @@ export class Renderer { // waiting-for-session advice below would overwrite it mid-wait. if (this.launchingChromium) return; if (!(await this.browser.sessionExists())) { + // A held banner names a real failure (launch mode); repainting + // generic waiting advice over it would erase the explanation. + if (Date.now() < this.bannerHoldUntil) return; // A missing binary is not 'session not started yet' — the waiting // advice below can never fix it, so say what's actually wrong. this.banner = this.agentBrowser @@ -1673,6 +1687,14 @@ export class Renderer { this.userAction(() => this.clickAt(mouse.col, mouse.row)); } + // A banner that names a real failure must outlive the next poll tick, or + // the generic waiting-for-session advice paints over it within a second. + holdBanner(text, ms = 30_000) { + this.banner = text; + this.bannerHoldUntil = Date.now() + ms; + this.header(); + } + // Observe-only is a pane-side latch, deliberately not a backend call: // nothing about the observed browser changes, input simply stops here. toggleObserveOnly() { diff --git a/scripts/lib.sh b/scripts/lib.sh index d41396c..f9835ef 100755 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -93,6 +93,47 @@ session_name() { printf 'herdr-cwd-%s\n' "$(printf '%s\n' "$PWD" | cksum | cut -d' ' -f1)" } +# Static backend arbitration, shared with the renderer's constructor: these +# read the same sources so launchers and pane reach the same verdict without +# a runtime marker. Must stay in lockstep with resolveCdpEndpoint() and the +# launch/observe knobs in bin/renderer.mjs. +cdp_endpoint_configured() { + if [ -n "${HERDR_BROWSER_CDP_URL:-}" ]; then + printf '%s\n' "$HERDR_BROWSER_CDP_URL" + return 0 + fi + if [ -n "${HERDR_PLUGIN_CONFIG_DIR:-}" ] && [ -f "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" ]; then + local v + v="$(head -n1 "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" | tr -d '[:space:][:cntrl:]')" + [ -n "$v" ] && printf '%s\n' "$v" && return 0 + fi + return 1 +} + +# Boolean knob files/envs: same accepted spellings as truthyConfig() in the +# renderer. A set-but-non-truthy env wins over the config file (|| semantics). +truthy_config() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in + 1 | true | yes | on) return 0 ;; + *) return 1 ;; + esac +} + +knob_configured() { # $1: env value (may be empty), $2: config file name + if [ -n "$1" ]; then + truthy_config "$1" + return $? + fi + if [ -n "${HERDR_PLUGIN_CONFIG_DIR:-}" ] && [ -f "${HERDR_PLUGIN_CONFIG_DIR}/$2" ]; then + truthy_config "$(head -n1 "${HERDR_PLUGIN_CONFIG_DIR}/$2")" + return $? + fi + return 1 +} + +launch_configured() { knob_configured "${HERDR_BROWSER_LAUNCH:-}" launch; } +observe_configured() { knob_configured "${HERDR_BROWSER_OBSERVE:-}" observe; } + require_agent_browser() { if ! command -v agent-browser >/dev/null 2>&1; then echo "herdr-browser: agent-browser CLI not found." >&2 diff --git a/scripts/open.sh b/scripts/open.sh index 73597e0..4554eb4 100755 --- a/scripts/open.sh +++ b/scripts/open.sh @@ -6,35 +6,43 @@ set -uo pipefail cd "${HERDR_PLUGIN_ROOT:-$(dirname "$0")/..}" || exit 1 . scripts/lib.sh -require_agent_browser require_herdr url="${1:-${HERDR_PLUGIN_CLICKED_URL:-}}" session="$(session_name)" -# Attach mode is decided from the same static sources the renderer reads, not -# from a runtime marker: on the first click after configuring an endpoint no -# pane has ever run, and taking the agent-browser path there would spawn -# exactly the invisible session attach mode promises never to create. -cdp_endpoint="" -if [ -n "${HERDR_BROWSER_CDP_URL:-}" ]; then - cdp_endpoint="$HERDR_BROWSER_CDP_URL" -elif [ -n "${HERDR_PLUGIN_CONFIG_DIR:-}" ] && [ -f "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" ]; then - cdp_endpoint="$(head -n1 "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" | tr -d '[:space:][:cntrl:]')" +# Backend arbitration from the same static sources the renderer reads, not a +# runtime marker: on the first click after configuring an endpoint (or a +# launch-first workspace) no pane has ever run, and taking the agent-browser +# path there would spawn exactly the invisible session those modes promise +# never to create. In both pane-owned modes agent-browser itself is not +# required — the requirement lives on the branch that actually invokes it. +pane_owns_navigation=0 +if cdp_endpoint_configured >/dev/null || launch_configured; then + pane_owns_navigation=1 fi -if [ -n "$url" ] && [ -n "$cdp_endpoint" ]; then +# A workspace configured observe-only exists to watch a run untouched; a +# link click must not navigate the observed session. (The runtime o toggle +# is pane state — the pane drops its own handoffs itself.) +if [ -n "$url" ] && observe_configured; then + echo "herdr-browser: observe-only workspace — not navigating (unset HERDR_BROWSER_OBSERVE / the observe config to navigate)" >&2 + url="" +fi + +if [ -n "$url" ] && [ "$pane_owns_navigation" -eq 1 ]; then if ! validate_url "$url"; then echo "herdr-browser: refusing URL (must start with http:// or https://, no credentials): $url" >&2 exit 2 fi - # Attach mode: hand the URL to the pane, which navigates the attached + # Hand the URL to the pane, which navigates its attached or launched # target. The renderer watches this file, so pickup does not wait for a # backed-off poll tick. handoff="$(state_dir)/navigate-$(ws_id)" umask 077 printf '%s\n' "$url" > "$handoff" elif [ -n "$url" ]; then + require_agent_browser if ! validate_url "$url"; then echo "herdr-browser: refusing URL (must start with http:// or https://, no credentials): $url" >&2 exit 2 diff --git a/scripts/record.sh b/scripts/record.sh index f6ea2c0..7fbfc7a 100755 --- a/scripts/record.sh +++ b/scripts/record.sh @@ -15,14 +15,11 @@ start | stop) ;; esac # Recording captures the workspace's agent-browser session. A workspace -# configured for CDP attach mode has no such session — starting one here -# would record a fresh, unrelated headless browser, not the observed one. -cdp_endpoint="${HERDR_BROWSER_CDP_URL:-}" -if [ -z "$cdp_endpoint" ] && [ -n "${HERDR_PLUGIN_CONFIG_DIR:-}" ] && [ -f "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" ]; then - cdp_endpoint="$(head -n1 "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" | tr -d '[:space:][:cntrl:]')" -fi -if [ -n "$cdp_endpoint" ]; then - echo "herdr-browser: recording captures agent-browser sessions, but this workspace is configured for CDP attach mode (cdp-url). Record from the automation client that owns the browser, or remove the cdp-url configuration to record an agent-browser session." >&2 +# configured for CDP attach mode or launch mode has no such session — +# starting one here would record a fresh, unrelated headless browser, not +# the browser the pane shows. +if cdp_endpoint_configured >/dev/null || launch_configured; then + echo "herdr-browser: recording captures agent-browser sessions, but this workspace is configured for attach/launch mode. Record from the automation client that owns the browser, or remove the cdp-url/launch configuration to record an agent-browser session." >&2 exit 1 fi diff --git a/tests/launchers.test.mjs b/tests/launchers.test.mjs index d272cfb..7371001 100644 --- a/tests/launchers.test.mjs +++ b/tests/launchers.test.mjs @@ -585,7 +585,7 @@ test("record refuses in attach mode instead of recording an unrelated browser", const env = freshEnv({ HERDR_BROWSER_CDP_URL: "http://127.0.0.1:9222" }); const r = runScript("record.sh", ["start"], env); assert.notEqual(r.status, 0); - assert.match(r.stderr, /attach mode/); + assert.match(r.stderr, /attach\/launch mode/); // The config-file source must reach the same verdict as the env var. const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "hb-rec-cfg-")); fs.writeFileSync(path.join(cfg, "cdp-url"), "http://127.0.0.1:9222\n"); @@ -595,5 +595,59 @@ test("record refuses in attach mode instead of recording an unrelated browser", freshEnv({ HERDR_PLUGIN_CONFIG_DIR: cfg }), ); assert.notEqual(r2.status, 0); - assert.match(r2.stderr, /attach mode/); + assert.match(r2.stderr, /attach\/launch mode/); + // Launch-first workspaces have no agent-browser session either. + const r3 = runScript( + "record.sh", + ["start"], + freshEnv({ HERDR_BROWSER_LAUNCH: "1" }), + ); + assert.notEqual(r3.status, 0); + assert.match(r3.stderr, /attach\/launch mode/); +}); + +test("open in a launch-first workspace hands the URL to the pane, never agent-browser", () => { + const r = runScript( + "open.sh", + ["http://localhost:3000/app"], + freshEnv({ HERDR_BROWSER_LAUNCH: "1" }), + ); + assert.equal(r.status, 0, r.stderr); + assert.doesNotMatch(log(), /agent-browser --session/); + assert.equal( + fs.readFileSync(path.join(stateDir, "navigate-w9"), "utf8").trim(), + "http://localhost:3000/app", + ); + fs.rmSync(path.join(stateDir, "navigate-w9"), { force: true }); +}); + +test("open in attach/launch mode works without agent-browser installed", () => { + // Strip the agent-browser stub from PATH; herdr and core tools remain. + const noAb = fs.mkdtempSync(path.join(os.tmpdir(), "hb-noab-")); + for (const t of ["herdr"]) { + fs.copyFileSync(path.join(stubDir, t), path.join(noAb, t)); + fs.chmodSync(path.join(noAb, t), 0o755); + } + const env = freshEnv({ + HERDR_BROWSER_LAUNCH: "1", + PATH: `${noAb}:${path.dirname(process.execPath)}:/usr/bin:/bin`, + HERDR_BIN_PATH: path.join(noAb, "herdr"), + }); + const r = runScript("open.sh", ["http://localhost:3000"], env); + assert.equal(r.status, 0, r.stderr); + assert.match(log(), /plugin pane open/); + fs.rmSync(path.join(stateDir, "navigate-w9"), { force: true }); +}); + +test("open in an observe-only workspace refuses navigation but still opens the pane", () => { + const r = runScript( + "open.sh", + ["http://localhost:3000"], + freshEnv({ HERDR_BROWSER_OBSERVE: "1" }), + ); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stderr, /observe-only workspace/); + assert.doesNotMatch(log(), /agent-browser --session .* open/); + assert.ok(!fs.existsSync(path.join(stateDir, "navigate-w9"))); + assert.match(log(), /plugin pane open/); }); diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index 3e0853b..b7a196f 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -2517,3 +2517,37 @@ test("a crashed launched Chromium banners l-to-relaunch instead of redialing a d "no rediscovery loop against a port that died with the browser", ); }); + +test("a launch-failure banner survives the next waiting-for-session tick", { skip: !canCdp }, async () => { + const r = quiet(mkRenderer()); + await r.launchChromium(); // no PATH candidates in the test env -> no Chromium + assert.match(r.banner, /no Chromium found/); + r.browser = { ...r.browser, sessionExists: async () => false }; + await r.tick(); + assert.match( + r.banner, + /no Chromium found/, + "generic waiting advice must not paint over the failure explanation", + ); +}); + +test("a crash before the queued attach still latches and never dials the corpse", { skip: !canCdp }, async () => { + const bin = fakeChromiumScript(); + const r = quiet(mkRenderer({ HERDR_BROWSER_CHROMIUM: bin })); + let attachCalls = 0; + r.attachTo = async () => attachCalls++; + // Defer queued user actions so the crash lands between the port read and + // the attach, the exact window the exit-handler guard must cover. + const deferred = []; + r.userAction = (fn) => deferred.push(fn); + await r.launchChromium(); + const child = r.launchedChild; + assert.ok(child, "launch reached the port read"); + child.kill("SIGKILL"); + for (let i = 0; i < 50 && r.launchedChild; i++) + await new Promise((res) => setTimeout(res, 100)); + assert.match(r.banner, /press l to relaunch/); + assert.equal(r.streamCooldownUntil, Number.MAX_SAFE_INTEGER); + for (const fn of deferred) await fn(); + assert.equal(attachCalls, 0, "the queued attach must notice the corpse"); +}); From 31abeb6b355002752be21d9139077614ff0e660a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 07:15:35 +0000 Subject: [PATCH 12/15] =?UTF-8?q?test:=20dead=20PATH=20by=20default=20in?= =?UTF-8?q?=20renderer=20test=20env=20=E2=80=94=20the=20probe=20found=20re?= =?UTF-8?q?al=20Chrome=20on=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The banner-hold test built a renderer with no PATH at all; sh then falls back to the system default path, where CI runners have a real /usr/bin/google-chrome — the probe found it, the test launched an actual browser, and its open handles (child process, CDP WebSocket) hung the Node 22 job until the workflow timeout. mkRenderer now sets PATH=/nonexistent by default so the launch-mode probe is deterministically empty in every unit test; tests that want a real browser (the integration test) construct their own env with a real PATH. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- tests/renderer.test.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index b7a196f..0344744 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -60,6 +60,10 @@ const mkRenderer = (over = {}) => HERDR_BROWSER_SESSION: "hb-test", HERDR_PLUGIN_STATE_DIR: fs.mkdtempSync(path.join(os.tmpdir(), "hb-r-")), HOME: os.homedir(), + // Dead by default: with PATH unset entirely, sh falls back to the + // system default path and the launch-mode probe can find and start a + // REAL browser on CI runners — whose open handles then hang the run. + PATH: "/nonexistent", ...over, }); // Silence painting; keep state transitions observable. @@ -2519,8 +2523,10 @@ test("a crashed launched Chromium banners l-to-relaunch instead of redialing a d }); test("a launch-failure banner survives the next waiting-for-session tick", { skip: !canCdp }, async () => { - const r = quiet(mkRenderer()); - await r.launchChromium(); // no PATH candidates in the test env -> no Chromium + // PATH must be explicitly dead: with no PATH at all, sh falls back to the + // system default path and can find a real browser on CI runners. + const r = quiet(mkRenderer({ PATH: "/nonexistent" })); + await r.launchChromium(); assert.match(r.banner, /no Chromium found/); r.browser = { ...r.browser, sessionExists: async () => false }; await r.tick(); From e84f72555c25c3a168c8285e0c65e01a94a89968 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 07:32:08 +0000 Subject: [PATCH 13/15] fix: make clicks work in every unattached state; runtime backend marker; shell parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round two of review findings, all in the unattached-pane paths the first round created: - A Cmd+click in a launch workspace whose browser is not up now triggers the launch and parks the URL, delivered on attach (fresh within 2 min). Previously the handoff was written but nothing consumed it — the click silently vanished, and a later successful attach replayed an arbitrarily old URL. Handoff files older than 5 minutes are now dropped outright. - A pane switched to attach/launch at runtime (the a or l key) leaves a backend marker; open.sh routes clicks to the handoff file when a live pane holds the marker, instead of spawning the invisible agent-browser session the arbitration exists to prevent. A dead pane's stale marker is ignored, and cleanup removes it. - The post-failure waiting banner in a launch workspace now gives accurate advice — press l or Cmd+click to retry — instead of advertising a click path that previously did nothing. - cdp_endpoint_configured treats whitespace-only env values as unset, restoring the lockstep its comment claims with resolveCdpEndpoint, and is a silent predicate (callers no longer redirect dead output). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- bin/renderer.mjs | 60 +++++++++++++++++++++++++++++++++++++--- scripts/lib.sh | 22 ++++++++++----- scripts/open.sh | 10 ++++++- scripts/record.sh | 2 +- tests/launchers.test.mjs | 39 ++++++++++++++++++++++++++ tests/renderer.test.mjs | 55 ++++++++++++++++++++++++++++++++++++ 6 files changed, 175 insertions(+), 13 deletions(-) diff --git a/bin/renderer.mjs b/bin/renderer.mjs index a401f5f..949ba2f 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -688,6 +688,9 @@ export class Renderer { this.launchedChild = null; this.launchedEndpoint = null; this.launchingChromium = false; + // A click that arrived while the launch was still coming up parks its + // URL here and is delivered on attach (see startNavigateWatch/attachCdp). + this.pendingNavigateUrl = null; // A launch failure explains itself for this long before the generic // waiting-for-session banner may paint over it (see tick). this.bannerHoldUntil = 0; @@ -848,6 +851,17 @@ export class Renderer { this.selfCreated = false; // never inherit ownership across a switch this.browser = makeCdpBrowser(endpoint); this.attached = false; + // Static sources cannot see a runtime switch; the marker lets open.sh + // route clicks to the handoff file while this pane is alive. + try { + fs.writeFileSync( + path.join(this.stateDir, `backend-${safeWsId(this.env.HERDR_WORKSPACE_ID)}`), + "attach\n", + { mode: 0o600 }, + ); + } catch { + /* marker is best-effort; static workspaces never need it */ + } this.cdpGuid = null; this.resetBackendState(); this.pushConsole( @@ -1031,6 +1045,13 @@ export class Renderer { this.cdpIdentity = id; this.attached = true; this.startNavigateWatch(); + // A click while the launch was still coming up parked its URL here; + // deliver it now that there is a browser, unless it has gone stale. + const pending = this.pendingNavigateUrl; + this.pendingNavigateUrl = null; + if (pending && Date.now() - pending.at < 120_000) { + this.userAction(() => this.browser.open(pending.url)); + } this.lastUrl = sanitizeText(id.url ?? ""); this.lastTitle = sanitizeText(id.title ?? ""); this.lastFrameAt = Date.now(); @@ -1057,26 +1078,44 @@ export class Renderer { // The tick-time read stays as the fallback for platforms where fs.watch // misses events (some network filesystems). startNavigateWatch() { - if (this.backend !== "attach" || this.navigateWatcher) return; + if ( + (this.backend !== "attach" && !this.launchConfigured) || + this.navigateWatcher + ) + return; this.navigateFile = path.join( this.stateDir, `navigate-${safeWsId(this.env.HERDR_WORKSPACE_ID)}`, ); const consume = () => { let url; + let age = 0; try { + age = Date.now() - fs.statSync(this.navigateFile).mtimeMs; url = fs.readFileSync(this.navigateFile, "utf8").split("\n")[0].trim(); fs.unlinkSync(this.navigateFile); } catch { return; // nothing pending } if (!url) return; + // A handoff can predate this pane (written before it started, or + // while its launch was broken); navigating to an hours-old click + // out of nowhere is worse than dropping it. + if (age > 300_000) return; // A Cmd+click handoff is page-affecting input like any other; the // URL is consumed (file already unlinked) but not forwarded. if (this.observeOnly) { this.noteObserveBlocked(); return; } + // Launch workspace with no browser up: the click is an explicit + // ask — (re)launch and navigate once attached. + if (!this.attached && this.launchConfigured) { + this.pendingNavigateUrl = { url, at: Date.now() }; + this.launchAttempted = true; + this.launchChromium(); + return; + } this.userAction(() => this.browser.open(url)); }; this.consumeNavigateFile = consume; @@ -1461,6 +1500,7 @@ export class Renderer { // on failure the banner stands and the keys are back in charge. if (this.launchConfigured && !this.launchAttempted) { this.launchAttempted = true; + this.startNavigateWatch(); // clicks must work even mid-launch this.launchChromium(); return; } @@ -1473,9 +1513,13 @@ export class Renderer { if (Date.now() < this.bannerHoldUntil) return; // A missing binary is not 'session not started yet' — the waiting // advice below can never fix it, so say what's actually wrong. - this.banner = this.agentBrowser - ? `waiting for session "${this.session}" — Cmd+click a localhost link or have your agent use --session ${this.session}` - : "agent-browser is not installed — press l to launch a local Chromium, or: npm install -g agent-browser"; + // In a launch workspace the accurate advice is to retry the + // launch — a click also retries it (see startNavigateWatch). + this.banner = this.launchConfigured + ? "Chromium launch failed — press l or Cmd+click a localhost link to retry" + : this.agentBrowser + ? `waiting for session "${this.session}" — Cmd+click a localhost link or have your agent use --session ${this.session}` + : "agent-browser is not installed — press l to launch a local Chromium, or: npm install -g agent-browser"; this.header(); return; } @@ -2121,6 +2165,14 @@ export class Renderer { /* already closed */ } this.live = null; + // The runtime-backend marker routes clicks only while this pane lives. + try { + fs.unlinkSync( + path.join(this.stateDir, `backend-${safeWsId(this.env.HERDR_WORKSPACE_ID)}`), + ); + } catch { + /* never written */ + } if (this.launchedChild) { // The pane spawned this browser; quitting must not leak it. SIGTERM // lets Chrome flush its profile — its own exit handles the rest. diff --git a/scripts/lib.sh b/scripts/lib.sh index f9835ef..181b734 100755 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -98,16 +98,24 @@ session_name() { # a runtime marker. Must stay in lockstep with resolveCdpEndpoint() and the # launch/observe knobs in bin/renderer.mjs. cdp_endpoint_configured() { + # Silent predicate. Whitespace-only values are treated as unset, and a + # set-but-blank env var does NOT fall through to the config file — both + # exactly as resolveCdpEndpoint() decides. + local v="" if [ -n "${HERDR_BROWSER_CDP_URL:-}" ]; then - printf '%s\n' "$HERDR_BROWSER_CDP_URL" - return 0 - fi - if [ -n "${HERDR_PLUGIN_CONFIG_DIR:-}" ] && [ -f "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" ]; then - local v + v="$(printf '%s' "$HERDR_BROWSER_CDP_URL" | tr -d '[:space:][:cntrl:]')" + elif [ -n "${HERDR_PLUGIN_CONFIG_DIR:-}" ] && [ -f "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" ]; then v="$(head -n1 "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" | tr -d '[:space:][:cntrl:]')" - [ -n "$v" ] && printf '%s\n' "$v" && return 0 fi - return 1 + [ -n "$v" ] +} + +# A pane that switched to attach/launch mode at runtime (the a or l key) +# leaves this marker; static sources cannot see that switch. Trust it only +# alongside a live pane — a crashed pane's stale marker must not swallow +# clicks into a handoff file nobody consumes. +backend_marker_file() { + printf '%s/backend-%s\n' "$(state_dir)" "$(ws_id)" } # Boolean knob files/envs: same accepted spellings as truthyConfig() in the diff --git a/scripts/open.sh b/scripts/open.sh index 4554eb4..397f274 100755 --- a/scripts/open.sh +++ b/scripts/open.sh @@ -18,9 +18,17 @@ session="$(session_name)" # never to create. In both pane-owned modes agent-browser itself is not # required — the requirement lives on the branch that actually invokes it. pane_owns_navigation=0 -if cdp_endpoint_configured >/dev/null || launch_configured; then +if cdp_endpoint_configured || launch_configured; then pane_owns_navigation=1 fi +# Runtime switches (the a/l keys) are invisible to static sources; the pane +# leaves a marker while attached. Only a live pane's marker counts. +if [ "$pane_owns_navigation" -eq 0 ] && [ -f "$(backend_marker_file)" ]; then + marker_pane="$(cat "$(pane_id_file)" 2>/dev/null || true)" + if pane_alive "$marker_pane"; then + pane_owns_navigation=1 + fi +fi # A workspace configured observe-only exists to watch a run untouched; a # link click must not navigate the observed session. (The runtime o toggle diff --git a/scripts/record.sh b/scripts/record.sh index 7fbfc7a..7558cfc 100755 --- a/scripts/record.sh +++ b/scripts/record.sh @@ -18,7 +18,7 @@ esac # configured for CDP attach mode or launch mode has no such session — # starting one here would record a fresh, unrelated headless browser, not # the browser the pane shows. -if cdp_endpoint_configured >/dev/null || launch_configured; then +if cdp_endpoint_configured || launch_configured; then echo "herdr-browser: recording captures agent-browser sessions, but this workspace is configured for attach/launch mode. Record from the automation client that owns the browser, or remove the cdp-url/launch configuration to record an agent-browser session." >&2 exit 1 fi diff --git a/tests/launchers.test.mjs b/tests/launchers.test.mjs index 7371001..0b7ac48 100644 --- a/tests/launchers.test.mjs +++ b/tests/launchers.test.mjs @@ -651,3 +651,42 @@ test("open in an observe-only workspace refuses navigation but still opens the p assert.ok(!fs.existsSync(path.join(stateDir, "navigate-w9"))); assert.match(log(), /plugin pane open/); }); + +test("a live runtime-attach pane's marker routes clicks to the handoff file", () => { + fs.writeFileSync(path.join(stateDir, "pane-id-w9"), "w9:p7\n"); + fs.writeFileSync(path.join(stateDir, "backend-w9"), "attach\n"); + const r = runScript( + "open.sh", + ["http://localhost:5000/x"], + freshEnv({ STUB_PANE_ALIVE: "0" }), + ); + assert.equal(r.status, 0, r.stderr); + assert.doesNotMatch(log(), /agent-browser --session .* open/); + assert.equal( + fs.readFileSync(path.join(stateDir, "navigate-w9"), "utf8").trim(), + "http://localhost:5000/x", + ); + fs.rmSync(path.join(stateDir, "navigate-w9"), { force: true }); + fs.rmSync(path.join(stateDir, "backend-w9"), { force: true }); + fs.rmSync(path.join(stateDir, "pane-id-w9"), { force: true }); +}); + +test("a dead pane's stale backend marker is ignored", () => { + fs.writeFileSync(path.join(stateDir, "backend-w9"), "attach\n"); + fs.rmSync(path.join(stateDir, "pane-id-w9"), { force: true }); + const r = runScript("open.sh", ["http://localhost:5001/y"]); + assert.equal(r.status, 0, r.stderr); + assert.match(log(), /agent-browser --session herdr-ws-w9 open/); + fs.rmSync(path.join(stateDir, "backend-w9"), { force: true }); +}); + +test("whitespace-only HERDR_BROWSER_CDP_URL is not attach mode (renderer parity)", () => { + const r = runScript( + "open.sh", + ["http://localhost:5002/z"], + freshEnv({ HERDR_BROWSER_CDP_URL: " " }), + ); + assert.equal(r.status, 0, r.stderr); + assert.match(log(), /agent-browser --session herdr-ws-w9 open/); + assert.ok(!fs.existsSync(path.join(stateDir, "navigate-w9"))); +}); diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index 0344744..a644f5f 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -2557,3 +2557,58 @@ test("a crash before the queued attach still latches and never dials the corpse" for (const fn of deferred) await fn(); assert.equal(attachCalls, 0, "the queued attach must notice the corpse"); }); + +// --- Round-2 review fixes: unattached handoff paths --- + +test("a click in a launch workspace with no browser up triggers the launch and navigates on attach", { skip: !canCdp }, async () => { + const r = quiet(mkRenderer({ HERDR_BROWSER_LAUNCH: "1" })); + let launches = 0; + r.launchChromium = async () => launches++; + r.startNavigateWatch(); + fs.writeFileSync( + path.join(r.stateDir, `navigate-${safeWsId(r.env.HERDR_WORKSPACE_ID)}`), + "http://localhost:3000/app\n", + ); + r.consumeNavigateFile(); + assert.equal(launches, 1, "the click retries the launch"); + assert.equal(r.pendingNavigateUrl?.url, "http://localhost:3000/app"); + + // On attach, the parked URL is delivered. + const opened = []; + r.browser = fakeCdpBackend(); + r.browser.open = async (u) => opened.push(u); + r.backend = "attach"; + await r.attachCdp(); + await flush(); + assert.deepEqual(opened, ["http://localhost:3000/app"]); + assert.equal(r.pendingNavigateUrl, null); +}); + +test("a stale handoff file is dropped, not replayed", { skip: !canCdp }, async () => { + const r = quiet(mkRenderer({ HERDR_BROWSER_LAUNCH: "1" })); + r.attached = true; + const opened = []; + r.browser = { ...r.browser, open: async (u) => opened.push(u) }; + r.startNavigateWatch(); + const f = path.join(r.stateDir, `navigate-${safeWsId(r.env.HERDR_WORKSPACE_ID)}`); + fs.writeFileSync(f, "http://localhost:3000/old\n"); + const old = new Date(Date.now() - 3_600_000); + fs.utimesSync(f, old, old); + r.consumeNavigateFile(); + await flush(); + assert.deepEqual(opened, [], "an hour-old click must not navigate out of nowhere"); + assert.ok(!fs.existsSync(f), "the stale file is still consumed"); +}); + +test("runtime attach writes the backend marker; cleanup removes it", async () => { + const r = quiet(mkRenderer()); + const marker = path.join( + r.stateDir, + `backend-${safeWsId(r.env.HERDR_WORKSPACE_ID)}`, + ); + r.browser = { ...r.browser, sessionExists: async () => false }; + await r.attachTo("http://127.0.0.1:9222"); + assert.equal(fs.readFileSync(marker, "utf8").trim(), "attach"); + r.cleanup(); + assert.ok(!fs.existsSync(marker)); +}); From a730f9aa0574ba18cb544ec916686ed4e95fba0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 08:33:49 +0000 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20launch=20rejection=20guard,=20profile=20chmod,=20CI=20creden?= =?UTF-8?q?tial=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven review findings verified and applied: - launchChromium gets a catch: it is fired without await from onKey, the navigate watcher, and the tick, so a throw inside (an unwritable profile dir, say) became an unhandled rejection that exited the pane through the uncaughtException handler. Unexpected failures now hold a banner, with a regression test asserting the promise resolves and the latch releases. - The launched browser's profile dir is chmod'd 0700 explicitly after mkdir, matching stateDir — mkdir's mode applies only on creation and the umask can relax it, and the profile holds cookies and session state. - CI checkout sets persist-credentials: false; no later step runs authenticated git, so the token has no reason to stay on disk with repository-controlled test code. - README: the requirements table names launch mode among the Node 22+ features, the launch section states the requirement, and the recording section covers launch workspaces alongside attach. - Help lines advertise a:attach and l:launch in both backends (compact labels keep the line near 80 columns); the unused spawnSync placeholder is gone from the abandon test; the five hand-rolled 50x100ms poll loops share one until() helper with explicit timeouts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- .github/workflows/ci.yml | 4 ++++ README.md | 12 +++++++----- bin/renderer.mjs | 17 +++++++++++++++-- tests/renderer.test.mjs | 41 +++++++++++++++++++++++++++------------- 4 files changed, 54 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cc7da2..c5f0247 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,10 @@ jobs: - name: Check out repository # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + # No later step runs authenticated git; don't leave the token in + # the checkout for repository-controlled test code to read. + persist-credentials: false - name: Set up Node.js # v4.0.3 uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b diff --git a/README.md b/README.md index 3b57e65..beefb2c 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Conductor). | Component | Requirement | Notes | | --- | --- | --- | | Herdr | `>= 0.7.0` | Tested with Herdr 0.7.4 | -| Node.js | `>= 20` | Node 22+ enables live WebSocket streaming and CDP attach mode | +| Node.js | `>= 20` | Node 22+ enables live WebSocket streaming, CDP attach mode, and launch mode | | agent-browser | Optional | Required for shared agent sessions; tested with agent-browser 0.33.x; failed-request reporting needs the `network requests` command | | Chromium/Chrome | Optional | Any Chromium-based browser enables launch mode (`l`) and attach mode | | chafa | Optional | ANSI rendering and streamed JPEGs in Kitty mode | @@ -293,7 +293,9 @@ says so plainly on older Node and keeps working in agent-browser mode. Press `l` and the pane launches a local Chromium with a loopback DevTools port and attaches to it — no agent-browser, no configuration. This is the -zero-setup path: open the pane, press `l`, press `u`, browse. +zero-setup path: open the pane, press `l`, press `u`, browse. Like attach +mode, launching needs Node 22 or newer (the built-in WebSocket client); the +pane says so instead of starting a browser it could never attach to. The launcher looks for `HERDR_BROWSER_CHROMIUM` (or the `chromium` config file), then probes `google-chrome`, `google-chrome-stable`, `chromium`, @@ -347,9 +349,9 @@ echo "my-agent-session" \ ## Recording -Recording captures the workspace's **agent-browser session**. In CDP attach -mode there is no such session, so the record actions refuse with an -explanation instead of silently recording a fresh, unrelated headless +Recording captures the workspace's **agent-browser session**. Attach and +launch workspaces have no such session, so the record actions refuse with +an explanation instead of silently recording a fresh, unrelated headless browser — record from the automation client that owns the browser instead. Start and stop recording through the existing recording actions. Each new diff --git a/bin/renderer.mjs b/bin/renderer.mjs index 949ba2f..185eb0c 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -913,7 +913,11 @@ export class Renderer { this.stateDir, `chromium-profile-${safeWsId(this.env.HERDR_WORKSPACE_ID)}`, ); + // mkdir's mode applies only on creation (and umask can relax it); + // the profile holds the launched browser's cookies and session + // state, so tighten explicitly like the constructor does stateDir. fs.mkdirSync(profile, { recursive: true, mode: 0o700 }); + fs.chmodSync(profile, 0o700); const portFile = path.join(profile, "DevToolsActivePort"); try { fs.unlinkSync(portFile); // a stale port must never win the wait below @@ -993,6 +997,13 @@ export class Renderer { if (child.exitCode !== null || child.signalCode !== null) return; return this.attachTo(ep, { note: "— launched Chromium —" }); }); + } catch (err) { + // Callers fire-and-forget this promise; an uncaught throw here + // (say, an unwritable profile dir) would surface as an unhandled + // rejection and take the whole pane down instead of explaining. + this.holdBanner( + `launch failed: ${sanitizeText(err?.message ?? "unknown error")}`, + ); } finally { this.launchingChromium = false; } @@ -1331,11 +1342,13 @@ export class Renderer { `${ESC}[${bottomRow};1H${truncate(text, cols)}${ESC}[K`, ); } else { + // Every advertised key is handled in both backends (a and l work + // everywhere); t only moves targets on an attach backend. const help = this.observeOnly ? " observe-only: input is not forwarded o:enable-input q:quit" : this.backend === "attach" - ? " u:url click:page i:type b/f:back-fwd r:reload j/k:scroll t:target o:observe q:quit" - : " u:url click:page i:type b/f:back-fwd r:reload j/k:scroll l:launch o:observe q:quit"; + ? " u:url a:attach l:launch i:type b/f:hist r:reload j/k:scroll t:target o:observe q:quit" + : " u:url a:attach l:launch i:type b/f:hist r:reload j/k:scroll o:observe q:quit"; process.stdout.write( `${ESC}[${bottomRow};1H${ESC}[2m${truncate(help, cols)}${ESC}[K${ESC}[0m`, ); diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index a644f5f..3b2b79b 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -80,6 +80,13 @@ const flush = async () => { // attachCdp (and launchChromium) refuse on Node < 22 before touching the // backend, so attach-behavior tests only make sense where attach is possible. const canCdp = typeof WebSocket === "function"; +// Poll until cond() or the timeout; launch tests wait on real child I/O. +const until = async (cond, ms = 5_000) => { + const end = Date.now() + ms; + while (Date.now() < end && !cond()) + await new Promise((res) => setTimeout(res, 100)); + return cond(); +}; test("reconcile: first poll returns everything", () => { const r = reconcileConsole({ count: 0, tail: [] }, e(["a", "b"])); @@ -2392,9 +2399,7 @@ test("launch mode: l spawns the configured chromium, waits for the port, attache r.attachTo = async (ep) => attachedTo.push(ep); r.attached = false; r.onKey("l"); // reachable while unattached - // The port wait polls every 200ms; give the fake time to write the file. - for (let i = 0; i < 50 && !attachedTo.length; i++) - await new Promise((res) => setTimeout(res, 100)); + await until(() => attachedTo.length > 0); assert.deepEqual(attachedTo, ["http://127.0.0.1:9876"]); assert.ok(r.launchedChild, "the pane records the child it owns"); const pid = r.launchedChild.pid; @@ -2409,7 +2414,6 @@ test("launch mode: l spawns the configured chromium, waits for the port, attache test("launch mode: attaching to a different endpoint kills the launched browser", async () => { const r = quiet(mkRenderer()); - const child = spawnSync("sh", ["-c", "echo"], {}); // placeholder shape let killed = 0; r.launchedChild = { kill: () => killed++, exitCode: null }; r.launchedEndpoint = "http://127.0.0.1:9876"; @@ -2417,7 +2421,6 @@ test("launch mode: attaching to a different endpoint kills the launched browser" await r.attachTo("http://127.0.0.1:9333"); assert.equal(killed, 1, "abandoning a launched browser must not leak it"); assert.equal(r.launchedChild, null); - void child; }); test("launch mode: no chromium found reports instead of failing silently", { skip: !canCdp }, async () => { @@ -2482,8 +2485,7 @@ test("launch-first workspace launches once from the first unattached tick", { sk r.browser = { ...r.browser, sessionExists: async () => false }; await r.tick(); // triggers the launch instead of waiting for a session assert.equal(r.launchAttempted, true); - for (let i = 0; i < 50 && !attachedTo.length; i++) - await new Promise((res) => setTimeout(res, 100)); + await until(() => attachedTo.length > 0); assert.deepEqual(attachedTo, ["http://127.0.0.1:9876"]); r.cleanup(); }); @@ -2505,13 +2507,11 @@ test("a crashed launched Chromium banners l-to-relaunch instead of redialing a d r.attached = true; }; await r.launchChromium(); - for (let i = 0; i < 50 && !r.attached; i++) - await new Promise((res) => setTimeout(res, 100)); + await until(() => r.attached); const child = r.launchedChild; assert.ok(child); child.kill("SIGKILL"); // crash, not a pane-initiated quit - for (let i = 0; i < 50 && r.launchedChild; i++) - await new Promise((res) => setTimeout(res, 100)); + await until(() => !r.launchedChild); assert.equal(r.launchedChild, null); assert.equal(r.attached, false); assert.match(r.banner, /press l to relaunch/); @@ -2550,8 +2550,7 @@ test("a crash before the queued attach still latches and never dials the corpse" const child = r.launchedChild; assert.ok(child, "launch reached the port read"); child.kill("SIGKILL"); - for (let i = 0; i < 50 && r.launchedChild; i++) - await new Promise((res) => setTimeout(res, 100)); + await until(() => !r.launchedChild); assert.match(r.banner, /press l to relaunch/); assert.equal(r.streamCooldownUntil, Number.MAX_SAFE_INTEGER); for (const fn of deferred) await fn(); @@ -2612,3 +2611,19 @@ test("runtime attach writes the backend marker; cleanup removes it", async () => r.cleanup(); assert.ok(!fs.existsSync(marker)); }); + +test("an unexpected launchChromium throw banners instead of rejecting unhandled", { skip: !canCdp }, async () => { + const r = quiet(mkRenderer({ HERDR_BROWSER_CHROMIUM: "/bin/true" })); + // Force a throw inside the guarded body: an unwritable profile parent. + const origMkdir = fs.mkdirSync; + fs.mkdirSync = () => { + throw new Error("EACCES: permission denied, mkdir"); + }; + try { + await r.launchChromium(); // must resolve, never reject + } finally { + fs.mkdirSync = origMkdir; + } + assert.match(r.banner, /launch failed: EACCES/); + assert.equal(r.launchingChromium, false, "the latch is released on failure"); +}); From 09abcaa103e8eae4118e24891053c1fbd4fda651 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 09:37:07 +0000 Subject: [PATCH 15/15] fix: close a pane-created session when switching backends, as on quit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit's re-review flagged that attachTo cleared selfCreated without closing the session, so a session created by this pane's own navigation idled as a leaked daemon for up to 30 minutes after an a/l-key switch — inconsistent with the quit path, which closes it, and with the launched- Chromium rule this branch established (abandoning a browser the pane owns kills it). The quit-path close now lives in closeOwnSession(), called from both cleanup and the backend switch; sessions others created are untouched (the selfCreated/ownershipEnabled guard is inside the helper), and the regression test covers both sides. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q7GSCAnQs6T6FgK9fzNXCY --- bin/renderer.mjs | 37 +++++++++++++++++++++++-------------- tests/renderer.test.mjs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/bin/renderer.mjs b/bin/renderer.mjs index 185eb0c..b2a2eb2 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -846,9 +846,12 @@ export class Renderer { this.stopNetworkTimer(); this.cdpEndpoint = endpoint; this.backend = "attach"; + // A session this pane created is closed on abandonment exactly as on + // quit; ownership never crosses the switch either way. + this.closeOwnSession(); this.ownershipEnabled = false; this.backendName = "browser endpoint"; - this.selfCreated = false; // never inherit ownership across a switch + this.selfCreated = false; this.browser = makeCdpBrowser(endpoint); this.attached = false; // Static sources cannot see a runtime switch; the marker lets open.sh @@ -1752,6 +1755,24 @@ export class Renderer { this.header(); } + // A session that exists only because the user navigated in this pane is + // closed (daemon included) when the pane is done with it — on quit and + // on switching backends alike — instead of idling as a leaked daemon + // until the 30-minute reaper. Sessions others created are never touched. + // Short timeout: a wedged daemon must not freeze the caller — its own + // idle reaper collects the session anyway. + closeOwnSession() { + if (!this.selfCreated || !this.ownershipEnabled) return; + try { + spawnSync(this.bin, ["--session", this.session, "close"], { + timeout: 2_000, + }); + } catch { + /* already gone */ + } + this.selfCreated = false; + } + // Observe-only is a pane-side latch, deliberately not a backend call: // nothing about the observed browser changes, input simply stops here. toggleObserveOnly() { @@ -2205,19 +2226,7 @@ export class Renderer { /* endpoint already gone */ } } - if (this.selfCreated && this.ownershipEnabled) { - // The session exists only because the user navigated in this pane; - // quitting the pane ends it (and its daemon) instead of leaking it. - // Short timeout: a wedged daemon must not freeze the quit path — its - // own idle reaper collects the session anyway. - try { - spawnSync(this.bin, ["--session", this.session, "close"], { - timeout: 2_000, - }); - } catch { - /* already gone */ - } - } + this.closeOwnSession(); for (const f of [ this.shot, this.shot + ".tmp", diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index 3b2b79b..631d53e 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -2627,3 +2627,35 @@ test("an unexpected launchChromium throw banners instead of rejecting unhandled" assert.match(r.banner, /launch failed: EACCES/); assert.equal(r.launchingChromium, false, "the latch is released on failure"); }); + +test("switching backends closes a session this pane created instead of leaking its daemon", async () => { + const r = quiet(mkRenderer()); + r.selfCreated = true; + let closes = 0; + const orig = r.closeOwnSession.bind(r); + r.closeOwnSession = () => { + closes++; + orig(); + }; + r.browser = { ...r.browser, sessionExists: async () => false }; + await r.attachTo("http://127.0.0.1:9222"); + assert.equal(closes, 1, "the abandoned self-created session is closed, as on quit"); + assert.equal(r.selfCreated, false); + + // A session someone else created is never touched by the switch. + const other = quiet(mkRenderer()); + other.selfCreated = false; + let otherCloses = 0; + const origOther = other.closeOwnSession.bind(other); + other.closeOwnSession = () => { + otherCloses++; + origOther(); + }; + other.browser = { ...other.browser, sessionExists: async () => false }; + await other.attachTo("http://127.0.0.1:9222"); + assert.equal(other.selfCreated, false); + // closeOwnSession may be invoked but must no-op without ownership; the + // observable contract is that it never runs the close for foreign sessions + // — guarded inside by the selfCreated check. + void otherCloses; +});