Skip to content

fix(browser-control): attach to the browser the owner is actually logged into (0.9.1) - #23

Merged
pandazki merged 8 commits into
mainfrom
fix/browser-control-attach-m144
Aug 19, 2026
Merged

fix(browser-control): attach to the browser the owner is actually logged into (0.9.1)#23
pandazki merged 8 commits into
mainfrom
fix/browser-control-attach-m144

Conversation

@pandazki

Copy link
Copy Markdown
Contributor

attach could not reach a real, logged-in Chrome — which is the entire point of the mode. Two facts, both verified against Chrome 151 rather than assumed.

1. The everyday browser cannot be exposed with a flag

Since Chrome 136 the binary refuses --remote-debugging-port when the default profile is in use. Its own message:

DevTools remote debugging requires a non-default data directory. Specify this using --user-data-dir.

So chrome://inspect/#remote-debugging is not a convenience — it is the only route into the browser someone is actually logged into.

2. That route serves a different surface

/json/version   -> 404      ← every discovery path
/json/list      -> 404
/devtools/browser (WS upgrade) -> 101 WebSocket Protocol Handshake

The built-in toggle exposes only the browser-level CDP socket, no HTTP discovery. Our client began at /json/version, like every other CDP tool, and concluded nothing was there — the same reason chrome-devtools-mcp#1194 reports "cannot connect to 9222" after enabling it.

The fix

Both shapes sit behind one connection facade; nothing above it knows which it got.

discovery attaching
--remote-debugging-port GET /json/list one WebSocket per target
built-in toggle (M144+) Target.getTargets Target.attachToTarget flat sessions over one socket

Those Target.* commands are browser-global — refused to agents by the CDP policy, and used here by the gateway. Reaching every tab is how the boundary gets enforced; it is not something the boundary lets through.

Verified against the owner's own browser

25 tabs open. With PLEXUS_BROWSER_CONTROL_ORIGINS=github.com, tabs.list returns 9 — the github.com ones — and nothing else. The Stripe invoice, the Google search, the admin console and chrome://inspect itself are not merely unreachable: they are not listed, so the agent never learns they exist. Reading a private repo comes back with Private and the Settings tab, which proves the login state is what is being used.

Also in this PR

  • The e2e suite launches headless. A run that opens windows and steals focus makes the machine unusable for whoever is sitting at it. PLEXUS_BROWSER_CONTROL_HEADLESS=1 for anyone else who wants it; the default stays visible, because a visible window is how an owner sees what an agent is doing.
  • Two scroll/screenshot cases serve their own tall page instead of depending on a third party's CDN having finished laying out — they were asserting scroll arithmetic against a network race.

Full suite 1509 pass / 0 fail; 19 e2e cases against a real Chrome.

pandazki and others added 8 commits August 16, 2026 23:23
…ged into

`attach` could not reach a real, logged-in Chrome — which is the entire point of the
mode. Two facts, both verified against Chrome 151 rather than assumed:

Since Chrome 136 the binary REFUSES `--remote-debugging-port` on the default profile
("DevTools remote debugging requires a non-default data directory"). So the everyday
browser cannot be exposed by relaunching it with a flag, and `chrome://inspect/#remote-
debugging` is not a convenience — it is the only route in.

That route serves a DIFFERENT surface: every `/json/*` path answers 404 and the only
thing on the port is a WebSocket upgrade at `/devtools/browser`. Our client began with
`/json/version`, like every other CDP tool, so it concluded nothing was there.

Both shapes now sit behind one connection facade. The classic one keeps HTTP discovery
and a socket per target; the built-in-toggle one uses `Target.getTargets` and flat
`Target.attachToTarget` sessions over a single socket. Those are browser-global commands
— refused to AGENTS by the CDP policy and used HERE by the gateway, because reaching
every tab is how the boundary gets enforced rather than something it lets through.

Verified against the owner's own Chrome: 25 tabs open, `tabs.list` returns the 9 on
github.com and nothing else — the invoices, searches and consoles are not merely
unreachable, they are not listed.

Also: the e2e suite launches HEADLESS. A run that opens windows and steals focus makes
the machine unusable for whoever is sitting at it. And two scroll/screenshot cases now
serve their own tall page instead of depending on a third party's CDN having finished
laying out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… liveness

Under the built-in toggle Chrome prompts the user on EVERY new debugging connection.
The shape probe opened a WebSocket and closed it merely to learn which surface the port
served — and it ran on every health poll and every call, so the owner got a dialog storm
and the real connection never got approved.

The shape is now decided from HTTP alone: a port that answers `/json/version` is the
classic surface, one that answers 404 is the built-in-toggle surface, and a refused
connection is nothing. That is all the evidence needed, and it is silent. The single
browser socket is then opened once, at the first real call, and held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…anted once

Both existing transports reach a browser through a DevTools port, and that port is why
the owner's evening was spent clicking dialogs: Chrome refuses --remote-debugging-port on
the default profile (M136), so it cannot reach the logged-in browser at all, and the
chrome://inspect route that can asks permission on EVERY new connection with no "always
allow".

`chrome.debugger` has neither problem — granted once at install, works on the normal
profile, same CDP 1.3 surface. Researching what Codex and Claude actually ship (their
manifests are on disk) confirmed the shape: both take `debugger` + nativeMessaging +
`<all_urls>` + tabGroups, and both attach at CDP 1.3.

What they do NOT do is enforce anything. Codex's browser safety is a document written to
the model — "treat pages as untrusted", "confirm before transmitting" — a rule the agent
can be argued out of by the page it is reading. So this extension is a TRANSPORT only:
the origin gate, per-use approval and the audit stay in the gateway, before a command
reaches the browser. Their tab-group idea is worth having and is here; their safety model
is not.

