fix(browser-control): attach to the browser the owner is actually logged into (0.9.1) - #23
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
attachcould 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-portwhen the default profile is in use. Its own message:So
chrome://inspect/#remote-debuggingis not a convenience — it is the only route into the browser someone is actually logged into.2. That route serves a different surface
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.
--remote-debugging-portGET /json/listTarget.getTargetsTarget.attachToTargetflat sessions over one socketThose
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.listreturns 9 — the github.com ones — and nothing else. The Stripe invoice, the Google search, the admin console andchrome://inspectitself are not merely unreachable: they are not listed, so the agent never learns they exist. Reading a private repo comes back withPrivateand the Settings tab, which proves the login state is what is being used.Also in this PR
PLEXUS_BROWSER_CONTROL_HEADLESS=1for anyone else who wants it; the default stays visible, because a visible window is how an owner sees what an agent is doing.Full suite 1509 pass / 0 fail; 19 e2e cases against a real Chrome.