chrome_browser · v0.1.3 · 2026-10-25 · sha256 9539cddbc186ab1e

chrome_browser v0.1.3A

Immutable. This exact content never changes and is served at /api/v1/blob/9539cddbc186ab1e.

---
name: chrome_browser
description: Act in your own Chrome through a paired extension - one explicitly shared tab or the agent's own background tab, trusted input without taking focus, and a clean handoff of the live page to you and back.
version: 0.1.3
type: script
runtime: node
license: MIT
permissions: [net, fs, subprocess]
when_to_use: The owner asks to read or act on a page in their real, signed-in Chrome, wants to keep working in another tab meanwhile, or needs to take over the same page for a login, CAPTCHA or decision and hand it back.
model_experience:
  what_model_sees: Seven browser_* tools appear when the host has bound this skill's MCP server to the task. browser_state is one JSON object; actions return a receipt with one outcome per step. Installing the skill alone adds no tool.
  token_effect: Seven small schemas per round while bound. Snapshots, page text and screenshots cost what they contain; a read with cache_key can be searched later without reading the page again.
timeout_sec: 300
scripts:
  - name: mcp_server.mjs
    description: The task's browser executor, an MCP stdio server. The host starts it for one task attempt; it is not run by hand.
  - name: setup.mjs
    description: Setup Ouroboros runs for the owner. "init" creates pairing, "install-native --browser" registers that browser's local helper, "pair" confirms the extension, "rotate" replaces pairing, and "recover --confirm" moves malformed state aside. It never prints the credential.
install_specs:
  - kind: npm
    package: ws@8.22.0
---

# Chrome browser

This skill lets one Ouroboros task work in the owner's real Chrome without
taking the browser away from them. It has three parts:

- `extension/` — a Chrome extension (Manifest V3) the owner loads once;
- `scripts/mcp_server.mjs` — the executor the host starts for a task attempt;
- `scripts/setup.mjs` — the owner's pairing helper.

It needs an Ouroboros host that binds a skill's MCP server to a task with
`browser_protocol: ouroboros_chrome` and answers the guard described in
[`PROTOCOL.md`](PROTOCOL.md). On a host without that, the skill installs and
does nothing. POSIX hosts only in this version. Node 20 or newer (the scripts
use the global Web Crypto).

**What the permissions mean.** `net`: one loopback WebSocket port and the
host's guard socket. `fs` and `subprocess` are declared because
`browser_execute` runs the script it is given inside the executor's own Node
process: the `vm` realm is not a sandbox, and such a script can reach the file
system and start processes with the rights of that process. The host admits
`browser_execute` only where it allows unrestricted execution; the typed
`browser_action` operations use neither. The executor reads and writes only
its state directory; `install-native` also writes one native-host registration
for the browser it names, in that browser's user data directory.
`OUROBOROS_CHROME_STATE_DIR` names the state directory for
an installation (or a disposable test root); whoever sets the executor's
environment chooses it, and the skill does not confine it further.

## What the owner does once

Loading and pairing in personal Chrome are owner gestures, not agent consent.
Ouroboros prepares the package, the private credential and a local helper; it
does not silently load the extension or enter that credential in the owner's
browser. The owner runs no script and copies no credential.

1. Ouroboros runs `setup.mjs init` and then `setup.mjs install-native --browser
   chrome` (or `chrome-for-testing`, `chromium`) as the OS user who runs that
   browser. The first creates private `pairing.json` (mode 600); the second
   registers a local helper for that browser on macOS or Linux. For a browser
   started with its own `--user-data-dir`, add `--user-data-dir` with the same
   directory. Neither prints the credential; repeating `install-native` updates
   paths after a move.
2. The owner opens `chrome://extensions`, turns on **Developer mode**, chooses
   **Load unpacked**, and picks the printed `extension` folder.
3. Ouroboros starts `setup.mjs pair`, which waits up to 120 s, and asks the
   owner to click the extension icon and choose **Connect**. The popup asks the
   local helper for the existing pairing, stores it in this Chrome profile and
   shares no tab; `pair` then records which extension proved it, and only that
   one is admitted afterwards. The popup shows **Pairing saved** until a task
   connects, then **Connected**. If the helper is unavailable, **Manual
   pairing** accepts the credential and port from `pairing.json`; never paste
   it into a chat.
4. Add the MCP server that `setup.mjs status` prints under `mcp_server` in
   Settings → MCP.