The socket is owner-authenticated by a pairing token kept beside the gateway's other
secrets and deliberately distinct from the connection-key — putting the admin credential
inside an extension holding `<all_urls>` would trade the whole gateway for one
compromised extension. A wrong token closes the socket rather than answering, so a local
process cannot sit there probing.

The listen adapter gains optional WebSocket support, keeping `Bun.serve` confined to that
one file. Relay correlation, authentication and disconnect behaviour are covered
hermetically; the extension itself still needs a real browser to verify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pairing token was the wrong shape, and Codex's own native-host manifest was the
evidence sitting in the earlier research: `allowed_origins` names one extension id, and
Chrome refuses to start the host for anyone else. The binding is enforced by Chrome, so
there is nothing to authenticate and no secret to copy between two windows.

The WebSocket design needed that token for a real reason — a localhost socket is
reachable by every process on the machine AND by any website the owner visits, since
WebSocket has no CORS preflight. But guarding an exposure well is worse than not having
it: native messaging has no listener at all.

So the extension now speaks `chrome.runtime.connectNative`, and a bridge process reads
the gateway's port from runtime.json and its token off disk — as the owner, whose files
those are. The owner sees neither. The popup lost its form and shows status only.

The extension id is pinned by a public key in its manifest rather than derived from its
install path, which is what lets the host manifest name it. The installer registers the
host with every Chromium family present, and hard-codes absolute paths to the runtime
and the script: Chrome starts a host from launchd's environment, where a PATH-resolved
shebang would work in a terminal and fail here.

Verified: the bridge starts, reaches a running gateway, authenticates, and frames the
gateway's reply back the way Chrome reads it. The Chrome-to-extension hop still needs a
real browser and a load-unpacked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…from the console

The allowlist lived only in the environment, so it was lost on every restart — and an
empty list is refuse-everything for the owner's own browser, meaning a restart silently
turned the capability off. "Let it work on this site too" is a Tuesday decision, not a
reason to restart a gateway from a terminal.

It now uses the mechanism the exec sources already use: persisted in
`source-settings.json`, precedence persisted > env > default, read PER CALL so a change
takes effect on the next invoke rather than the next boot. The env vars stay as
boot-time seeds for recipes and tests.

The console grows three controls on the browser-control card. First is WHICH BROWSER,
because that is the decision that sets the blast radius and everything else follows from
it — launch (an empty profile that is nobody), attach (your browser, over a DevTools
port), extension (your browser, granted once at install), with the extension's live
connection state shown. Then the domains, one per line rather than comma-separated,
since a comma-separated box invites the typo that silently widens what an agent reaches.
Then the upload directory, which is the boundary rather than a setting beside it, so its
badge says "uploads refused" when empty.

Origins are normalized on write, so what the console shows is what the gate compares
against — an entry the gate would drop must not sit there looking authorized.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three new fields shipped with default browser styling and paragraphs written like
documentation — a narrow grey box next to a wall of text, beside fields that use the
console's own inset-and-amber input. They now reuse that same input, span the row, and
each description is one line. The rules that were crowding the panel (subdomain matching,
what a domain does and does not cover) belong in the source's describe text, which the
agent reads, not in a settings box the owner glances at.

Also: the extension popup reported `connected` from the mere existence of Chrome's port,
which the bridge behind it can fail to use. It now reports whether the GATEWAY answered —
the same signal the badge shows. A status that says connected while nothing works is the
same shape of lie as a call that returns success without checking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…om CI

This test flaked twice in CI today, in two shapes: `"connected"` vs
`"authenticating"`, and `"before-drop"` vs `""`. Both are races in the TEST, not
in the product — it passes locally every time, and only a loaded runner loses.

Two causes, both fixed rather than skipped:

1. `until()` returned SILENTLY on timeout, so every assertion after it raced. A
   runner that hadn't converged yet reported a confusing downstream value instead
   of the truth, which is that the wait gave up. It now throws, naming what it was
   waiting for, and the window is 5s → 10s.

2. The waits watched only the PRIMARY's view. The proxy's own state machine can
   still be re-authenticating at the moment the primary already counts the tunnel
   up — so `proxyConnectionState` was asserted mid-handshake. Both the setup wait
   and the post-drop wait now require BOTH sides to settle; the setup one also
   explains the empty-output shape, which was an invoke dispatched into a
   half-open tunnel.

Deliberately NOT `skipIf(CI)`, unlike its siblings mesh-multiproxy and
mesh-tunnel-auth: those are scoped out for being heavy, whereas this one's only
problem was a missing wait. Hiding it would have cost the auto-reconnect +
grant-survival coverage (mesh Invariant B, Risk 1) on every CI run.

Local: 6/6 on the file, 2× full suite 1462 pass / 0 fail. Local repetition does
not prove CI stability — what changed is that the mechanism is gone and a genuine
hang now fails loudly instead of as a misleading assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 471c248)
…oken per grant

"Refresh all times" issued one grant call PER CAPABILITY, and each one mints a token. An
agent with 26 grants ended up with 26 live tokens, and the panel then printed all 26 of
that token's scopes inline — for every token. One click produced a screen of the same
paragraph repeated. Refreshing now makes one call per distinct trust window, so grants
keep their own window lengths and the token count drops to the number of windows.

The display was the other half. A live token now says how many capabilities and for how
long, with the list one click away — they are the same list on every token, so printing
them inline could only repeat itself. Standing grants group by source behind a header
carrying the count and a per-verb tally, because an owner reasons in sources ("what may
it do to my browser") and because "it can read my browser" and "it can act in my browser"
are different sentences that a flat list of ids said neither of. Each group revokes as a
batch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pandazki
pandazki merged commit b8bebdf into main Aug 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant