chrome_browser · v0.1.2 · 2026-10-22 · sha256 9bb644b63f396331
chrome_browser v0.1.2A
Immutable. This exact content never changes and is served at /api/v1/blob/9bb644b63f396331.
---
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.2
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: Owner setup. "status" shows what is configured, "init" creates the private pairing credential, "pair" confirms the extension, "rotate" replaces the credential. 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 skill's own code reads and writes
only its state directory. `OUROBOROS_CHROME_STATE_DIR` names that 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 can prepare the package and private credential; it does not silently
load the extension or enter that credential in the owner's browser.
1. Run `setup.mjs init`. It prints the extension folder and the path of a
private file, `pairing.json` (mode 600), in the skill's state directory.
2. In `chrome://extensions`, turn on **Developer mode**, choose **Load
unpacked**, and pick the printed `extension` folder.
3. Open `pairing.json` yourself, copy the value of `credential`, click the
extension's icon, paste it under **Pairing**, check the port, and choose
**Save and connect**. Do not paste the credential into a chat: the setup
script never prints it, and no tool ever returns it.
4. Run `setup.mjs pair`. It waits for that extension to prove the credential
and records which extension it was; only that one is admitted afterwards.
5. Add the MCP server that `setup.mjs status` prints under `mcp_server` in
Settings → MCP.
`setup.mjs rotate` replaces the credential and forgets the extension;
**Forget pairing** in the popup removes the extension's copy.
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
Long browser work belongs to a short-context child of the same model: the
main agent keeps the conversation and decides the semantic forks, the executor
drives the page and comes back only at a fork. 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
```