Free Shipping on all orders · Priority Mail Shipping with fee of $8.00
Software / Case studies / browser-broker
Case study 8 of 9

browser-broker

A proxy on Chrome's debugging port that lets several agents share one logged-in browser without seeing or closing each other's tabs. It filters CDP per connection, swallows focus-stealing and browser-quitting commands, and needs no client changes.

github.com/nicedreamzapp/browser-broker, Python, MIT, 19 commits, 2026-09-05 to 2026-10-06

The problem

Several AI agents (Claude Code sessions, Playwright scripts, chrome-devtools-mcp, his own browser-agent) wanted to use one real browser profile, the one with the logins. The Chrome DevTools Protocol has no notion of ownership (README.md), so every agent could see every tab, grab the human's tab, steal window focus, or quit the whole browser. Running separate browsers loses the logins.

Constraints

  • No changes to clients. Whatever already connected to port 9222 had to keep working: Playwright's connectOverCDP, Puppeteer, raw websockets, and legacy GET /json callers.
  • The human's own browser, so agent tabs live as background tabs in the human's window.
  • Chromium throttles hidden tabs. The launch script sets the background-timer flags, and the README notes that requestAnimationFrame still never fires in a hidden tab.
  • macOS open -a and AppleScript activate bypass CDP entirely, so a CDP proxy can't intercept them.

What he built

proxy.py (about 960 lines) takes over port 9222 and moves the real browser behind it to 9229.

  • Registry maps each tab to an owner. create() registers ownership before returning, because Chrome announces new targets to auto-attached clients immediately (comment at proxy.py:210-212). Owned tab ids are saved so orphans are closed after a restart.
  • _browser_session() gives each connection a filtered view: Target.getTargets, attach events, sessions and child frames only show the caller's own tabs. It holds the createTarget reply until attachedToTarget has been delivered, because Playwright and Puppeteer expect that order and the proxy now spans two sockets.
  • FOCUS_STEALERS (Page.bringToFront, Target.activateTarget) and BROWSER_KILLERS (Browser.close, Browser.crash, Browser.setWindowBounds) are answered with success and never forwarded.
  • front_door() is a raw asyncio server on 9222, because the websockets library rejects any HTTP method but GET before user code runs, and newer clients send PUT /json/new.
  • Stage is a queue for the rare case an agent needs to show the human a page: one at a time, with a busy response (HTTP 409) and a dwell time. A sample Claude Code PreToolUse hook redirects open -a calls to it.

The README states the ownership rule is "deliberately dumb": only tabs the proxy created are visible. Focus detection was rejected because "a heuristic that is wrong once is worse than a rule that is boring."

Upstream credit: Chrome DevTools Protocol and Chromium, the Python websockets library. Linux support was verified by an outside contributor (@Hronom).

Hard bugs

  • Puppeteer quit the browser (84248ea). browser.close() sends Browser.close, which through raw CDP closed every window and tab. Now swallowed. The same commit added front_door and fixed a hang in Playwright's new_context.
  • Agents closed each other's pages (b06d599). The stage kept a single tab and closed the previous one regardless of owner. It now tracks one tab per owner.
  • Window thrown back and forth (01d16b9). Two Claude Code sessions ran open -a minutes apart and fought over the window while the proxy log was clean. The cause was macOS calls that never touch CDP; the fix was the stage plus the hook.
  • Claim corrected by measurement (5fb3028). The docs said the background flags kept hidden tabs painting. A test showed 0 animation frames in a hidden tab while timers kept running, and the docs were corrected.

Results (from the repo)

  • Stage behavior, measured (README.md): agent A shown after 0.7 s; agent B refused in 0.0 s while A held the stage; agent C waited and was shown at 7.9 s.
  • Hidden tab after 4 s: 0 animation frames while a 100 ms timer kept ticking, on Ubuntu/Chrome and macOS/Brave (README.md).
  • Verified by hand on macOS, Linux (outside contributor) and Windows 11. No automated tests; the README says so.

What it shows

He debugs at the protocol level, chooses a simple rule that can be explained over a clever one that can be wrong, and corrects his own docs when a measurement disagrees with them.