browser control — drive a real Chrome; the boundary is which browser (0.9.0) - #22
Merged
Conversation
Foundation for browser control, with the research settled and the two load-bearing pieces verified. The capability surface (entries/bridge/manifest) is NOT wired yet — these modules are inert until it is. What the research settles (docs/design/browser-control.md): - Chrome needs no extension to be attached to. Since M144 an external process can request a remote-debugging session against the user's RUNNING Chrome; Chrome itself shows the permission dialog and the "controlled by automated test software" banner. This machine runs Chrome 151, so it is available on stable. That removes the main reason to build, sign, distribute and maintain an extension plus a native-messaging host. - But Chrome's consent is ALL-OR-NOTHING: it authorizes the browser, not a set of sites, and exposes no per-tab or per-origin scoping. The boundary the owner actually wants has to come from Plexus. Which is the point of Plexus. So: one capability surface, two modes differing only in where the CDP endpoint comes from — `launch` (a fresh Chrome on an ephemeral port with its own user-data-dir, no cookies) and `attach` (the user's Chrome, owner opt-in, reaching every session it is logged into). The origin gate is the security spine and carries three properties: an EMPTY allowlist denies everything (unset is inert, not open); comparison is by parsed ORIGIN, not string prefix, so `github.com.evil.com` and `github.com@evil.com` cannot impersonate an allowed host; and it judges the URL that will actually be acted on, never a field the agent declares alongside it. Refusals name the requested origin and the owner's remedy but never the allowlist, so a denial is not an enumeration oracle. 15 tests, including every impersonation case. The CDP client takes no dependency: Puppeteer/Playwright would each add a browser download and a large tree to wrap what is a request/response correlator plus five methods over Bun's native fetch + WebSocket. It holds no policy — it drives whatever target it is handed, so the gate stays the single place the boundary lives. A closed socket fails every in-flight call rather than stranding it. Verified against real Chrome: discovery, target listing, navigate, a fixed-script read (real page text), a 15KB screenshot, and the gate refusing the lookalike origin. Arbitrary JS evaluation is deliberately not a capability — it would make the origin gate decorative. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…origin gate Completes the source: entries, per-session bridge, SourceModule, registration and the how-to-use skill, on the foundation committed earlier. Surface: `tabs.list` / `page.read` / `page.screenshot` (read) and `page.navigate` / `page.click` / `page.type` (execute — per-use approval by default under ADR-5, which the agent cannot lift). Deliberately no arbitrary JavaScript evaluation: it would make the origin boundary decorative, since a page can fetch anywhere its own origin allows. The gate runs in front of every call. `navigate` is judged on its DESTINATION; every other op on the tab's CURRENT url. The allowlist is also the DIRECTORY — tabs on unauthorized origins are neither listed nor addressable, and an unauthorized targetId is refused with the SAME message as a made-up one, so the error cannot be used to enumerate the owner's open tabs. Two bugs found by driving a real Chrome rather than trusting the code: - The gate judged the url from `/json/list`, a snapshot that LAGS a navigation. That would have refused calls on pages that are now authorized and — the half that matters — admitted calls on pages that had since moved away. It now reads the live url from the page itself, which is what the design always claimed. - Chrome's HTTP discovery names the target id `id`; the CDP domain names it `targetId`. Reading `targetId` off the HTTP JSON yields undefined silently, so every id-keyed lookup missed: the session never remembered its own tab and the tabs list shipped without ids. Normalized in one place in the client. Health is honest: an empty allowlist reports UNAVAILABLE with the reason, rather than "ok" for a capability that would refuse every call. Verified end to end against real Chrome (6 cases): navigate/read/screenshot on an authorized origin; an unauthorized origin refused without disclosing what IS authorized; everything refused when nothing is authorized; tabs invisible under a different allowlist; a real targetId and a fake one refused identically; and a click that leaves the authorized origin reported rather than silently followed. Plus 15 hermetic gate tests. Both source rosters updated. 1483 pass, 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pandazki
marked this pull request as ready for review
August 14, 2026 06:12
A runnable end-to-end demo through the REAL gateway rather than the bridge class: enroll -> handshake -> grant -> invoke, driving Chrome to www.deepseek.com and returning the page's own text. It exercises the three boundaries in one run: an un-credentialed invoke is denied, an unauthorized origin is refused with a message that does not name what IS authorized, and an agent the owner did not opt in as standing gets `grant_pending_user` for the execute verb. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…holds one socket An allowlist entry now authorizes its host AND that host's subdomains, because a site whose apex redirects to `www` is one site to the owner who typed it. The match is on the parsed host at a dot boundary, so `deepseek.com.evil.com` and `evildeepseek.com` stay out; an IP entry matches exactly, since suffix logic on numbers would let `168.1.5` admit `192.168.1.5`; a single-label entry is dropped so `com` cannot authorize the web. Scheme still has to match. The debugging socket is now held for the life of the session instead of redialled per invoke, so a run of calls on one tab is one conversation — under `attach` that is also what stops Chrome being asked to re-authorize between two steps of the same task. What is reused is the transport, never the verdict: the gate still reads the tab's live URL on every call. A socket that dies between calls is re-established once, and only because that error proves nothing ran. Plexus now puts back what it takes: sockets and Plexus-opened tabs are closed on shutdown, and the launch profile no longer restores the previous run's tabs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`page.screenshot` is a read verb, so the demo now shows the same tab coming back as PNG bytes alongside its text. `PLEXUS_DEMO_SHOT` writes the image to disk so a run leaves something lookable behind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…settle `read` and `screenshot` only ever saw the viewport, and nothing could wait for an app that renders after load, so a long page read as short and a slow one read as empty. `page.scroll` moves the viewport to an element, to top/bottom, or by pixels, and reports `atBottom` so a loop knows when it has seen everything. `page.wait` blocks for a selector, a string, or loading to finish, and returns `found:false` on timeout rather than erroring — a timeout is a fact about the page, not a broken call. `page.screenshot` takes `fullPage`. Both new verbs are `read`: moving the viewport or waiting dispatches nothing on the site's behalf. The post-navigation settle now polls for the document to finish instead of sleeping a fixed 800ms, which was simultaneously too long for a fast page and too short for a slow one. The design doc now records what is deliberately NOT mapped — arbitrary evaluation, console/network, history, cookies and storage — so absence reads as a decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…wo new reads Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Probing a real form found two failures that reading the code would not. A form field has NO rendered text, so `page.read` — the only way an agent could look — shows the label "Email" and nothing saying the field is `input[name=em]`. The agent had to invent selectors, the exact thing its skill tells it not to do. `page.elements` snapshots the interactive elements with a selector that resolves, plus label, type, current value, options and required/checked state. Selectors are COMPUTED; the page's DOM is not stamped with ref attributes just so we can look at it. A password reports its length and never its content. `page.type` wrote `el.value` directly, which React ignores: it installs its own value setter, sees no change when the property is written behind its back, and swallows the event — so the field looked filled, React's state stayed empty, and the call returned `typed: true`. It now goes through the native prototype setter, handles textarea, contenteditable and `<select>` (by value or visible label), and reports whether the field really holds the value — without echoing the value back. Navigation waited on `readyState`, which the OUTGOING document still satisfies for a moment after `Page.navigate` returns; a navigation could report the previous page's empty title, or fail outright on a page that had loaded. It now arms `Page.loadEventFired` before navigating, and a state read that lands on a context swap retries instead of failing. The e2e drives a local page carrying both hazards, and asserts the framework saw the change by reading it back off the page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four things real browser use needs and the verb set could not do. FRAMES. A cross-site iframe runs in its own renderer and Chrome exposes it as its own target with its own debugger socket — verified against Chrome 151, not assumed — so a frame is driven exactly like a tab and JUDGED exactly like one, on its own domain. An authorized page does not authorize what it embeds; under attach that is what stops an allowed page carrying a logged-in SSO frame into reach. `frames.list` shows only authorized frames; the page verbs take a frame's targetId. SHADOW DOM. One deep resolver now backs elements, click, type, wait and press — patching it into some expressions and not others passes tests while real use breaks. A shadow- hosted element has no document-level CSS path, so selectors became hop paths (`my-form >>> input[name=email]`), produced by the snapshot and accepted by every acting verb. Closed roots stay unreachable and are recorded as a limit. KEYS. `page.press` sends real key events, so Enter submits and arrows walk a suggestion list. `type` gains `keystrokes`, because a value written into a search box never makes its suggestions appear — that listens for keydown. It verifies the field afterwards rather than assuming, and a keyDown carrying `text` already inserts the character, so sending a `char` event too typed "plexus" as "pplleexxuuss". UPLOAD. A file input's value is not settable from page JS — the protection that stops a website helping itself to your disk — so this goes through the CDP DOM domain. It hands a website a local file, so the jail is the feature: paths are relative to one owner-named directory under the same lexical-plus-realpath confinement the file sources use, and unset means every upload is refused. Audit gets the full path and size; the wire gets the file name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…undary The goal changed, and it is a better goal. What actually decides the blast radius is WHICH BROWSER an agent gets — a fresh empty profile that is nobody, or the browser the owner is logged into. Inside an authorized page, `click` + `type` already equal full user agency: it can order, send, delete, change settings. Withholding `evaluate` on top of that prevented no real harm and only made the capability worse than the alternatives an owner would reach for instead. So the page surface is open. `page.evaluate` runs arbitrary JavaScript; `page.cdp` sends any page-scoped DevTools command verbatim. What stays closed is the part of CDP that does not belong to any page. Page-scoped commands act as the document and are therefore bounded by the browser's OWN same-origin policy — a real limit, enforced by Chrome, not invented here. Browser-global ones act as the browser: `Target.attachToTarget` reaches the tab holding your bank, and `Network.getAllCookies` hands over every site at once. Withholding those is what keeps "anything on github.com, nothing of your bank" a true sentence rather than a slogan. It is an allowlist of domains, so a domain Chrome adds next version is refused until someone looks at it. Four commands inside page domains are withheld too, each because it steps around a boundary with its own gated verb: the cookie jar, `Page.navigate`, `DOM.setFileInputFiles`, and the download-behaviour setters. An empty allowlist now means the open web for a LAUNCHED browser — a wall around a browser with no cookies and no sessions protects nothing and only breaks the first call. For the owner's own browser it still means refuse everything. The scheme rule holds in both, so "the whole web" never means the local disk or Chrome's settings pages. The residual is stated rather than papered over: JavaScript can send a tab anywhere, because the browser owns navigation. The agent still cannot READ where it landed, and is told the tab left — the same property `page.click` has always had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new execute-class source that drives a real Chrome over the DevTools Protocol with no Puppeteer/Playwright dependency. The owner's decision is WHICH BROWSER: a fresh empty profile that is nobody, or the browser they are logged into. The page surface is fully open — arbitrary JavaScript and raw page-scoped CDP included — because inside an authorized page `click` + `type` already equal full user agency. The browser-global half of CDP is withheld, which is what keeps a domain allowlist meaningful rather than decorative. Sixteen verbs: tabs/frames listing, read, elements, screenshot, scroll, wait (read); navigate, click, type, press, upload, evaluate, cdp (execute). Frames are judged on their own domain, shadow roots are addressed with hop paths, file upload is confined to an owner-named directory, and everything is audited. 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.
A new execute-class source,
browser-control, that drives a real Chrome over the DevTools Protocol. No Puppeteer/Playwright dependency — CDP is JSON over a WebSocket and Bun ships both.The decision this feature is built around
Which browser the agent gets.
launch(default)attach(owner opt-in)--user-data-dirEverything else is downstream of that. Inside an authorized page,
click+typealready equal full user agency — it can order, send, delete, change settings — so withholdingevaluateon top of that prevents no real harm and only makes the capability worse than the alternatives an owner would reach for instead. The page surface is open,page.evaluateand rawpage.cdpincluded.The one split that is kept
Chrome's protocol mixes two different things under one socket:
Runtime,DOM,Page,Network,Input,Emulation,Debugger, …) acts as the document, and is therefore bounded by the browser's own same-origin policy — a real limit, enforced by Chrome, not invented here. Fully available.Target,Browser,Storage, …) acts as the browser.Target.attachToTargetreaches the tab holding your bank;Network.getAllCookieshands over every site at once. Withheld.That split is what keeps "the agent can do anything on github.com, and cannot reach your bank" a true sentence rather than a slogan. It is an allowlist of domains, so one Chrome adds next version is refused until someone looks at it. Four commands inside page domains are also withheld, each because it steps around a boundary with its own gated verb: the cookie jar,
Page.navigate,DOM.setFileInputFiles, and the download-behaviour setters.Verbs
read —
tabs.list,frames.list,page.read,page.elements,page.screenshot,page.scroll,page.waitexecute —
page.navigate,page.click,page.type,page.press,page.upload,page.evaluate,page.cdpdeepseek.comcoverswww.deepseek.com, matched on the parsed host at a dot boundary —deepseek.com.evil.comandevildeepseek.comstay out, and an IP entry matches exactly so168.1.5cannot admit192.168.1.5.my-form >>> input[name=email]) produced bypage.elementsand accepted by every acting verb.attachthat is what stops Chrome being asked to re-authorize between two steps of the same task.Found by driving a real browser, not by reading the code
/json/listsnapshot, which lags a navigation — it would have admitted a page that had moved away. It now reads the live URL from the page.id; the CDP domain names ittargetId. Reading the wrong one is silentlyundefined.readyStateafterPage.navigateanswers about the outgoing document, which is stillcompletefor a moment. Navigations reported the previous page's empty title, or failed on a page that had loaded.page.typewroteel.valuedirectly. React installs its own setter, sees no change, and swallows the event — the field looked filled, React's state stayed empty, and the call returnedtyped: true. It now goes through the native prototype setter and verifies rather than assuming.keyDowncarryingtextalready inserts the character; sending acharevent too typed "plexus" as "pplleexxuuss".Residual risk, stated
attachreaches the owner's authenticated web. Inside an allowed domain, anything reachable without re-authentication is reachable by an approved call. JavaScript can also send a tab anywhere — the browser owns navigation — but the agent cannot read where it landed, and is told the tab left.Not verified from here: Chrome M144's permission-dialog cadence under
attach, which needs the owner to enablechrome://inspect/#remote-debuggingon their own browser.Verified
Full suite 1509 pass / 0 fail; 19 e2e cases against a real Chrome, plus the origin gate and CDP policy as pure units.
examples/browser-control-demo/run.tsdrives the whole agent loop — enroll → handshake → grant → invoke — to a real site.