`setup.mjs rotate` replaces the credential and clears the pinned extension.
The popup keeps its old copy and disables **Connect** until the owner chooses
**Forget pairing**. Start `pair`, then ask the owner to choose **Forget pairing**
and **Connect**. The same steps apply after changing the port with `init --port`.
If setup reports malformed pairing, nothing is replaced.
`setup.mjs recover --confirm` moves that file aside unchanged (same bytes and
mode) and creates a new credential;
then the owner chooses **Forget pairing** and **Connect** while `pair` waits.

Use the pairing in one Chrome profile at a time. To move it to another
profile, choose **Forget pairing** in the old one or turn the extension off
there; **Stop sharing** ends tab access but keeps the connection. The helper
sees the extension ID, not the profile, and the listener admits one extension
connection, so a second profile cannot be detected: when the popup cannot
connect it says that no task may be running or that another profile may be
connected. Automatic multi-profile provisioning is not supported; separate state directories and ports alone do not arrange separate native-host registrations.

An unpacked extension still requires Developer mode and Load unpacked. Native
bootstrap registration supports Google Chrome, Chrome for Testing and Chromium
on macOS and Linux; elsewhere only Manual pairing remains. This package is not
published in the Chrome Web Store and does not provide one-click installation.
A managed Chrome policy may also block Developer mode, unpacked extensions, or
Native Messaging.

After a package update, use **Reload** for this unpacked extension in
`chrome://extensions`. Do not forget the pairing or enter the credential again.
The build digest checks package files on disk, not the old worker's loaded code;
verify the new input-preparation receipt and the requested page effect after Reload.

## What happens in a task

- The executor listens on one loopback port (default 47831). The extension
  tries that port when its popup opens, every 30 seconds, and within seconds
  after its connection to an executor closes (retries at 1, 2, 4, 8 and 16 s),
  so a task that just started may wait up to 30 s for it.
- If the first `browser_state` runs before setup or pairing is complete, the
  same executor checks the pairing file again on a later call. A listener
  already listening is kept. A busy port (`BROWSER_PORT_BUSY`: another task's
  executor holds it) is tried again on the next call, not remembered.
- **One port, one executor at a time.** When a lease ends with nothing to
  resume — `browser_disconnect`, a refused document, a refused connection — the
  executor closes its listener so another task's executor (for example a
  delegated browser executor) can bind the port. A handoff keeps it. The next
  `browser_connect` binds the port again and waits for the extension.
- **Your tab.** Open the tab, click the extension icon, **Share this tab**.
  Its id appears in `browser_state.approved_tabs`; `browser_connect` with that
  `tab_id` attaches. The extension has no permission to list tabs or to read
  their titles and addresses, and no other id can be connected.
- **The agent's own tab.** `browser_connect` with a `url` opens a new tab in
  the background. While the agent holds it, it is closed again on
  `browser_disconnect`, executor exit, a lost connection or a refused
  navigation. Once it has been handed to the owner (`browser_handoff`, Stop
  sharing, Cancel) it is theirs and is never closed by the skill — also not
  after `browser_resume`, on executor exit, a worker restart or Forget pairing.
- While attached, Chrome shows its own "started debugging this browser" bar
  and the extension icon shows **ON** on that tab. **Cancel** on that bar, or
  **Stop sharing** in the popup, ends the session at once and for good.
- Nothing in this skill activates a tab, focuses a window or moves the system
  pointer. Clicks and typing are Chrome's own trusted input events sent to the
  attached tab, which may stay in the background.

## Tools

| Tool | Use |
| --- | --- |
| `browser_state` | Who owns the executor, what is leased, whether it was revoked or detached. |
| `browser_connect` | Exactly `{tab_id}` (a shared tab) or exactly `{url}` (own background tab). |
| `browser_action` | One typed operation, or `sequence` with up to 32 of them: a single step or a short fixed series without a loop or a condition. |
| `browser_execute` | The main tool for a series of steps: a script in the task's persistent realm runs one whole mechanical stretch (a loop over known pages or elements, waits, results kept in `vars`) in one call. Only where the host allows unrestricted execution. |
| `browser_handoff` | Detach now and leave the page, with everything typed into it, to the owner. |
| `browser_resume` | Attach to the handed-off tab again. |
| `browser_disconnect` | End the lease and report whether Chrome confirmed the detach. |

