How it works¶
Everything on this page is read from proxy.py, the file the installer runs.
The layout¶
tools ──▶ 127.0.0.1:9222 front door (asyncio TCP server)
│ plain HTTP: answered here (/json, /json/new, /json/version ...)
│ websocket upgrade: piped byte for byte to ──▶ inner websocket server
│ (loopback, random port)
▼
Registry: which tab belongs to which owner
│
▼
your browser 127.0.0.1:9229 (real profile)
scripts ──▶ 127.0.0.1:9223 REST API (lease, renew, release, show, status, stage)
The front door exists because the websockets library rejects any HTTP method
other than GET before user code runs, and Chrome 111+ clients send PUT /json/new.
So proxy.py reads the request head itself, answers plain HTTP directly, and splices
websocket upgrades through to an inner websockets server on an ephemeral loopback port.
The proxy also keeps one control websocket of its own to the browser
(Upstream). It uses it to create and close tabs and to list targets. If that
socket drops, any waiting calls fail at once and the next call reconnects.
Ownership¶
The rule is simple on purpose: the proxy only hands out tabs it created. It never lists, attaches to, or closes a tab it didn't make. Your tabs are invisible to every agent, with no detection heuristics. The README explains why focus detection was rejected: window checks give false negatives across macOS Spaces.
The Registry maps each target id to an owner and a kind:
| Kind | Who | Lifetime |
|---|---|---|
ws |
A browser-level websocket client. Owner id is ws:<8 hex chars>, one per connection. |
Closed when that connection ends. |
pool |
A legacy HTTP client via GET /json or /json/new. |
Closed when its page socket closes, or after 60 s if nobody ever attaches. |
rest |
A POST /lease from the REST API. |
Closed on /release, or when the TTL passes without a /renew. |
Each tab is created with Target.createTarget and background: true: an ordinary
background tab in the window you already have open. Ownership is registered before
the reply goes back, because Chrome announces new targets to auto-attached clients
right away and the filter has to know the tab is theirs.
Filtering a browser-level client¶
For each browser-level client, proxy.py opens its own upstream browser socket and
pumps messages both ways through two filters.
Client to browser:
- Commands about a target id (
attachToTarget,closeTarget,detachFromTarget,getWindowForTarget) are refused with Chrome's ownNo target with given id foundif the target isn't this client's. getTargetsandcreateTargetare answered by the proxy itself.- Focus commands and browser-wide commands are answered with an empty success.
Browser to client:
Target.attachedToTargetfor a target that isn't this client's (and isn't nested under one of its sessions) is dropped, and the proxy detaches that session upstream.- Session traffic (
sessionId) is passed only for sessions this client owns. targetCreated,targetInfoChangedandtargetDestroyedare passed only for this client's targets. Aseenset makes sure the client still hearstargetDestroyedfor its own tab after the registry has forgotten it.
There is a small race to handle: Chrome may announce a new target before the
proxy's createTarget reply comes back. While this client has a create in flight,
the filter waits up to about 1 second for the registry to catch up. Otherwise it
decides at once, so someone else's tab never stalls the event stream.
If an upstream call fails while handling a command, that one command gets a
browser-broker: ... error and the session stays up.
Page sockets¶
A connection to /devtools/page/<id> is accepted only for a tab in the registry.
A pool tab accepts one socket. Messages are passed through untouched except for the
focus and browser-wide commands. When the socket closes, a pool tab is closed; a
REST lease tab stays until its lease ends.
Leases and the reaper¶
Every 5 seconds a background task:
- trims
launchd.err/proxy.errif they are plain files over 10 MB; - closes REST tabs past their expiry and pool tabs never attached within 60 s;
- drops bookkeeping for tabs that no longer exist in the browser (for example after a browser restart);
- writes a heartbeat line once per
BROKER_HEARTBEATseconds, so a silent log means the proxy is dead.
Surviving restarts¶
The proxy writes the ids of every tab it owns to tabs.json (atomically, via
tabs.json.tmp). On startup it closes any of those ids that are still open in the
browser, so a crash doesn't leave orphaned agent tabs in your window. Tabs the
browser itself restores after a browser restart come back with new ids and can't be
told apart from yours, so they are left alone.
At startup the proxy needs the browser to answer on the upstream port. If it doesn't,
it logs no browser answering CDP on 9229 ... launch it with --remote-debugging-port=9229 first
and exits with code 1, and the service manager restarts it.
The stage¶
Everything above keeps agents out of your way. The stage is the one deliberate exception: a way for an agent to put a page in front of you, one agent at a time.
It exists because open -a "Brave Browser" <url> and AppleScript activate are
macOS calls, not CDP, so the proxy can't see or block them. Two Claude Code sessions
running open -a minutes apart threw the window back and forth while the proxy log
stayed clean.
POST /show (or bb-show, or broker_client.show()):
- waits up to
waitseconds (default 30) for the stage lock; if it doesn't get it, returns409with who holds it and for how long; - closes this owner's previous stage tab, if any. Never another owner's;
- opens the URL in a foreground tab, calls
Target.activateTarget, and on macOS runsosascript -e 'tell application "<BROWSER_APP>" to activate'; - returns at once and holds the lock for
dwellseconds (default 20), then releases it.
If showing fails, the lock is released immediately so later callers don't get stuck
on 409. POST /unstage releases it early. On Windows only Target.activateTarget
is used.
The stage is cooperative. It works because bb-show is shorter to type than
open -a "Brave Browser". To enforce it in Claude Code, use the example PreToolUse
hook described in Configuration.
Hidden tabs and rendering¶
Agent tabs are background tabs, so their visibilityState is hidden. Two things
follow, both measured in issue #1:
- Without the
--disable-*backgrounding*flags, Chromium throttles a background tab's timers to about once a second, which is enough to stop a modern single-page app from rendering any rows. The launch scripts pass those flags. - With the flags, timers and fetch callbacks run normally, but
requestAnimationFramestill never fires in a hidden document. That is Page Visibility behaviour and no flag changes it. In the test, a hidden lease tab fired 0 animation frames in 4 seconds while its 100 ms timer kept ticking, on both Ubuntu/Chrome 153 and macOS/Brave.Page.captureScreenshotstill returned a real PNG, so a screenshot is not proof the page is live.
Security model¶
Localhost only, no authentication. Anything that can reach 127.0.0.1:9223 can
drive your logged-in browser, which is already true of a browser with remote
debugging on. Don't expose either port. The ownership filter keeps cooperating
tools apart; it is not a sandbox against a hostile local process, which could talk
to port 9229 directly.