docs GitHub

Architecture

0049: Deep Chrome control

Status: accepted for 0.2 (capsule-sight). Supersedes the "out of 0.2" note in ADR 0015.

Decision

The person's own Chrome is driven through ONE first-party Vyre extension using chrome.debugger, a native-messaging host, and a module in vyred. No debugging port, no browser relaunch. The extension loads unpacked in 0.2 (the Store listing is 0.3) and Vyre updates it by replacing files and asking it to reload.

agent/CLI --tool--> module chrome (local/hands-chrome-mac/index.js, in vyred)
                      floor (URL tier) . oversight (plan, interject, Esc) . redact
                      |  unix socket / named pipe, framed JSON (shared/proto.js)
                    native host (native-host/host.js, spawned by Chrome, relays, reads nothing)
                      |  Chrome native messaging (stdio, 4-byte length frames)
                    extension service worker (extension/background.js = the shell)
                      caps/tabs  caps/page  caps/devtools  caps/net  caps/api  caps/ghl  caps/batch
                      (vault's fill, save and API-key capture register here as caps/vault.js)
                      |  chrome.debugger (CDP), one attach per tab, kept
                    the person's tabs

One extension, capabilities as modules

extension/background.js is the shell: it connects to the host, authenticates the tab floor, dispatches {id, op, args} to the capability that registered op, redacts the result (shared/sk/siteops/redact.js), and answers. A capability is a file in extension/caps/ that default-exports { name, ops: { "<cap>.<name>": async (args, ctx) => result }, onEvent?(evt, ctx) } and is listed in extension/caps/index.js. ctx gives ctx.cdp (attach/send/on), ctx.tabs, ctx.emit(event), ctx.stopped() (true while the person's stop is in force) and ctx.floorAllows(tabId, op). The vault team adds caps/vault.js the same way. Nothing else in the extension is theirs or mine to special-case.

Speed (a measured number, not a claim)

Per-call cost is one host round trip. So:

  • One chrome.debugger attach per tab, kept until the tab closes or the person detaches.
  • page.snapshot is one Runtime.evaluate. page.fill sets every field in one evaluate.
  • batch.run takes a list of steps and runs them inside the service worker with no host hop between steps. It halts at the first failure, on stop, or on a floor refusal, and says which step.
  • net.* reads a ring buffer the extension keeps; no round trip per event.
  • Tabs are reused (see tabs): opening a tab costs 200 ms to 2 s and steals attention.

scripts/chrome-bench.mjs measures per-op latency against a fixture page and a GoHighLevel-shaped fixture, and prints p50/p95. The number in the work doc comes from a GitHub runner, not a guess.

Tabs: reuse, never open needless ones

tabs.use {match?: {url?, origin?, title?}, url?, openIfMissing?} finds an existing tab in the person's windows first (exact URL, then same origin and path prefix, then same origin), activates nothing unless focus: true, and only opens a tab when none matches and url is given. A tab Vyre opened is remembered and is the only kind tabs.close may close.

DevTools

  • inspect: DOM (outerHTML, attributes, box model), computed styles, matched CSS rules, event listeners.
  • source: Debugger.scriptParsed list, script source by id, search across scripts, source maps' names.
  • console: ring buffer of Runtime.consoleAPICalled, exceptions, Log.entryAdded, and eval.
  • network: Network.* ring buffer (bounded by count and bytes), live watch, response bodies on demand, and acting on events: net.on {filter, then} (block, mock, modify headers, wait then run a step) through the Fetch domain. net.replay re-issues a captured request from inside the page, so the page's own cookies authenticate it and no credential leaves the browser.
  • learn an app's API: api.learn reduces captured XHR/fetch traffic to a catalog (method, path template, query/body shape, auth kind, sample status) with values redacted; api.call invokes a catalog entry from inside the page.

Redaction

Cookies, tokens, session ids, CSRF tokens, API keys and passwords are removed before the model sees them (shared/sk/siteops/redact.js): names and lengths stay, values never. It runs in the extension on every result and again in the module on arrival. There is no argument that asks for a raw value.

Floor and Gate

The URL tier (floor-url.js): blind origins (bank, password-manager web vaults, Vyre's own surfaces, chrome://, the Web Store) return nothing and refuse every op; hands-only origins are readable but not actable. The module classifies before sending and the extension re-checks in the worker. An outward act (a real type=submit, a form post, a Send/Pay/Post control decided from the DOM, not a label) the person's own turn asked for runs free; otherwise it is a Gate card of kind act with the field values shown, released only if the page signature is unchanged.

Oversight

oversight.js is the logic behind the panel (capsule-pro draws it): the agent posts its plan first (chrome.plan), the person can interject by prompt or voice (chrome.interject), and Esc stops and waits (chrome.stop); nothing continues until chrome.resume after the person answers. The extension's ctx.stopped() reads the same flag, pushed as a stop event, so an in-flight batch halts within one step.

Native host and install

native-host/host.js is spawned by Chrome as run.vyre.chrome, relays frames between stdio and the module's socket (<VYRE_HOME>/run/chrome.sock, mode 0600; a named pipe on Windows), and exits when either side closes. native-host/install.js writes the host manifest (macOS ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/, other Chromium browsers beside it; Windows HKCU\Software\Google\Chrome\NativeMessagingHosts\run.vyre.chrome), pinning allowed_origins to the extension id derived from the manifest key.

Frames

A tab is not one document. GoHighLevel's whole Workflows UI is a cross-origin iframe in its own process, so a page tool that only sees the top frame sees only the shell's nav (a real first session failed exactly this way). Frames are first-class in every page tool.

  • What is reachable. lib/cdp.js keeps the child sessions Chrome hands over (cross-origin iframes, nested ones too) and lib/frames.js lists every frame in tree order. A frame is readable when a script can run in it: its own session, or its own execution context when it shares the top page's process. page.snapshot, page.act, page.fill, page.wait and page.eval (and batch.run and the ghl ops on top of them) look in all readable frames, top first.
  • Snapshot. The snapshot script runs in every readable frame and the results merge into one. With more than one frame every control carries frame (its index in the frame list) and frameOrigin, and its path is the path inside that frame, so a control is identified by (frame, path). frames lists each frame (index, parent, depth, origin, url without its query, readable, control count). The limit is split across frames with a floor per frame, and inside a frame the main content, dialogs and drawers come before navigation and header chrome. The page's own bounding box of a control is box (it was called frame before frames existed).
  • Selectors. A selector may carry frame: an index, a frame id, top, or a piece of an origin or URL. Without it the search covers every frame and the usual rule holds: two matches are tied, unless exactly one sits inside an open dialog or drawer, which wins. A tie between frames says which frames in candidates and tiedFrames. A snapshot control's selector includes its frame, so it can be copied as it is.
  • Acting. Scripts that find, fill or focus a control run in the control's own frame. A click is located inside the frame, moved by the frame's offset in the top viewport (ctx.frames.offset) and dispatched on the top page's session, which is where Chrome routes input into an iframe. The hit test stays inside the frame. Keys focus the element in its frame and go out on the top session. page.fill runs one script per frame. A frame is looked up again by its index and origin on every act, since a frame gets a new id when it navigates; a frame that is gone is a stale control and is retried inside the existing retry budget.
  • Waiting. Every poll lists the frames again, so a frame that appears late or navigates mid-wait is picked up. Settled means no spinner, a quiet DOM and a quiet network in every readable frame. Dialogs are per frame: a dialog in the shell is in front of every frame, a dialog in a frame is in front of that frame and the frames inside it.
  • page.eval takes an optional frame and runs there. The password-field guard runs in every readable frame of the tab before any script, so a login form in an iframe refuses the script wherever it would have run. The send-hold shim goes into, and is read back from, the frame the script runs in.
  • Honest reporting. A frame Vyre cannot read is never left out: the snapshot lists it in notReadable (index, origin, why), says "N frames not readable: <origins>" at the top of text, and says when one covers a large part of the viewport ("Frame 2 (...) covers about 80% of the viewport and is not readable"). Every failure detail says which frame it looked in (frame, frameOrigin, and searched, frames on a page with more than one), and its page snippet comes from the frame the target was expected in. Traces carry frame and frameOrigin.
  • GoHighLevel. A child frame on a leadconnectorhq.com host whose host or path is the automation or workflows app is the workflow builder, even under a white-label shell. The page module works this out from the frame's own origin (state.ghlFrame, ghlFrames), which a page cannot forge beyond its own frames, and the builder-tile rule accepts tiles in that frame. A real Send or Delete is still held.
  • A frame scrolled out of the top viewport is scrolled into view before the click (frames.reveal), and the egress guard allows the origins of the tab's own frames, so a script inside the builder iframe that talks to its own API host is not held wrongly.

Frames: network, console and api

The page tools walk every frame; the network side had to as well, because a cross-origin iframe's traffic belongs to its own CDP session (a first real session learned 82 endpoints and none for the workflow builder). Capture, console, sources, api.learn and api.call, the egress guard and the send-hold now cover every session of a tab, nested ones included.

  • Capture. net enables Network on the top session and on every child session, including ones that attach later (Target.attachedToTarget through cdp.on, plus cdp.children on each start). A record is keyed by (session, requestId), so two frames cannot collide: a top request keeps its bare id, a child's id is <session>:<id>. Each record carries frame (the origin of the document that made it) and net.list adds frameIndex where ctx.frames.list knows it. net.list, net.get and net.watch take an optional frame (a piece of an origin), as an argument or inside filter. Buffers, caps, eviction and redaction stay per tab and unchanged. A child that navigates to a blind page purges only its own records; the top page still purges all.
  • Console and sources. Runtime, Log and Debugger are switched on in every child session too, and idle-off restores them. Ring entries and scripts carry frame; dev.console.read and dev.sources.list/search filter by it. Script ids are per session, so a child's script is addressed as <session>:<id> and read from its own session. dev.inspect and dev.console.eval take frame and run in that frame (its session, or its execution context for a same-process frame). The password-field guard scans every readable frame, and the send-hold shim stays in the frame of the script.
  • api. api.learn learns from all frames and each catalog entry records frame, the origin it was learned in. api.call runs the fetch inside that frame by default (or the frame named by its optional frame), so the frame's own cookies and auth sign it, and its origin guard compares against that frame's origin, not the top page's. Credentials still never leave the browser and results are redacted as before.
  • Egress guard. Fetch interception (block outside known origins) is enabled on every session and Fetch.requestPaused is answered on the session that paused the request, keyed by (session, requestId). The allowed set is the tab's origin, origins in captured traffic (all frames), the resource timing of every readable frame, and the origins of the tab's own frames, so a builder script talking to its own API host is not held. stop() restores Fetch on every session; DNR stays tab-scoped and contained: "partial" is still reported. This lifts the earlier note that the guard judged a script against the top page's origin alone. Limit: a child session that attaches while a guard is up is covered from the moment we enable Fetch on it, and a request it makes in between is not intercepted.

Known limits

  • Chrome shows its own "started debugging this browser" bar while attached and, in load-unpacked mode, a developer-extensions bar on every start. Neither can be hidden; the guided install screen says so.
  • GoHighLevel selectors and internal API shapes are only verifiable against a live account. The fixture proves the machinery and the speed; a live run by the person is the acceptance check.

Hardening after review (reviewer-2, 30 Sep)

  • A page chooses the names in the URLs, bodies and console lines Vyre reads, so redaction never throws (safeDecode, guarded), the bridge drops a bad frame and keeps serving, and a redaction failure masks the whole field.
  • chrome.release replays only the record the module itself stored under the Gate's id, after re-checking the causing agent's grant. The card carries only what the person reads.
  • After the one-time grant, scripts, API reads and writes, replays and GoHighLevel runs are hands-free. The Gate holds only what the no-nag list holds: a request whose effect is to SEND something as the person (a message, an email, a post, a payment) that nobody asked for and no standing permission covers. It is judged by method and endpoint (extension/shared/outbound.js, which knows GoHighLevel's conversation, campaign, invoice, payment and social-posting endpoints, and reads a GraphQL mutation's name). Tagging a contact, saving a workflow or reading is never held. api.call and net.replay are judged before they run. A script's own fetch, XHR and sendBeacon sends are held back and reported while the rest of the script runs (a shim installed around the evaluation, not a wrapper around the script, so a page's CSP cannot break it); the held card is released by re-running with asked. The person's own turn (asked) runs everything free. A page with a visible password field is still not one page.eval or dev.console.eval runs on, because that rule is invisible: it counts type=password, fields that declare a password autocomplete, and fields a show-password toggle switched to type=text (by name, id, label or placeholder), and looks inside open shadow roots and same-origin iframes. Closed shadow roots and cross-origin frames cannot be read from the page and are the one blind spot.
  • One floor list (extension/shared/floor.js) is imported by the module and the extension.
  • The network buffer is judged per record and emptied when a tab navigates to a blind page. The console ring and the script cache are emptied whenever a page's execution contexts are cleared (a navigation or reload), and the person's own blind list is applied to the buffer, not only the built-in one.
  • The host tells the module which origin Chrome launched it for, and the bridge only takes a hello from a host that named the manifest key's id. This is not authentication (any process of this user can write the same bytes to the socket); it keeps a host started by another extension or profile from being taken for ours. The socket's 0600 mode in a 0700 folder keeps other users out, and the floor, the grant and the Gate keep a same-user process honest. A replacement connection raises a quiet chrome.replaced event, never a prompt.
  • Accepted: the manifest key is public, so anyone can build an unpacked extension with the same id. Reaching the host that way needs the person's own Chrome profile.
  • Not done: naming chrome.sock in the floor's rule that denies a shell from talking to vyred's sockets.