Operations: `observe`, `read`, `get_text`, `get_attribute`, `snapshot`,
`dom_snapshot`, `ax_snapshot`, `screenshot`, `wait`, `navigate`, `click`,
`double_click`, `fill`, `type`, `press`, `check`, `uncheck`, `scroll`,
`cached_text`. A target is `{selector}` (CSS, must match one element, or add
`nth`) or `{handle}` from `snapshot`.
For `read` and `get_text`, `start` is a zero-based text offset and `max_chars`
is the returned window size (20,000 by default, at most 200,000). Their
`coverage` gives the captured `[start,end)` and total text length. To keep a
larger window without returning it all, pass `cache_key` and `capture_chars`
(up to 200,000); the response still returns only `max_chars`. For example,
`{"start": 0, "max_chars": 2000, "capture_chars": 60000, "cache_key": "raw"}`
captures up to 60,000 characters and returns the first 2,000.
`cached_text` is answered only while a lease is connected; the cache itself
survives handoff and navigation, so resume first. It reports its coverage; a
search with no matches and
`complete:false` says only that the captured window has no match. To inspect
an uncaptured offset, issue another `read` or `get_text` with that `start`.
Coverage describes rendered DOM text, not proof that a virtualized site exposed
the entire underlying file.
Before first input in each document/attachment, the executor prepares one
renderer frame without activating the tab; pixels are discarded, not returned.
A missing frame refuses before input, never triggers a sacrificial/repeated click.
Still verify the requested effect: dispatch acknowledgement alone is not success.

The operation enum excludes `select`, `drag`, `upload`, and `submit`: validating
hosts refuse them at the schema boundary. Direct executor calls additionally
return `BROWSER_OPERATION_UNSUPPORTED` without dispatch. A normal `click` or `press` can
still trigger a page's own form submission. `type` and `fill` verify that the
intended field holds focus after their trusted pointer click, before sending
keyboard input. If the page redirects focus, the click stays dispatched and
the step fails without typing into the other field.

```json
{"operation": "sequence", "args": {"steps": [
  {"operation": "click", "args": {"selector": "#start"}},
  {"operation": "wait",  "args": {"selector": "#step2", "timeout_ms": 8000}},
  {"operation": "fill",  "args": {"selector": "#step2", "text": "value"}},
  {"operation": "read",  "args": {"cache_key": "after"}}
]}}
```

```js
// browser_execute: values in `vars` stay for the next call
vars.snap = await browser.snapshot({});
const links = vars.snap.elements.filter((e) => e.tag === 'a');
await browser.click({ handle: links[0].handle });
return (await browser.read({ cache_key: 'page' })).length;
```

## One call per mechanical stretch

Step-by-step control — one tool call, one model round, the next call — is for
the moments that need judgment, not the norm. The norm is one `browser_execute`
(or one typed `sequence`) for the whole mechanical stretch up to the next
semantic uncertainty: a loop over a known list of pages or elements, reading
many pages, waiting with `browser.wait`, collecting results in `vars`, and
returning one compact summary. A model round costs seconds; a step inside a
script costs milliseconds, and the receipt lists each step's outcome, not every
page's text — only what the script returns comes back.

Observe and start a new round only when something needs judgment: a login or
CAPTCHA, a page whose structure is not what the script expected, an action
whose effect is unknown, or a decision the owner would want to make. A new
page by itself is not such a moment: navigate, wait, read and go on inside the
script. `sequence` is for a short fixed series (at most 32 steps, stopping at
the first failure); anything with a loop or a condition is a script.

This is not "one script for everything". Write the script to stop at the first
thing it cannot decide and return what it collected together with what it saw
(the URL and the first lines of the unexpected page, for example); keep its
position in `vars` so the next call continues instead of starting over. The
rules of this skill hold inside a script exactly as between calls: a step with
an `unknown` outcome is not sent again — read the page; a login or CAPTCHA is
a `browser_handoff`, never a retry; one script is bounded by
`OUROBOROS_CHROME_EXECUTE_TIMEOUT_MS` (50 s by default) and by the host's
Stop. Keep each `browser_execute` under about 50 s: the host's per-call limit
is 60 s by default (`MCP_TOOL_TIMEOUT_SEC`) and a call that exceeds it ends
the whole task's browser session, not just the script. Split a longer loop
into resumable chunks that keep their position in `vars`, and prefer
`browser.wait` to `sleep` (30 s at most per call). What a cut script already
put in `vars` stays there.

