Skip to content

browser control — drive a real Chrome; the boundary is which browser (0.9.0) - #22

Merged
pandazki merged 11 commits into
mainfrom
feat/browser-control
Aug 16, 2026
Merged

browser control — drive a real Chrome; the boundary is which browser (0.9.0)#22
pandazki merged 11 commits into
mainfrom
feat/browser-control

Conversation

@pandazki

@pandazki pandazki commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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)
profile one Plexus starts, its own empty --user-data-dir the browser the owner is already using
what is reachable the public web, as nobody everything that browser is logged into
no domains named the open web — a wall around a browser with no cookies protects nothing refuses everything

Everything else is downstream of that. Inside an authorized page, click + type already equal full user agency — it can order, send, delete, change settings — so withholding evaluate on 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.evaluate and raw page.cdp included.

The one split that is kept

Chrome's protocol mixes two different things under one socket:

  • page-scoped (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.
  • browser-global (Target, Browser, Storage, …) acts as the browser. Target.attachToTarget reaches the tab holding your bank; Network.getAllCookies hands 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

readtabs.list, frames.list, page.read, page.elements, page.screenshot, page.scroll, page.wait
executepage.navigate, page.click, page.type, page.press, page.upload, page.evaluate, page.cdp

  • Domains cover subdomains. deepseek.com covers www.deepseek.com, matched on the parsed host at a dot boundary — deepseek.com.evil.com and evildeepseek.com stay out, and an IP entry matches exactly so 168.1.5 cannot admit 192.168.1.5.
  • Frames are judged on their own domain. A cross-site iframe is its own CDP target (verified against Chrome 151), so an authorized page does not authorize what it embeds.
  • Shadow roots are addressed with hop paths (my-form >>> input[name=email]) produced by page.elements and accepted by every acting verb.
  • Upload is jailed to one owner-named directory, under the same lexical-plus-realpath confinement the file sources use; unset means every upload is refused.
  • One socket per session, not per invoke — under attach that 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

  • The gate judged the /json/list snapshot, which lags a navigation — it would have admitted a page that had moved away. It now reads the live URL from the page.
  • Chrome's HTTP discovery names the target id id; the CDP domain names it targetId. Reading the wrong one is silently undefined.
  • Waiting on readyState after Page.navigate answers about the outgoing document, which is still complete for a moment. Navigations reported the previous page's empty title, or failed on a page that had loaded.
  • page.type wrote el.value directly. React installs its own setter, sees no change, and swallows the event — the field looked filled, React's state stayed empty, and the call returned typed: true. It now goes through the native prototype setter and verifies rather than assuming.
  • A keyDown carrying text already inserts the character; sending a char event too typed "plexus" as "pplleexxuuss".

Residual risk, stated

attach reaches 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 enable chrome://inspect/#remote-debugging on 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.ts drives the whole agent loop — enroll → handshake → grant → invoke — to a real site.

pandazki and others added 2 commits August 14, 2026 13:50
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
pandazki marked this pull request as ready for review August 14, 2026 06:12
pandazki and others added 9 commits August 14, 2026 20:30
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>
@pandazki pandazki changed the title browser control — plan + origin gate + dependency-free CDP client (foundation) browser control — drive a real Chrome; the boundary is which browser (0.9.0) Aug 14, 2026
@pandazki
pandazki merged commit 00c17c2 into main Aug 16, 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