```js
// browser_execute: every page of a known list in one call; stop where judgment is needed
const snap = await browser.snapshot({ max_nodes: 300 });
const urls = snap.elements
  .filter((e) => e.tag === 'a' && e.href && e.href.includes('/models/'))
  .map((e) => new URL(e.href, snap.url).href);
vars.sizes = vars.sizes || {};
for (const url of urls) {
  if (url in vars.sizes) continue;  // a later call continues, it does not restart
  await browser.navigate(url);
  await browser.wait({ selector: 'h1', timeout_ms: 10000 });
  const page = await browser.read({ max_chars: 20000 });
  const size = page.text.match(/Model size\s+([\d.]+[KMB]) params/);
  if (!size) return { stopped_at: url, seen: page.text.slice(0, 300), done: vars.sizes };
  vars.sizes[url] = size[1];
}
return { pages: urls.length, sizes: vars.sizes };
```

## Delegating to an executor

Delegate browser work to a task-focused child of the same model. Keep the main
agent's full context unchanged: it briefs, supervises and decides semantic forks;
the child drives the page. Do not mirror each click or reread every page in main. The standing policy (escalation
classes E0–E7, how the child waits for an answer, the return schema) and the
brief template live once in
[`references/executor-brief.md`](references/executor-brief.md); the child reads
that file first, the parent does not paste it.

1. Release the browser: `browser_disconnect` frees the port (a handoff keeps it).
2. Fill the brief template; `owner_words` — the owner's exact sentences — is
   mandatory, and the parent withholds what would bias a measurement.
3. Schedule, then wait with `wait_task` (not a cold sleep: the child may escalate):

```
schedule_subagent(
  subagent_id="<the same API-model row as the main agent>",
  objective="<the goal in one sentence>",
  expected_output="the return JSON of references/executor-brief.md",
  input_sources="declared",
  write_surface="external_workspace", write_root="<project folder>",
  initial_tool_names=["mcp_chrome_browser__browser_state", "mcp_chrome_browser__browser_connect",
    "mcp_chrome_browser__browser_action", "mcp_chrome_browser__browser_execute",
    "mcp_chrome_browser__browser_handoff", "mcp_chrome_browser__browser_resume",
    "mcp_chrome_browser__browser_disconnect",
    "escalate", "await_messages", "read_file", "finish_task"],
  external_tool_grants=[<the same seven mcp_chrome_browser__* names>],
  context="<the filled brief, starting with: read references/executor-brief.md of the chrome_browser skill>")
```

The tool names carry the MCP server id from Settings → MCP (`chrome_browser`
with the entry `setup.mjs status` prints). `external_tool_grants` is what lets
the child use the browser tools at all; `initial_tool_names` only makes them
resident from its first round.

4. A child's escalation arrives as mail. Answer with
   `forward_to_worker(task_id=<child>, message=...)`; when it is above your
   authority, `escalate` it to the owner yourself, quoting the child's
   question verbatim. The child sleeps warm meanwhile, keeping its tab.

## Reading a receipt

Every step has an outcome. `observed`: a value was read. `not_dispatched`:
nothing reached Chrome. `dispatched`: Chrome acknowledged the input, the effect
was not checked. `confirmed`: the effect was read back. `unknown`: something
may have been sent and the answer is missing — it is never sent again; read
the page before deciding.

Before a pointer action the extension scrolls the target into view, reads its
box twice a short interval apart, and asks the page which element would
receive a pointer event at that point. A hidden, disabled, moving or covered
target is refused with nothing sent — hidden includes fully transparent
(`opacity: 0`) on the element or an ancestor. There is no force option and no
`element.click()` fallback.

## Handoff

`browser_handoff` detaches Chrome's debugger. The page is not reloaded and
nothing typed into it is lost; the owner logs in, solves the CAPTCHA or edits
the form by hand. `browser_resume` attaches again to the same tab and reads the
document afresh; element handles from before are stale, cached text is kept.
A resume the host refuses leaves the tab with the owner, still handed off and
open: resume again later, or end the handoff with `browser_disconnect`.
If `browser_disconnect` races a pending resume, it ends that handoff; a later
refusal cannot make the old tab resumable again.
Never ask the owner for a password or a code in chat.

## Limits you must not hide

- **Requests.** While attached, a request Chrome pauses for the tab waits for
  the host's verdict. Verified on a real Chromium: main-frame documents, form
  posts and fetch/XHR, each with its complete body, and every redirect hop of
  them as a request of its own. Not verified, so not claimed: iframe documents
  and requests from inside iframes, uploads with file parts (reported as an
  incomplete body, which the host refuses), beacons, dedicated and shared
  workers, service workers, WebSockets, and tabs or pop-ups the page opens. Do
  not describe the guard as network containment.
- **The first load of the agent's own tab** happens before the debugger is
  attached: the host admits the URL and then sees the landing URL, but that
  load's individual requests are not judged.
- **Between calls**, a current compatible host may admit complete bodyless
  GET/HEAD subresource reads on the same valid lease, under its current task
  and destination policy. Documents, bodies and unknown resource types are
  refused; older hosts refuse all requests without an invocation. GET/HEAD
  describe request shape, not a guarantee that the server changes nothing.
- **Navigation after the call.** A click (or key, or check) that asks the page
  to navigate — a link, a form post, a script — is followed inside its step,
  also when Chrome starts that navigation late, for at most 10 seconds. The
  step's `navigation` says what became of it: `committed`, `refused`,
  `same_document`, `abandoned`, `unknown`, or `null` when the input asked for
  none. A navigation the page starts later by itself, when no call is running,
  is refused at its request and does not happen. But one still `unknown` at
  the bound can commit after the call: the host then refuses the new document,
  the lease is released (`navigation_refused`) and the page has already
  changed. Nothing is ever sent again; read the page.
- **A refused document fails its call.** When the host refuses a document the
  page has committed, the call reports an error with the host's code, the step
  keeps its true outcome, and the page is left on that document.
- **Same-document changes** (`#fragment`, `history.pushState`) change only
  `lease.url`.
- **A script that never yields** blocks the executor, including
  `browser_handoff`; the host's Stop still ends the process. The script realm
  is not a sandbox.
- **A script is cut where it waits.** A handoff, a disconnect, a revocation or
  a lost connection ends the running `browser_execute`: the steps it started
  answer (cut), the script gets one turn to catch that and return, and a
  script still waiting then — also on a promise of its own — ends with
  `BROWSER_INVOCATION_CLOSED` (or the host's code for a refused document). The
  script itself cannot be stopped; its `browser` object and `sleep` only
  refuse from then on. `browser.state()` returns a promise, like every other
  member: `await` it.
- **Revoked while attaching.** If you stop sharing, forget the pairing, cancel
  on Chrome's bar or close the tab while it is still being attached, the
  connection is refused and, for the first three, the session is revoked for
  good. A tab the agent was opening then stays open: it is yours.
- **JavaScript dialogs** (`alert`, `confirm`, `beforeunload`) are not
  answered by this skill. Steps are written to report `BROWSER_DIALOG_OPEN`
  while one is open; that path has not been exercised against a real dialog.
- **Not implemented:** `select`, `drag`, `upload`, `submit`; elements inside
  iframes; more than one lease.
- **If the extension is removed or crashes** while attached, Chrome detaches
  the debugger itself and lets paused requests continue. The executor reports
  a lost transport and never claims a confirmed detach.
- **Executor exit is not proof of detach.** Only `debugger_detach_ack: true`
  in `last_closure` is.

## Testing

`tests/regressions.test.mjs` in this package runs the executor, the extension
worker and the setup script against a mocked extension socket, a mocked
`chrome.*` and a disposable guard socket — no Chrome, no pairing:
`node --test --test-force-exit tests/regressions.test.mjs`.
`node --test tests/observation_startup.test.mjs` also checks late pairing,
source-bound text windows and cached-read coverage without a browser.

`tests/chrome_browser/headless.test.mjs` and
`tests/chrome_browser/input_frame.test.mjs` in the Hub repository drive the
real extension in a disposable `--headless=new` Chromium. Its pairing step types the credential
into the popup for the owner; it says nothing about a personal Chrome.

The Core registry consumer uses the actual `ToolRegistry`/`MCPManager` with a
disposable installation. Run it from the Hub root with explicit paths to the
Core checkout, its Python environment, full Chromium, and the skill's Node
dependency directory (the directory containing `node_modules`, not
`node_modules` itself):

```sh
OUROBOROS_CHROME_TEST_CORE=/absolute/path/to/core \
OUROBOROS_CHROME_TEST_PYTHON=/absolute/path/to/core/.venv/bin/python \
OUROBOROS_CHROME_TEST_CHROMIUM=/absolute/path/to/Google-Chrome-for-Testing \
OUROBOROS_CHROME_TEST_NODE_ENV=/absolute/path/to/chrome_browser/.ouroboros_env/node \
node tests/chrome_browser/core_integration.mjs
```