diff --git a/.env.example b/.env.example
index 813969f..c6fa5eb 100644
--- a/.env.example
+++ b/.env.example
@@ -12,10 +12,9 @@
# Port the daemon's WebSocket + HTTP server binds on. The extension connects
# here. Override only if something else owns 7225 on your machine.
# WS_PORT=7225
-
-# Interface to bind. 127.0.0.1 keeps the daemon loopback-only (recommended);
-# do NOT set 0.0.0.0 unless you understand the exposure.
-# WS_HOST=127.0.0.1
+# The daemon always binds its HTTP/WebSocket control plane to 127.0.0.1.
+# There is intentionally no WS_HOST override: remote callers (including n8n)
+# need a separately authenticated transport, not a public browser socket.
# --- Daemon state --------------------------------------------------------
diff --git a/README.md b/README.md
index 4d69a27..d94f647 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -34,7 +34,7 @@ It already has your browser open right there. It just can't see it.
- **Multiple agents at once.** Cursor can drive tab 10 while Claude drives tab 11 — both through one shared daemon, neither blocking the other.
- **Tab targeting, not "the active tab."** Every action names a `tabId`. Move your mouse, switch tabs, watch YouTube — the agent keeps working on the tab you told it to. It never hijacks the page you're reading.
- **Per-tab isolation.** Element refs, console logs, and network buffers are scoped per tab. A ref from tab 10 can never click something in tab 20.
-- **Per-tab concurrency.** Two actions on the *same* tab serialize (no races); actions on *different* tabs run in parallel.
+- **Per-tab concurrency.** Two actions on the _same_ tab serialize (no races); actions on _different_ tabs run in parallel.
- **Tab locking.** An agent can claim a tab so others queue behind it instead of racing (`browser_tabs { action: "lock" }`). Locks survive Chrome's service-worker recycling (`chrome.storage.session`).
- **Agent-control shield.** While an agent works on a tab you see a translucent blue inner frame and your input on that tab is blocked (mouse, keyboard, wheel) — the badge shows `agent controlling the tab` and disappears when the action finishes. Locking a tab keeps a plain frame for the lock's lifetime.
- **Same-origin iframe piercing.** Legacy/enterprise UIs that live inside iframes (e.g. an ONT console in `iframe#mainFrame`) are reachable: all locator tools search iframe documents, and `find`/`click_text` walk every frame.
@@ -44,13 +44,19 @@ It already has your browser open right there. It just can't see it.
- **Real, trusted input.** Clicks, typing and key presses go through the Chrome DevTools Protocol, so the page sees `isTrusted` events, focus really moves, default actions run (Tab moves focus, Enter submits, arrows drive autocomplete menus) and focus/blur fire even while the window is in the background — legacy grids and lookup widgets behave as they do for a person. One debugger session per tab is reused and detached after 30 s idle (the yellow "being debugged" banner shows only while it is attached). Pass `trusted: false` for the old synthetic events with no banner; they are also the automatic fallback when the debugger can't attach.
- **Batches.** `browser_batch` runs a list of tool calls in one round-trip and stops at the first failure — a click → type → Tab → wait → read sequence is one call instead of five.
- **Console-style JavaScript.** `browser_evaluate` accepts code as you'd type it in DevTools: top-level `await`, several statements, the last expression's value is returned, DOM nodes come back as readable descriptions — and page CSP doesn't block it.
+- **Refs that stay right.** Snapshot/find refs resolve through one shared page runtime: the registered element first, then the first *visible* selector match (across open and closed shadow roots and same-origin iframes), then a verified fallback that only re-binds when role, tag and name identify one element — an ambiguous match is reported as gone instead of clicked. Hidden duplicates are skipped.
+- **Shadow DOM everywhere.** `snapshot`, `text`, `find`, `click_text`, `wait` and every locator see web components (open and closed roots, slots), so sites like caniuse read like any other page.
+- **Frozen tabs don't freeze the agent.** Every page call has an 8 s budget; a tab that stops answering is reported as `TAB_WEDGED` in seconds, later calls fail fast after a 1.5 s probe, and `browser_navigate` / `browser_tabs reload` replace the frozen tab in place (the result carries the new `tabId`).
+- **Coordinates when you need them.** Click, hover and wheel-scroll at `x`/`y`, triple-click, ctrl/shift-click, key sequences with `repeat`, and zoomed `region` screenshots that tell you how image pixels map to those coordinates.
+- **Record and replay.** `browser_gif` records a flow as an animated GIF (clicks marked) for the user; `browser_shortcuts` saves a flow with `{{variables}}` and replays it in one call.
+- **Several browsers.** Every connected Chrome profile is its own connection; `browser_list_browsers` / `browser_select_browser` pick one per session (one browser behaves exactly as before).
- **Honest errors.** Every tool failure reaches your agent as a real `isError` result with the full payload — no "success" responses hiding failures mid-workflow.
---
## How it works
-Three pieces, all on your machine. Nothing leaves localhost.
+Three pieces, all on your machine. Nothing leaves localhost. The daemon control plane is hard-bound to `127.0.0.1`; there is no supported remote/n8n listener.
```mermaid
flowchart LR
@@ -139,7 +145,11 @@ By default the daemon names each connection after its parent IDE ("Cursor", "Cla
"mcpServers": {
"browser-controller": {
"command": "node",
- "args": ["/path/to/browser-controller/mcp-server/dist/index.js", "--agent", "My Project Agent"]
+ "args": [
+ "/path/to/browser-controller/mcp-server/dist/index.js",
+ "--agent",
+ "My Project Agent"
+ ]
}
}
}
@@ -165,6 +175,45 @@ Green dot = you're connected. Your agent can now see your browser.
> These secrets prevent any other local process from opening a WebSocket and driving your authenticated browser sessions. To rotate them, stop your MCP clients, delete the folder, and the next run recreates both secrets. See [SECURITY.md](SECURITY.md) for the full threat model.
+### Runtime lifecycle (authoritative daemon)
+
+The **daemon** is the only process that owns the extension-facing runtime. MCP
+clients are thin stdio adapters and may start it automatically, but deployment
+scripts should use the lifecycle commands below so there is one restart owner.
+Do not run a second `daemon.js` or install a launch supervisor that competes for
+port `7225`.
+
+```bash
+npm run build
+npm run daemon:start # start, or report the already-running PID
+npm run daemon:status # JSON health/runtime information
+npm run daemon:stop # graceful SIGTERM; removes runtime metadata
+npm run daemon:restart # stop, then start; preserves token/enrollment
+```
+
+The daemon survives browser/Chrome restarts: token, enrollment secret, and
+pairing remain in `~/.browser-controller/` (override with `BC_STATE_DIR`), and
+the extension reconnects to the same endpoint. The lifecycle wrapper reads the
+`daemon.json` PID and is safe to run repeatedly; a stale lock is removed only
+when its recorded PID is not alive.
+
+### Expected endpoints
+
+- **MCP endpoint:** stdio, launched as `node mcp-server/dist/index.js` (the
+ standard `mcpServers` command/args form is shown above).
+- **Extension runtime:** `ws://127.0.0.1:7225` by default, with HTTP
+ `/pair`, `/status`, and `/kill?sessionId=...` on the same port. These are
+ daemon/popup endpoints, not an MCP HTTP transport.
+- **MCP client IPC:** `~/.browser-controller/daemon.sock` on Unix or
+ `\\.\pipe\browser-controller` on Windows. It is internal and token-authenticated.
+- **State:** `~/.browser-controller/{daemon.json,daemon.lock,token.json,
+ enrollment.json,daemon.log}`. `WS_PORT`, `WS_HOST`, and `BC_STATE_DIR` are the
+ supported configuration overrides.
+
+This preserves the real Chrome session workflow: no Playwright/headless browser
+is launched, and the existing Chrome profile, cookies, logins, and tabs remain
+the browser being controlled.
+
### Permission and trust boundary
The unpacked extension deliberately requests Chrome's powerful `debugger`, `scripting`, `webRequest`, and `` permissions. They are what let it inspect network activity, inject functions, upload files through CDP, and automate any normal web tab you select. They also mean a connected MCP agent can read and change sensitive pages in your signed-in browser. Install the extension only from source you trust, pair it only with a trusted local daemon, and do not expose the daemon port beyond localhost. Chrome-protected pages such as `chrome://`, the Web Store, and DevTools remain inaccessible.
@@ -173,7 +222,7 @@ The unpacked extension deliberately requests Chrome's powerful `debugger`, `scri
## Using it
-The model is **tab-first**: the agent always says *which* tab to act on. It never assumes "the active tab."
+The model is **tab-first**: the agent always says _which_ tab to act on. It never assumes "the active tab."
### Basic workflow
@@ -262,6 +311,7 @@ npm run setup:cursor # or: node mcp-server/dist/index.js --setup cursor
```
This installs:
+
- `~/.cursor/rules/browser-controller.mdc` — the tab-targeting workflow, dropdown handling, when to lock tabs
- `~/.cursor/commands/check-browser.md` — adds `/check-browser` to your Cursor chat
@@ -284,35 +334,36 @@ See [`agent-config/`](agent-config/) for manual installation or to customize the
## What It Can Do
-25 tools. Every page-interaction tool takes a **`tabId`** (the one exception is `browser_navigate`, where it's optional).
+30 tools. Every page-interaction tool takes a **`tabId`** (the one exception is `browser_navigate`, where it's optional).
**See**
| Tool | What it does |
|------|-------------|
| `browser_observe` | Compact atomic semantic observation with snapshot/document identity, geometry, state, and dynamic allowed actions |
-| `browser_snapshot` | Accessibility tree with element refs. Compact mode (default) returns only interactive elements. Traverses open shadow DOM + same-origin iframes. |
-| `browser_screenshot` | Capture a tab as an image over CDP — `maxWidth` / `scale` / `jpeg` to cut tokens, `fullPage` for the whole page |
-| `browser_text` | Extract raw text from page or element |
-| `browser_find` | Query elements by natural language — walks same-origin iframes too |
+| `browser_snapshot` | Accessibility tree with element refs. Compact mode (default) returns only interactive elements; `filter` / `depth` / `ref` (subtree) / `maxChars` (default 20k) keep it small. Traverses open + closed shadow DOM, slots and same-origin iframes. |
+| `browser_screenshot` | Capture a tab as an image over CDP — `maxWidth` / `scale` / `jpeg` to cut tokens, `fullPage` for the whole page, `region` to zoom; reports the pixel → x/y mapping |
+| `browser_text` | Extract text from page or element (incl. shadow DOM); `mode:"article"` = main content only; `offset` paging |
+| `browser_find` | Query elements by natural language ("search input", "Save button") — tokenized, role-aware, shadow DOM + same-origin iframes |
**Interact**
| Tool | What it does |
|------|-------------|
| `browser_act` | Safely click/type/select/focus/hover/keypress/scroll/upload against a `browser_observe` snapshot |
-| `browser_click` | Real (trusted) click by ref or CSS selector — pierces same-origin iframes |
-| `browser_click_text` | Click by visible text. Works through React portals and overlays |
-| `browser_type` | Real key presses into inputs and contenteditable fields; returns the resulting value |
-| `browser_press_key` | Real key presses and combos (`Enter`, `Tab`, `ctrl+a`) |
+| `browser_click` | Real (trusted) click by ref, CSS selector or `x`/`y` — `clickCount` 1-3, `modifiers`; pierces same-origin iframes and shadow DOM |
+| `browser_click_text` | Click by visible text (case-insensitive, shadow DOM too) with a real click on the owning control. Works through React portals and overlays |
+| `browser_type` | Real key presses into inputs and contenteditable fields (or the focused field); returns the resulting value |
+| `browser_press_key` | Real key presses, combos (`Enter`, `Tab`, `ctrl+a`), sequences (`"ArrowDown ArrowDown Enter"`) and `repeat` |
| `browser_batch` | Run several tool calls in one round-trip; stops at the first failure |
-| `browser_scroll` | Scroll pages and virtual containers |
-| `browser_hover` | Trigger tooltips and dropdowns |
+| `browser_shortcuts` | Save a flow with `{{variables}}`, replay it in one call |
+| `browser_scroll` | Scroll pages and virtual containers, or wheel-scroll at `x`/`y` |
+| `browser_hover` | Trigger tooltips and dropdowns (ref, selector or `x`/`y`) |
| `browser_select` | Pick from native `` dropdowns |
-| `browser_wait` | Wait for elements to appear or disappear |
-| `browser_fill_form` | Fill multiple form fields in one call (React/Vue-safe setters) |
+| `browser_wait` | Wait for elements (any visible match), text, a URL change, or a delay |
+| `browser_fill_form` | Fill multiple form fields in one call (React/Vue-safe setters; selects by value or label) |
| `browser_drag` | Drag element-to-element (uses CDP for reliability) |
-| `browser_upload_file` | Upload files through ` ` (uses CDP, strict-CSP safe) |
+| `browser_upload_file` | Upload files through ` ` (CDP, strict-CSP safe), or bytes / a screenshot into an input or a drop zone |
Uploading files — no file dialog
@@ -332,32 +383,36 @@ Paths are absolute and local to the machine running the browser. Omit `ref`/`sel
| Tool | What it does |
|------|-------------|
-| `browser_navigate` | Go to a URL in a tab (`tabId` optional, defaults to active) |
-| `browser_tabs` | List / create / close / focus / **lock** / **unlock** tabs |
+| `browser_navigate` | Go to a URL in a tab (`tabId` optional, defaults to active); replaces a frozen tab |
+| `browser_tabs` | List / create (`active:false` for background) / close / focus / reload / **lock** / **unlock** tabs |
+| `browser_resize_window` | Resize or maximize the window holding a tab (responsive testing) |
+| `browser_list_browsers` / `browser_select_browser` | See the connected browsers (profiles) and route this session to one |
**Debug & Advanced**
| Tool | What it does |
|------|-------------|
-| `browser_console` | Console output (log, warn, error) — per-tab, capped at 200 entries |
-| `browser_network` | XHR/fetch requests with status codes — per-tab, optional `limit` |
+| `browser_console` | The page's console output (log, info, warn, error, debug, uncaught errors) — per-tab, capped at 200 entries; `pattern` / `level` / `limit` |
+| `browser_intercept` | Block, redirect or set request headers per tab (Chrome session rules); captures + HAR export. `mock`/`log` rules are ledger-only |
+| `browser_network` | Requests with status codes and failures — per-tab; `urlPattern`, regex `filter`, `failed`, `limit` |
+| `browser_gif` | Record the agent's actions in a tab as an animated GIF (clicks marked); export writes the file |
| `browser_evaluate` | Run JavaScript like the DevTools console: top-level `await`, last value returned, not blocked by CSP |
| `browser_handle_dialog` | Dismiss/accept an open alert/confirm/prompt via CDP (works on frozen pages) |
-| `browser_run_action` | Run a self-contained JS action object via CDP |
+| `browser_run_action` | Run a self-contained JS action object via CDP |
---
## How Others Compare
-| | Browser Controller | Playwright MCP | Chrome DevTools MCP |
-|---|---|---|---|
-| Uses your existing browser | Yes | No, launches new | Partial, needs debug port |
-| Sessions and cookies | Already there | Fresh profile | Manual setup |
-| Works behind corporate SSO | Yes | No | Depends |
-| Multiple agents, multiple tabs | Yes | No | No |
-| Tab-targeting (won't hijack active tab) | Yes | N/A | No |
-| Authenticated local connection | Yes | N/A | No |
-| Setup | Build from source + extension | Headless browser | Chrome with `--remote-debugging-port` |
+| | Browser Controller | Playwright MCP | Chrome DevTools MCP |
+| --------------------------------------- | ----------------------------- | ---------------- | ------------------------------------- |
+| Uses your existing browser | Yes | No, launches new | Partial, needs debug port |
+| Sessions and cookies | Already there | Fresh profile | Manual setup |
+| Works behind corporate SSO | Yes | No | Depends |
+| Multiple agents, multiple tabs | Yes | No | No |
+| Tab-targeting (won't hijack active tab) | Yes | N/A | No |
+| Authenticated local connection | Yes | N/A | No |
+| Setup | Build from source + extension | Headless browser | Chrome with `--remote-debugging-port` |
---
@@ -366,20 +421,20 @@ Paths are absolute and local to the machine running the browser. Omit `ref`/`sel
| Env var | Default | What it does |
|---------|---------|-------------|
| `WS_PORT` | `7225` | WebSocket port the daemon uses for the extension connection |
-| `BROWSER_CONTROLLER_PROGRESSIVE` | (unset) | Set to `1` to enable progressive tool disclosure: only the `browser_tools` meta tool is visible at startup (~150 tokens instead of loading all 25 definitions). The agent discovers tools via `browser_tools {action:"list"/"search"}` and activates them with `{action:"details", tool:"…"}`. Default (unset) shows all tools upfront — safe for agents whose instructions call tools directly. |
+| `BROWSER_CONTROLLER_PROGRESSIVE` | (unset) | Set to `1` to enable progressive tool disclosure: only the `browser_tools` meta tool is visible at startup (~150 tokens instead of loading all 26 definitions). The agent discovers tools via `browser_tools {action:"list"/"search"}` and activates them with `{action:"details", tool:"…"}`. Default (unset) shows all tools upfront — safe for agents whose instructions call tools directly. |
| `MCP_AGENT_NAME` | (auto: IDE name) | Override the agent name shown in the popup (same as `--agent`) |
### Daemon state files
The daemon keeps everything in `~/.browser-controller/` (Windows: `%USERPROFILE%\.browser-controller\`):
-| File | Purpose |
-|------|---------|
-| `enrollment.json` | One-time pairing secret for the extension (mode `0600`) |
-| `token.json` | Auth token the extension must present on every WebSocket connection (mode `0600`) |
-| `daemon.sock` | The IPC socket thin clients connect to (AF_UNIX on mac/linux; named pipe on Windows) |
-| `daemon.json` | Daemon metadata (pid, port, start time) — used to detect a running daemon |
-| `daemon.log` | Daemon stdout/stderr when spawned by a client |
+| File | Purpose |
+| ----------------- | ------------------------------------------------------------------------------------ |
+| `enrollment.json` | One-time pairing secret for the extension (mode `0600`) |
+| `token.json` | Auth token the extension must present on every WebSocket connection (mode `0600`) |
+| `daemon.sock` | The IPC socket thin clients connect to (AF_UNIX on mac/linux; named pipe on Windows) |
+| `daemon.json` | Daemon metadata (pid, port, start time) — used to detect a running daemon |
+| `daemon.log` | Daemon stdout/stderr when spawned by a client |
To fully reset: stop your MCP clients, delete the folder, and the next run recreates it with fresh secrets.
diff --git a/SECURITY.md b/SECURITY.md
index 14c4f1a..4e59aa6 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -28,7 +28,8 @@ Report vulnerabilities through:
## Security Model
-- **Local-only communication**: WebSocket between extension and server runs on localhost only.
+- **Local-only communication**: the daemon's HTTP/WebSocket control plane is hard-bound to IPv4 loopback (`127.0.0.1`), not configurable through `WS_HOST`. WebSocket traffic between the extension and daemon remains local. MCP clients use stdio and then an authenticated local IPC socket; they do not get a TCP listener.
+- **Remote callers and n8n**: Browser Controller has no supported remote or n8n ingress protocol. Do not publish port 7225 or change the listener to a wildcard address. A remote integration should run on the same host and reach the MCP client through its approved local process boundary, or be given a separately authenticated transport; browser-control credentials must not be reused as a network API key.
- **Origin validation (exact-match on a pinned extension ID)**: the daemon pins the extension's `chrome-extension://` Origin on first contact, then rejects every later request whose Origin is not an exact match. This applies to BOTH the WebSocket upgrade and the HTTP endpoints (`/pair`, `/status`, `/kill`) through one shared gate — a web page and a co-installed hostile extension (which carries its own Origin and cannot forge ours) are both rejected. The browser sets the `Origin` header; it cannot be forged from page JS.
- **Token auth on the control plane**: the WebSocket upgrade additionally requires the daemon's auth token (sent out-of-band via `Sec-WebSocket-Protocol` subprotocol, with a `?token=` legacy fallback). HTTP endpoints cannot require that token because `/pair` is how it is obtained; they instead require the browser-asserted pinned Origin plus the separate `X-BC-Enrollment` secret.
- **Versioned capability handshake**: transport authentication is necessary but not sufficient to mark the extension ready. The daemon and extension advertise a protocol major, application version, and capabilities before tool traffic is routed. An explicitly incompatible protocol major is rejected; application-version differences alone are diagnostic because compatible patch releases can share the same wire contract. Peers that omit the protocol field are identified as legacy during the migration window rather than being silently mistaken for a current peer.
diff --git a/deploy/systemd/browser-controller-daemon.service b/deploy/systemd/browser-controller-daemon.service
new file mode 100644
index 0000000..4c0c229
--- /dev/null
+++ b/deploy/systemd/browser-controller-daemon.service
@@ -0,0 +1,17 @@
+# Example systemd user unit. Adjust the checkout path (and node location if
+# `node` is not on the user manager's PATH) to match your installation.
+[Unit]
+Description=Browser Controller shared daemon
+After=graphical-session.target
+
+[Service]
+Type=simple
+WorkingDirectory=%h/browser-controller
+Environment=HOME=%h
+ExecStart=/usr/bin/env node %h/browser-controller/mcp-server/dist/daemon.js
+Restart=always
+RestartSec=2
+NoNewPrivileges=true
+
+[Install]
+WantedBy=default.target
diff --git a/eslint.config.js b/eslint.config.js
index 3ae527d..0406d3f 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -27,7 +27,8 @@ export default [
parserOptions: {
requireConfigFile: false,
babelOptions: {
- presets: [['@babel/preset-typescript', { allowDeclareFields: true }]],
+ // Babel 8 always allows `declare` fields (the option was removed).
+ presets: ['@babel/preset-typescript'],
},
},
},
diff --git a/extension/console-main.js b/extension/console-main.js
new file mode 100644
index 0000000..3cc1301
--- /dev/null
+++ b/extension/console-main.js
@@ -0,0 +1,36 @@
+/**
+ * MAIN-world console capture. content.js runs in the extension's isolated
+ * world, where patching `console` only sees the extension's own calls — the
+ * page's console.log/warn/info/debug/error never reached browser_console.
+ * This script patches the PAGE's console and hands each entry to content.js
+ * as a JSON string on a private DOM event (object details don't cross
+ * worlds). Uncaught errors / rejections are still captured by content.js.
+ */
+(function () {
+ 'use strict';
+ if (window.__bcConsoleMain) return;
+ Object.defineProperty(window, '__bcConsoleMain', { value: true });
+ const EVENT = '__bc_console_entry';
+ const LEVELS = ['log', 'info', 'warn', 'error', 'debug'];
+ const fmt = (a) => {
+ if (typeof a === 'string') return a;
+ if (a instanceof Error) return `${a.name}: ${a.message}`;
+ if (a && typeof a === 'object') {
+ try { return JSON.stringify(a); } catch { return Object.prototype.toString.call(a); }
+ }
+ return String(a);
+ };
+ for (const level of LEVELS) {
+ const orig = console[level];
+ if (typeof orig !== 'function') continue;
+ const patched = function (...args) {
+ try {
+ let text = args.map(fmt).join(' ');
+ if (text.length > 2000) text = text.slice(0, 2000) + '…[truncated]';
+ document.dispatchEvent(new CustomEvent(EVENT, { detail: JSON.stringify({ level, text }) }));
+ } catch { /* never break the page's logging */ }
+ return orig.apply(this, args);
+ };
+ try { console[level] = patched; } catch { /* frozen console */ }
+ }
+})();
diff --git a/extension/content.js b/extension/content.js
index 8e38748..95daab3 100644
--- a/extension/content.js
+++ b/extension/content.js
@@ -37,6 +37,15 @@
console.info = (...a) => capture('info', ...a);
console.debug = (...a) => capture('debug', ...a);
+ // Page console entries from console-main.js (MAIN world), JSON on a DOM event.
+ document.addEventListener('__bc_console_entry', (e) => {
+ let entry;
+ try { entry = JSON.parse(e.detail); } catch { return; }
+ if (!entry || typeof entry.text !== 'string') return;
+ if (entry.text.indexOf('ResizeObserver loop') !== -1) return;
+ try { chrome.runtime.sendMessage({ type: 'console', level: String(entry.level || 'log'), text: entry.text.slice(0, 2100) }); } catch {}
+ });
+
window.addEventListener('error', (e) => {
// Silence the well-known ResizeObserver loop warning: it's a benign browser
// notice (element resized during its own observation callback), not a real
diff --git a/extension/events.js b/extension/events.js
index 58fadf2..d4dc8a6 100644
--- a/extension/events.js
+++ b/extension/events.js
@@ -13,9 +13,10 @@ import {
persistSessionState,
dropTabState,
dropDocumentState,
-} from './lib/state.js';
-import { showLockShield, hideLockShield } from './lib/overlay.js';
-import { lockTabUi, releaseTabUi } from './lib/lock-ops.js';
+} from "./lib/state.js";
+import { showLockShield, hideLockShield } from "./lib/overlay.js";
+import { enrichCapture } from "./handlers/intercept.js";
+import { lockTabUi, releaseTabUi } from "./lib/lock-ops.js";
import {
getOpenTabs,
buildStatusPayload,
@@ -23,7 +24,7 @@ import {
applyPort,
applyToken,
applyEnrollment,
-} from './lib/connection.js';
+} from "./lib/connection.js";
export function registerEventListeners() {
chrome.runtime.onMessage.addListener((msg, sender, respond) => {
@@ -32,12 +33,17 @@ export function registerEventListeners() {
// before that, Chrome logs "message channel closed before a response was
// received". Every branch below responds synchronously (or is fire-and-forget),
// so we return false (or nothing) — Chrome handles it without the warning.
- if (msg.type === 'console' && sender.tab?.id != null) {
+ if (msg.type === "console" && sender.tab?.id != null) {
const buf = getTabBuffer(consoleByTab, sender.tab.id);
- pushCapped(buf, { level: msg.level, text: msg.text, timestamp: Date.now(), url: sender.tab.url });
+ pushCapped(buf, {
+ level: msg.level,
+ text: msg.text,
+ timestamp: Date.now(),
+ url: sender.tab.url,
+ });
return false; // fire-and-forget; no response expected
}
- if (msg.type === 'getStatus') {
+ if (msg.type === "getStatus") {
// Async: fetch tabs before responding so the popup gets a full snapshot
// (connection + locks + open tabs) in one message. Returning true signals
// Chrome we'll call respond() asynchronously.
@@ -46,15 +52,15 @@ export function registerEventListeners() {
});
return true; // async response
}
- if (msg.type === 'setPort') {
+ if (msg.type === "setPort") {
respond(applyPort(msg.port));
return false;
}
- if (msg.type === 'setToken') {
+ if (msg.type === "setToken") {
respond(applyToken(msg.token));
return false;
}
- if (msg.type === 'setEnrollment') {
+ if (msg.type === "setEnrollment") {
// The popup owns the user-facing entry of the enrollment secret. Persist,
// re-pair, reconnect — all inside connection.js. The onMessage listener is
// NOT async, so we .then() and return true (Chrome keeps the respond()
@@ -64,38 +70,40 @@ export function registerEventListeners() {
});
return true; // async response — respond() fires from the .then()
}
- if (msg.type === 'unlockAll') {
+ if (msg.type === "unlockAll") {
// Snapshot BEFORE unlockAll() — unlockAll clears the map, so reading after
// would lose the list of tabs whose shields need removing.
const prev = tabLocks.snapshot();
tabLocks.unlockAll();
persistSessionState();
for (const { tabId } of prev) hideLockShield(tabId);
- broadcastStatus('All tab locks cleared');
+ broadcastStatus("All tab locks cleared");
respond({ success: true });
return false;
}
- if (msg.type === 'lockTab') {
+ if (msg.type === "lockTab") {
const owner = msg.sessionId;
if (msg.tabId == null || !owner) {
- respond({ success: false, error: 'tabId and sessionId required' });
+ respond({ success: false, error: "tabId and sessionId required" });
return false;
}
// lockTabUi is async (it awaits the shield injection) — keep Chrome's
// respond() channel open for the async reply.
lockTabUi(msg.tabId, owner, `Tab ${msg.tabId} pinned to ${owner}`)
.then((shielded) => respond({ success: true, shielded }))
- .catch((err) => respond({ success: false, error: err?.message || String(err) }));
+ .catch((err) =>
+ respond({ success: false, error: err?.message || String(err) }),
+ );
return true;
}
- if (msg.type === 'unlockTab') {
+ if (msg.type === "unlockTab") {
// { tabId } — release one tab's lock (vs unlockAll which clears all).
if (msg.tabId == null) {
- respond({ success: false, error: 'tabId required' });
+ respond({ success: false, error: "tabId required" });
return false;
}
const was = releaseTabUi(msg.tabId);
- broadcastStatus(`Tab ${msg.tabId} unpinned (was ${was || '-'})`);
+ broadcastStatus(`Tab ${msg.tabId} unpinned (was ${was || "-"})`);
respond({ success: true, previousSession: was || null });
return false;
}
@@ -106,12 +114,34 @@ export function registerEventListeners() {
(details) => {
if (details.tabId == null || details.tabId < 0) return; // not a real tab
const buf = getTabBuffer(networkByTab, details.tabId);
- pushCapped(buf, {
+ const entry = {
method: details.method,
url: details.url,
status: details.statusCode,
type: details.type,
timestamp: details.timeStamp,
+ };
+ pushCapped(buf, entry);
+ // Intercept ledger enrichment (best-effort; never breaks capture).
+ try {
+ enrichCapture(details.tabId, entry);
+ } catch {}
+ },
+ { urls: [""] },
+ );
+
+ // Requests that never completed (DNS failure, blocked, aborted, CORS…):
+ // onCompleted never fires for them, so without this they were invisible.
+ chrome.webRequest.onErrorOccurred.addListener(
+ (details) => {
+ if (details.tabId == null || details.tabId < 0) return;
+ const buf = getTabBuffer(networkByTab, details.tabId);
+ pushCapped(buf, {
+ method: details.method,
+ url: details.url,
+ error: details.error,
+ type: details.type,
+ timestamp: details.timeStamp,
});
},
{ urls: [''] },
@@ -136,11 +166,11 @@ export function registerEventListeners() {
// from the short-lived per-call listener inside handleNavigate — they share no
// state and Chrome supports multiple onUpdated listeners (review NOTE 7c).
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
- if (changeInfo.status === 'loading') {
+ if (changeInfo.status === "loading") {
dropDocumentState(tabId);
persistSessionState();
}
- if (changeInfo.status === 'complete' && tabLocks.owner(tabId)) {
+ if (changeInfo.status === "complete" && tabLocks.owner(tabId)) {
showLockShield(tabId);
}
});
diff --git a/extension/handlers/cdp.js b/extension/handlers/cdp.js
index cf1a3d7..457b7bd 100644
--- a/extension/handlers/cdp.js
+++ b/extension/handlers/cdp.js
@@ -3,9 +3,10 @@
* upload_file — the two tools that cannot be implemented with
* chrome.scripting (CSP bypass / DOM.setFileInputFiles).
*/
-import { resolveTab, safeExec } from '../lib/page-exec.js';
+import { resolveTab, execDom, getFallback } from '../lib/page-exec.js';
import { MAX_RESULT_CHARS } from '../lib/state.js';
import { ensureCdp } from '../lib/cdp-session.js';
+import { handleScreenshot } from './tabs.js';
export async function handleRunAction(params, _sessionId, _agentName, signal) {
const { tabId, code, actionParams = {} } = params;
@@ -67,71 +68,135 @@ export async function handleRunAction(params, _sessionId, _agentName, signal) {
}
}
+/** Main-world expression returning the node marked data-bc-upload=token (pierces open shadow roots / same-origin frames). */
+export function findMarkedExpression(token) {
+ return `(() => { const s = '[data-bc-upload="${token}"]';
+ const q = (root, d) => { const hit = root.querySelector(s); if (hit || d > 6) return hit;
+ for (const el of root.querySelectorAll('*')) {
+ if (el.shadowRoot) { const h = q(el.shadowRoot, d + 1); if (h) return h; }
+ if (el.tagName === 'IFRAME') { try { const h = el.contentDocument && q(el.contentDocument, d + 1); if (h) return h; } catch (e) {} }
+ }
+ return null; };
+ return q(document, 0); })()`;
+}
+
+/**
+ * Page-side: build a File from base64 bytes and hand it to the page — into an
+ * (files + input/change events) or, for any other target,
+ * as a drag-and-drop (dragenter/dragover/drop with a DataTransfer), which is
+ * what upload drop zones listen for. No temp files, no file dialog.
+ */
+function pagePutFile(ref, sel, fb, b64, mime, name, x, y) {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ let target;
+ if (ref || sel) target = D.resolve(ref, sel, fb).el;
+ else if (Number.isFinite(x) && Number.isFinite(y)) target = D.elementAt(x, y);
+ else target = (D.queryAll('input[type="file"]', true) || [])[0] || null;
+ if (!target) return { success: false, error: 'Upload target not found' };
+ let bytes;
+ try {
+ const bin = atob(b64);
+ bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+ } catch { return { success: false, error: 'imageBase64 is not valid base64' }; }
+ const file = new File([bytes], name, { type: mime });
+ const dt = new DataTransfer();
+ dt.items.add(file);
+ if (target.tagName === 'INPUT' && target.type === 'file') {
+ target.files = dt.files;
+ target.dispatchEvent(new Event('input', { bubbles: true }));
+ target.dispatchEvent(new Event('change', { bubbles: true }));
+ return { success: true, mode: 'input', file: name, size: file.size };
+ }
+ const r = target.getBoundingClientRect();
+ const init = { bubbles: true, cancelable: true, composed: true, dataTransfer: dt, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2 };
+ for (const type of ['dragenter', 'dragover', 'drop']) target.dispatchEvent(new DragEvent(type, init));
+ return { success: true, mode: 'drop', file: name, size: file.size, target: D.describe(target) };
+}
+
+/** Upload bytes (base64 or a fresh screenshot) instead of a local path. */
+async function uploadBytes(tab, params) {
+ let b64 = params.imageBase64 || null;
+ let mime = params.mimeType || 'image/png';
+ let name = params.fileName || 'image.png';
+ if (params.fromScreenshot) {
+ const shot = await handleScreenshot({
+ tabId: params.screenshotTabId ?? tab.id, format: 'png',
+ ...(params.region ? { region: params.region } : {}),
+ });
+ if (!shot?.data) throw new Error('Screenshot for upload returned no data');
+ b64 = shot.data;
+ mime = 'image/png';
+ name = params.fileName || 'screenshot.png';
+ }
+ if (!b64) throw new Error('imageBase64 or fromScreenshot required');
+ const res = await execDom(tab.id, pagePutFile, [
+ params.ref || null, params.selector || null, getFallback(tab.id, params.ref),
+ b64, mime, name, params.x ?? null, params.y ?? null,
+ ]);
+ return res;
+}
+
export async function handleUploadFile(params) {
const { tabId, ref, selector, filePath, files: fileList } = params;
const tab = await resolveTab(tabId);
+ if (params.imageBase64 || params.fromScreenshot) return uploadBytes(tab, params);
const filePaths = fileList || (filePath ? [filePath] : []);
- if (filePaths.length === 0) throw new Error('filePath or files required');
+ if (filePaths.length === 0) throw new Error('filePath, files, imageBase64 or fromScreenshot required');
- let sel = 'input[type="file"]';
- if (ref) sel = `[data-mcp-ref="${ref}"]`;
- else if (selector) sel = selector;
-
- // Verify the target BEFORE the CDP round-trip: CDP's DOM.querySelector
- // happily resolves any node, and DOM.setFileInputFiles on a non-file input
- // fails with an opaque protocol error (or worse, on some Chrome versions,
- // appears to succeed). React onChange handlers also require a change/input
- // event after the files are set — CDP doesn't fire one.
- const check = await safeExec(tab.id, (s) => {
- const el = document.querySelector(s);
+ // Resolve in the page with the shared resolver (ref registry, visible-first
+ // selector across shadow roots / same-origin frames, verified fallback), then
+ // hand the node to CDP through a one-shot marker attribute.
+ const sel = selector || (ref ? null : 'input[type="file"]');
+ const what = selector || (ref ? `ref ${ref}` : 'input[type="file"]');
+ const token = `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
+ const check = await execDom(tab.id, (_ref, _sel, _fb, _token) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const el = D.resolve(_ref, _sel, _fb).el;
if (!el) return { found: false };
+ el.setAttribute('data-bc-upload', _token);
return {
found: true,
isFileInput: el.tagName === 'INPUT' && el.type === 'file',
multiple: !!el.multiple,
};
- }, [sel]).catch(() => null);
- if (check && check.found) {
- if (!check.isFileInput) throw new Error(`Element matching ${sel} is not an .`);
- if (filePaths.length > 1 && !check.multiple) {
- throw new Error(`File input matching ${sel} does not accept multiple files.`);
- }
+ }, [ref || null, sel, getFallback(tab.id, ref), token]).catch(() => null);
+ if (!check || !check.found) throw new Error(`File input not found: ${what}`);
+ if (!check.isFileInput) throw new Error(`Element matching ${what} is not an .`);
+ if (filePaths.length > 1 && !check.multiple) {
+ throw new Error(`File input matching ${what} does not accept multiple files.`);
}
// upload_file stays on CDP (DOM.setFileInputFiles is CDP-only).
let uploaded = false;
try {
const send = await ensureCdp(tab.id);
- await send('DOM.enable');
- const { root } = await send('DOM.getDocument');
-
- const { nodeId } = await send('DOM.querySelector', {
- nodeId: root.nodeId,
- selector: sel,
- });
-
- if (!nodeId) throw new Error(`File input not found with selector: ${sel}`);
-
- await send('DOM.setFileInputFiles', {
- files: filePaths,
- nodeId,
- });
+ // Find the marked node wherever it lives (open shadow roots, same-origin frames).
+ const { result } = await send('Runtime.evaluate', { expression: findMarkedExpression(token) });
+ if (!result || !result.objectId) throw new Error(`File input not found: ${what}`);
+ await send('DOM.setFileInputFiles', { files: filePaths, objectId: result.objectId });
uploaded = true;
} finally {
// Fire the events React/Vue file inputs listen for after a successful set,
- // and always remove the short-lived Observation V2 handoff marker.
+ // and always remove the one-shot marker (and the Observation V2 handoff marker).
try {
- await safeExec(tab.id, (s, notify) => {
- const el = document.querySelector(s);
- if (!el) return;
+ await execDom(tab.id, (_token, notify) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const el = (D.queryAll(`[data-bc-upload="${_token}"]`, true) || [])[0];
+ if (!el) return null;
if (notify) {
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
+ el.removeAttribute('data-bc-upload');
el.removeAttribute('data-bc-v2-upload');
- }, [sel, uploaded]);
+ return null;
+ }, [token, uploaded]);
} catch { /* page changed — CDP outcome still determines the tool result */ }
}
- return { success: true, files: filePaths, selector: sel };
+ return { success: true, files: filePaths, selector: what };
}
diff --git a/extension/handlers/find.js b/extension/handlers/find.js
new file mode 100644
index 0000000..d93aca0
--- /dev/null
+++ b/extension/handlers/find.js
@@ -0,0 +1,128 @@
+/**
+ * browser_find: natural-language element search (tokenized, role-aware,
+ * shadow DOM + same-origin iframes, wrapper/echo suppression). Refs go into
+ * the shared page registry so every ref tool can use them.
+ */
+import { safeExec, execDom, resolveTab } from '../lib/page-exec.js';
+import { fallbackByTab, persistSessionState, nextRefPrefix } from '../lib/state.js';
+import { PAGE_FALLBACK_INSTALL } from '../utils/smart-selector.js';
+
+export async function handleFind(params) {
+ const { tabId, query, limit = 10, role } = params;
+ await resolveTab(tabId);
+ await safeExec(tabId, PAGE_FALLBACK_INSTALL, []);
+ const refPrefix = nextRefPrefix('f');
+
+ return execDom(tabId, (_q, _lim, _refPrefix, _role) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const genFallback = (globalThis.__browserControllerFallbackRuntime || {}).generateFallback || null;
+ const fallbacks = {};
+
+ // Words that describe the KIND of element, mapped to the roles they mean.
+ const ROLE_WORDS = {
+ button: ['button'], btn: ['button'], link: ['link'], anchor: ['link'],
+ input: ['textbox', 'searchbox', 'combobox', 'spinbutton'], field: ['textbox', 'searchbox', 'combobox', 'spinbutton'],
+ textbox: ['textbox', 'searchbox'], box: ['textbox', 'searchbox', 'combobox', 'checkbox'], textarea: ['textbox'],
+ searchbox: ['searchbox'], checkbox: ['checkbox'], check: ['checkbox'], radio: ['radio'],
+ dropdown: ['combobox', 'listbox', 'button'], select: ['combobox', 'listbox'], combobox: ['combobox'],
+ tab: ['tab'], menu: ['menu', 'menubar', 'button'], menuitem: ['menuitem'], option: ['option'],
+ heading: ['heading'], title: ['heading'], image: ['img'], img: ['img'], icon: ['img', 'button'],
+ dialog: ['dialog', 'alertdialog'], modal: ['dialog', 'alertdialog'], switch: ['switch'], toggle: ['switch', 'button', 'checkbox'],
+ slider: ['slider'], list: ['list', 'listbox'], table: ['table', 'grid'], row: ['row'], cell: ['cell', 'gridcell'],
+ };
+ const STOP = new Set(['the', 'a', 'an', 'to', 'of', 'for', 'on', 'in', 'with', 'and', 'that', 'this', 'element', 'please']);
+ const words = String(_q).toLowerCase().split(/[^\p{L}\p{N}_-]+/u).filter((w) => w && !STOP.has(w));
+ const roleHints = new Set();
+ const content = [];
+ for (const w of words) {
+ if (ROLE_WORDS[w]) ROLE_WORDS[w].forEach((r) => roleHints.add(r));
+ else content.push(w);
+ }
+ // "search" names the purpose AND a role.
+ if (words.includes('search')) roleHints.add('searchbox');
+ const phrase = content.join(' ');
+ const wantRole = _role ? String(_role).toLowerCase() : null;
+
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'META', 'LINK', 'HEAD', 'HTML', 'BODY', 'BR', 'PATH']);
+ const cands = [];
+ for (const root of D.allRoots(true)) {
+ let els = [];
+ try { els = root.querySelectorAll('*'); } catch {}
+ for (const el of els) {
+ if (SKIP.has(el.tagName)) continue;
+ const r = D.roleOf(el);
+ if (r === 'none') continue;
+ if (wantRole && r !== wantRole) continue;
+ const name = D.nameOf(el).toLowerCase();
+ const attrs = [el.id, D.attr(el, 'name'), D.attr(el, 'type'), D.attr(el, 'placeholder'), D.attr(el, 'data-testid'),
+ D.attr(el, 'title'), typeof el.className === 'string' ? el.className : ''].join(' ').toLowerCase();
+ const interactive = D.isInteractive(el);
+ let score = 0;
+ let covered = 0;
+ for (const w of content) {
+ const inName = name.includes(w);
+ const inAttr = attrs.includes(w);
+ if (inName) score += new RegExp(`(^|[^\\p{L}\\p{N}])${w.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}([^\\p{L}\\p{N}]|$)`, 'u').test(name) ? 6 : 4;
+ else if (inAttr) score += 3;
+ if (inName || inAttr) covered++;
+ }
+ if (content.length && covered === 0) continue;
+ if (phrase && name === phrase) score += 12;
+ else if (phrase && content.length > 1 && name.includes(phrase)) score += 6;
+ if (roleHints.size) {
+ if (roleHints.has(r) || (roleHints.has('searchbox') && /search/.test(attrs) && ['textbox', 'searchbox', 'combobox'].includes(r))) score += 8;
+ else if (!content.length) continue;
+ else score -= 2;
+ }
+ if (interactive) score += 4;
+ else if (r === 'generic') score -= 3;
+ // A container whose text merely CONTAINS the words is a weak match.
+ if (name.length > 120) score -= 4;
+ const coverage = content.length ? covered / content.length : 1;
+ if (coverage < 0.5) continue;
+ score = Math.round(score * coverage * 10) / 10;
+ if (score <= 0) continue;
+ cands.push({ el, r, name, score, interactive });
+ }
+ }
+ cands.sort((a, b) => b.score - a.score);
+ // Visibility is the expensive check: only for the best-scoring pool.
+ const pool = [];
+ for (const c of cands) {
+ if (pool.length >= _lim * 6) break;
+ if (D.isVisible(c.el)) pool.push(c);
+ }
+ // Drop wrappers (an ancestor scoring no better than a descendant) and echoes
+ // (a descendant repeating the name of the control that contains it).
+ const kept = pool.filter((c) => !pool.some((o) => o !== c && (
+ (o.score >= c.score && D.composedContains(c.el, o.el))
+ || (o.interactive && !c.interactive && o.score >= c.score && o.name === c.name && D.composedContains(o.el, c.el)))));
+
+ const matches = [];
+ kept.slice(0, _lim).forEach((c, i) => {
+ const ref = `${_refPrefix}${i}`;
+ D.registry.set(ref, c.el);
+ try { if (genFallback) fallbacks[ref] = genFallback(c.el); } catch {}
+ const rect = D.centerOf(c.el).rect;
+ matches.push({
+ ref, role: c.r, name: D.nameOf(c.el).slice(0, 80), tag: c.el.tagName.toLowerCase(), score: c.score,
+ bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
+ });
+ });
+ return {
+ success: true, query: _q, matches,
+ ...(matches.length === 0 ? { hint: 'No match. Try fewer/other words, a role filter, browser_snapshot, or browser_text.' } : {}),
+ __fallbacks: fallbacks,
+ };
+ }, [query, limit, refPrefix, role || null]).then((res) => {
+ if (res && res.__fallbacks) {
+ const map = fallbackByTab.get(tabId) || new Map();
+ for (const [ref, fbEntry] of Object.entries(res.__fallbacks)) map.set(ref, fbEntry);
+ fallbackByTab.set(tabId, map);
+ delete res.__fallbacks;
+ persistSessionState();
+ }
+ return res;
+ });
+}
diff --git a/extension/handlers/gif.js b/extension/handlers/gif.js
new file mode 100644
index 0000000..a376c36
--- /dev/null
+++ b/extension/handlers/gif.js
@@ -0,0 +1,154 @@
+/**
+ * browser_gif: record what the agent does in a tab as an animated GIF
+ * (Claude-in-Chrome gif_creator). While recording, the router captures a
+ * downscaled frame after every page-changing action; export encodes the
+ * frames (lib/gif-encoder.js) with a red ring where clicks landed. The MCP
+ * server writes the file — the GIF bytes never go to the agent.
+ */
+import { resolveTab } from '../lib/page-exec.js';
+import { encodeGif, drawMarker } from '../lib/gif-encoder.js';
+import { handleScreenshot } from './tabs.js';
+
+/** Tools after which a frame is captured. Reads (text/snapshot/find…) don't change the page. */
+export const GIF_FRAME_TOOLS = new Set([
+ 'browser_navigate', 'browser_click', 'browser_type', 'browser_press_key', 'browser_scroll', 'browser_hover',
+ 'browser_select', 'browser_click_text', 'browser_drag', 'browser_fill_form', 'browser_act', 'browser_upload_file',
+ 'browser_handle_dialog', 'browser_run_action', 'browser_evaluate', 'browser_wait',
+]);
+
+const MAX_FRAMES_CAP = 500;
+/** Export travels in parts: the daemon's WebSocket frames are capped at 1 MB. */
+export const GIF_PART_BYTES = 600_000;
+/** tabId -> { frames, width, maxFrames, activate, recording, skipped, startedAt } */
+const recordings = new Map();
+
+export function isRecording(tabId) {
+ const r = recordings.get(tabId);
+ return !!(r && r.recording);
+}
+
+/** Capture one frame (after an action). Never throws: a failed frame is just skipped. */
+export async function recordFrame(tabId, label, result) {
+ const rec = recordings.get(tabId);
+ if (!rec || !rec.recording) return;
+ if (rec.frames.length >= rec.maxFrames) { rec.skipped++; return; }
+ try {
+ const tab = await chrome.tabs.get(tabId);
+ // Hidden tabs don't paint; activating one flashes it for ~150 ms (like browser_screenshot).
+ if (!tab.active && !rec.activate) { rec.skipped++; return; }
+ const shot = await handleScreenshot({ tabId, format: 'jpeg', quality: 70, maxWidth: rec.width });
+ if (!shot?.data) { rec.skipped++; return; }
+ const f = shot.frame;
+ const at = result && result.at && f && !f.page
+ ? [(result.at.x - f.origin[0]) * f.scale, (result.at.y - f.origin[1]) * f.scale]
+ : null;
+ rec.frames.push({ data: shot.data, t: Date.now(), label, ...(at ? { at } : {}) });
+ } catch {
+ rec.skipped++;
+ }
+}
+
+function b64ToBytes(b64) {
+ const bin = atob(b64);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+}
+
+function bytesToB64(bytes) {
+ let s = '';
+ for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
+ return btoa(s);
+}
+
+async function encodeRecording(rec) {
+ const decoded = [];
+ let W = 0;
+ let H = 0;
+ for (const fr of rec.frames) {
+ const bmp = await createImageBitmap(new Blob([b64ToBytes(fr.data)], { type: 'image/jpeg' }));
+ if (!W) { W = bmp.width; H = bmp.height; }
+ const canvas = new OffscreenCanvas(W, H);
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(bmp, 0, 0, W, H);
+ bmp.close?.();
+ const rgba = ctx.getImageData(0, 0, W, H).data;
+ if (fr.at) drawMarker(rgba, W, H, fr.at[0] * (W / (bmp.width || W)), fr.at[1] * (H / (bmp.height || H)));
+ decoded.push({ rgba, t: fr.t });
+ }
+ const frames = decoded.map((d, i) => ({
+ rgba: d.rgba,
+ // Real pacing, clamped so the GIF is watchable: 0.4 s … 2.5 s, 1.5 s on the last frame.
+ delayMs: i + 1 < decoded.length ? Math.min(2500, Math.max(400, decoded[i + 1].t - d.t)) : 1500,
+ }));
+ return { bytes: encodeGif(W, H, frames), width: W, height: H };
+}
+
+export async function handleGif(params) {
+ const { tabId, action } = params;
+ await resolveTab(tabId);
+ switch (action) {
+ case 'start': {
+ const rec = {
+ frames: [],
+ width: Math.min(Math.max(Number(params.width) || 800, 200), 1600),
+ maxFrames: Math.min(Math.max(Number(params.maxFrames) || 300, 1), MAX_FRAMES_CAP),
+ activate: params.activate !== false,
+ recording: true,
+ skipped: 0,
+ startedAt: Date.now(),
+ };
+ recordings.set(tabId, rec);
+ await recordFrame(tabId, 'start', null); // the first frame: how the page looked
+ return { success: true, recording: true, frames: rec.frames.length, width: rec.width, maxFrames: rec.maxFrames };
+ }
+ case 'frame': {
+ const rec = recordings.get(tabId);
+ if (!rec) throw new Error('Not recording this tab — call browser_gif action:"start" first.');
+ const was = rec.recording;
+ rec.recording = true;
+ await recordFrame(tabId, 'frame', null);
+ rec.recording = was;
+ return { success: true, frames: rec.frames.length };
+ }
+ case 'stop': {
+ const rec = recordings.get(tabId);
+ if (!rec) throw new Error('Not recording this tab.');
+ rec.recording = false;
+ return { success: true, recording: false, frames: rec.frames.length, skipped: rec.skipped, seconds: Math.round((Date.now() - rec.startedAt) / 1000) };
+ }
+ case 'status': {
+ const rec = recordings.get(tabId);
+ return rec
+ ? { success: true, recording: rec.recording, frames: rec.frames.length, skipped: rec.skipped }
+ : { success: true, recording: false, frames: 0 };
+ }
+ case 'clear': {
+ recordings.delete(tabId);
+ return { success: true, cleared: true };
+ }
+ case 'export': {
+ const rec = recordings.get(tabId);
+ if (!rec || (rec.frames.length === 0 && !rec.encoded)) throw new Error('No frames recorded for this tab.');
+ rec.recording = false;
+ // Encode once (part 0), then hand the bytes out part by part.
+ const part = Number.isInteger(params.part) && params.part > 0 ? params.part : 0;
+ if (part === 0 || !rec.encoded) rec.encoded = await encodeRecording(rec);
+ const { bytes, width, height } = rec.encoded;
+ const parts = Math.max(1, Math.ceil(bytes.length / GIF_PART_BYTES));
+ if (part >= parts) throw new Error(`part ${part} out of range (${parts} parts)`);
+ const chunk = bytes.subarray(part * GIF_PART_BYTES, (part + 1) * GIF_PART_BYTES);
+ const frames = rec.frames.length;
+ if (part === parts - 1) {
+ rec.encoded = null;
+ if (params.clear !== false) recordings.delete(tabId);
+ }
+ return { success: true, frames, width, height, bytes: bytes.length, part, parts, gifBase64: bytesToB64(chunk) };
+ }
+ default:
+ throw new Error(`Unknown action: ${action}`);
+ }
+}
+
+/** Test hook. */
+export function _recordings() { return recordings; }
diff --git a/extension/handlers/inspection.js b/extension/handlers/inspection.js
index 3bea899..f189c06 100644
--- a/extension/handlers/inspection.js
+++ b/extension/handlers/inspection.js
@@ -2,15 +2,18 @@
* Inspection handlers (extracted from background.js): wait, scroll, snapshot,
* find, text, evaluate — the read side of the toolset.
*/
-import { safeExec, resolveTab, getFallback } from '../lib/page-exec.js';
-import { fallbackByTab, lastSnapshotFingerprints, MAX_RESULT_CHARS, persistSessionState } from '../lib/state.js';
+import { safeExec, execDom, resolveTab, getFallback, assertResponsive, hasPoint } from '../lib/page-exec.js';
+import { trustedSender, pointInfo, releaseShield } from '../lib/trusted-input.js';
+import { fallbackByTab, lastSnapshotFingerprints, MAX_RESULT_CHARS, persistSessionState, nextRefPrefix } from '../lib/state.js';
import { PAGE_FALLBACK_INSTALL } from '../utils/smart-selector.js';
-import { PAGE_LEGACY_REF_INSTALL } from '../utils/legacy-refs.js';
import { withCdp } from '../lib/cdp-session.js';
import { cdpEvaluate } from '../lib/cdp-evaluate.js';
+/** Default output cap for snapshots (chars of serialized tree). */
+export const SNAPSHOT_MAX_CHARS = 20_000;
+
export async function handleWait(params, _sessionId, _agentName, signal) {
- const { tabId, selector, state = 'visible', timeout = 10000, delay } = params;
+ const { tabId, selector, state = 'visible', timeout = 10000, delay, text, urlIncludes } = params;
// A promise that rejects when this call is cancelled (client gone / bridge
// timeout forwarded). Long waits race against it so a cancelled call releases
@@ -22,6 +25,7 @@ export async function handleWait(params, _sessionId, _agentName, signal) {
})
: null;
+ const hasCondition = !!selector || text != null || !!urlIncludes;
if (delay) {
const sleep = new Promise((r) => setTimeout(r, Math.min(delay, 30000)));
try {
@@ -29,55 +33,94 @@ export async function handleWait(params, _sessionId, _agentName, signal) {
} catch {
return { success: false, error: 'aborted', waited: 0 };
}
- return { success: true, waited: delay };
+ return { success: true, waited: delay }; // documented: a delay ignores the conditions
}
- if (!selector) return { success: false, error: 'Need selector or delay' };
+ if (!hasCondition) return { success: false, error: 'Need selector, text, urlIncludes or delay' };
await resolveTab(tabId);
const start = Date.now();
+ const what = selector || (text != null ? `text "${text}"` : `url containing "${urlIncludes}"`);
while (Date.now() - start < timeout) {
// Bail the moment the caller is gone so we don't pin the tab mutex for the
// full timeout window after the originating agent was evicted (consistent
// with handleNavigate / handleRunAction).
if (signal?.aborted) return { success: false, error: 'aborted', selector, state };
- const found = await safeExec(tabId, (_sel, _state) => {
- const el = document.querySelector(_sel);
- if (_state === 'hidden') return !el || el.offsetParent === null;
- if (_state === 'attached') return !!el;
- return el && el.offsetParent !== null;
- }, [selector, state]);
-
- if (found) return { success: true, selector, state, elapsed: Date.now() - start };
+ let found;
+ try {
+ found = await execDom(tabId, (_sel, _state, _text, _url) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const hidden = _state === 'hidden';
+ if (_url != null && !location.href.includes(_url)) return false;
+ if (_text != null) {
+ const has = D.pageText(document.body).toLowerCase().includes(String(_text).toLowerCase());
+ if (hidden ? has : !has) return false;
+ }
+ if (_sel) {
+ // Every match across shadow roots / same-origin frames, not just the first.
+ const all = D.queryAll(_sel, true);
+ if (all === null) return { error: `Invalid CSS selector: ${_sel}` };
+ if (_state === 'attached') return all.length > 0;
+ const anyVisible = all.some((el) => D.isVisible(el));
+ return hidden ? !anyVisible : anyVisible;
+ }
+ return true;
+ }, [selector ?? null, state, text ?? null, urlIncludes ?? null]);
+ } catch { found = false; /* navigating: the next document isn't ready yet */ }
+ if (found && found.error) return { success: false, error: found.error };
+
+ if (found === true) {
+ return {
+ success: true,
+ ...(selector ? { selector } : {}),
+ ...(text != null ? { text } : {}),
+ ...(urlIncludes ? { urlIncludes } : {}),
+ state,
+ elapsed: Date.now() - start,
+ };
+ }
await new Promise((r) => setTimeout(r, 200));
}
- return { success: false, error: `Timeout waiting for ${selector} to be ${state}` };
+ return { success: false, error: `Timeout waiting for ${what} to be ${state}` };
}
export async function handleScroll(params) {
const { tabId, direction = 'down', amount = 500, selector, toElement, position } = params;
await resolveTab(tabId);
+ // x/y: a real mouse-wheel event at that point — scrolls whatever is under
+ // it (inner panels, maps, virtual lists) exactly like a user's wheel.
+ if (hasPoint(params) && !toElement && !position && !selector) {
+ const send = await trustedSender(tabId, true);
+ if (!send) throw new Error(`Scrolling at x/y needs the debugger (CDP), which could not attach to tab ${tabId}. Use selector/toElement instead.`);
+ const deltaX = direction === 'right' ? amount : direction === 'left' ? -amount : 0;
+ const deltaY = direction === 'down' ? amount : direction === 'up' ? -amount : 0;
+ const info = await pointInfo(tabId, params.x, params.y);
+ try {
+ await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: params.x, y: params.y, deltaX, deltaY });
+ } finally {
+ await releaseShield(tabId);
+ }
+ return { success: true, input: 'cdp', at: { x: params.x, y: params.y }, deltaX, deltaY, refsMayBeStale: true, ...(info.hit ? { over: info.hit } : {}) };
+ }
const fb = getFallback(tabId, toElement);
- if (fb) await safeExec(tabId, PAGE_FALLBACK_INSTALL, []);
- await safeExec(tabId, PAGE_LEGACY_REF_INSTALL, []);
- return safeExec(tabId, (_dir, _amt, _sel, _toEl, _pos, _fb) => {
+ return execDom(tabId, (_dir, _amt, _sel, _toEl, _pos, _fb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
if (_toEl) {
- const resolveFallback = (globalThis.__browserControllerFallbackRuntime || {}).resolveFallback || null;
- const resolveRef = (globalThis.__browserControllerLegacyRefRuntime || {}).resolveRef || null;
- const el = (resolveRef ? resolveRef(_toEl) : null) ||
- document.querySelector(`[data-mcp-ref="${_toEl}"]`) ||
- document.querySelector(_toEl) ||
- (_fb && resolveFallback ? resolveFallback(_fb) : null);
+ // toElement accepts a ref or a CSS selector (first visible match).
+ let el = D.resolve(_toEl, null, _fb).el;
+ if (!el) { try { el = D.resolve(null, _toEl, null).el; } catch { el = null; } }
if (el) {
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ el.scrollIntoView({ behavior: 'instant', block: 'center' });
return { success: true, scrolledTo: 'element' };
}
return { success: false, error: 'Element not found' };
}
- const target = _sel ? document.querySelector(_sel) : window;
+ const target = _sel ? D.resolve(null, _sel, null).el : window;
if (!target) return { success: false, error: 'Scroll container not found' };
if (_pos === 'top') {
@@ -118,7 +161,9 @@ export async function handleScroll(params) {
* DOM with permanent data-mcp-ref attributes.
*/
export async function handleSnapshot(params) {
- const { tabId, selector, compact = true } = params;
+ const { tabId, selector, ref: rootRef, depth, maxChars = SNAPSHOT_MAX_CHARS } = params;
+ // filter:"interactive"|"all" (Claude-in-Chrome naming) is an alias of compact.
+ const compact = params.filter === 'all' ? false : params.filter === 'interactive' ? true : params.compact !== false;
await resolveTab(tabId);
// Install the fallback page runtime first (v2 install-once pattern): the
@@ -127,196 +172,185 @@ export async function handleSnapshot(params) {
// extension CSP (script-src 'self', no unsafe-eval) throws in every
// isolated world, which silently killed fallback capture before this fix.
await safeExec(tabId, PAGE_FALLBACK_INSTALL, []);
- await safeExec(tabId, PAGE_LEGACY_REF_INSTALL, []);
// isNew feature: pass the fingerprints seen in the PREVIOUS snapshot so the
// page function can mark newly-appeared elements. Array is serializable.
- const prevFingerprints = lastSnapshotFingerprints.get(tabId) || [];
- const refPrefix = `e-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-`;
+ const prevFingerprints = lastSnapshotFingerprints.get(tabId) || null;
+ const refPrefix = nextRefPrefix('s');
- return safeExec(tabId, (_sel, _compact, _prevFingerprints, _refPrefix) => {
+ return execDom(tabId, (_sel, _compact, _prevFingerprints, _refPrefix, _rootRef, _depth, _maxChars) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
let refCount = 0;
/** @type {Record} ref -> fallback, returned to background */
const fallbacks = {};
/** @type {string[]} fingerprints of THIS snapshot (role|name), returned to background */
const fingerprints = [];
- const prevSet = new Set(_prevFingerprints);
+ // No previous snapshot → nothing is "new" (marking every node wasted tokens).
+ const prevSet = _prevFingerprints ? new Set(_prevFingerprints) : null;
// Descriptor generator comes from the pre-installed page runtime.
const genFallback = (globalThis.__browserControllerFallbackRuntime || {}).generateFallback || null;
- const registerRef = (globalThis.__browserControllerLegacyRefRuntime || {}).registerRef || null;
const skipTags = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'PATH', 'BR', 'HR', 'WBR', 'META', 'LINK']);
+ const maxDepth = Number.isInteger(_depth) && _depth >= 0 ? _depth : Infinity;
+ // Output budget: stop emitting nodes once the serialized size reaches it.
+ let budget = Number.isInteger(_maxChars) && _maxChars > 0 ? _maxChars : Infinity;
+ let truncated = false;
+ // 'show' = render normally, 'pass' = no box of its own (display:contents,
+ // slots) but its children may render, false = hidden subtree.
function vis(el) {
- const s = getComputedStyle(el);
- if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false;
+ const s = D.styleOf(el);
+ if (!s || s.display === 'none') return false;
+ if (s.display === 'contents' || el.tagName === 'SLOT') return 'pass';
+ if (s.visibility === 'hidden' || s.visibility === 'collapse' || parseFloat(s.opacity) === 0) {
+ // visibility is inherited but can be re-enabled below; keep walking.
+ return 'pass';
+ }
const r = el.getBoundingClientRect();
- return r.width > 0 && r.height > 0;
+ if (r.width > 0 && r.height > 0) return 'show';
+ // Zero-size wrappers (custom-element hosts, overflow containers) can still hold visible children.
+ return el.childElementCount > 0 || D.shadowOf(el) ? 'pass' : false;
}
- function role(el) {
- const r = el.getAttribute('role');
- if (r) return r;
- const map = {
- A: 'link', BUTTON: 'button', SELECT: 'combobox', TEXTAREA: 'textbox', IMG: 'img',
- H1: 'heading', H2: 'heading', H3: 'heading', H4: 'heading', H5: 'heading', H6: 'heading',
- NAV: 'navigation', MAIN: 'main', HEADER: 'banner', FOOTER: 'contentinfo', FORM: 'form',
- TABLE: 'table', UL: 'list', OL: 'list', LI: 'listitem',
- };
- if (el.tagName === 'INPUT') {
- const t = el.type?.toLowerCase();
- if (t === 'checkbox') return 'checkbox';
- if (t === 'radio') return 'radio';
- return 'textbox';
- }
- return map[el.tagName] || 'generic';
- }
+ const role = (el) => D.roleOf(el);
+ // Landmarks/regions are named only by an explicit label: their text is just
+ // their children's names again (token noise).
+ const elName = (el, r) => (landmarkRoles.has(r) && r !== 'dialog'
+ ? D.clean(D.attr(el, 'aria-label') || D.attr(el, 'title'))
+ : D.nameOf(el)).slice(0, 80);
+ const isInteractive = (el) => D.isInteractive(el);
- function elName(el) {
- const raw = (
- el.getAttribute('aria-label') || el.getAttribute('alt') ||
- el.getAttribute('title') || el.getAttribute('placeholder') ||
- ''
- ).trim();
- if (raw) return raw.slice(0, 80);
- const text = el.innerText;
- if (!text) return '';
- const first = text.split('\n')[0].trim();
- return first.slice(0, 80);
- }
+ const landmarkRoles = new Set(['navigation', 'main', 'banner', 'contentinfo', 'form', 'search', 'complementary', 'region', 'dialog']);
- function isInteractive(el) {
- const tags = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'];
- return tags.includes(el.tagName) || el.onclick || el.getAttribute('tabindex') !== null ||
- el.getAttribute('role') === 'button' || el.getAttribute('role') === 'link' ||
- el.getAttribute('role') === 'tab' || el.getAttribute('role') === 'menuitem' ||
- el.getAttribute('role') === 'option' || el.getAttribute('role') === 'switch' ||
- el.getAttribute('contenteditable') === 'true';
+ // Flat-tree children: open AND closed shadow roots, slotted content,
+ // same-origin iframe bodies (lib/page-dom.js flatChildren).
+ function childrenOf(el) {
+ return D.flatChildren(el).filter((c) => c.nodeType === 1);
}
- const landmarkRoles = new Set(['navigation', 'main', 'banner', 'contentinfo', 'form', 'search', 'complementary', 'region']);
+ const origin = location.origin;
+ function hrefOf(el) {
+ const h = el.href;
+ if (!h || typeof h !== 'string') return null;
+ if (h.startsWith(origin + '/')) return h.slice(origin.length); // same-origin: path only
+ return h;
+ }
- // Children including shadow DOM (open roots) and same-origin iframes.
- function childrenOf(el) {
- const out = [];
- for (const c of el.children) out.push(c);
- if (el.shadowRoot) {
- for (const c of el.shadowRoot.children) out.push(c);
- }
- // same-origin iframes: expose their document body children too.
- if (el.tagName === 'IFRAME') {
- try {
- const doc = el.contentDocument;
- if (doc && doc.body) for (const c of doc.body.children) out.push(c);
- } catch { /* cross-origin: skip */ }
- }
- return out;
+ function emit(el, r, n, extra, isNewCheck) {
+ const ref = `${_refPrefix}${refCount++}`;
+ D.registry.set(ref, el);
+ try { if (genFallback) fallbacks[ref] = genFallback(el); } catch {}
+ const fp = `${r}|${n}`;
+ fingerprints.push(fp);
+ const node = { ref, role: r, ...extra };
+ if (n) node.name = n;
+ if (isNewCheck && prevSet && !prevSet.has(fp)) node.isNew = true;
+ if (el.value !== undefined && el.value !== '' && typeof el.value !== 'object') node.value = String(el.value).slice(0, 200);
+ if (el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) node.checked = el.checked;
+ else if (D.attr(el, 'aria-checked')) node.checked = D.attr(el, 'aria-checked') === 'true';
+ if (D.attr(el, 'aria-expanded')) node.expanded = D.attr(el, 'aria-expanded') === 'true';
+ if (D.attr(el, 'aria-selected') === 'true') node.selected = true;
+ if (el.disabled) node.disabled = true;
+ if (el.tagName === 'A') { const h = hrefOf(el); if (h) node.href = h; }
+ budget -= JSON.stringify(node).length + 16;
+ return node;
}
- function buildCompact(el) {
+ function buildCompact(el, d) {
if (!el || el.nodeType !== 1) return null;
if (skipTags.has(el.tagName)) return null;
- if (!vis(el)) return null;
+ if (budget <= 0) { truncated = true; return null; }
+ const v = vis(el);
+ if (!v) return null;
- const ia = isInteractive(el);
+ const ia = v === 'show' && isInteractive(el);
const r = role(el);
- const isLandmark = landmarkRoles.has(r);
+ const isLandmark = v === 'show' && (landmarkRoles.has(r) || (r === 'heading'));
+ const own = ia || isLandmark;
- const kids = [];
- for (const c of childrenOf(el)) {
- const cn = buildCompact(c);
- if (cn) Array.isArray(cn) ? kids.push(...cn) : kids.push(cn);
+ let node = null;
+ if (own) {
+ if (d > maxDepth) { truncated = true; return null; }
+ node = emit(el, r, elName(el, r), {}, true);
}
+ const kids = [];
+ if (!(own && d >= maxDepth)) {
+ for (const c of childrenOf(el)) {
+ const cn = buildCompact(c, own ? d + 1 : d);
+ if (cn) Array.isArray(cn) ? kids.push(...cn) : kids.push(cn);
+ }
+ } else if (childrenOf(el).length) truncated = true;
- if (!ia && !isLandmark && r !== 'heading') {
- return kids.length === 0 ? null : kids.length === 1 ? kids[0] : kids;
- }
-
- const ref = `${_refPrefix}${refCount++}`;
- if (registerRef) registerRef(ref, el);
- const n = elName(el);
- try { if (genFallback) fallbacks[ref] = genFallback(el); } catch {}
-
- // isNew: mark elements whose (role|name) wasn't in the previous snapshot.
- const fp = `${r}|${n}`;
- fingerprints.push(fp);
- const isNew = !prevSet.has(fp);
-
- const node = { ref, role: r };
- if (n) node.name = n;
- if (isNew) node.isNew = true;
- if (el.value !== undefined && el.value !== '') node.value = String(el.value);
- if (el.checked !== undefined) node.checked = el.checked;
- if (el.disabled) node.disabled = true;
- if (el.href && el.tagName === 'A') node.href = el.href;
+ if (!own) return kids.length === 0 ? null : kids.length === 1 ? kids[0] : kids;
if (kids.length) node.children = kids;
-
return node;
}
- function buildFull(el, depth) {
+ function buildFull(el, d) {
if (!el || el.nodeType !== 1) return null;
if (skipTags.has(el.tagName)) return null;
- if (!vis(el)) return null;
+ if (budget <= 0) { truncated = true; return null; }
+ const v = vis(el);
+ if (!v) return null;
const r = role(el);
- const n = elName(el);
- const ia = isInteractive(el);
+ const ia = v === 'show' && isInteractive(el);
+ const n = v === 'show' ? elName(el, r) : '';
- if (r === 'generic' && !n && !ia && depth > 1) {
+ if (v !== 'show' || (r === 'generic' && !n && !ia && d > 1)) {
const kids = [];
for (const c of childrenOf(el)) {
- const cn = buildFull(c, depth + 1);
+ const cn = buildFull(c, d + (v === 'show' ? 1 : 0));
if (cn) Array.isArray(cn) ? kids.push(...cn) : kids.push(cn);
}
return kids.length === 0 ? null : kids.length === 1 ? kids[0] : kids;
}
+ if (d > maxDepth) { truncated = true; return null; }
- const ref = `${_refPrefix}${refCount++}`;
- if (registerRef) registerRef(ref, el);
- try { if (genFallback) fallbacks[ref] = genFallback(el); } catch {}
-
- // isNew: mark elements whose (role|name) wasn't in the previous snapshot.
- const fp = `${r}|${n}`;
- fingerprints.push(fp);
- const isNew = !prevSet.has(fp);
-
- const node = { ref, role: r };
- if (r === 'generic') node.tag = el.tagName.toLowerCase();
- if (n) node.name = n;
- if (isNew) node.isNew = true;
- if (el.value !== undefined && el.value !== '') node.value = String(el.value);
- if (el.checked !== undefined) node.checked = el.checked;
- if (el.disabled) node.disabled = true;
- if (el.href && el.tagName === 'A') node.href = el.href;
-
+ const node = emit(el, r, n, r === 'generic' ? { tag: el.tagName.toLowerCase() } : {}, true);
const kids = [];
for (const c of childrenOf(el)) {
- const cn = buildFull(c, depth + 1);
+ const cn = buildFull(c, d + 1);
if (cn) Array.isArray(cn) ? kids.push(...cn) : kids.push(cn);
}
if (kids.length) node.children = kids;
-
return node;
}
- const root = _sel ? document.querySelector(_sel) : document.body;
+ let root = document.body;
+ if (_rootRef) {
+ root = D.registry.get(_rootRef);
+ if (!D.connected(root)) return { success: false, error: `ref ${_rootRef} is gone — take a new snapshot` };
+ } else if (_sel) {
+ const hit = D.resolve(null, _sel, null);
+ if (hit.error === 'INVALID_SELECTOR') return { success: false, error: `Invalid CSS selector: ${_sel}` };
+ root = hit.el;
+ }
if (!root) return { success: false, error: 'Root element not found' };
- const tree = _compact ? buildCompact(root) : buildFull(root, 0);
+ const tree = _compact ? buildCompact(root, 0) : buildFull(root, 0);
return {
success: true,
url: location.href,
title: document.title,
compact: _compact,
tree,
+ ...(truncated ? {
+ truncated: true,
+ hint: 'Output capped (maxChars/depth). Scope it with selector or ref (a subtree), or raise maxChars.',
+ } : {}),
// internal: background stores these per-tab; never sent to the agent.
__fallbacks: fallbacks,
__fingerprints: fingerprints,
};
- }, [selector, compact, prevFingerprints, refPrefix]).then((res) => {
+ }, [selector ?? null, compact, prevFingerprints, refPrefix, rootRef ?? null, depth ?? null, maxChars]).then((res) => {
// Store the fallbacks per-tab so click/type can resolve stale refs, and
- // persist them across service-worker recycles (MV3 lifetime).
+ // persist them across service-worker recycles (MV3 lifetime). Merged, not
+ // replaced: a scoped snapshot must not invalidate refs from the full one.
if (res && res.__fallbacks) {
- const map = new Map(Object.entries(res.__fallbacks));
+ const map = fallbackByTab.get(tabId) || new Map();
+ for (const [ref, fbEntry] of Object.entries(res.__fallbacks)) map.set(ref, fbEntry);
+ // Bound the map: keep the most recent entries.
+ while (map.size > 3000) map.delete(map.keys().next().value);
fallbackByTab.set(tabId, map);
delete res.__fallbacks; // keep it out of the agent-visible payload
persistSessionState();
@@ -330,104 +364,49 @@ export async function handleSnapshot(params) {
});
}
-export async function handleFind(params) {
- const { tabId, query, limit = 10 } = params;
- await resolveTab(tabId);
- await safeExec(tabId, PAGE_FALLBACK_INSTALL, []);
- await safeExec(tabId, PAGE_LEGACY_REF_INSTALL, []);
- const refPrefix = `f-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-`;
-
- return safeExec(tabId, (_q, _lim, _refPrefix) => {
- const qLow = _q.toLowerCase();
- const matches = [];
- const fallbacks = {};
- const genFallback = (globalThis.__browserControllerFallbackRuntime || {}).generateFallback || null;
- const registerRef = (globalThis.__browserControllerLegacyRefRuntime || {}).registerRef || null;
-
- function aName(el) {
- return (el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') ||
- el.getAttribute('placeholder') || el.innerText?.slice(0, 200) || '').trim();
- }
-
- function aRole(el) {
- const r = el.getAttribute('role');
- if (r) return r;
- const map = { A: 'link', BUTTON: 'button', INPUT: 'input', SELECT: 'combobox', TEXTAREA: 'textbox', IMG: 'image' };
- return map[el.tagName] || el.tagName.toLowerCase();
- }
-
- // Same-origin iframe piercing (field report: legacy UIs live entirely
- // inside #mainFrame — the top-document walk saw none of it).
- const roots = [document.body];
- (function collectFrames(doc, depth) {
- if (depth >= 3) return;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d && d.body) { roots.push(d.body); collectFrames(d, depth + 1); } } catch {}
- }
- })(document, 0);
- let rc = 0;
- let node;
- for (const root of roots) {
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
- while ((node = walker.nextNode()) && matches.length < _lim * 3) {
- const s = getComputedStyle(node);
- const rect = node.getBoundingClientRect();
- if (s.display === 'none' || s.visibility === 'hidden' || rect.width === 0) continue;
-
- const n = aName(node).toLowerCase();
- const r = aRole(node).toLowerCase();
- const id = (node.id || '').toLowerCase();
- let score = 0;
- if (n.includes(qLow)) score += 10;
- if (r.includes(qLow)) score += 5;
- if (id.includes(qLow)) score += 3;
- if (score === 0) continue;
-
- const ref = `${_refPrefix}${rc++}`;
- if (registerRef) registerRef(ref, node);
- try { if (genFallback) fallbacks[ref] = genFallback(node); } catch {}
- matches.push({
- ref, role: r, name: n.slice(0, 100), tag: node.tagName.toLowerCase(), score,
- bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
- });
- }
- }
-
- matches.sort((a, b) => b.score - a.score);
- return { success: true, query: _q, matches: matches.slice(0, _lim), __fallbacks: fallbacks };
- }, [query, limit, refPrefix]).then((res) => {
- if (res && res.__fallbacks) {
- const map = fallbackByTab.get(tabId) || new Map();
- for (const [ref, fbEntry] of Object.entries(res.__fallbacks)) map.set(ref, fbEntry);
- fallbackByTab.set(tabId, map);
- delete res.__fallbacks;
- persistSessionState();
- }
- return res;
- });
-}
-
export async function handleGetPageText(params) {
// Default must match the MCP schema (text.ts: maxLength .default(5000)) —
// it drifted 10x here once, so direct-WS callers got 50000 while MCP callers
// got 5000 from the same knob.
- const { tabId, selector, maxLength = 5000 } = params;
+ const { tabId, selector, maxLength = 5000, mode = 'all', offset = 0 } = params;
await resolveTab(tabId);
- const args = selector === undefined ? [null, maxLength] : [selector, maxLength];
-
- return safeExec(tabId, (_sel, _max) => {
- const root = _sel ? document.querySelector(_sel) : document.body;
+ const max = Math.min(Math.max(1, Number(maxLength) || 5000), 100_000);
+ const from = Math.max(0, Number(offset) || 0);
+
+ return execDom(tabId, (_sel, _max, _mode, _from) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const article = _mode === 'article';
+ let root = document.body;
+ if (_sel) {
+ const hit = D.resolve(null, _sel, null);
+ if (hit.error === 'INVALID_SELECTOR') return { success: false, error: `Invalid CSS selector: ${_sel}` };
+ root = hit.el;
+ } else if (article) {
+ root = D.articleRoot();
+ }
if (!root) return { success: false, error: 'Element not found' };
- let text = root.innerText || root.textContent || '';
- text = text.replace(/\t/g, ' ').replace(/\n\s*\n/g, '\n\n').replace(/ +/g, ' ').trim();
+ // Composed text: includes open/closed shadow roots and same-origin frames
+ // (innerText alone misses web-component content such as caniuse's tables).
+ let text = D.pageText(root, { article, max: _from + _max + 1000 });
+ const total = text.length;
+ if (_from) text = text.slice(_from);
const truncated = text.length > _max;
if (truncated) text = text.slice(0, _max) + '...';
- return { success: true, url: location.href, title: document.title, text, length: text.length, truncated };
- }, args);
+ return {
+ success: true, url: location.href, title: document.title, text, length: text.length, truncated,
+ ...(_from ? { offset: _from } : {}),
+ ...(truncated ? { nextOffset: _from + _max } : {}),
+ ...(article ? { mode: 'article' } : {}),
+ ...(total && _from >= total ? { note: `offset ${_from} is past the end (${total} chars)` } : {}),
+ };
+ }, [selector ?? null, max, mode, from]);
}
+export { handleFind } from './find.js';
+
/**
* evaluate (task 1.5): runs in the page's MAIN world via chrome.scripting — no
* chrome.debugger, so no yellow "is being debugged" banner. Replaces the old
@@ -444,6 +423,7 @@ export async function handleEvaluate(params, _sessionId, _agentName, signal) {
// Default: REPL semantics over CDP (top-level await, last expression is the
// result, not blocked by CSP). mode:"scripting" (or no debugger available)
// keeps the banner-free chrome.scripting path below.
+ await assertResponsive(tabId);
if (mode !== 'scripting') {
let attached = false;
try {
diff --git a/extension/handlers/interaction-advanced.js b/extension/handlers/interaction-advanced.js
index 6fe73ef..61c94fa 100644
--- a/extension/handlers/interaction-advanced.js
+++ b/extension/handlers/interaction-advanced.js
@@ -3,7 +3,7 @@
* orchestration. Kept separate from the common pointer/keyboard handlers so
* each module stays focused and reviewable.
*/
-import { resolveTab, safeExec } from '../lib/page-exec.js';
+import { resolveTab, safeExec, execDom, getFallback } from '../lib/page-exec.js';
import { withCdp } from '../lib/cdp-session.js';
import { openShield, releaseShield } from '../lib/trusted-input.js';
@@ -64,32 +64,19 @@ export async function handleDrag(params) {
let sx = startX, sy = startY, ex = endX, ey = endY;
if (sx == null || sy == null || ex == null || ey == null) {
- const coords = await safeExec(tabId, (_sRef, _sSel, _eRef, _eSel) => {
- function deepQuery(sel) {
- const query = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const frame of doc.querySelectorAll('iframe')) {
- try {
- const child = frame.contentDocument;
- if (child) { const el = query(child, depth + 1); if (el) return el; }
- } catch {}
- }
- return null;
- };
- return query(document, 0);
- }
-
- function find(ref, selector) {
- let el = ref ? deepQuery(`[data-mcp-ref="${ref}"]`) : null;
- if (!el && selector) el = deepQuery(selector);
+ const coords = await execDom(tabId, (_sRef, _sSel, _eRef, _eSel, _sFb, _eFb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ function find(ref, selector, fb) {
+ if (!ref && !selector) return null;
+ const el = D.resolve(ref, selector, fb).el;
if (!el) return null;
el.scrollIntoView({ behavior: 'instant', block: 'center' });
- const rect = el.getBoundingClientRect();
- return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
+ const { x, y } = D.centerOf(el);
+ return { x, y };
}
- return { start: find(_sRef, _sSel), end: find(_eRef, _eSel) };
- }, [startRef, startSelector, endRef, endSelector]);
+ return { start: find(_sRef, _sSel, _sFb), end: find(_eRef, _eSel, _eFb) };
+ }, [startRef, startSelector, endRef, endSelector, getFallback(tabId, startRef), getFallback(tabId, endRef)]);
if (coords.start) { sx = coords.start.x; sy = coords.start.y; }
if (coords.end) { ex = coords.end.x; ey = coords.end.y; }
@@ -127,21 +114,12 @@ export async function handleFillForm(params) {
}
await resolveTab(tabId);
- return safeExec(tabId, (_fields, _submit) => {
- function deepQuery(sel) {
- const query = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const frame of doc.querySelectorAll('iframe')) {
- try {
- const child = frame.contentDocument;
- if (child) { const el = query(child, depth + 1); if (el) return el; }
- } catch {}
- }
- return null;
- };
- return query(document, 0);
- }
+ // Attach each ref's snapshot descriptor so stale refs re-resolve (verified) in the page.
+ const withFb = fields.map((f) => (f && f.ref ? { ...f, fb: getFallback(tabId, f.ref) } : f));
+
+ return execDom(tabId, (_fields, _submit) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
const setNativeValue = (target, nextValue) => {
const prototype = target instanceof HTMLTextAreaElement
@@ -154,9 +132,8 @@ export async function handleFillForm(params) {
const results = [];
let containingForm = null;
for (const field of _fields) {
- const { ref, selector, value, clear } = field;
- let el = ref ? deepQuery(`[data-mcp-ref="${ref}"]`) : null;
- if (!el && selector) el = deepQuery(selector);
+ const { ref, selector, value, clear, fb } = field;
+ const el = D.resolve(ref, selector, fb).el;
if (!el) {
results.push({ selector: selector || ref, success: false, error: 'Not found' });
continue;
@@ -164,20 +141,27 @@ export async function handleFillForm(params) {
el.focus();
if (el.form && !containingForm) containingForm = el.form;
- if (clear !== false) {
+ const isChoice = el.tagName === 'SELECT' || el.type === 'checkbox' || el.type === 'radio';
+ if (clear !== false && !isChoice) {
if (el.isContentEditable) el.textContent = '';
else setNativeValue(el, '');
el.dispatchEvent(new Event('input', { bubbles: true }));
}
if (el.tagName === 'SELECT') {
- const option = Array.from(el.options).find((candidate) => candidate.value === String(value));
+ // Match the option's value first, then its visible label.
+ const want = String(value);
+ const options = Array.from(el.options);
+ const option = options.find((candidate) => candidate.value === want)
+ || options.find((candidate) => candidate.textContent.trim() === want.trim())
+ || options.find((candidate) => candidate.textContent.trim().toLowerCase() === want.trim().toLowerCase());
if (!option) {
results.push({ selector: selector || ref, success: false, error: `Option "${value}" not found` });
continue;
}
- setNativeValue(el, String(value));
- el.dispatchEvent(new Event('change', { bubbles: true }));
+ const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
+ if (setter) setter.call(el, option.value); else el.value = option.value;
+ el.dispatchEvent(new Event('input', { bubbles: true }));
} else if (el.type === 'checkbox' || el.type === 'radio') {
const checked = value === true || value === 'true';
if (el.checked !== checked) el.click();
@@ -205,5 +189,5 @@ export async function handleFillForm(params) {
return failed === 0
? { success: true, fields: results }
: { success: false, error: `${failed} of ${results.length} fields failed`, fields: results };
- }, [fields, submit]);
+ }, [withFb, submit]);
}
diff --git a/extension/handlers/interaction.js b/extension/handlers/interaction.js
index af074db..386c6ba 100644
--- a/extension/handlers/interaction.js
+++ b/extension/handlers/interaction.js
@@ -3,15 +3,19 @@
* hover, select, click_text, dialog, drag, fill_form — the write side that
* drives the page's event system (synthetic events) or CDP when required.
*/
-import { resolveTab, requireTarget, safeExec, getFallback } from '../lib/page-exec.js';
+import { resolveTab, requireTarget, hasPoint, execDom, getFallback } from '../lib/page-exec.js';
import { autoReSnapshot } from './inspection.js';
-import { PAGE_FALLBACK_INSTALL } from '../utils/smart-selector.js';
-import { trustedSender, locateTarget, releaseShield, cdpClickAt, cdpKeyPress, cdpTypeText, keyDefinition } from '../lib/trusted-input.js';
+import { trustedSender, locateTarget, releaseShield, cdpClickAt, cdpKeyPress, cdpTypeText, keyDefinition, modifierBits, pointInfo } from '../lib/trusted-input.js';
export { handleDialog, handleDrag, handleFillForm } from './interaction-advanced.js';
/** Shared REF_GONE recovery: re-snapshot and hand fresh refs back (no auto-retry). */
-async function refGone(tabId, res, ref) {
+async function refGone(tabId, res, ref, selector) {
+ // A selector that matches nothing is usually the wrong page (navigation,
+ // postback), not a virtualized feed — say which locator failed.
+ if (!(res._ref || ref) && selector) {
+ return { success: false, error: `No element matches selector ${selector} on the current page (${res.url || 'navigated?'}).` };
+ }
const fresh = await autoReSnapshot(tabId);
return {
success: false,
@@ -21,25 +25,63 @@ async function refGone(tabId, res, ref) {
}
const BUTTONS = new Set(['left', 'right', 'middle']);
+const MODS = new Set(['ctrl', 'alt', 'shift', 'meta']);
+
+/** clickCount from the params (1–3; doubleClick = 2). */
+function clickCountOf(params) {
+ const n = Number(params.clickCount);
+ if (Number.isInteger(n) && n >= 1) return Math.min(n, 3);
+ return params.doubleClick ? 2 : 1;
+}
+
+/** Modifier names held during a click ("ctrl+click" opens links in a new tab). */
+function clickModifiers(params) {
+ const mods = Array.isArray(params.modifiers) ? params.modifiers.filter((m) => MODS.has(m)) : [];
+ return modifierBits(mods);
+}
+
+/** Coordinate actions need CDP: there is no element to dispatch synthetic events on. */
+async function requireCdp(tabId, what) {
+ const send = await trustedSender(tabId, true);
+ if (!send) throw new Error(`${what} at x/y needs the debugger (CDP), which could not attach to tab ${tabId}. Use ref or selector instead.`);
+ return send;
+}
+
+/** Real mouse click at viewport coordinates (the same CSS-pixel frame as browser_screenshot). */
+async function clickAtPoint(tabId, params) {
+ const { x, y, button = 'left' } = params;
+ if (!BUTTONS.has(button)) throw new Error(`Unknown button ${button}`);
+ const send = await requireCdp(tabId, 'Clicking');
+ const info = await pointInfo(tabId, x, y);
+ try {
+ await cdpClickAt(send, x, y, { button, clickCount: clickCountOf(params), modifiers: clickModifiers(params) });
+ } finally {
+ await releaseShield(tabId);
+ }
+ return {
+ success: true, input: 'cdp', at: { x, y },
+ ...(info.hit ? { hit: info.hit } : {}),
+ ...(info.inView === false ? { warning: 'point is outside the viewport' } : {}),
+ };
+}
export async function handleClick(params) {
const { tabId, ref, selector, button = 'left', doubleClick = false, trusted } = params;
await resolveTab(tabId);
- requireTarget(params);
+ requireTarget(params, { allowPoint: true });
+ if (!ref && !selector) return clickAtPoint(tabId, params);
+ // Snapshot-time descriptor used by the shared resolver when the ref is stale.
const fb = getFallback(tabId, ref);
- // Install the fallback page runtime only when a descriptor exists (v2
- // install-once pattern — eval rebuilding is impossible under MV3 CSP).
- if (fb) await safeExec(tabId, PAGE_FALLBACK_INSTALL, []);
// Trusted path: a real mouse click at the element's centre over CDP, so
// focus moves, default actions run and the page sees isTrusted:true.
const send = await trustedSender(tabId, trusted);
if (send && BUTTONS.has(button)) {
const loc = await locateTarget(tabId, { ref, selector, fb });
- if (loc && loc.success === false && loc.error === 'REF_GONE') return refGone(tabId, loc, ref);
+ if (loc && loc.success === false && loc.error === 'REF_GONE') return refGone(tabId, loc, ref, selector);
if (loc?.success && loc.visible) {
try {
- await cdpClickAt(send, loc.x, loc.y, { button, clickCount: doubleClick ? 2 : 1 });
+ await cdpClickAt(send, loc.x, loc.y, { button, clickCount: clickCountOf(params), modifiers: clickModifiers(params) });
} finally {
await releaseShield(tabId);
}
@@ -54,33 +96,19 @@ export async function handleClick(params) {
// Zero-size element: no point to hit — fall through to the synthetic path.
}
- const res = await safeExec(tabId, async (_ref, _sel, _btn, _dbl, _fb) => {
- // Same-origin iframe piercing (field report: legacy UIs live inside
- // #mainFrame — top-document lookups missed every element).
- function deepQuery(sel) {
- const q = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d) { const el = q(d, depth + 1); if (el) return el; } } catch {}
- }
- return null;
- };
- return q(document, 0);
- }
+ const res = await execDom(tabId, async (_ref, _sel, _btn, _dbl, _fb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
- let el = _ref ? deepQuery(`[data-mcp-ref="${_ref}"]`) : null;
- let via = 'ref';
- if (!el && _sel) { el = deepQuery(_sel); via = 'selector'; }
- // Resolver comes from the pre-installed page runtime (no eval).
- const resolveFallback = (globalThis.__browserControllerFallbackRuntime || {}).resolveFallback || null;
- // Smart-selector fallback (plan task 3): ref broke → try robust selector,
- // then text+role+tag scan. The agent doesn't request this; it's automatic.
- if (!el && _fb && resolveFallback) { el = resolveFallback(_fb); if (el) via = 'fallback'; }
+ // ref registry → first visible selector match → verified fallback (lib/page-dom.js).
+ const found = D.resolve(_ref, _sel, _fb);
+ if (found.error === 'INVALID_SELECTOR') return { success: false, error: `Invalid CSS selector: ${_sel}` };
+ let el = found.el || null;
+ const via = found.via || 'ref';
if (!el) {
// Element is gone (likely virtualized away on scroll). Abort WITHOUT
// clicking — the background auto-re-snapshots and embeds fresh refs.
- return { success: false, error: 'REF_GONE', _ref };
+ return { success: false, error: 'REF_GONE', _ref, url: location.href };
}
el.scrollIntoView({ behavior: 'instant', block: 'center' });
@@ -95,7 +123,7 @@ export async function handleClick(params) {
if (!visible0) {
await new Promise((r) => setTimeout(r, 200));
// re-resolve the element (it may have been re-rendered with a new node)
- el = _ref ? deepQuery(`[data-mcp-ref="${_ref}"]`) : el;
+ el = D.resolve(_ref, _sel, _fb).el || el;
if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' });
}
if (!el) return { success: false, error: 'REF_GONE', _ref };
@@ -133,26 +161,31 @@ export async function handleClick(params) {
// Auto-re-snapshot and embed fresh refs so the agent retries in ONE step.
// We do NOT auto-retry the click: it's non-idempotent and the element that
// re-appears may be a different post after the scroll shifted the feed.
- if (res && res.success === false && res.error === 'REF_GONE') return refGone(tabId, res, ref);
+ if (res && res.success === false && res.error === 'REF_GONE') return refGone(tabId, res, ref, selector);
return res;
}
export async function handleType(params) {
const { tabId, ref, selector, text, clear = false, trusted } = params;
await resolveTab(tabId);
- requireTarget(params);
+ // No ref/selector: type into the element that has focus (like a user
+ // typing after clicking a field).
+ const focusedOnly = !ref && !selector;
+ // Snapshot-time descriptor used by the shared resolver when the ref is stale.
const fb = getFallback(tabId, ref);
- // Install the fallback page runtime only when a descriptor exists (v2
- // install-once pattern — eval rebuilding is impossible under MV3 CSP).
- if (fb) await safeExec(tabId, PAGE_FALLBACK_INSTALL, []);
// Trusted path: focus the field, then real key presses over CDP (keydown /
// keypress / input / keyup per character). Like a user, this does NOT fire
// `change` until focus leaves the field — press Tab to commit.
const send = await trustedSender(tabId, trusted);
if (send) {
- const loc = await locateTarget(tabId, { ref, selector, fb, mode: clear ? 'clear' : 'focus' });
- if (loc && loc.success === false && loc.error === 'REF_GONE') return refGone(tabId, loc, ref);
+ const mode = focusedOnly ? (clear ? 'focused-clear' : 'focused') : clear ? 'clear' : 'focus';
+ const loc = await locateTarget(tabId, { ref, selector, fb, mode });
+ if (loc && loc.error === 'NO_FOCUS') {
+ await releaseShield(tabId);
+ return { success: false, error: 'No field has focus: pass ref/selector, or click the field first.' };
+ }
+ if (loc && loc.success === false && loc.error === 'REF_GONE') return refGone(tabId, loc, ref, selector);
if (loc?.success && (loc.focused || loc.visible)) {
let after;
try {
@@ -175,30 +208,25 @@ export async function handleType(params) {
await releaseShield(tabId);
}
- const res = await safeExec(tabId, (_ref, _sel, _text, _clear, _fb) => {
- // Same-origin iframe piercing (field report: legacy UIs live inside
- // #mainFrame — top-document lookups missed every element).
- function deepQuery(sel) {
- const q = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d) { const el = q(d, depth + 1); if (el) return el; } } catch {}
- }
- return null;
- };
- return q(document, 0);
- }
+ const res = await execDom(tabId, (_ref, _sel, _text, _clear, _fb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
- let el = _ref ? deepQuery(`[data-mcp-ref="${_ref}"]`) : null;
- let via = 'ref';
- if (!el && _sel) { el = deepQuery(_sel); via = 'selector'; }
- const resolveFallback = (globalThis.__browserControllerFallbackRuntime || {}).resolveFallback || null;
- if (!el && _fb && resolveFallback) { el = resolveFallback(_fb); if (el) via = 'fallback'; }
+ let found;
+ if (!_ref && !_sel) {
+ const a = document.activeElement;
+ if (!a || a === document.body) return { success: false, error: 'No field has focus: pass ref/selector, or click the field first.' };
+ found = { el: a, via: 'active' };
+ } else {
+ found = D.resolve(_ref, _sel, _fb);
+ }
+ if (found.error === 'INVALID_SELECTOR') return { success: false, error: `Invalid CSS selector: ${_sel}` };
+ const el = found.el || null;
+ const via = found.via || 'ref';
if (!el) {
// Element gone (virtualized feed) — abort WITHOUT typing; background
// auto-re-snapshots and embeds fresh refs for a one-step retry.
- return { success: false, error: 'REF_GONE', _ref };
+ return { success: false, error: 'REF_GONE', _ref, url: location.href };
}
el.focus();
@@ -235,7 +263,7 @@ export async function handleType(params) {
// Virtualization recovery (same as click): type target is gone, so
// auto-re-snapshot and embed fresh refs. No auto-retry (non-idempotent).
- if (res && res.success === false && res.error === 'REF_GONE') return refGone(tabId, res, ref);
+ if (res && res.success === false && res.error === 'REF_GONE') return refGone(tabId, res, ref, selector);
return res;
}
@@ -258,55 +286,54 @@ export async function handlePressKey(params) {
const { tabId, ref, selector, trusted } = params;
const { key, mods: modifiers } = parseKeyCombo(params.key, params.modifiers || []);
await resolveTab(tabId);
+ const fb = getFallback(tabId, ref);
+
+ // "ArrowDown ArrowDown Enter" / "ctrl+a Backspace": a space-separated key
+ // sequence; `repeat` presses the whole sequence N times.
+ const raw = String(params.key ?? '');
+ const seq = raw.length > 1 && /\s/.test(raw.trim()) ? raw.trim().split(/\s+/) : [raw];
+ const combos = seq.map((k) => parseKeyCombo(k, params.modifiers || []));
+ const repeat = Math.min(Math.max(1, Number.isInteger(params.repeat) ? params.repeat : 1), 100);
// Trusted path: a real key press, so default actions run (Tab moves focus
// and fires blur/focusout, Enter submits, arrows drive autocomplete menus).
let knownKey = true;
- try { keyDefinition(key); } catch { knownKey = false; }
+ for (const c of combos) { try { keyDefinition(c.key); } catch { knownKey = false; } }
+ if (!knownKey && (combos.length > 1 || repeat > 1)) throw new Error(`Unknown key in "${raw}"`);
const send = knownKey ? await trustedSender(tabId, trusted) : null;
if (send) {
- const loc = await locateTarget(tabId, { ref, selector, mode: ref || selector ? 'focus' : 'active' });
+ const loc = await locateTarget(tabId, { ref, selector, fb, mode: ref || selector ? 'focus' : 'active' });
if (!loc || loc.success === false) {
await releaseShield(tabId);
if (ref || selector) return { success: false, error: `Element ${ref ? `with ref ${ref}` : `with selector ${selector}`} not found` };
}
let after;
try {
- await cdpKeyPress(send, key, modifiers);
+ for (let r = 0; r < repeat; r++) {
+ for (const c of combos) await cdpKeyPress(send, c.key, c.mods);
+ }
} finally {
after = await releaseShield(tabId);
}
- return { success: true, key, ...(modifiers.length ? { modifiers } : {}), input: 'cdp', ...(after?.focusedTag ? { focused: after.focusedTag } : {}) };
+ return {
+ success: true, key: combos.length > 1 ? raw : key, ...(modifiers.length && combos.length === 1 ? { modifiers } : {}),
+ ...(repeat > 1 ? { repeat } : {}), input: 'cdp', ...(after?.focusedTag ? { focused: after.focusedTag } : {}),
+ };
}
- return safeExec(tabId, (_key, _mods, _ref, _sel) => {
- // Same-origin iframe piercing (field report: legacy UIs live inside
- // #mainFrame — top-document lookups missed every element).
- function deepQuery(sel) {
- const q = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d) { const el = q(d, depth + 1); if (el) return el; } } catch {}
- }
- return null;
- };
- return q(document, 0);
- }
+ if (combos.length > 1 || repeat > 1) throw new Error('Key sequences and repeat need the debugger (CDP); press keys one at a time with trusted:false.');
+ return execDom(tabId, (_key, _mods, _ref, _sel, _fb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
let target = document.activeElement || document.body;
// When the caller names a target, an unresolved ref/selector must FAIL —
// silently falling back to activeElement sent Enter to the wrong control
// with a success result. (Omitting both is still legitimate: intentional
// activeElement targeting.)
- if (_ref) {
- const el = deepQuery(`[data-mcp-ref="${_ref}"]`);
- if (!el) return { success: false, error: `Element with ref ${_ref} not found` };
- el.focus();
- target = el;
- } else if (_sel) {
- const el = deepQuery(_sel);
- if (!el) return { success: false, error: `Element with selector ${_sel} not found` };
+ if (_ref || _sel) {
+ const el = D.resolve(_ref, _sel, _fb).el;
+ if (!el) return { success: false, error: _ref ? `Element with ref ${_ref} not found` : `Element with selector ${_sel} not found` };
el.focus();
target = el;
}
@@ -327,17 +354,28 @@ export async function handlePressKey(params) {
target.dispatchEvent(new KeyboardEvent('keyup', init));
return { success: true, key: _key };
- }, [key, modifiers, ref, selector]);
+ }, [key, modifiers, ref, selector, fb]);
}
export async function handleHover(params) {
const { tabId, ref, selector, trusted } = params;
await resolveTab(tabId);
- requireTarget(params);
+ requireTarget(params, { allowPoint: true });
+ if (!ref && !selector && hasPoint(params)) {
+ const sendAt = await requireCdp(tabId, 'Hovering');
+ const info = await pointInfo(tabId, params.x, params.y);
+ try {
+ await sendAt('Input.dispatchMouseEvent', { type: 'mouseMoved', x: params.x, y: params.y });
+ } finally {
+ await releaseShield(tabId);
+ }
+ return { success: true, input: 'cdp', at: { x: params.x, y: params.y }, ...(info.hit ? { hit: info.hit } : {}) };
+ }
+ const fb = getFallback(tabId, ref);
const send = await trustedSender(tabId, trusted);
if (send) {
- const loc = await locateTarget(tabId, { ref, selector });
+ const loc = await locateTarget(tabId, { ref, selector, fb });
if (loc?.success && loc.visible) {
try {
await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: loc.x, y: loc.y });
@@ -350,23 +388,11 @@ export async function handleHover(params) {
if (loc && loc.success === false) return { success: false, error: 'Element not found' };
}
- return safeExec(tabId, (_ref, _sel) => {
- // Same-origin iframe piercing (field report: legacy UIs live inside
- // #mainFrame — top-document lookups missed every element).
- function deepQuery(sel) {
- const q = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d) { const el = q(d, depth + 1); if (el) return el; } } catch {}
- }
- return null;
- };
- return q(document, 0);
- }
+ return execDom(tabId, (_ref, _sel, _fb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
- let el = _ref ? deepQuery(`[data-mcp-ref="${_ref}"]`) : null;
- if (!el && _sel) el = deepQuery(_sel);
+ const el = D.resolve(_ref, _sel, _fb).el;
if (!el) return { success: false, error: 'Element not found' };
el.scrollIntoView({ behavior: 'instant', block: 'center' });
@@ -380,7 +406,7 @@ export async function handleHover(params) {
el.dispatchEvent(new MouseEvent('mousemove', init));
return { success: true };
- }, [ref, selector]);
+ }, [ref, selector, fb]);
}
export async function handleSelect(params) {
@@ -390,24 +416,13 @@ export async function handleSelect(params) {
if (value === undefined && label === undefined && index === undefined) {
throw new Error('One of value, label, or index is required to pick an option.');
}
+ const fb = getFallback(tabId, ref);
- return safeExec(tabId, (_ref, _sel, _val, _lbl, _idx) => {
- // Same-origin iframe piercing (field report: legacy UIs live inside
- // #mainFrame — top-document lookups missed every element).
- function deepQuery(sel) {
- const q = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d) { const el = q(d, depth + 1); if (el) return el; } } catch {}
- }
- return null;
- };
- return q(document, 0);
- }
+ return execDom(tabId, (_ref, _sel, _val, _lbl, _idx, _fb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
- let el = _ref ? deepQuery(`[data-mcp-ref="${_ref}"]`) : null;
- if (!el && _sel) el = deepQuery(_sel);
+ const el = D.resolve(_ref, _sel, _fb).el;
if (!el) return { success: false, error: 'Element not found' };
if (el.tagName !== 'SELECT') return { success: false, error: 'Not a select element' };
@@ -424,69 +439,98 @@ export async function handleSelect(params) {
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('input', { bubbles: true }));
return { success: true, selected: el.value };
- }, [ref, selector, value, label, index]);
+ }, [ref, selector, value, label, index, fb]);
}
export async function handleClickByText(params) {
- const { tabId, text, index = 0, exact = false } = params;
+ const { tabId, text, index = 0, exact = false, trusted } = params;
await resolveTab(tabId);
-
- return safeExec(tabId, (_text, _index, _exact) => {
- const textLower = _text.toLowerCase();
- const candidates = [];
- // Same-origin iframe piercing — walk every frame body, not just the top.
- const roots = [document.body];
- (function collectFrames(doc, depth) {
- if (depth >= 3) return;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d && d.body) { roots.push(d.body); collectFrames(d, depth + 1); } } catch {}
- }
- })(document, 0);
- let node;
- for (const root of roots) {
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
- while ((node = walker.nextNode())) {
- const s = getComputedStyle(node);
- if (s.display === 'none' || s.visibility === 'hidden') continue;
- const r = node.getBoundingClientRect();
- if (r.width === 0 || r.height === 0) continue;
-
- const nodeText = (node.innerText || node.textContent || '').trim();
- const firstLine = nodeText.split('\n')[0].trim();
- const match = _exact
- ? firstLine === _text
- : firstLine.toLowerCase().includes(textLower);
-
- if (match) {
- candidates.push({ el: node, text: firstLine, depth: getDepth(node) });
+ const tempRef = `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`;
+
+ // Page side: find the element by accessible name / composed text (shadow
+ // roots + same-origin frames), climb to the control that owns it, and park
+ // it in the ref registry so the click itself goes through the normal path.
+ const found = await execDom(tabId, (_text, _index, _exact, _ref) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const want = D.clean(_text).toLowerCase();
+ if (!want) return { success: false, error: 'text is required' };
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'HTML', 'BODY', 'META', 'LINK']);
+ const hits = [];
+ const seen = new Set();
+ const matches = (s) => {
+ const t = D.clean(s).toLowerCase();
+ if (!t) return false;
+ return _exact ? t === want : t.includes(want);
+ };
+ for (const root of D.allRoots(true)) {
+ let els = [];
+ try { els = root.querySelectorAll('*'); } catch {}
+ for (const el of els) {
+ if (SKIP.has(el.tagName)) continue;
+ const own = D.isInteractive(el) ? D.nameOf(el) : D.composedText(el, 200);
+ // aria-label / title / value also count as the element's text.
+ if (!matches(own) && !matches(D.attr(el, 'aria-label')) && !matches(D.attr(el, 'title'))
+ && !(el.tagName === 'INPUT' && matches(el.value))) continue;
+ // Climb to the control that owns this text (MUI: inside ).
+ let target = el;
+ let cur = el;
+ for (let i = 0; i < 6 && cur; i++) {
+ if (D.isInteractive(cur)) { target = cur; break; }
+ let r = null;
+ try { r = cur.getRootNode(); } catch {}
+ cur = cur.parentElement || (r && r.host) || null;
+ }
+ if (seen.has(target) || !D.isVisible(target)) continue;
+ seen.add(target);
+ hits.push({ el: target, interactive: D.isInteractive(target), len: D.clean(own).length });
}
}
+ // Prefer controls over plain text; then the most specific (shortest) text; keep document order otherwise.
+ hits.forEach((h, i) => { h.i = i; });
+ hits.sort((a, b) => (b.interactive - a.interactive) || (a.len - b.len) || (a.i - b.i));
+ // Drop a candidate that merely contains a better one (wrapper rows).
+ const best = hits.filter((h) => !hits.some((o) => o !== h && o.i !== h.i && D.composedContains(h.el, o.el) && o.interactive >= h.interactive));
+ if (best.length === 0) return { success: false, error: `No element found with text "${_text}"` };
+ if (!Number.isInteger(_index) || _index < 0 || _index >= best.length) {
+ return { success: false, error: `Only ${best.length} matches, index ${_index} out of range` };
}
+ const chosen = best[_index].el;
+ D.registry.set(_ref, chosen);
+ return { success: true, clicked: D.nameOf(chosen).slice(0, 80) || D.composedText(chosen, 80), role: D.roleOf(chosen), matchCount: best.length };
+ }, [text, index, exact, tempRef]);
+ if (!found || found.success === false) return found;
- function getDepth(el) { let d = 0; let p = el; while ((p = p.parentElement)) d++; return d; }
-
- candidates.sort((a, b) => b.depth - a.depth);
-
- if (candidates.length === 0) return { success: false, error: `No element found with text "${_text}"` };
- // Guard the full range: a negative index used to read candidates[-1] and
- // crash with a raw TypeError (schema bounds only protect MCP callers).
- if (!Number.isInteger(_index) || _index < 0 || _index >= candidates.length) {
- return { success: false, error: `Only ${candidates.length} matches, index ${_index} out of range` };
+ // Trusted click on the parked element (same path as browser_click).
+ const send = await trustedSender(tabId, trusted);
+ if (send) {
+ const loc = await locateTarget(tabId, { ref: tempRef });
+ if (loc?.success && loc.visible) {
+ try {
+ await cdpClickAt(send, loc.x, loc.y);
+ } finally {
+ await releaseShield(tabId);
+ }
+ return { ...found, input: 'cdp', ...(loc.occludedBy ? { warning: `click point is covered by ${loc.occludedBy}` } : {}) };
}
+ await releaseShield(tabId);
+ }
- const target = candidates[_index].el;
+ return execDom(tabId, (_ref, _found) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const target = D.registry.get(_ref);
+ if (!D.connected(target)) return { success: false, error: 'Element disappeared before the click' };
target.scrollIntoView({ behavior: 'instant', block: 'center' });
const rect = target.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
- const init = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0 };
-
+ const init = { bubbles: true, cancelable: true, composed: true, view: target.ownerDocument.defaultView, clientX: x, clientY: y, button: 0 };
target.dispatchEvent(new MouseEvent('mouseover', init));
target.dispatchEvent(new MouseEvent('mousedown', init));
if (target.focus) target.focus();
target.dispatchEvent(new MouseEvent('mouseup', init));
target.dispatchEvent(new MouseEvent('click', init));
-
- return { success: true, clicked: candidates[_index].text, matchCount: candidates.length };
- }, [text, index, exact]);
+ return _found;
+ }, [tempRef, found]);
}
diff --git a/extension/handlers/intercept.js b/extension/handlers/intercept.js
new file mode 100644
index 0000000..de57bbd
--- /dev/null
+++ b/extension/handlers/intercept.js
@@ -0,0 +1,318 @@
+/**
+ * Intercept handler: rule CRUD + capture ledger + HAR export.
+ * Enforcement via declarativeNetRequest is best-effort — when DNR is absent
+ * (tests, denied permission) every response reports enforcement:'capture-only'
+ * and matches are ledger-marked instead of applied (arch ADR-1/ADR-2).
+ */
+import { resolveTab } from "../lib/page-exec.js";
+import { getTabBuffer, networkByTab, PER_TAB_CAP } from "../lib/state.js";
+import { validateRuleSet, matchRule, scopeKey } from "../lib/intercept.js";
+
+/** scopeKey -> rules[] */
+export const rulesByScope = new Map();
+/** tabId -> enriched capture entries (capped) */
+export const interceptLedgerByTab = new Map();
+
+// Privacy note: captures store method/url/status/type/timestamp (+ intercept
+// metadata) only — headers and bodies are never captured, so HAR export is
+// redacted by omission (response._redacted: true marks this guarantee).
+
+function allRules() {
+ const out = [];
+ for (const rules of rulesByScope.values()) out.push(...rules);
+ return out;
+}
+
+/** Rules that apply to a tab, each with the scope it is stored under. */
+function scopedRulesForTab(tabId) {
+ const out = [];
+ for (const [scope, rules] of rulesByScope) {
+ const ids = scope === "global" ? null : scope.replace(/^tabs:/, "").split(",").map(Number);
+ if (ids === null || (tabId != null && ids.includes(tabId))) {
+ for (const rule of rules) out.push({ scope, rule });
+ }
+ }
+ return out;
+}
+
+/** What Chrome enforces right now (capture-only without DNR). */
+function currentEnforcement() {
+ if (!dnrAvailable()) return "capture-only";
+ return rulesByScope.size === 0 ? "full" : lastSync.enforcement;
+}
+
+function dnrAvailable() {
+ try {
+ return !!globalThis.chrome?.declarativeNetRequest?.updateSessionRules;
+ } catch {
+ return false;
+ }
+}
+
+/** Our session-rule id range (other extension code may use other ids). */
+const DNR_ID_BASE = 1000;
+const DNR_ID_MAX = 5999;
+/** Chrome resource types a DNR condition accepts. */
+const DNR_TYPES = new Set([
+ "main_frame", "sub_frame", "stylesheet", "script", "image", "font", "object",
+ "xmlhttprequest", "ping", "csp_report", "media", "websocket", "webtransport", "webbundle", "other",
+]);
+
+/**
+ * Result of the last sync: which stored rules Chrome actually enforces.
+ * installed: Map<"scope|ruleId", dnrId>; unsupported: [{id, scope, action, reason}].
+ */
+let lastSync = { enforcement: "capture-only", installed: new Map(), unsupported: [], reason: "not synced" };
+
+/** Tab ids a stored scope applies to (null = every tab). */
+function scopeTabs(scope) {
+ if (scope === "global") return null;
+ return [...new Set(scope.replace(/^tabs:/, "").split(",").map(Number).filter(Number.isInteger))];
+}
+
+/** One stored rule → a DNR session rule, or {reason} when Chrome can't enforce it. */
+function toDnrRule(rule, scope, id) {
+ const condition = { regexFilter: rule.match };
+ const tabs = rule.tabIds && rule.tabIds.length ? [...new Set(rule.tabIds)] : scopeTabs(scope);
+ if (tabs) condition.tabIds = tabs; // tab scoping: session rules only
+ if (rule.types && rule.types.length) {
+ const types = rule.types.filter((t) => DNR_TYPES.has(t));
+ if (types.length === 0) return { reason: `no enforceable resource type in [${rule.types.join(", ")}]` };
+ condition.resourceTypes = types;
+ }
+ if (rule.action === "block") return { rule: { id, priority: 1, action: { type: "block" }, condition } };
+ if (rule.action === "redirect") {
+ return { rule: { id, priority: 1, action: { type: "redirect", redirect: { url: rule.redirectUrl } }, condition } };
+ }
+ if (rule.action === "header") {
+ const headers = Object.entries(rule.headers || {});
+ if (headers.length === 0) return { reason: "header rule has no headers" };
+ return {
+ rule: {
+ id, priority: 1, condition,
+ action: {
+ type: "modifyHeaders",
+ // Request headers are set (added or replaced) on matching requests.
+ requestHeaders: headers.map(([header, value]) => ({ header, operation: "set", value: String(value) })),
+ },
+ },
+ };
+ }
+ if (rule.action === "mock") {
+ return { reason: "mock responses cannot be enforced with declarativeNetRequest (ledger-only)" };
+ }
+ return { reason: null }; // log: capture-only by design
+}
+
+/**
+ * Sync every enabled rule to Chrome as SESSION rules (the only kind that can
+ * be tab-scoped): remove every session rule we installed before — including
+ * after a clear, or from a previous service worker — then add the current
+ * set. Never throws; degrades to capture-only.
+ */
+async function syncDnr() {
+ if (!dnrAvailable()) {
+ lastSync = { enforcement: "capture-only", installed: new Map(), unsupported: [], reason: "no-dnr" };
+ return lastSync;
+ }
+ const dnr = globalThis.chrome.declarativeNetRequest;
+ const installed = new Map();
+ const unsupported = [];
+ const addRules = [];
+ let next = DNR_ID_BASE;
+ for (const [scope, rules] of rulesByScope) {
+ for (const r of rules) {
+ if (r.enabled === false || r.action === "log") continue;
+ if (next > DNR_ID_MAX) {
+ unsupported.push({ id: r.id, scope, action: r.action, reason: "too many rules to enforce" });
+ continue;
+ }
+ const out = toDnrRule(r, scope, next);
+ if (!out.rule) {
+ if (out.reason) unsupported.push({ id: r.id, scope, action: r.action, reason: out.reason });
+ continue;
+ }
+ addRules.push(out.rule);
+ installed.set(`${scope}|${r.id}`, next);
+ next++;
+ }
+ }
+ try {
+ const existing = (await dnr.getSessionRules()) || [];
+ const removeRuleIds = existing.map((x) => x.id).filter((id) => id >= DNR_ID_BASE && id <= DNR_ID_MAX);
+ await dnr.updateSessionRules({ removeRuleIds, addRules });
+ lastSync = {
+ enforcement: unsupported.length ? "partial" : "full",
+ installed,
+ unsupported,
+ reason: unsupported.length ? "some rules are ledger-only (see unsupported)" : undefined,
+ };
+ } catch (err) {
+ lastSync = { enforcement: "capture-only", installed: new Map(), unsupported, reason: err?.message || String(err) };
+ }
+ return lastSync;
+}
+
+/** Is this stored rule actually installed in Chrome? */
+function isEnforced(scope, ruleId) {
+ return lastSync.installed.has(`${scope}|${ruleId}`);
+}
+
+/** Enrich one network capture with rule matches (called from events.js; never throws). */
+export function enrichCapture(tabId, entry) {
+ try {
+ const req = { url: entry.url, type: entry.type, tabId };
+ const matches = scopedRulesForTab(tabId).filter((x) => matchRule(x.rule, req));
+ if (matches.length === 0) return entry;
+ entry.intercept = {
+ matchedRuleIds: matches.map((m, i) => m.rule.id ?? `r${i + 1}`),
+ applied: matches.map((m, i) => ({
+ ruleId: m.rule.id ?? `r${i + 1}`,
+ // "enforced" only when Chrome really has the rule installed.
+ outcome: isEnforced(m.scope, m.rule.id) ? "enforced" : "ledger-only",
+ })),
+ };
+ const ledger = getTabBuffer(interceptLedgerByTab, tabId);
+ ledger.push({ ...entry, timestamp: entry.timestamp ?? Date.now() });
+ if (ledger.length > PER_TAB_CAP)
+ ledger.splice(0, ledger.length - PER_TAB_CAP);
+ return entry;
+ } catch {
+ return entry;
+ }
+}
+
+function captureKey(e) {
+ return `${e.method || "GET"}|${e.url}|${e.status ?? 0}|${e.timestamp ?? 0}`;
+}
+
+/**
+ * All captures for a tab, deduplicated. enrichCapture mutates the network
+ * buffer entry in place AND ledgers a copy, so a naive concat would return
+ * every matched request twice. The network buffer is the base; the ledger
+ * only contributes entries not already present (e.g. injected in tests or by
+ * future capture sources).
+ */
+export function collectCaptures(tabId) {
+ const buffered = getTabBuffer(networkByTab, tabId);
+ const seen = new Set(buffered.map(captureKey));
+ const extra = (interceptLedgerByTab.get(tabId) ?? []).filter(
+ (e) => !seen.has(captureKey(e)),
+ );
+ return [...buffered, ...extra];
+}
+
+function filterByPattern(entries, filter) {
+ if (!filter) return entries;
+ let re;
+ try {
+ re = new RegExp(filter);
+ } catch (err) {
+ throw new Error(`Invalid filter regex: ${err?.message || err}`, { cause: err });
+ }
+ return entries.filter((e) => re.test(e.url));
+}
+
+function buildHar(tabId, entries) {
+ const harEntries = entries.map((e) => ({
+ startedDateTime: new Date(e.timestamp ?? Date.now()).toISOString(),
+ request: { method: e.method || "GET", url: e.url, headers: [] },
+ response: { status: e.status ?? 0, headers: [], _redacted: true },
+ timings: { wait: 0 },
+ _intercept: e.intercept ?? null,
+ }));
+ return {
+ log: {
+ version: "1.2",
+ creator: { name: "browser-controller", version: "2.2.0" },
+ pages: [{ id: `tab-${tabId}`, title: `Tab ${tabId}` }],
+ entries: harEntries,
+ },
+ };
+}
+
+export async function handleIntercept(params) {
+ const { action, tabId, rules, filter, limit } = params;
+ switch (action) {
+ case "set-rules": {
+ validateRuleSet(rules ?? []);
+ const withIds = (rules ?? []).map((r, i) => ({
+ enabled: true,
+ ...r,
+ id: r.id ?? `r${i + 1}`,
+ }));
+ const scope = scopeKey(
+ withIds.flatMap((r) => r.tabIds ?? (tabId != null ? [tabId] : [])),
+ );
+ // Replacing a scope's rules replaces them (an empty set removes the scope).
+ if (withIds.length) rulesByScope.set(scope, withIds);
+ else rulesByScope.delete(scope);
+ const sync = await syncDnr();
+ const unsupported = sync.unsupported.filter((u) => u.scope === scope);
+ return {
+ success: true,
+ enforcement: sync.enforcement,
+ ...(sync.reason ? { reason: sync.reason } : {}),
+ scope,
+ ruleCount: withIds.length,
+ enforced: withIds.filter((r) => isEnforced(scope, r.id)).length,
+ ...(unsupported.length ? { unsupported } : {}),
+ };
+ }
+ case "list-rules": {
+ const scoped = tabId != null
+ ? scopedRulesForTab(tabId)
+ : [...rulesByScope].flatMap(([scope, rules]) => rules.map((rule) => ({ scope, rule })));
+ return {
+ success: true,
+ enforcement: currentEnforcement(),
+ rules: scoped.map(({ scope, rule }) => ({ ...rule, scope, enforced: isEnforced(scope, rule.id) })),
+ };
+ }
+ case "clear-rules": {
+ if (tabId != null) {
+ let cleared = 0;
+ for (const [scope, scopeRules] of [...rulesByScope]) {
+ if (scope === "global") continue;
+ if (scopeTabs(scope).includes(tabId)) {
+ cleared += scopeRules.length;
+ rulesByScope.delete(scope);
+ }
+ }
+ // Global rules stay (they are not tab-scoped); report honestly.
+ await syncDnr();
+ return {
+ success: true,
+ cleared,
+ note: "global rules retained; omit tabId to clear all",
+ };
+ }
+ const cleared = allRules().length;
+ rulesByScope.clear();
+ await syncDnr();
+ return { success: true, cleared };
+ }
+ case "list-captures": {
+ if (tabId == null) throw new Error("tabId required for list-captures");
+ await resolveTab(tabId);
+ let entries = collectCaptures(tabId);
+ entries = filterByPattern(entries, filter);
+ if (limit && Number.isInteger(limit) && limit > 0)
+ entries = entries.slice(-limit);
+ return {
+ success: true,
+ enforcement: currentEnforcement(),
+ captures: entries,
+ };
+ }
+ case "export-har": {
+ if (tabId == null) throw new Error("tabId required for export-har");
+ await resolveTab(tabId);
+ const entries = collectCaptures(tabId);
+ const har = buildHar(tabId, entries);
+ return { success: true, entries: har.log.entries.length, har };
+ }
+ default:
+ throw new Error(`Unknown intercept action: ${action}`);
+ }
+}
diff --git a/extension/handlers/navigation.js b/extension/handlers/navigation.js
index 7d9b69f..3d9d73e 100644
--- a/extension/handlers/navigation.js
+++ b/extension/handlers/navigation.js
@@ -2,7 +2,7 @@
* Navigation handler (extracted from background.js): the one page tool allowed
* to omit tabId (documented active-tab fallback).
*/
-import { resolveTab, safeExec } from '../lib/page-exec.js';
+import { resolveTab, safeExec, replaceFrozenTab } from '../lib/page-exec.js';
import { isHashOnlyChange } from '../utils/navigation.js';
import { handleSnapshot } from './inspection.js';
@@ -18,11 +18,11 @@ export async function getActiveTab() {
return tab;
}
-export async function handleNavigate(params, _sessionId, _agentName, signal) {
+export async function handleNavigate(params, sessionId, _agentName, signal) {
let { url } = params;
const { waitUntil = 'load', tabId, snapshot: wantSnapshot = true } = params;
// navigate is the one page tool allowed to omit tabId → active tab fallback.
- const tab = tabId != null ? await resolveTab(tabId) : await getActiveTab();
+ let tab = tabId != null ? await resolveTab(tabId) : await getActiveTab();
// Fix #2 (hash-aware): a hash-only navigation does NOT reload the document,
// so `chrome.tabs.onUpdated` never fires `status === 'complete'` and the wait
@@ -35,6 +35,10 @@ export async function handleNavigate(params, _sessionId, _agentName, signal) {
: historyStep === 'forward' ? chrome.tabs.goForward(tab.id)
: chrome.tabs.update(tab.id, { url });
+ // A frozen page (TAB_WEDGED) would hold the navigation hostage: replace the tab.
+ let replacedTabId = null;
+ const fresh = !historyStep ? await replaceFrozenTab(tab, null, sessionId) : null;
+ if (fresh) { replacedTabId = tab.id; tab = fresh; }
const currentTab = await chrome.tabs.get(tab.id);
const hashOnly = !historyStep && isHashOnlyChange(currentTab.url, url);
@@ -123,7 +127,7 @@ export async function handleNavigate(params, _sessionId, _agentName, signal) {
if (historyStep) url = (await chrome.tabs.get(tab.id)).url;
if (!wantSnapshot) {
- return { url, status: 'navigated', tabId: tab.id };
+ return { url, status: 'navigated', tabId: tab.id, ...(replacedTabId ? { replacedTabId, note: `tab ${replacedTabId} was frozen and has been replaced by tab ${tab.id}` } : {}) };
}
try {
const snap = await handleSnapshot({ tabId: tab.id, compact: true });
@@ -132,10 +136,11 @@ export async function handleNavigate(params, _sessionId, _agentName, signal) {
url,
status: 'navigated',
tabId: tab.id,
+ ...(replacedTabId ? { replacedTabId, note: `tab ${replacedTabId} was frozen and has been replaced by tab ${tab.id}` } : {}),
snapshot: snapObj && snapObj.content ? snapObj.content : snapObj,
};
} catch {
// snapshot failed (protected page / 401 / etc) — navigation still succeeded.
- return { url, status: 'navigated', tabId: tab.id };
+ return { url, status: 'navigated', tabId: tab.id, ...(replacedTabId ? { replacedTabId } : {}) };
}
}
diff --git a/extension/handlers/tabs.js b/extension/handlers/tabs.js
index 96f69b3..0395170 100644
--- a/extension/handlers/tabs.js
+++ b/extension/handlers/tabs.js
@@ -3,18 +3,19 @@
* lifecycle (list/create/close/focus/lock/unlock), console/network reads,
* screenshot.
*/
-import { resolveTab } from '../lib/page-exec.js';
+import { resolveTab, replaceFrozenTab, safeExec } from '../lib/page-exec.js';
import {
tabLocks,
+ wedgedTabs,
windowCaptureMutex,
consoleByTab,
networkByTab,
getTabBuffer,
persistSessionState,
-} from '../lib/state.js';
-import { showLockShield, hideLockShield } from '../lib/overlay.js';
-import { broadcastStatus } from '../lib/connection.js';
-import { lockTabUi, releaseTabUi } from '../lib/lock-ops.js';
+} from "../lib/state.js";
+import { showLockShield, hideLockShield } from "../lib/overlay.js";
+import { broadcastStatus } from "../lib/connection.js";
+import { lockTabUi, releaseTabUi } from "../lib/lock-ops.js";
import { withCdp, ensureViewport } from '../lib/cdp-session.js';
const CDP_CAPTURE_TIMEOUT_MS = 4000;
@@ -27,12 +28,35 @@ function withTimeout(promise, ms, what) {
]).finally(() => clearTimeout(timer));
}
+/** Pixel size of a base64 PNG/JPEG (null if it can't be read). */
+export function imageSize(b64) {
+ let bin;
+ try { bin = atob(String(b64).slice(0, 87_384)); } catch { return null; }
+ const at = (i) => bin.charCodeAt(i);
+ if (bin.length > 24 && at(0) === 0x89 && bin.slice(1, 4) === 'PNG') {
+ return { width: ((at(16) << 24) | (at(17) << 16) | (at(18) << 8) | at(19)) >>> 0, height: ((at(20) << 24) | (at(21) << 16) | (at(22) << 8) | at(23)) >>> 0 };
+ }
+ if (at(0) === 0xff && at(1) === 0xd8) {
+ let i = 2;
+ while (i + 9 < bin.length) {
+ if (at(i) !== 0xff) { i++; continue; }
+ const marker = at(i + 1);
+ const len = (at(i + 2) << 8) | at(i + 3);
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
+ return { width: (at(i + 7) << 8) | at(i + 8), height: (at(i + 5) << 8) | at(i + 6) };
+ }
+ i += 2 + len;
+ }
+ }
+ return null;
+}
+
/**
* CDP capture (Page.captureScreenshot): works on a tab that is NOT the active
* one in its window, so the user's view is never switched, and can downscale
* (`scale`) or cap the width (`maxWidth`) to save image tokens.
*/
-async function cdpScreenshot(tabId, { format, quality, scale, maxWidth, fullPage }) {
+async function cdpScreenshot(tabId, { format, quality, scale, maxWidth, fullPage, region }) {
return withCdp(tabId, async (send) => {
let metrics = await send('Page.getLayoutMetrics');
if (!(metrics?.cssVisualViewport?.clientWidth > 0)) {
@@ -41,22 +65,52 @@ async function cdpScreenshot(tabId, { format, quality, scale, maxWidth, fullPage
}
const vv = metrics.cssVisualViewport;
const content = metrics.cssContentSize || metrics.contentSize;
- const width = fullPage ? Math.ceil(content.width) : vv.clientWidth;
- const height = fullPage ? Math.min(Math.ceil(content.height), 16_000) : vv.clientHeight;
- let s = Math.min(1, Math.max(0.05, scale ?? 1));
- if (maxWidth && width * s > maxWidth) s = maxWidth / width;
+ // region: a viewport rectangle (CSS px, same frame as click/hover x/y) —
+ // zoom into small UI with scale > 1.
+ let originX = 0;
+ let originY = 0;
+ let width = fullPage ? Math.ceil(content.width) : vv.clientWidth;
+ let height = fullPage ? Math.min(Math.ceil(content.height), 16_000) : vv.clientHeight;
+ if (region) {
+ originX = Math.max(0, Math.min(region.x, vv.clientWidth - 1));
+ originY = Math.max(0, Math.min(region.y, vv.clientHeight - 1));
+ width = Math.max(1, Math.min(region.width, vv.clientWidth - originX));
+ height = Math.max(1, Math.min(region.height, vv.clientHeight - originY));
+ }
+ let s = Math.min(region ? 4 : 1, Math.max(0.05, scale ?? (region ? 2 : 1)));
+ // The capture comes out at clip.scale × devicePixelRatio (device pixels):
+ // the deprecated device-pixel metrics against the CSS ones give the ratio.
+ const dpr = metrics.visualViewport?.clientWidth > 0 ? metrics.visualViewport.clientWidth / vv.clientWidth : 1;
+ if (maxWidth && width * s * dpr > maxWidth) s = maxWidth / (width * dpr);
const { data } = await withTimeout(send('Page.captureScreenshot', {
format,
...(format === 'jpeg' ? { quality } : {}),
- captureBeyondViewport: !!fullPage,
- clip: { x: fullPage ? 0 : vv.pageX, y: fullPage ? 0 : vv.pageY, width, height, scale: s },
+ captureBeyondViewport: !!fullPage && !region,
+ clip: {
+ x: fullPage && !region ? 0 : vv.pageX + originX,
+ y: fullPage && !region ? 0 : vv.pageY + originY,
+ width, height, scale: s,
+ },
}), CDP_CAPTURE_TIMEOUT_MS, 'Page.captureScreenshot');
- return { data, width: Math.round(width * s), height: Math.round(height * s) };
+ // How image pixels map to the viewport coordinates click/hover/scroll take:
+ // viewportX = origin[0] + imageX / scale (fullPage: page coordinates instead).
+ // The real image size is the ground truth (it includes the device pixel
+ // ratio: an 800 px viewport at DPR 2 is a 1600 px image, scale 2).
+ const size = imageSize(data);
+ const imgW = size?.width || Math.round(width * s * dpr);
+ const imgH = size?.height || Math.round(height * s * dpr);
+ const frame = {
+ scale: Math.round((imgW / width) * 1000) / 1000,
+ origin: [Math.round(originX), Math.round(originY)],
+ viewport: [Math.round(vv.clientWidth), Math.round(vv.clientHeight)],
+ ...(fullPage && !region ? { page: true, scrollY: Math.round(vv.pageY) } : {}),
+ };
+ return { data, width: imgW, height: imgH, frame };
});
}
export async function handleScreenshot(params) {
- const { tabId, format = 'png', quality = 80, scale, maxWidth, fullPage = false } = params;
+ const { tabId, format = 'png', quality = 80, scale, maxWidth, fullPage = false, region } = params;
const tab = await resolveTab(tabId);
const protectedPage = /^(chrome|chrome-extension|devtools|edge|about):/i.test(tab.url || '');
let cdpError = null;
@@ -65,8 +119,8 @@ export async function handleScreenshot(params) {
// locked — always take it out of the picture; the router restores it.
const wasLocked = !!tabLocks.owner(tabId);
await hideLockShield(tabId);
- const opts = { format, quality, scale, maxWidth, fullPage };
- const done = (shot, via) => ({ success: true, format, via, width: shot.width, height: shot.height, data: shot.data });
+ const opts = { format, quality, scale, maxWidth, fullPage, region };
+ const done = (shot, via) => ({ success: true, format, via, width: shot.width, height: shot.height, frame: shot.frame, data: shot.data });
try {
if (tab.active) {
const shot = await cdpScreenshot(tabId, opts);
@@ -97,7 +151,10 @@ export async function handleScreenshot(params) {
}
}
return windowCaptureMutex.run(tab.windowId, async () => {
- const [previousActive] = await chrome.tabs.query({ active: true, windowId: tab.windowId });
+ const [previousActive] = await chrome.tabs.query({
+ active: true,
+ windowId: tab.windowId,
+ });
const changedActiveTab = previousActive?.id !== tabId;
if (changedActiveTab) {
await chrome.tabs.update(tabId, { active: true });
@@ -109,15 +166,20 @@ export async function handleScreenshot(params) {
try {
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
format,
- quality: format === 'jpeg' ? quality : undefined,
+ quality: format === "jpeg" ? quality : undefined,
});
- return { success: true, format, data: dataUrl.split(',')[1], ...(cdpError ? { cdpFallback: cdpError } : {}) };
+ return { success: true, format, data: dataUrl.split(",")[1], ...(cdpError ? { cdpFallback: cdpError } : {}) };
} finally {
if (wasLocked) await showLockShield(tabId);
if (changedActiveTab && previousActive?.id != null) {
- const [currentActive] = await chrome.tabs.query({ active: true, windowId: tab.windowId });
+ const [currentActive] = await chrome.tabs.query({
+ active: true,
+ windowId: tab.windowId,
+ });
if (currentActive?.id === tabId) {
- await chrome.tabs.update(previousActive.id, { active: true }).catch(() => {});
+ await chrome.tabs
+ .update(previousActive.id, { active: true })
+ .catch(() => {});
}
}
}
@@ -125,20 +187,35 @@ export async function handleScreenshot(params) {
}
export async function handleConsole(params) {
- const { tabId, clear = false } = params;
+ const { tabId, clear = false, pattern, level, limit } = params;
// Validate the tab (audit finding, seen live): a wrong tabId used to return
// an empty success instead of an actionable error.
await resolveTab(tabId);
const buf = getTabBuffer(consoleByTab, tabId);
- const msgs = [...buf];
+ let msgs = [...buf];
+ const total = msgs.length;
+ if (level) {
+ const want = new Set((Array.isArray(level) ? level : [level]).map((l) => String(l).toLowerCase()));
+ msgs = msgs.filter((m) => want.has(String(m.level).toLowerCase()));
+ }
+ if (pattern) {
+ let re;
+ try { re = new RegExp(pattern, 'i'); } catch (err) { throw new Error(`Invalid pattern regex: ${err?.message || err}`, { cause: err }); }
+ msgs = msgs.filter((m) => re.test(m.text));
+ }
+ if (Number.isInteger(limit) && limit > 0 && msgs.length > limit) msgs = msgs.slice(-limit);
if (clear) consoleByTab.set(tabId, []);
- return { success: true, messages: msgs };
+ return { success: true, messages: msgs, ...(msgs.length !== total ? { total } : {}) };
}
export async function handleNetwork(params) {
- const { tabId, filter, clear = false, limit } = params;
+ const { tabId, clear = false, limit, filter, urlPattern, failed } = params;
await resolveTab(tabId); // same as handleConsole — no empty fake successes
let reqs = [...getTabBuffer(networkByTab, tabId)];
+ // urlPattern: plain substring (Claude-in-Chrome style); filter: regex.
+ if (urlPattern) reqs = reqs.filter((r) => String(r.url).includes(urlPattern));
+ // failed:true = only requests that errored (DNS, blocked, aborted…) or got a 4xx/5xx.
+ if (failed === true) reqs = reqs.filter((r) => r.error || (typeof r.status === 'number' && r.status >= 400));
if (filter) {
// An invalid pattern used to throw a raw SyntaxError out of the handler;
// surface it as an actionable error instead.
@@ -146,7 +223,7 @@ export async function handleNetwork(params) {
try {
re = new RegExp(filter);
} catch (err) {
- throw new Error(`Invalid filter regex: ${err?.message || err}`);
+ throw new Error(`Invalid filter regex: ${err?.message || err}`, { cause: err });
}
reqs = reqs.filter((r) => re.test(r.url));
}
@@ -160,7 +237,7 @@ export async function handleNetwork(params) {
export async function handleTabs(params, sessionId) {
const { action, tabId, url } = params;
switch (action) {
- case 'list': {
+ case "list": {
// ALL windows, not {currentWindow:true}: "current window" is ill-defined
// in an MV3 service worker, and tabs in other windows were invisible and
// unfocusable. windowId disambiguates duplicates across windows.
@@ -172,7 +249,7 @@ export async function handleTabs(params, sessionId) {
tabs: tabs.map((t) => {
const entry = { id: t.id, windowId: t.windowId, title: t.title, active: t.active };
const url = String(t.url || '');
- entry.url = url.length > 80 ? url.slice(0, 77) + '...' : url;
+ entry.url = params.fullUrls || url.length <= 80 ? url : url.slice(0, 77) + '...';
const owner = tabLocks.owner(t.id);
if (owner) entry.lockedBy = owner; // omit when null — saves tokens
return entry;
@@ -180,11 +257,44 @@ export async function handleTabs(params, sessionId) {
};
}
case 'create': {
- const t = await chrome.tabs.create({ url: url || 'about:blank' });
- return { success: true, tabId: t.id, url: t.url };
+ // active:false opens it in the background: the user's current tab stays in front.
+ const t = await chrome.tabs.create({ url: url || 'about:blank', ...(params.active === false ? { active: false } : {}) });
+ return { success: true, tabId: t.id, url: t.url || t.pendingUrl || url || 'about:blank', ...(params.active === false ? { active: false } : {}) };
}
- case 'close': {
+ case 'reload': {
if (!tabId) throw new Error('tabId required');
+ const reloadOwner = tabLocks.owner(tabId);
+ if (reloadOwner && reloadOwner !== sessionId) {
+ throw new Error(`Tab ${tabId} is locked by ${reloadOwner} — unlock it from that session before reloading.`);
+ }
+ const current = await resolveTab(tabId);
+ // A frozen page would block the reload until it frees up: replace the tab.
+ const fresh = await replaceFrozenTab(current, current.url, sessionId);
+ if (fresh) {
+ return { success: true, reloaded: fresh.id, replacedTabId: tabId, url: current.url, note: `tab ${tabId} was frozen and has been replaced by tab ${fresh.id}` };
+ }
+ const done = new Promise((resolve) => {
+ const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(false); }, 30_000);
+ function listener(id, info) {
+ if (id === tabId && info.status === 'complete') {
+ clearTimeout(timer);
+ chrome.tabs.onUpdated.removeListener(listener);
+ resolve(true);
+ }
+ }
+ chrome.tabs.onUpdated.addListener(listener);
+ });
+ await chrome.tabs.reload(tabId, { bypassCache: params.bypassCache === true });
+ const loaded = await done;
+ wedgedTabs.delete(tabId);
+ const t = await chrome.tabs.get(tabId);
+ return {
+ success: true, reloaded: tabId, url: t.url,
+ ...(loaded ? {} : { warning: 'load did not complete within 30s' }),
+ };
+ }
+ case "close": {
+ if (!tabId) throw new Error("tabId required");
// A locked tab belongs to its owner session — closing it from another
// session (or from an anonymous no-session caller) would destroy the
// work the lock exists to protect.
@@ -196,28 +306,37 @@ export async function handleTabs(params, sessionId) {
releaseTabUi(tabId); // release + persist + shield removal
return { success: true, closed: tabId };
}
- case 'focus': {
- if (!tabId) throw new Error('tabId required');
+ case "focus": {
+ if (!tabId) throw new Error("tabId required");
const focusOwner = tabLocks.owner(tabId);
if (focusOwner && focusOwner !== sessionId) {
throw new Error(`Tab ${tabId} is locked by ${focusOwner} — unlock it from that session before focusing.`);
}
- await chrome.tabs.update(tabId, { active: true });
- return { success: true, focused: tabId };
+ const focusedTab = await chrome.tabs.update(tabId, { active: true });
+ // window:true also brings its window to the front (OS focus).
+ if (params.window === true && focusedTab?.windowId != null) {
+ await chrome.windows.update(focusedTab.windowId, { focused: true }).catch(() => {});
+ }
+ return { success: true, focused: tabId, ...(params.window === true ? { windowFocused: true } : {}) };
}
- case 'lock': {
- if (!tabId) throw new Error('tabId required');
+ case "lock": {
+ if (!tabId) throw new Error("tabId required");
const owner = sessionId;
- if (!owner) throw new Error('lock requires an authenticated session');
+ if (!owner) throw new Error("lock requires an authenticated session");
// Validate the tab exists — locking a phantom id would create an entry
// that onRemoved never cleans (it only fires for real tabs).
await resolveTab(tabId);
- const shielded = await lockTabUi(tabId, owner, `Tab ${tabId} locked by ${owner}`);
+ const shielded = await lockTabUi(
+ tabId,
+ owner,
+ `Tab ${tabId} locked by ${owner}`,
+ );
return { success: true, locked: tabId, owner, shielded };
}
- case 'unlock': {
- if (!tabId) throw new Error('tabId required');
- if (!sessionId) throw new Error('unlock requires an authenticated session');
+ case "unlock": {
+ if (!tabId) throw new Error("tabId required");
+ if (!sessionId)
+ throw new Error("unlock requires an authenticated session");
const was = tabLocks.owner(tabId);
tabLocks.unlock(tabId, sessionId);
if (tabLocks.owner(tabId)) {
@@ -225,10 +344,36 @@ export async function handleTabs(params, sessionId) {
}
persistSessionState();
hideLockShield(tabId);
- broadcastStatus(`Tab ${tabId} unlocked (was ${was || '-'})`);
+ broadcastStatus(`Tab ${tabId} unlocked (was ${was || "-"})`);
return { success: true, unlocked: tabId, previousSession: was || null };
}
default:
throw new Error(`Unknown action: ${action}`);
}
}
+
+/** Resize / change the state of the window that holds a tab. */
+export async function handleResizeWindow(params, sessionId) {
+ const { tabId, width, height, state } = params;
+ const tab = await resolveTab(tabId);
+ const owner = tabLocks.owner(tabId);
+ if (owner && owner !== sessionId) throw new Error(`Tab ${tabId} is locked by ${owner} — unlock it from that session first.`);
+ const update = {};
+ if (width != null) update.width = Math.round(width);
+ if (height != null) update.height = Math.round(height);
+ if (state) update.state = state;
+ // Sizes only apply to a normal window; a maximized one must be restored first.
+ if (update.width != null || update.height != null) {
+ if (update.state && update.state !== 'normal') { delete update.width; delete update.height; }
+ else update.state = 'normal';
+ }
+ if (Object.keys(update).length === 0) throw new Error('width, height or state is required');
+ const win = await chrome.windows.update(tab.windowId, update);
+ await new Promise((r) => setTimeout(r, 200)); // let the page re-layout
+ let viewport = null;
+ try { viewport = await safeExec(tabId, () => [window.innerWidth, window.innerHeight], [], { timeoutMs: 2000 }); } catch { /* protected page */ }
+ return {
+ success: true, windowId: win.id, state: win.state, width: win.width, height: win.height,
+ ...(Array.isArray(viewport) ? { viewport: { width: viewport[0], height: viewport[1] } } : {}),
+ };
+}
diff --git a/extension/lib/cdp-session.js b/extension/lib/cdp-session.js
index c531a39..97ea91a 100644
--- a/extension/lib/cdp-session.js
+++ b/extension/lib/cdp-session.js
@@ -13,6 +13,16 @@
*/
export const IDLE_MS = 30_000;
+/** A CDP command on a frozen renderer never answers: bound the setup probes. */
+const SETUP_MS = 5_000;
+
+function bounded(promise, what) {
+ let timer;
+ return Promise.race([
+ promise,
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`CDP ${what} timed out (page not responding)`)), SETUP_MS); }),
+ ]).finally(() => clearTimeout(timer));
+}
/** tabId -> { ready: Promise, timer } */
const sessions = new Map();
@@ -33,9 +43,9 @@ async function attach(tabId) {
// A service-worker restart forgets the map but Chrome may keep our
// attachment: probe it and reuse instead of failing.
if (!/already attached/i.test(String(err?.message || err))) throw err;
- await chrome.debugger.sendCommand(target, 'Runtime.evaluate', { expression: '1' });
+ await bounded(chrome.debugger.sendCommand(target, 'Runtime.evaluate', { expression: '1' }), 'attach probe');
}
- await chrome.debugger.sendCommand(target, 'Emulation.setFocusEmulationEnabled', { enabled: true }).catch(() => {});
+ await bounded(chrome.debugger.sendCommand(target, 'Emulation.setFocusEmulationEnabled', { enabled: true }), 'focus emulation').catch(() => {});
// Can fail while the page is still loading; locateTarget retries it.
await ensureViewport(tabId).catch(() => {});
}
@@ -48,9 +58,9 @@ async function attach(tabId) {
*/
export async function ensureViewport(tabId) {
const target = { tabId };
- const { result } = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {
+ const { result } = await bounded(chrome.debugger.sendCommand(target, 'Runtime.evaluate', {
expression: 'innerWidth * innerHeight', returnByValue: true,
- });
+ }), 'viewport probe');
if (result?.value > 0) return;
const tab = await chrome.tabs.get(tabId);
const win = await chrome.windows.get(tab.windowId);
diff --git a/extension/lib/connection.js b/extension/lib/connection.js
index b7783e3..9e49578 100644
--- a/extension/lib/connection.js
+++ b/extension/lib/connection.js
@@ -91,12 +91,28 @@ async function autoPairToken() {
return '';
}
+/** This browser profile's identity for multi-browser routing (persisted). */
+let browserIdentity = {};
+
+function defaultBrowserLabel(id) {
+ const ua = navigator.userAgentData;
+ const brand = ua?.brands?.find((b) => !/Not.?A.?Brand|Chromium/i.test(b.brand))?.brand || 'Chrome';
+ const platform = ua?.platform || navigator.platform || '';
+ return `${brand}${platform ? ` on ${platform}` : ''} (${id.slice(0, 4)})`;
+}
+
export async function initConnection() {
try {
- const stored = await chrome.storage.local.get(['wsPort', 'wsToken', 'enrollmentSecret']);
+ const stored = await chrome.storage.local.get(['wsPort', 'wsToken', 'enrollmentSecret', 'bcBrowserId', 'bcBrowserLabel']);
if (stored.wsPort) wsPort = stored.wsPort;
if (stored.wsToken) wsToken = stored.wsToken;
if (stored.enrollmentSecret) enrollmentSecret = stored.enrollmentSecret;
+ let id = stored.bcBrowserId;
+ if (!id) {
+ id = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)).replace(/-/g, '').slice(0, 12);
+ chrome.storage.local.set({ bcBrowserId: id });
+ }
+ browserIdentity = { browserId: id, browserLabel: stored.bcBrowserLabel || defaultBrowserLabel(id) };
} catch {}
// Restore lock ownership + fallbacks BEFORE connecting: the shield sweep
@@ -251,7 +267,7 @@ export async function connect() {
socket.close(1002, 'incompatible protocol');
return;
}
- socket.send(JSON.stringify(buildExtensionHelloAck(chrome.runtime.getManifest().version)));
+ socket.send(JSON.stringify(buildExtensionHelloAck(chrome.runtime.getManifest().version, browserIdentity)));
clearTimeout(handshakeTimeout);
handshakeTimeout = null;
connected = true;
diff --git a/extension/lib/gif-encoder.js b/extension/lib/gif-encoder.js
new file mode 100644
index 0000000..1ac366f
--- /dev/null
+++ b/extension/lib/gif-encoder.js
@@ -0,0 +1,130 @@
+/**
+ * Minimal animated-GIF encoder (GIF89a) for action recordings — no
+ * dependencies, runs in the service worker and in node tests.
+ *
+ * Palette: a fixed 256-colour table (6×6×6 colour cube + 40 greys). UI
+ * screenshots are dominated by greys/white and flat colours, so a fixed
+ * palette looks fine, needs no per-frame quantisation and keeps encoding
+ * fast. Pixel data is LZW-compressed as the format requires.
+ */
+
+const GREYS = 40;
+
+/** 256×RGB fixed palette: 216-colour cube followed by 40 greys. */
+export function buildPalette() {
+ const pal = new Uint8Array(256 * 3);
+ let i = 0;
+ for (let r = 0; r < 6; r++) for (let g = 0; g < 6; g++) for (let b = 0; b < 6; b++) {
+ pal[i++] = r * 51; pal[i++] = g * 51; pal[i++] = b * 51;
+ }
+ for (let k = 0; k < GREYS; k++) {
+ const v = Math.round((k * 255) / (GREYS - 1));
+ pal[i++] = v; pal[i++] = v; pal[i++] = v;
+ }
+ return pal;
+}
+
+/** RGBA pixels → palette indices (greyish pixels use the finer grey ramp). */
+export function indexPixels(rgba, count) {
+ const out = new Uint8Array(count);
+ for (let p = 0, q = 0; p < count; p++, q += 4) {
+ const r = rgba[q];
+ const g = rgba[q + 1];
+ const b = rgba[q + 2];
+ const max = r > g ? (r > b ? r : b) : (g > b ? g : b);
+ const min = r < g ? (r < b ? r : b) : (g < b ? g : b);
+ if (max - min < 14) {
+ out[p] = 216 + Math.round((((r + g + b) / 3) * (GREYS - 1)) / 255);
+ } else {
+ out[p] = Math.round(r / 51) * 36 + Math.round(g / 51) * 6 + Math.round(b / 51);
+ }
+ }
+ return out;
+}
+
+/** GIF LZW compression of palette indices (min code size 8), as sub-blocks. */
+export function lzwEncode(indices) {
+ const MIN = 8;
+ const CLEAR = 1 << MIN;
+ const EOI = CLEAR + 1;
+ const bytes = [];
+ let cur = 0;
+ let bits = 0;
+ let codeSize = MIN + 1;
+ const emit = (code) => {
+ cur |= code << bits;
+ bits += codeSize;
+ while (bits >= 8) { bytes.push(cur & 0xff); cur >>>= 8; bits -= 8; }
+ };
+ let dict = new Map();
+ let next = EOI + 1;
+ emit(CLEAR);
+ let prefix = indices.length ? indices[0] : 0;
+ for (let i = 1; i < indices.length; i++) {
+ const k = indices[i];
+ const key = prefix * 256 + k;
+ const hit = dict.get(key);
+ if (hit !== undefined) { prefix = hit; continue; }
+ emit(prefix);
+ if (next < 4096) {
+ dict.set(key, next++);
+ if (next > (1 << codeSize) && codeSize < 12) codeSize++;
+ } else {
+ emit(CLEAR);
+ dict = new Map();
+ next = EOI + 1;
+ codeSize = MIN + 1;
+ }
+ prefix = k;
+ }
+ if (indices.length) emit(prefix);
+ emit(EOI);
+ if (bits > 0) bytes.push(cur & 0xff);
+ // Split into ≤255-byte sub-blocks.
+ const out = [MIN];
+ for (let i = 0; i < bytes.length; i += 255) {
+ const chunk = bytes.slice(i, i + 255);
+ out.push(chunk.length, ...chunk);
+ }
+ out.push(0);
+ return out;
+}
+
+/**
+ * frames: [{ rgba: Uint8ClampedArray|Uint8Array (width*height*4), delayMs }]
+ * All frames share width×height. Returns the GIF file bytes.
+ */
+export function encodeGif(width, height, frames, { loop = 0 } = {}) {
+ const pal = buildPalette();
+ const out = [];
+ const u16 = (v) => { out.push(v & 0xff, (v >> 8) & 0xff); };
+ const str = (s) => { for (const ch of s) out.push(ch.charCodeAt(0)); };
+ str('GIF89a');
+ u16(width); u16(height);
+ out.push(0xf7, 0, 0); // global colour table, 8 bits/channel, 256 entries
+ for (const v of pal) out.push(v);
+ // NETSCAPE2.0 loop extension
+ out.push(0x21, 0xff, 0x0b); str('NETSCAPE2.0'); out.push(0x03, 0x01); u16(loop); out.push(0);
+ for (const f of frames) {
+ const delay = Math.max(2, Math.round((f.delayMs ?? 500) / 10));
+ out.push(0x21, 0xf9, 0x04, 0x00); u16(delay); out.push(0, 0); // graphic control
+ out.push(0x2c); u16(0); u16(0); u16(width); u16(height); out.push(0); // image descriptor
+ const data = lzwEncode(indexPixels(f.rgba, width * height));
+ for (const b of data) out.push(b);
+ }
+ out.push(0x3b);
+ return Uint8Array.from(out);
+}
+
+/** Paint a red ring (click marker) into RGBA pixels. */
+export function drawMarker(rgba, width, height, cx, cy, radius = 9) {
+ for (let y = Math.max(0, Math.floor(cy - radius - 2)); y <= Math.min(height - 1, Math.ceil(cy + radius + 2)); y++) {
+ for (let x = Math.max(0, Math.floor(cx - radius - 2)); x <= Math.min(width - 1, Math.ceil(cx + radius + 2)); x++) {
+ const d = Math.hypot(x - cx, y - cy);
+ if (d <= radius + 1.5 && d >= radius - 1.5) {
+ const q = (y * width + x) * 4;
+ rgba[q] = 255; rgba[q + 1] = 0; rgba[q + 2] = 0; rgba[q + 3] = 255;
+ }
+ }
+ }
+}
diff --git a/extension/lib/intercept.js b/extension/lib/intercept.js
new file mode 100644
index 0000000..d71ab69
--- /dev/null
+++ b/extension/lib/intercept.js
@@ -0,0 +1,99 @@
+/**
+ * Pure intercept-rule engine (no chrome deps — unit-testable).
+ * Mirrors the MCP-side zod constraints in mcp-server/src/tools/intercept.ts;
+ * the wire boundary is untrusted so both ends validate (ADR-4).
+ */
+
+export const MAX_RULES = 50;
+export const MAX_MATCH_LEN = 500;
+export const MAX_MOCK_BODY = 20_000;
+
+const VALID_ACTIONS = new Set(["log", "block", "redirect", "header", "mock"]);
+
+/** Validate one rule; throws an actionable Error on the first problem. */
+export function validateRule(rule, index = 0) {
+ const where = `rules[${index}]`;
+ if (!rule || typeof rule !== "object")
+ throw new Error(`${where}: rule must be an object`);
+ if (
+ rule.id !== undefined &&
+ (typeof rule.id !== "string" || rule.id.length < 1 || rule.id.length > 64)
+ ) {
+ throw new Error(`${where}: id must be a 1-64 char string`);
+ }
+ if (
+ typeof rule.match !== "string" ||
+ rule.match.length < 1 ||
+ rule.match.length > MAX_MATCH_LEN
+ ) {
+ throw new Error(`${where}: match must be a 1-${MAX_MATCH_LEN} char regex`);
+ }
+ if (rule.match.trim() === ".*" || rule.match.trim() === ".+") {
+ throw new Error(
+ `${where}: match-all patterns are rejected (scope your rule)`,
+ );
+ }
+ try {
+ new RegExp(rule.match);
+ } catch (err) {
+ throw new Error(`${where}: invalid match regex: ${err?.message || err}`, { cause: err });
+ }
+ if (!VALID_ACTIONS.has(rule.action)) {
+ throw new Error(
+ `${where}: action must be one of log|block|redirect|header|mock`,
+ );
+ }
+ if (rule.action === "redirect" && typeof rule.redirectUrl !== "string") {
+ throw new Error(`${where}: redirect rules require redirectUrl`);
+ }
+ if (rule.mockBody !== undefined && rule.mockBody.length > MAX_MOCK_BODY) {
+ throw new Error(`${where}: mockBody exceeds ${MAX_MOCK_BODY} chars`);
+ }
+ if (rule.types !== undefined && !Array.isArray(rule.types)) {
+ throw new Error(`${where}: types must be an array`);
+ }
+ if (rule.tabIds !== undefined && !Array.isArray(rule.tabIds)) {
+ throw new Error(`${where}: tabIds must be an array`);
+ }
+ return rule;
+}
+
+/** Validate a rule set (cap + per-rule). Returns the rules unchanged. */
+export function validateRuleSet(rules) {
+ if (!Array.isArray(rules)) throw new Error("rules must be an array");
+ if (rules.length > MAX_RULES)
+ throw new Error(`Too many rules: ${rules.length} > ${MAX_RULES}`);
+ const seen = new Set();
+ rules.forEach((r, i) => {
+ validateRule(r, i);
+ const id = r.id ?? `r${i + 1}`;
+ if (seen.has(id)) throw new Error(`Duplicate rule id: ${id}`);
+ seen.add(id);
+ });
+ return rules;
+}
+
+/** Does a rule match this request? Disabled rules never match. */
+export function matchRule(rule, { url, type, tabId }) {
+ if (rule.enabled === false) return false;
+ if (rule.tabIds && tabId != null && !rule.tabIds.includes(tabId))
+ return false;
+ if (rule.types && type != null && !rule.types.includes(type)) return false;
+ try {
+ return new RegExp(rule.match).test(url);
+ } catch {
+ return false;
+ }
+}
+
+/** All matching rules for a request, in order. */
+export function evaluateRules(rules, req) {
+ return (rules || []).filter((r) => matchRule(r, req));
+}
+
+/** Scope key for storage: global or sorted tab list. */
+export function scopeKey(tabIds) {
+ if (!tabIds || tabIds.length === 0) return "global";
+ // Deduplicated: two rules for tab 15 are scope "tabs:15", not "tabs:15,15".
+ return `tabs:${[...new Set(tabIds)].sort((a, b) => a - b).join(",")}`;
+}
diff --git a/extension/lib/page-dom.js b/extension/lib/page-dom.js
new file mode 100644
index 0000000..00cd0a2
--- /dev/null
+++ b/extension/lib/page-dom.js
@@ -0,0 +1,437 @@
+/**
+ * Shared page-side DOM runtime: one element resolver and one composed-tree
+ * walker for every tool, installed once per document (v2 install-once pattern,
+ * see observation-v2.js — injected source can't import modules).
+ *
+ * Why: refs used to be looked up by a `[data-mcp-ref]` attribute nothing
+ * writes any more, so every ref action fell through to the smart-selector
+ * fallback, whose first step returned the FIRST querySelector match — clicks
+ * "succeeded" on the wrong element. Selectors also acted on the first match
+ * even when it was hidden, and nothing looked inside shadow roots.
+ *
+ * Resolution order: ref registry → selector (first VISIBLE match across the
+ * composed tree: open/closed shadow roots + same-origin iframes) → verified
+ * fallback (unique, or nth among exact role/tag/name matches). Anything
+ * ambiguous is reported as gone instead of guessed.
+ */
+
+export const PAGE_DOM_VERSION = 1;
+
+export function PAGE_DOM_INSTALL(version) {
+ if (globalThis.__bcDom && globalThis.__bcDom.v === version) return false;
+ const REGISTRY_KEY = '__browserControllerLegacyRefRegistry';
+ const registry = globalThis[REGISTRY_KEY] instanceof Map ? globalThis[REGISTRY_KEY] : new Map();
+ globalThis[REGISTRY_KEY] = registry;
+
+ /** Computed style from the element's own window (frames have their own). */
+ function styleOf(el) {
+ try { return ((el.ownerDocument && el.ownerDocument.defaultView) || globalThis).getComputedStyle(el); } catch { return null; }
+ }
+ const connected = (el) => !!el && el.isConnected !== false;
+
+ const clean = (v, max = 160) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);
+ const attr = (el, n) => (el && el.getAttribute ? el.getAttribute(n) || '' : '');
+
+ /** Open or closed shadow root (closed ones via chrome.dom in the isolated world). */
+ function shadowOf(el) {
+ if (!el || el.nodeType !== 1) return null;
+ if (el.shadowRoot) return el.shadowRoot;
+ try {
+ if (typeof chrome !== 'undefined' && chrome.dom && chrome.dom.openOrClosedShadowRoot) return chrome.dom.openOrClosedShadowRoot(el) || null;
+ } catch { /* not an element that can host a root */ }
+ return null;
+ }
+
+ function frameDoc(el) {
+ if (!el || el.tagName !== 'IFRAME' && el.tagName !== 'FRAME') return null;
+ try { return el.contentDocument || null; } catch { return null; }
+ }
+
+ /** Every search root: documents (top + same-origin frames) and shadow roots. */
+ function allRoots(withShadow) {
+ const roots = [];
+ const seen = new Set();
+ const visit = (root, depth) => {
+ if (!root || seen.has(root) || depth > 6) return;
+ seen.add(root);
+ roots.push(root);
+ let els = [];
+ try { els = root.querySelectorAll(withShadow ? '*' : 'iframe,frame'); } catch {}
+ for (const el of els) {
+ const d = frameDoc(el);
+ if (d) visit(d, depth + 1);
+ if (withShadow) { const s = shadowOf(el); if (s) visit(s, depth + 1); }
+ }
+ };
+ visit(document, 0);
+ return roots;
+ }
+
+ function queryAll(sel, withShadow) {
+ const out = [];
+ for (const root of allRoots(withShadow)) {
+ try { for (const el of root.querySelectorAll(sel)) out.push(el); } catch { return null; /* invalid selector */ }
+ }
+ return out;
+ }
+
+ /** Composed-ancestor aware: display/visibility/opacity/content-visibility + a real box. */
+ function isVisible(el) {
+ if (!connected(el)) return false;
+ // An element in a hidden/transparent/zero-size iframe is not visible either.
+ let frameEl;
+ try { frameEl = el.ownerDocument && el.ownerDocument.defaultView ? el.ownerDocument.defaultView.frameElement : null; } catch { frameEl = null; }
+ if (frameEl && !isVisible(frameEl)) return false;
+ try {
+ if (typeof el.checkVisibility === 'function'
+ && !el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, contentVisibilityAuto: true })) return false;
+ } catch { /* old engine */ }
+ let r;
+ try { r = el.getBoundingClientRect(); } catch { return false; }
+ if (r.width > 0 && r.height > 0) return true;
+ // display:contents hosts / slots have no box of their own: visible if a child is.
+ try {
+ const st = styleOf(el);
+ if (st && st.display === 'contents') {
+ for (const c of flatChildren(el)) if (c.nodeType === 1 && isVisible(c)) return true;
+ }
+ } catch {}
+ return false;
+ }
+
+ /** Flat-tree children: shadow content replaces light children, slots show what is assigned. */
+ function flatChildren(node) {
+ if (!node) return [];
+ if (node.nodeType === 1) {
+ const s = shadowOf(node);
+ if (s) return Array.from(s.childNodes);
+ if (node.tagName === 'SLOT' && typeof node.assignedNodes === 'function') {
+ const assigned = node.assignedNodes({ flatten: true });
+ if (assigned.length) return assigned;
+ }
+ const d = frameDoc(node);
+ if (d) return d.body ? [d.body] : [];
+ }
+ return Array.from(node.childNodes || node.children || []);
+ }
+
+ function hasShadowHosts() {
+ for (const root of allRoots(false)) {
+ let els = [];
+ try { els = root.querySelectorAll('*'); } catch {}
+ for (const el of els) if (shadowOf(el)) return true;
+ }
+ return false;
+ }
+
+ const INPUT_BUTTONS = ['button', 'submit', 'reset', 'image'];
+ function roleOf(el) {
+ const explicit = clean(attr(el, 'role')).split(' ')[0];
+ if (explicit) return explicit;
+ const tag = String(el.tagName || '').toLowerCase();
+ const type = String(el.type || attr(el, 'type') || '').toLowerCase();
+ if (tag === 'button' || tag === 'summary') return 'button';
+ if (tag === 'a') return el.hasAttribute('href') ? 'link' : 'generic';
+ if (tag === 'textarea') return 'textbox';
+ if (tag === 'select') return el.multiple ? 'listbox' : 'combobox';
+ if (tag === 'option') return 'option';
+ if (tag === 'img') return 'img';
+ if (/^h[1-6]$/.test(tag)) return 'heading';
+ if (tag === 'input') {
+ if (INPUT_BUTTONS.includes(type)) return 'button';
+ if (type === 'checkbox') return 'checkbox';
+ if (type === 'radio') return 'radio';
+ if (type === 'range') return 'slider';
+ if (type === 'search') return 'searchbox';
+ if (type === 'hidden') return 'none';
+ return 'textbox';
+ }
+ if (el.isContentEditable) return 'textbox';
+ const map = { nav: 'navigation', main: 'main', header: 'banner', footer: 'contentinfo', form: 'form', dialog: 'dialog', table: 'table', ul: 'list', ol: 'list', li: 'listitem', aside: 'complementary' };
+ return map[tag] || 'generic';
+ }
+
+ /** Composed text (includes shadow content) — innerText misses shadow roots. */
+ function composedText(el, max = 300) {
+ let out = '';
+ let budget = max * 4; // raw chars incl. whitespace; stops long before a big container's full text
+ const walk = (n) => {
+ if (budget <= 0) return;
+ if (n.nodeType === 3) { out += n.nodeValue; budget -= n.nodeValue.length; return; }
+ if (n.nodeType === 1 && n.childNodes === undefined && !shadowOf(n)) {
+ const t = String(n.textContent ?? n.innerText ?? ''); out += t; budget -= t.length; return; // minimal DOMs
+ }
+ if (n.nodeType !== 1 && n.nodeType !== 11) return;
+ if (n.nodeType === 1 && (n.tagName === 'SCRIPT' || n.tagName === 'STYLE')) return;
+ for (const c of flatChildren(n)) walk(c);
+ };
+ walk(el);
+ return clean(out, max);
+ }
+
+ function nameOf(el) {
+ const labelledBy = attr(el, 'aria-labelledby');
+ if (labelledBy) {
+ let root = null;
+ try { root = el.getRootNode(); } catch {}
+ const t = labelledBy.split(/\s+/).map((id) => {
+ const l = (root && root.getElementById && root.getElementById(id)) || el.ownerDocument.getElementById(id);
+ return l ? clean(l.textContent) : '';
+ }).filter(Boolean).join(' ');
+ if (t) return clean(t);
+ }
+ const aria = clean(attr(el, 'aria-label'));
+ if (aria) return aria;
+ try {
+ const labels = Array.from(el.labels || []).map((l) => clean(l.textContent)).filter(Boolean);
+ if (labels.length) return clean(labels.join(' '));
+ } catch {}
+ const tag = String(el.tagName || '').toLowerCase();
+ const type = String(el.type || '').toLowerCase();
+ if (tag === 'input' && INPUT_BUTTONS.includes(type) && el.value) return clean(el.value);
+ for (const a of ['alt', 'title', 'placeholder']) { const v = clean(attr(el, a)); if (v) return v; }
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') return '';
+ // Bounded composed text: CSS-independent (innerText applies text-transform:
+ // uppercase), includes shadow content, and never materialises a huge
+ // container's whole textContent.
+ return composedText(el, 200);
+ }
+
+ const INTERACTIVE_ROLES = new Set(['button', 'link', 'textbox', 'searchbox', 'combobox', 'listbox', 'checkbox', 'radio', 'switch', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem']);
+ function isInteractive(el) {
+ const tag = el.tagName;
+ if (['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY'].includes(tag)) return tag !== 'A' || el.hasAttribute('href') || el.hasAttribute('onclick');
+ if (INTERACTIVE_ROLES.has(roleOf(el))) return true;
+ if (el.isContentEditable) return true;
+ const ti = attr(el, 'tabindex');
+ if (ti !== '' && Number(ti) >= 0) return true;
+ return typeof el.onclick === 'function';
+ }
+
+ /** Exact, or wanted + non-letter suffix ("Login »"). Mirrors isPreciseTextMatch. */
+ function preciseMatch(candidate, wanted) {
+ const c = clean(candidate).toLowerCase();
+ const w = clean(wanted).toLowerCase();
+ if (!w) return false;
+ if (c === w) return true;
+ return w.length > 2 && c.length > w.length && c.startsWith(w) && !/[a-zà-ÿ-ۿ]/i.test(c.slice(w.length));
+ }
+
+ function firstVisible(list) {
+ if (!list || !list.length) return null;
+ for (const el of list) if (isVisible(el)) return el;
+ return null;
+ }
+
+ function bySelector(sel) {
+ let all = queryAll(sel, false);
+ if (all === null) return { error: 'INVALID_SELECTOR' };
+ let el = firstVisible(all);
+ if (el) return { el, count: all.length };
+ const deep = queryAll(sel, true) || [];
+ el = firstVisible(deep) || deep[0] || all[0] || null;
+ return el ? { el, count: deep.length || all.length, hidden: !isVisible(el) } : null;
+ }
+
+ /** Fallback descriptor → element, only when the match is unambiguous. */
+ function byFallback(fb) {
+ if (!fb) return null;
+ const wantTag = fb.tag || null;
+ const wantRole = fb.role || null;
+ const wantNth = typeof fb.nth === 'number' && fb.nth >= 0 ? fb.nth : 0;
+ const effRole = (el) => attr(el, 'role') || el.tagName.toLowerCase();
+ const textOk = (el) => !fb.text || preciseMatch(nameOf(el), fb.text)
+ || preciseMatch(clean(el.textContent).split('\n')[0], fb.text)
+ || preciseMatch(attr(el, 'aria-label') || attr(el, 'alt') || attr(el, 'title') || attr(el, 'placeholder'), fb.text);
+ const same = (el) => (!wantTag || el.tagName === wantTag) && (!wantRole || effRole(el) === wantRole);
+
+ if (fb.robustSelector) {
+ const cands = (queryAll(fb.robustSelector, true) || []).filter((el) => same(el) && isVisible(el));
+ const named = fb.text ? cands.filter(textOk) : cands;
+ if (named.length === 1) return named[0];
+ if (named.length > wantNth) return named[wantNth];
+ if (named.length > 1) return null; // several look-alikes and the ordinal no longer fits: don't guess
+ }
+ if (!fb.text) return null;
+ const matches = [];
+ const sel = wantTag ? wantTag.toLowerCase() : '*';
+ for (const el of queryAll(sel, true) || []) {
+ if (!same(el) || !isVisible(el)) continue;
+ if (textOk(el)) matches.push(el);
+ }
+ if (matches.length > wantNth) return matches[wantNth];
+ if (matches.length === 1) return matches[0];
+ return null;
+ }
+
+ /**
+ * ref → selector → verified fallback. Returns { el, via } or { error, url }.
+ * via: 'ref' | 'selector' | 'fallback'.
+ */
+ function resolve(ref, sel, fb) {
+ if (ref) {
+ const el = registry.get(ref);
+ if (connected(el)) return { el, via: 'ref' };
+ if (el) registry.delete(ref);
+ }
+ if (sel) {
+ const hit = bySelector(sel);
+ if (hit && hit.error) return { error: hit.error, url: location.href };
+ if (hit) return { el: hit.el, via: 'selector', ...(hit.hidden ? { hidden: true } : {}) };
+ }
+ if (fb) {
+ const el = byFallback(fb);
+ if (el) {
+ if (ref) registry.set(ref, el); // re-bind so the next call is a direct hit
+ return { el, via: 'fallback' };
+ }
+ }
+ return { error: 'REF_GONE', url: location.href };
+ }
+
+ /** Top-level viewport centre of an element (adds same-origin iframe offsets). */
+ function centerOf(el) {
+ const rect = el.getBoundingClientRect();
+ let x = rect.left + rect.width / 2;
+ let y = rect.top + rect.height / 2;
+ let win = el.ownerDocument ? el.ownerDocument.defaultView : null;
+ while (win && win !== globalThis.window && win.frameElement) {
+ const fr = win.frameElement.getBoundingClientRect();
+ const cs = win.frameElement.ownerDocument.defaultView.getComputedStyle(win.frameElement);
+ x += fr.left + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.paddingLeft) || 0);
+ y += fr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0);
+ win = win.parent;
+ }
+ return { x, y, rect };
+ }
+
+ /** Composed hit-test: the deepest element at a top-level point (pierces shadow roots and same-origin frames). */
+ function elementAt(x, y) {
+ let doc = document;
+ let px = x;
+ let py = y;
+ let hit = null;
+ for (let guard = 0; guard < 8; guard++) {
+ let h = doc.elementFromPoint(px, py);
+ while (h) {
+ const s = shadowOf(h);
+ const inner = s && s.elementFromPoint ? s.elementFromPoint(px, py) : null;
+ if (!inner || inner === h) break;
+ h = inner;
+ }
+ if (!h) break;
+ hit = h;
+ const d = frameDoc(h);
+ if (!d) break;
+ const fr = h.getBoundingClientRect();
+ px -= fr.left; py -= fr.top;
+ doc = d;
+ }
+ return hit;
+ }
+
+ function composedContains(a, b) {
+ let cur = b;
+ const seen = new Set();
+ while (cur && !seen.has(cur)) {
+ if (cur === a) return true;
+ seen.add(cur);
+ let root = null;
+ try { root = cur.getRootNode(); } catch {}
+ let frameEl = null;
+ try { frameEl = root && root.nodeType === 9 && root.defaultView ? root.defaultView.frameElement : null; } catch {}
+ cur = cur.parentElement || (root && root.host) || frameEl || null;
+ }
+ return false;
+ }
+
+ function describe(el) {
+ if (!el) return null;
+ return el.tagName.toLowerCase() + (el.id ? `#${el.id}` : '')
+ + (typeof el.className === 'string' && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '');
+ }
+
+ const BLOCK_DISPLAY = /^(block|flex|grid|list-item|table|table-row|table-caption|flow-root|inline-block)$/;
+ const ARTICLE_SKIP_ROLES = new Set(['navigation', 'banner', 'contentinfo', 'complementary', 'search', 'menu', 'menubar', 'toolbar', 'dialog', 'alertdialog']);
+ const ARTICLE_SKIP_TAGS = new Set(['NAV', 'FOOTER', 'ASIDE', 'HEADER', 'FORM', 'BUTTON', 'DIALOG']);
+ const ARTICLE_SKIP_HINT = /(^|[-_ ])(cookie|consent|sidebar|side-bar|menu|navbar|nav|footer|breadcrumbs?|share|social|advert|ads|promo|newsletter|related|comments?)([-_ ]|$)/i;
+
+ /**
+ * Visible text of the flat tree (shadow roots, slots, same-origin frames),
+ * block elements on their own lines. `article` skips navigation, headers,
+ * footers, sidebars, banners and similar page chrome.
+ */
+ function flatText(root, { article = false, max = 1e7 } = {}) {
+ const parts = [];
+ let len = 0;
+ const push = (t) => { parts.push(t); len += t.length; };
+ // pre: inside white-space:pre* the text keeps its own line breaks.
+ const walk = (n, pre) => {
+ if (len > max) return;
+ if (n.nodeType === 3) {
+ if (pre) { if (n.nodeValue.trim()) push(n.nodeValue.replace(/\n/g, '\u2029')); return; }
+ const v = n.nodeValue.replace(/\s+/g, ' ');
+ if (v.trim()) push(v);
+ return;
+ }
+ if (n.nodeType === 11 || n.nodeType === 9) { for (const c of flatChildren(n)) walk(c, pre); return; }
+ if (n.nodeType !== 1) return;
+ const tag = n.tagName;
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE' || tag === 'svg' || tag === 'SVG') return;
+ if (tag === 'BR') { push('\n'); return; }
+ if (article && n !== root) {
+ if (ARTICLE_SKIP_TAGS.has(tag) && !(tag === 'HEADER' && n.closest && n.closest('article, main'))) return;
+ if (ARTICLE_SKIP_ROLES.has(roleOf(n))) return;
+ const hint = `${n.id || ''} ${typeof n.className === 'string' ? n.className : ''}`;
+ if (hint.trim() && ARTICLE_SKIP_HINT.test(hint)) return;
+ if (attr(n, 'aria-hidden') === 'true') return;
+ }
+ const st = styleOf(n);
+ if (st && (st.display === 'none' || st.visibility === 'hidden' || st.contentVisibility === 'hidden')) return;
+ const block = st ? BLOCK_DISPLAY.test(st.display) : false;
+ const inPre = st ? /^pre/.test(st.whiteSpace || '') : pre;
+ if (block) push('\n');
+ for (const c of flatChildren(n)) walk(c, inPre);
+ if (st && st.display === 'table-cell') push(' \t ');
+ if (block) push('\n');
+ };
+ walk(root, false);
+ return parts.join('')
+ .replace(/[ \t]*\n[ \t]*/g, '\n')
+ .replace(/\n{3,}/g, '\n\n')
+ .replace(/[ \t]{2,}/g, ' ')
+ .replace(/\u2029/g, '\n') // preformatted line breaks survive the collapsing above
+ .trim();
+ }
+
+ /** Main-content root for article mode: the visible //[role=main] with the most text. */
+ function articleRoot() {
+ let best = null;
+ let bestLen = 0;
+ for (const root of allRoots(false)) {
+ let els = [];
+ try { els = root.querySelectorAll('article, main, [role="main"], [itemprop="articleBody"]'); } catch {}
+ for (const el of els) {
+ const l = (el.textContent || '').length;
+ if (l > bestLen && isVisible(el)) { best = el; bestLen = l; }
+ }
+ }
+ return best || document.body;
+ }
+
+ /** Page text: native innerText when there is no shadow DOM (fast), the flat-tree walker otherwise. */
+ function pageText(root, { article = false, max = 1e7 } = {}) {
+ if (!article && root.ownerDocument === document && typeof root.innerText === 'string' && !hasShadowHosts()) {
+ return root.innerText.replace(/\t/g, ' ').replace(/\n\s*\n/g, '\n\n').replace(/ +/g, ' ').trim();
+ }
+ return flatText(root, { article, max });
+ }
+
+ globalThis.__bcDom = Object.freeze({
+ v: version,
+ clean, attr, shadowOf, frameDoc, allRoots, queryAll, isVisible, flatChildren, hasShadowHosts,
+ roleOf, nameOf, composedText, isInteractive, preciseMatch, resolve, centerOf, elementAt,
+ composedContains, describe, registry, flatText, articleRoot, pageText, styleOf, connected,
+ });
+ return true;
+}
diff --git a/extension/lib/page-exec.js b/extension/lib/page-exec.js
index f6c7a02..958840e 100644
--- a/extension/lib/page-exec.js
+++ b/extension/lib/page-exec.js
@@ -2,7 +2,8 @@
* Page-execution primitives (extracted from background.js): tab resolution,
* the locator guard, and safeExec. Everything a handler needs to touch a page.
*/
-import { fallbackByTab } from './state.js';
+import { fallbackByTab, wedgedTabs, tabLocks, persistSessionState } from './state.js';
+import { PAGE_DOM_INSTALL, PAGE_DOM_VERSION } from './page-dom.js';
/**
* Resolve a tab by id, throwing a clear, actionable error if it's gone.
@@ -18,7 +19,7 @@ export async function resolveTab(tabId) {
if (!tab) throw new Error(`Tab ${tabId} not found, call browser_tabs list first.`);
return tab;
} catch (err) {
- throw new Error(`Tab ${tabId} not found, call browser_tabs list first. (${err.message || err})`);
+ throw new Error(`Tab ${tabId} not found, call browser_tabs list first. (${err.message || err})`, { cause: err });
}
}
@@ -29,12 +30,20 @@ export async function resolveTab(tabId) {
* misleading "Element undefined is gone from the DOM" after a wasted
* round-trip (critical audit #10).
*/
-export function requireTarget(params) {
+export function requireTarget(params, { allowPoint = false } = {}) {
+ if (allowPoint && hasPoint(params)) return;
if (!params.ref && !params.selector) {
- throw new Error('ref or selector is required (get refs from browser_snapshot / browser_find).');
+ throw new Error(allowPoint
+ ? 'ref or selector is required, or x+y viewport coordinates (get refs from browser_snapshot / browser_find).'
+ : 'ref or selector is required (get refs from browser_snapshot / browser_find).');
}
}
+/** Viewport coordinates given (and no element locator): act at that point. */
+export function hasPoint(params) {
+ return Number.isFinite(params.x) && Number.isFinite(params.y);
+}
+
/**
* Get the stored smart-selector fallback for a (tabId, ref). Returns null if
* the ref was never snapshotted or the snapshot pre-dates the fallback feature.
@@ -46,6 +55,71 @@ export function getFallback(tabId, ref) {
return map.get(ref) || null;
}
+/** Default budget for one page function (below every tool's own timeout). */
+export const PAGE_EXEC_TIMEOUT_MS = 8_000;
+/** Probe budget for a tab already known to be unresponsive. */
+export const WEDGE_PROBE_MS = 1_500;
+
+/** Race a promise against a timer; the timer's error comes from makeError(). */
+export function withTimeout(promise, ms, makeError) {
+ let timer;
+ const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(makeError()), ms); });
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
+}
+
+export function wedgedError(tabId, ms) {
+ const err = new Error(`TAB_WEDGED: tab ${tabId} did not respond within ${(ms / 1000).toFixed(1)}s `
+ + '(frozen main thread or a huge document). browser_navigate it elsewhere, reload it '
+ + '(browser_tabs action:"reload") or close it; other tabs are unaffected.');
+ err.code = 'TAB_WEDGED';
+ return err;
+}
+
+/** Is a previously wedged tab answering again? Clears the mark when it is. */
+export async function probeResponsive(tabId, ms = WEDGE_PROBE_MS) {
+ try {
+ await withTimeout(chrome.scripting.executeScript({ target: { tabId }, func: () => 1 }), ms, () => wedgedError(tabId, ms));
+ wedgedTabs.delete(tabId);
+ return true;
+ } catch (err) {
+ if (err && err.code === 'TAB_WEDGED') return false;
+ wedgedTabs.delete(tabId); // a different failure (protected page, gone): not a wedge
+ return true;
+ }
+}
+
+/**
+ * A frozen page holds every navigation/reload of its tab hostage until its
+ * main thread frees up (57 s measured on a busy loop; CDP Page.crash and
+ * tabs.discard don't help — discard even changes the tab id). Closing a tab
+ * never waits for the page, so replace it: a new tab at the same position,
+ * then close the frozen one. Returns the new tab, or null when the tab was
+ * not wedged. Callers report `replacedTabId` so the agent switches ids.
+ */
+export async function replaceFrozenTab(tab, url, sessionId = null) {
+ if (!wedgedTabs.has(tab.id)) return null;
+ // Defence in depth (the router checks too): never replace another session's tab.
+ const owner = tabLocks.owner(tab.id);
+ if (owner && owner !== sessionId) {
+ throw new Error(`Tab ${tab.id} is locked by ${owner} — unlock it from that session first.`);
+ }
+ const fresh = await chrome.tabs.create({ windowId: tab.windowId, index: tab.index, url: url || 'about:blank', active: !!tab.active });
+ wedgedTabs.delete(tab.id);
+ // The replacement keeps the caller's lock on the tab it is replacing.
+ if (owner) {
+ tabLocks.release(tab.id);
+ tabLocks.lock(fresh.id, owner);
+ persistSessionState();
+ }
+ chrome.tabs.remove(tab.id).catch(() => { /* already gone */ });
+ return fresh;
+}
+
+/** Fail fast when the tab is known to be frozen (one short probe, no queueing). */
+export async function assertResponsive(tabId) {
+ if (wedgedTabs.has(tabId) && !(await probeResponsive(tabId))) throw wedgedError(tabId, WEDGE_PROBE_MS);
+}
+
/**
* safeExec (task 2.5): run chrome.scripting.executeScript against a tab,
* turning "can't access chrome:// / webstore / devtools pages" into a clear
@@ -60,19 +134,42 @@ export async function safeExec(tabId, func, args = [], opts = {}) {
throw new Error(`Cannot access protected page (${tab.url}). Tab ${tabId} is a browser-internal page.`);
}
const sanitized = args.map((a) => (a === undefined ? null : a));
+ await assertResponsive(tabId);
+ // An in-flight executeScript can't be aborted: without a timer a frozen page
+ // pinned the tab's mutex until the caller's own timeout, and every queued
+ // call after it waited too (benchmark: 2 × 125 s on one JSON page).
+ const ms = opts.timeoutMs ?? PAGE_EXEC_TIMEOUT_MS;
try {
- const results = await chrome.scripting.executeScript({
+ const results = await withTimeout(chrome.scripting.executeScript({
target: { tabId },
func,
args: sanitized,
...(opts.world ? { world: opts.world } : {}),
+ }), ms, () => {
+ wedgedTabs.set(tabId, Date.now());
+ return wedgedError(tabId, ms);
});
return results[0]?.result;
} catch (err) {
+ if (err && err.code === 'TAB_WEDGED') throw err;
const msg = err?.message || String(err);
if (/cannot access|Cannot access|not allowed|No tab with id/i.test(msg)) {
- throw new Error(`Cannot execute on tab ${tabId}: ${msg}`);
+ throw new Error(`Cannot execute on tab ${tabId}: ${msg}`, { cause: err });
}
throw err;
}
}
+
+/**
+ * Run a page function that uses the shared DOM runtime (globalThis.__bcDom,
+ * lib/page-dom.js). Page functions start with
+ * `if (!globalThis.__bcDom) return { __needDom: true };`
+ * so the steady state costs one executeScript; the runtime is installed and
+ * the call repeated only when the document doesn't have it yet.
+ */
+export async function execDom(tabId, func, args = [], opts = {}) {
+ const res = await safeExec(tabId, func, args, opts);
+ if (!res || res.__needDom !== true) return res;
+ await safeExec(tabId, PAGE_DOM_INSTALL, [PAGE_DOM_VERSION], opts);
+ return safeExec(tabId, func, args, opts);
+}
diff --git a/extension/lib/protocol.js b/extension/lib/protocol.js
index 7e2fc69..2aaaafd 100644
--- a/extension/lib/protocol.js
+++ b/extension/lib/protocol.js
@@ -39,11 +39,15 @@ export function validateDaemonHello(message) {
return { ok: true, legacy: false };
}
-export function buildExtensionHelloAck(appVersion) {
+export function buildExtensionHelloAck(appVersion, identity = {}) {
return {
type: 'helloAck',
protocolVersion: PROTOCOL_VERSION,
...(appVersion ? { appVersion } : {}),
capabilities: EXTENSION_PROTOCOL_CAPABILITIES,
+ // Multi-browser: a stable id per browser profile (+ a human label) so the
+ // daemon can keep several browsers connected and route sessions to one.
+ ...(identity.browserId ? { browserId: identity.browserId } : {}),
+ ...(identity.browserLabel ? { browserLabel: identity.browserLabel } : {}),
};
}
diff --git a/extension/lib/router.js b/extension/lib/router.js
index 5eca362..16334e9 100644
--- a/extension/lib/router.js
+++ b/extension/lib/router.js
@@ -5,15 +5,17 @@
* dispatch() rebuilt the tool map on every call.
*/
import { runOnTab as runOnTabLib } from './tab-concurrency.js';
-import { tabLocks, tabMutex, observationSnapshots, persistSessionState } from './state.js';
+import { tabLocks, tabMutex, observationSnapshots, persistSessionState, wedgedTabs } from './state.js';
import { sendJson, updateBadge, broadcastStatus, isWsConnected, setCurrentActivity } from './connection.js';
import { showLockShield, hideLockShield } from './overlay.js';
import { getActiveTab, handleNavigate } from '../handlers/navigation.js';
import { handleClick, handleType, handlePressKey, handleHover, handleSelect, handleClickByText, handleDialog, handleDrag, handleFillForm } from '../handlers/interaction.js';
import { handleWait, handleScroll, handleSnapshot, handleFind, handleGetPageText, handleEvaluate } from '../handlers/inspection.js';
-import { handleTabs, handleConsole, handleNetwork, handleScreenshot } from '../handlers/tabs.js';
+import { handleTabs, handleConsole, handleNetwork, handleScreenshot, handleResizeWindow } from '../handlers/tabs.js';
import { handleRunAction, handleUploadFile } from '../handlers/cdp.js';
+import { handleIntercept } from '../handlers/intercept.js';
import { handleObserve, handleAct } from '../handlers/agent-api.js';
+import { handleGif, isRecording, recordFrame, GIF_FRAME_TOOLS } from '../handlers/gif.js';
// sessionId arrives as a first-class top-level field on the WS message (audit
// M1) — the daemon no longer injects it into params. We read it here so the
@@ -59,8 +61,11 @@ const HANDLERS = {
browser_fill_form: handleFillForm,
browser_find: handleFind,
browser_text: handleGetPageText,
+ browser_intercept: handleIntercept,
browser_observe: handleObserve,
browser_act: handleAct,
+ browser_resize_window: handleResizeWindow,
+ browser_gif: handleGif,
};
/** All tool names the router can dispatch (exported for the drift-guard test). */
@@ -72,6 +77,14 @@ export async function dispatch(tool, params, sessionId, agentName, signal) {
return handler(params, sessionId, agentName, signal);
}
+const OVERLAY_MS = 1_500;
+function overlayStep(promise) {
+ let timer;
+ return Promise.race([promise, new Promise((r) => { timer = setTimeout(r, OVERLAY_MS); })])
+ .catch(() => {})
+ .finally(() => clearTimeout(timer));
+}
+
function sendResponse(id, response) {
sendJson({ id, ...response });
}
@@ -84,10 +97,10 @@ function sendResponse(id, response) {
* can surface it verbatim instead of a bare message.
*/
function sendToolResponse(id, result) {
- if (result && typeof result === 'object' && result.success === false) {
+ if (result && typeof result === "object" && result.success === false) {
sendResponse(id, {
success: false,
- error: String(result.error || 'Tool failed'),
+ error: String(result.error || "Tool failed"),
result,
});
} else {
@@ -97,13 +110,13 @@ function sendToolResponse(id, result) {
/** Which tabId does this call target? null = tab-agnostic (tabs list/create). */
function extractTabId(_tool, params) {
- return typeof params.tabId === 'number' ? params.tabId : null;
+ return typeof params.tabId === "number" ? params.tabId : null;
}
export async function handleMessage(msg) {
// Control messages (non-tool) from the daemon. These carry a `type` and no
// `tool`; handle them here before the tool-dispatch path assumes a tool call.
- if (msg.type === 'releaseSession') {
+ if (msg.type === "releaseSession") {
// Session ids are unique even when two live clients share a display name.
// Releasing one session therefore cannot unlock its sibling's tabs.
const owner = msg.sessionId;
@@ -118,12 +131,14 @@ export async function handleMessage(msg) {
persistSessionState();
for (const tabId of released) hideLockShield(tabId);
if (released.length) {
- broadcastStatus(`Released ${released.length} lock(s) from disconnected agent ${owner}`);
+ broadcastStatus(
+ `Released ${released.length} lock(s) from disconnected agent ${owner}`,
+ );
}
}
return; // control message — no response expected
}
- if (msg.type === 'cancel') {
+ if (msg.type === "cancel") {
// The daemon/bridge aborted a call (client gone / timeout). Abort the
// in-flight handler so it short-circuits and releases the tab mutex NOW —
// otherwise a slow navigate (55s) blocks every later call on the same tab
@@ -132,11 +147,15 @@ export async function handleMessage(msg) {
// interrupted via the AbortSignal it was given.
const cancelledId = msg.id;
if (cancelledId && activeControllers.has(cancelledId)) {
- try { activeControllers.get(cancelledId).abort(); } catch { /* already settled */ }
+ try {
+ activeControllers.get(cancelledId).abort();
+ } catch {
+ /* already settled */
+ }
}
return; // control message — no response expected
}
- if (msg.type === 'ping') {
+ if (msg.type === "ping") {
// already handled in onmessage, but be defensive
return;
}
@@ -148,7 +167,7 @@ export async function handleMessage(msg) {
// Resolve navigate's documented active-tab fallback before lock/mutex routing.
// This freezes the target even if the user changes focus while the call waits.
- if (tool === 'browser_navigate' && typeof p.tabId !== 'number') {
+ if (tool === "browser_navigate" && typeof p.tabId !== "number") {
p.tabId = (await getActiveTab()).id;
}
const tabId = extractTabId(tool, p);
@@ -170,15 +189,34 @@ export async function handleMessage(msg) {
// and even handle_dialog. Close/focus retain their ownership checks inside
// handleTabs; dialog handling uses CDP directly and must reach the native
// prompt without waiting for page execution to settle.
- const bypassesMutex = (tool === 'browser_tabs' && (p.action === 'close' || p.action === 'focus'))
- || tool === 'browser_handle_dialog';
+ // A wedged tab's queue may still hold calls waiting on a frozen page:
+ // navigate/reload are the recovery path and need no page cooperation.
+ const bypassesMutex = (tool === 'browser_tabs' && (p.action === 'close' || p.action === 'focus' || p.action === 'reload'))
+ || tool === 'browser_handle_dialog'
+ || (tool === 'browser_navigate' && wedgedTabs.has(tabId));
+
+ // Skipping the queue must not skip lock ownership: runOnTab enforces it for
+ // queued calls, so the frozen-tab navigate path checks it here.
+ if (tool === 'browser_navigate' && bypassesMutex) {
+ const owner = tabLocks.owner(tabId);
+ if (owner && owner !== sessionId) {
+ sendResponse(id, { success: false, error: `Tab ${tabId} is locked by ${owner} — unlock it from that session first.` });
+ return;
+ }
+ }
// Tools without a tabId (tabs list/create, console-less) run directly.
if (tabId == null || bypassesMutex) {
const controller = new AbortController();
activeControllers.set(id, controller);
try {
- const result = await dispatch(tool, p, sessionId, agentName, controller.signal);
+ const result = await dispatch(
+ tool,
+ p,
+ sessionId,
+ agentName,
+ controller.signal,
+ );
sendToolResponse(id, result);
} catch (err) {
sendResponse(id, { success: false, error: err.message || String(err) });
@@ -203,10 +241,18 @@ export async function handleMessage(msg) {
// the AGENT (user request: "agent {name} controlling the tab"), not the
// running tool. agentName is a top-level WS field (audit M1); fall back
// to a generic label for anonymous direct-WS callers.
- await showLockShield(tabId, agentName ? `agent ${agentName} controlling the tab` : 'agent controlling the tab');
+ // Best effort and bounded: the shield is cosmetic, a frozen page must not block the tool.
+ if (!wedgedTabs.has(tabId)) {
+ await overlayStep(showLockShield(tabId, agentName ? `agent ${agentName} controlling the tab` : 'agent controlling the tab'));
+ }
try {
const result = await dispatch(tool, p, sessionId, agentName, controller.signal);
sendToolResponse(id, result);
+ // GIF recording: capture the page after the action (the reply is already
+ // sent; the tab mutex keeps the next call from racing the capture).
+ if (GIF_FRAME_TOOLS.has(tool) && isRecording(tabId) && !(result && result.success === false)) {
+ await recordFrame(tabId, tool, result);
+ }
} catch (err) {
sendResponse(id, { success: false, error: err.message || String(err) });
} finally {
@@ -214,8 +260,10 @@ export async function handleMessage(msg) {
updateBadge(isWsConnected() ? 'connected' : 'disconnected');
// A locked tab keeps a plain frame (no label) for the lock's lifetime;
// an unlocked tab loses the frame once this action completes.
- if (tabLocks.owner(tabId)) await showLockShield(tabId);
- else await hideLockShield(tabId);
+ if (!wedgedTabs.has(tabId)) {
+ if (tabLocks.owner(tabId)) await overlayStep(showLockShield(tabId));
+ else await overlayStep(hideLockShield(tabId));
+ }
}
},
)
diff --git a/extension/lib/state.js b/extension/lib/state.js
index 7068654..6e61ed7 100644
--- a/extension/lib/state.js
+++ b/extension/lib/state.js
@@ -33,6 +33,14 @@ export const networkByTab = new Map();
* @type {Map>}
*/
export const fallbackByTab = new Map();
+/**
+ * Tabs whose page stopped answering chrome.scripting (frozen main thread, a
+ * giant document). Map. While a tab is here every page call
+ * first probes it with a short timeout and fails fast with TAB_WEDGED instead
+ * of queueing behind executeScript calls that can never be aborted.
+ * Cleared when the tab starts a new navigation or is closed.
+ */
+export const wedgedTabs = new Map();
/**
* isNew feature: Map of "fingerprints" (role|name) from the
* PREVIOUS snapshot. The next snapshot marks any ref whose fingerprint isn't
@@ -65,7 +73,7 @@ export function pushCapped(arr, item, cap = PER_TAB_CAP) {
// --- MV3 session persistence (architecture item) ---------------------------
-const SESSION_STATE_KEY = 'bcSessionState';
+const SESSION_STATE_KEY = "bcSessionState";
/**
* Persist lock ownership + smart-selector fallbacks to chrome.storage.session.
@@ -104,12 +112,14 @@ export async function loadSessionState() {
// lock() refuses to steal: a stale entry for a tab another live session
// re-locked is impossible here (we're the only instance), but the guard
// costs nothing.
- try { tabLocks.lock(tabId, sessionId); } catch {}
+ try {
+ tabLocks.lock(tabId, sessionId);
+ } catch {}
}
}
- if (state.fallbacks && typeof state.fallbacks === 'object') {
+ if (state.fallbacks && typeof state.fallbacks === "object") {
for (const [tabId, entries] of Object.entries(state.fallbacks)) {
- if (entries && typeof entries === 'object') {
+ if (entries && typeof entries === "object") {
fallbackByTab.set(Number(tabId), new Map(Object.entries(entries)));
}
}
@@ -120,6 +130,7 @@ export async function loadSessionState() {
/** Invalidate document-bound refs without releasing the tab's durable lock. */
export function dropDocumentState(tabId) {
+ wedgedTabs.delete(tabId);
fallbackByTab.delete(tabId);
lastSnapshotFingerprints.delete(tabId);
observationSnapshots.invalidateTab(tabId);
@@ -127,6 +138,7 @@ export function dropDocumentState(tabId) {
/** Drop one tab's durable state (tab closed). */
export function dropTabState(tabId) {
+ wedgedTabs.delete(tabId);
consoleByTab.delete(tabId);
networkByTab.delete(tabId);
fallbackByTab.delete(tabId);
@@ -134,3 +146,12 @@ export function dropTabState(tabId) {
observationSnapshots.dropTab(tabId);
tabLocks.release(tabId);
}
+
+// Short refs ("s4k2-17"): a per-worker salt keeps refs from a recycled service
+// worker from colliding with live ones in the page registry.
+const REF_SALT = Math.random().toString(36).slice(2, 4);
+let refSeq = 0;
+export function nextRefPrefix(kind) {
+ refSeq = (refSeq + 1) % 1296;
+ return `${kind}${REF_SALT}${refSeq.toString(36)}-`;
+}
diff --git a/extension/lib/trusted-input.js b/extension/lib/trusted-input.js
index 1c8fd72..7ee78c6 100644
--- a/extension/lib/trusted-input.js
+++ b/extension/lib/trusted-input.js
@@ -9,7 +9,7 @@
* The synthetic path stays as the fallback when CDP can't attach.
*/
import { ensureCdp, ensureViewport, hasCdp } from './cdp-session.js';
-import { safeExec } from './page-exec.js';
+import { safeExec, execDom } from './page-exec.js';
const MOD_BITS = { alt: 1, ctrl: 2, meta: 4, shift: 8 };
@@ -107,42 +107,51 @@ export async function cdpClickAt(send, x, y, { button = 'left', clickCount = 1,
}
/**
- * Page-side: resolve the target (ref → selector → smart fallback, piercing
- * same-origin iframes), scroll it into view, optionally focus/select it, and
+ * Page-side: resolve the target through the shared DOM runtime (ref registry →
+ * first visible selector match across shadow roots and same-origin iframes →
+ * verified fallback), scroll it into view, optionally focus/select it, and
* return its centre in TOP-level viewport coordinates (what CDP expects).
* Also opens the lock shield for the agent's own trusted input for a few
* seconds, since trusted events are otherwise blocked by it.
* Kept self-contained: it is serialized into the page by chrome.scripting.
*/
function pageLocate(ref, sel, fb, mode) {
- function deepQuery(s) {
- const q = (doc, depth) => {
- try { const el = doc.querySelector(s); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d) { const el = q(d, depth + 1); if (el) return el; } } catch {}
- }
- return null;
- };
- return q(document, 0);
- }
- let el = ref ? deepQuery(`[data-mcp-ref="${ref}"]`) : null;
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ let el = null;
let via = 'ref';
- if (!el && sel) { el = deepQuery(sel); via = 'selector'; }
- const resolveFallback = (globalThis.__browserControllerFallbackRuntime || {}).resolveFallback || null;
- if (!el && fb && resolveFallback) { el = resolveFallback(fb); if (el) via = 'fallback'; }
- if (!el && mode === 'active') { el = document.activeElement; via = 'active'; }
- if (!el) return { success: false, error: 'REF_GONE', _ref: ref };
+ if (ref || sel || fb) {
+ const r = D.resolve(ref, sel, fb);
+ if (r.error === 'INVALID_SELECTOR') return { success: false, error: `Invalid CSS selector: ${sel}` };
+ if (r.el) { el = r.el; via = r.via; }
+ }
+ if (!el && (mode === 'active' || mode === 'focused' || mode === 'focused-clear')) {
+ el = document.activeElement;
+ // Descend into focused shadow roots / same-origin frames.
+ for (let i = 0; el && i < 10; i++) {
+ const s = D.shadowOf(el);
+ if (s && s.activeElement) { el = s.activeElement; continue; }
+ const d = D.frameDoc(el);
+ if (d && d.activeElement) { el = d.activeElement; continue; }
+ break;
+ }
+ via = 'active';
+ // type without a target needs a real field, not the page body.
+ if (mode !== 'active' && (!el || el === document.body || el === document.documentElement)) {
+ return { success: false, error: 'NO_FOCUS' };
+ }
+ }
+ if (!el) return { success: false, error: 'REF_GONE', _ref: ref, url: location.href };
// Agent input pass-through for the lock shield (see overlay.js).
window.__bcAgentInputUntil = Date.now() + 8000;
const shield = document.getElementById('__bc-lock-shield');
if (shield) shield.style.pointerEvents = 'none';
- if (mode !== 'active') el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
- if (mode === 'focus' || mode === 'clear') {
+ if (via !== 'active') el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
+ if (mode === 'focus' || mode === 'clear' || mode === 'focused-clear') {
if (typeof el.focus === 'function') el.focus();
- if (mode === 'clear') {
+ if (mode === 'clear' || mode === 'focused-clear') {
if (el.isContentEditable) {
const r = el.ownerDocument.createRange();
r.selectNodeContents(el);
@@ -155,32 +164,22 @@ function pageLocate(ref, sel, fb, mode) {
}
}
- const rect = el.getBoundingClientRect();
- let x = rect.left + rect.width / 2;
- let y = rect.top + rect.height / 2;
- // Add the offsets of every enclosing same-origin iframe.
- let win = el.ownerDocument.defaultView;
- while (win && win !== window && win.frameElement) {
- const fr = win.frameElement.getBoundingClientRect();
- const cs = win.frameElement.ownerDocument.defaultView.getComputedStyle(win.frameElement);
- x += fr.left + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.paddingLeft) || 0);
- y += fr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0);
- win = win.parent;
+ const { x, y, rect } = D.centerOf(el);
+ let focusedEl = el.ownerDocument.activeElement;
+ for (let i = 0; focusedEl && i < 10; i++) {
+ const s = D.shadowOf(focusedEl);
+ if (s && s.activeElement) focusedEl = s.activeElement; else break;
}
- const doc = el.ownerDocument;
- const focused = doc.activeElement === el || (el.contains && el.contains(doc.activeElement));
+ const focused = focusedEl === el || D.composedContains(el, focusedEl);
const hasValue = 'value' in el && !el.isContentEditable && typeof el.value === 'string';
let fullySelected = false;
try { fullySelected = el.selectionStart === 0 && el.selectionEnd === el.value.length; } catch { /* number/email inputs */ }
- // What a real click at (x, y) would hit (top document only).
+ // What a real click at (x, y) would hit (pierces shadow roots and same-origin frames).
let occludedBy = null;
- if (win === window || !el.ownerDocument.defaultView.frameElement) {
- const hit = document.elementFromPoint(x, y);
- if (hit && hit !== el && !el.contains(hit) && !hit.contains(el)) {
- occludedBy = hit.tagName.toLowerCase() + (hit.id ? `#${hit.id}` : '')
- + (typeof hit.className === 'string' && hit.className.trim() ? `.${hit.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '');
- }
- }
+ try {
+ const hit = D.elementAt(x, y);
+ if (hit && !D.composedContains(el, hit) && !D.composedContains(hit, el)) occludedBy = D.describe(hit);
+ } catch { /* detached mid-measure */ }
return {
success: true,
x, y,
@@ -192,6 +191,8 @@ function pageLocate(ref, sel, fb, mode) {
hasText: hasValue ? el.value.length > 0 : (el.textContent || '').length > 0,
...(occludedBy ? { occludedBy } : {}),
...(via !== 'ref' ? { via } : {}),
+ // A fallback re-resolution says what it actually hit, so a wrong guess is visible.
+ ...(via === 'fallback' ? { target: { role: D.roleOf(el), name: D.nameOf(el).slice(0, 60) } } : {}),
};
}
@@ -201,7 +202,9 @@ function pageRelease() {
const shield = document.getElementById('__bc-lock-shield');
if (shield) shield.style.pointerEvents = 'auto';
let a = document.activeElement;
- while (a && a.tagName === 'IFRAME') {
+ for (let i = 0; a && i < 10; i++) {
+ if (a.shadowRoot && a.shadowRoot.activeElement) { a = a.shadowRoot.activeElement; continue; }
+ if (a.tagName !== 'IFRAME') break;
try { a = a.contentDocument.activeElement; } catch { break; }
}
if (!a || a === document.body) return { value: null };
@@ -209,12 +212,42 @@ function pageRelease() {
return { value: value == null ? null : value.slice(0, 500), focusedTag: a.tagName.toLowerCase() + (a.id ? `#${a.id}` : '') };
}
+/**
+ * Page-side: what is at a top-level viewport point (pierces shadow roots and
+ * same-origin frames), and open the shield pass-through for the agent's input.
+ */
+function pagePointInfo(x, y) {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ window.__bcAgentInputUntil = Date.now() + 8000;
+ const shield = document.getElementById('__bc-lock-shield');
+ if (shield) shield.style.pointerEvents = 'none';
+ const inView = x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight;
+ const el = D.elementAt(x, y);
+ if (!el) return { inView };
+ // Report the control that owns the point (e.g. the around an ).
+ let owner = el;
+ for (let cur = el, i = 0; cur && i < 6; i++) {
+ if (D.isInteractive(cur)) { owner = cur; break; }
+ let r = null;
+ try { r = cur.getRootNode(); } catch {}
+ cur = cur.parentElement || (r && r.host) || null;
+ }
+ const name = D.nameOf(owner).slice(0, 60);
+ return { inView, hit: { role: D.roleOf(owner), ...(name ? { name } : {}), tag: owner.tagName.toLowerCase() } };
+}
+
+/** Describe the element at (x, y) and let trusted input through the shield. */
+export async function pointInfo(tabId, x, y) {
+ try { return (await execDom(tabId, pagePointInfo, [x, y])) || {}; } catch { return {}; /* protected page: input still works */ }
+}
+
export async function locateTarget(tabId, { ref, selector, fb, mode = 'none' }) {
- const loc = await safeExec(tabId, pageLocate, [ref, selector, fb, mode]);
+ const loc = await execDom(tabId, pageLocate, [ref, selector, fb, mode]);
// Never-shown background tab: size its viewport, then measure again.
if (loc?.success && loc.zeroViewport && hasCdp(tabId)) {
await ensureViewport(tabId).catch(() => {});
- return safeExec(tabId, pageLocate, [ref, selector, fb, mode]);
+ return execDom(tabId, pageLocate, [ref, selector, fb, mode]);
}
return loc;
}
diff --git a/extension/manifest.json b/extension/manifest.json
index f6954e1..e94b820 100644
--- a/extension/manifest.json
+++ b/extension/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Browser Controller",
- "version": "2.3.0",
+ "version": "2.4.0",
"description": "Let AI agents control your real browser - your tabs, your sessions, your logins",
"icons": {
"16": "icons/icon16.png",
@@ -14,13 +14,14 @@
"scripting",
"debugger",
"webRequest",
+ "declarativeNetRequest",
+ "declarativeNetRequestFeedback",
"storage",
"alarms"
],
"host_permissions": [
"",
- "http://127.0.0.1:7225/*",
- "http://localhost:7225/*"
+ "http://127.0.0.1:7225/*"
],
"background": {
"service_worker": "background.js",
@@ -28,10 +29,21 @@
},
"content_scripts": [
{
- "matches": [""],
- "js": ["content.js"],
+ "matches": [
+ ""
+ ],
+ "js": [
+ "content.js"
+ ],
"run_at": "document_start",
"all_frames": true
+ },
+ {
+ "matches": [""],
+ "js": ["console-main.js"],
+ "run_at": "document_start",
+ "all_frames": true,
+ "world": "MAIN"
}
],
"action": {
diff --git a/mcp-server/src/bridge-connections.ts b/mcp-server/src/bridge-connections.ts
new file mode 100644
index 0000000..1f4c701
--- /dev/null
+++ b/mcp-server/src/bridge-connections.ts
@@ -0,0 +1,120 @@
+import { WebSocket } from 'ws';
+
+/**
+ * One connected extension = one browser (profile). Several can be connected
+ * at once (multi-browser); a reconnect of the same browser replaces its old
+ * socket. Legacy extensions that send no browserId all count as "default".
+ */
+export interface ExtensionConnection {
+ ws: WebSocket;
+ browserId: string;
+ label: string;
+ state: 'pending' | 'ready' | 'legacy' | 'incompatible';
+ handshakeTimer: ReturnType | null;
+ missedPongs: number;
+ connectedAt: number;
+}
+
+/** Tools the bridge answers itself (browser selection), never forwarded to an extension. */
+export const BRIDGE_TOOLS = new Set(['browser_list_browsers', 'browser_select_browser']);
+
+export function newConnection(ws: WebSocket): ExtensionConnection {
+ return {
+ ws, browserId: 'default', label: 'default', state: 'pending',
+ handshakeTimer: null, missedPongs: 0, connectedAt: Date.now(),
+ };
+}
+
+/** Normalised identity from an extension helloAck (absent = legacy "default"). */
+export function identityOf(info?: { browserId?: unknown; browserLabel?: unknown }): { browserId: string; label: string } {
+ const browserId = typeof info?.browserId === 'string' && info.browserId.trim() ? info.browserId.trim().slice(0, 64) : 'default';
+ const label = typeof info?.browserLabel === 'string' && info.browserLabel.trim() ? info.browserLabel.trim().slice(0, 120) : browserId;
+ return { browserId, label };
+}
+
+/**
+ * The set of extension connections plus each session's browser choice.
+ * Routing: a session's selected browser, else the default — the most
+ * recently connected live browser (so one browser behaves exactly as before).
+ */
+export class ExtensionConnections {
+ private conns = new Set();
+ /** sessionId -> browserId chosen with browser_select_browser. */
+ private sessionBrowser = new Map();
+
+ add(conn: ExtensionConnection): void { this.conns.add(conn); }
+ has(conn: ExtensionConnection): boolean { return this.conns.has(conn); }
+ delete(conn: ExtensionConnection): boolean { return this.conns.delete(conn); }
+ all(): ExtensionConnection[] { return [...this.conns]; }
+
+ static isLive(conn: ExtensionConnection): boolean {
+ return (conn.state === 'ready' || conn.state === 'legacy') && conn.ws.readyState === WebSocket.OPEN;
+ }
+
+ live(): ExtensionConnection[] {
+ return this.all().filter((c) => ExtensionConnections.isLive(c));
+ }
+
+ /** The default browser: the most recently connected live one. */
+ primary(): ExtensionConnection | null {
+ let best: ExtensionConnection | null = null;
+ for (const c of this.live()) if (!best || c.connectedAt >= best.connectedAt) best = c;
+ return best;
+ }
+
+ /** Where a session's calls go: its selected browser, else the default one. */
+ forSession(sessionId?: string): ExtensionConnection {
+ const want = sessionId ? this.sessionBrowser.get(sessionId) : undefined;
+ if (want) {
+ const chosen = this.live().find((c) => c.browserId === want);
+ if (chosen) return chosen;
+ throw new Error(`Selected browser "${want}" is not connected. browser_list_browsers shows the connected ones (browser_select_browser "auto" = default).`);
+ }
+ const primary = this.primary();
+ if (!primary) throw new Error('Chrome extension not connected. Make sure the Browser Controller extension is installed and enabled.');
+ return primary;
+ }
+
+ /** Other sockets of the same browser (a reconnect replaces them). */
+ siblingsOf(conn: ExtensionConnection): ExtensionConnection[] {
+ return this.all().filter((c) => c !== conn && c.browserId === conn.browserId);
+ }
+
+ /** browser_list_browsers: every connected extension, with this session's choice. */
+ list(sessionId?: string): Record {
+ const primary = this.primary();
+ const selected = sessionId ? this.sessionBrowser.get(sessionId) : undefined;
+ return {
+ success: true,
+ browsers: this.live().map((c) => ({
+ browserId: c.browserId,
+ label: c.label,
+ connectedAt: new Date(c.connectedAt).toISOString(),
+ ...(c === primary ? { default: true } : {}),
+ ...((selected ? selected === c.browserId : c === primary) ? { selected: true } : {}),
+ })),
+ ...(selected ? { selectedBrowserId: selected } : {}),
+ };
+ }
+
+ /** browser_select_browser: route this session's calls to one browser ("auto" = default). */
+ select(sessionId: string | undefined, browserId: unknown): Record {
+ if (!sessionId) throw new Error('Selecting a browser needs a client session (connect through the Browser Controller MCP server).');
+ const id = typeof browserId === 'string' ? browserId.trim() : '';
+ if (!id || id === 'auto') {
+ this.sessionBrowser.delete(sessionId);
+ return { success: true, selected: 'auto', browserId: this.primary()?.browserId ?? null };
+ }
+ const conn = this.live().find((c) => c.browserId === id || c.label === id);
+ if (!conn) throw new Error(`No connected browser "${id}". browser_list_browsers shows the connected ones.`);
+ this.sessionBrowser.set(sessionId, conn.browserId);
+ return { success: true, selected: conn.browserId, label: conn.label };
+ }
+
+ releaseSession(sessionId: string): void { this.sessionBrowser.delete(sessionId); }
+
+ clear(): void {
+ this.conns.clear();
+ this.sessionBrowser.clear();
+ }
+}
diff --git a/mcp-server/src/bridge.ts b/mcp-server/src/bridge.ts
index a8e11c9..efb3789 100644
--- a/mcp-server/src/bridge.ts
+++ b/mcp-server/src/bridge.ts
@@ -15,8 +15,15 @@ import {
isDaemonResponsiveOnPort,
tokensMatch,
} from './bridge-security.js';
+import {
+ ExtensionConnections,
+ identityOf,
+ newConnection,
+ type ExtensionConnection,
+} from './bridge-connections.js';
export { isDaemonResponsiveOnPort } from './bridge-security.js';
+export { BRIDGE_TOOLS } from './bridge-connections.js';
/** Handler for daemon-owned HTTP endpoints served on the bridge port. */
export type HttpRequestHandler = (
@@ -39,6 +46,8 @@ interface PendingRequest {
/** Abort listener registered for this request (audit C2); removed on settle. */
onAbort?: (() => void) | null;
signal?: AbortSignal | null;
+ /** The extension connection (browser) this call was sent to. */
+ conn?: ExtensionConnection;
}
interface BridgeOptions {
@@ -64,7 +73,7 @@ interface BridgeOptions {
export class ExtensionBridge {
private httpServer: http.Server | null = null;
private wss: WebSocketServer | null = null;
- private client: WebSocket | null = null;
+ private conns = new ExtensionConnections();
private pendingRequests = new Map();
private requestId = 0;
private port: number;
@@ -76,11 +85,9 @@ export class ExtensionBridge {
private defaultTimeoutMs: number;
private maxWsPayloadBytes: number;
private handshakeGraceMs: number;
- private handshakeState: 'disconnected' | 'pending' | 'ready' | 'legacy' | 'incompatible' = 'disconnected';
+ /** Reason of the latest failed extension handshake, until some browser connects fine. */
private handshakeError: string | null = null;
- private handshakeTimer: ReturnType | null = null;
private pingTimer: ReturnType | null = null;
- private missedPongs = 0;
private connectionWaiters: Array<{ resolve: () => void; reject: (err: Error) => void }> = [];
/** Optional HTTP handler (set by the daemon) for /pair, /status, etc. */
private httpHandler: HttpRequestHandler | null = null;
@@ -106,30 +113,47 @@ export class ExtensionBridge {
this.handshakeGraceMs = options.handshakeGraceMs ?? 25;
}
- private clearHandshakeTimer(): void {
- if (this.handshakeTimer) clearTimeout(this.handshakeTimer);
- this.handshakeTimer = null;
- }
-
- private markExtensionReady(state: 'ready' | 'legacy'): void {
- this.clearHandshakeTimer();
- this.handshakeState = state;
+ private markExtensionReady(conn: ExtensionConnection, state: 'ready' | 'legacy', info?: { browserId?: unknown; browserLabel?: unknown }): void {
+ if (conn.handshakeTimer) clearTimeout(conn.handshakeTimer);
+ conn.handshakeTimer = null;
+ conn.state = state;
+ conn.missedPongs = 0;
+ const { browserId, label } = identityOf(info);
+ conn.browserId = browserId;
+ conn.label = label;
+ // A reconnect of the same browser replaces its old socket.
+ for (const other of this.conns.siblingsOf(conn)) {
+ this.dropConn(other, 'Extension reconnected');
+ try { other.ws.close(); } catch { /* already closing */ }
+ }
this.handshakeError = null;
- this.missedPongs = 0;
this.startPingLoop();
this.connectionWaiters.forEach((waiter) => waiter.resolve());
this.connectionWaiters = [];
- console.error(`[Bridge] Extension connected (${state} protocol)`);
+ console.error(`[Bridge] Extension connected (${state} protocol, browser ${conn.label})`);
}
- private rejectExtensionHandshake(reason: string): void {
- this.clearHandshakeTimer();
- this.handshakeState = 'incompatible';
+ private rejectExtensionHandshake(conn: ExtensionConnection, reason: string): void {
+ if (conn.handshakeTimer) clearTimeout(conn.handshakeTimer);
+ conn.handshakeTimer = null;
+ conn.state = 'incompatible';
this.handshakeError = reason;
- const error = new Error(reason);
- this.connectionWaiters.forEach((waiter) => waiter.reject(error));
- this.connectionWaiters = [];
- this.rejectAllPending(reason);
+ if (this.conns.live().length === 0) {
+ const error = new Error(reason);
+ this.connectionWaiters.forEach((waiter) => waiter.reject(error));
+ this.connectionWaiters = [];
+ }
+ this.rejectAllPending(reason, conn);
+ }
+
+ /** Forget a connection and fail the calls that were waiting on it. */
+ private dropConn(conn: ExtensionConnection, reason: string): void {
+ if (!this.conns.has(conn)) return;
+ this.conns.delete(conn);
+ if (conn.handshakeTimer) clearTimeout(conn.handshakeTimer);
+ conn.handshakeTimer = null;
+ this.rejectAllPending(reason, conn);
+ if (this.conns.live().length === 0) this.stopPingLoop();
}
/**
@@ -160,6 +184,7 @@ export class ExtensionBridge {
throw new Error(
`Cannot listen on ${this.host}:${this.port}: ${owner} already owns the port. ` +
'Refusing to terminate another process automatically.',
+ { cause: err },
);
}
}
@@ -284,16 +309,11 @@ export class ExtensionBridge {
});
});
- this.wss.on('connection', (ws: WebSocket, req) => {
- if (this.client && this.client.readyState === WebSocket.OPEN) {
- this.client.close();
- }
-
- this.client = ws;
- this.clearHandshakeTimer();
- this.handshakeState = 'pending';
- this.handshakeError = null;
- this.missedPongs = 0;
+ this.wss.on('connection', (ws: WebSocket, _req) => {
+ // Every socket is its own connection (browser). A reconnect of the same
+ // browser replaces the old socket once the new one finished its handshake.
+ const conn = newConnection(ws);
+ this.conns.add(conn);
ws.on('message', (data: Buffer) => {
try {
@@ -303,15 +323,15 @@ export class ExtensionBridge {
const capabilities = validateCapabilities(msg.capabilities, ['tool-dispatch', 'ping-pong']);
if (!version.ok || !capabilities.ok) {
const reason = version.reason || capabilities.reason || 'Extension protocol handshake failed.';
- this.rejectExtensionHandshake(reason);
+ this.rejectExtensionHandshake(conn, reason);
ws.close(1002, 'incompatible protocol');
return;
}
- this.markExtensionReady(version.legacy || capabilities.legacy ? 'legacy' : 'ready');
+ this.markExtensionReady(conn, version.legacy || capabilities.legacy ? 'legacy' : 'ready', msg);
return;
}
if (msg.type === 'pong') {
- this.missedPongs = 0;
+ conn.missedPongs = 0;
return;
}
this.handleResponse(msg);
@@ -321,16 +341,9 @@ export class ExtensionBridge {
});
ws.on('close', () => {
- if (this.client !== ws) return;
- console.error('[Bridge] Extension disconnected');
- this.client = null;
- this.clearHandshakeTimer();
- if (this.handshakeState !== 'incompatible') {
- this.handshakeState = 'disconnected';
- this.handshakeError = null;
- }
- this.stopPingLoop();
- this.rejectAllPending('Extension disconnected');
+ if (!this.conns.has(conn)) return; // replaced or already dropped
+ console.error(`[Bridge] Extension disconnected (browser ${conn.label})`);
+ this.dropConn(conn, 'Extension disconnected');
});
ws.on('error', (err: Error) => {
@@ -340,11 +353,11 @@ export class ExtensionBridge {
// Modern extensions acknowledge immediately. The short fallback keeps
// pre-handshake extension builds usable during a rolling local upgrade.
setTimeout(() => {
- if (this.client !== ws || ws.readyState !== WebSocket.OPEN) return;
+ if (!this.conns.has(conn) || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(buildExtensionHello(APP_VERSION)));
- this.handshakeTimer = setTimeout(() => {
- if (this.client === ws && this.handshakeState === 'pending') {
- this.markExtensionReady('legacy');
+ conn.handshakeTimer = setTimeout(() => {
+ if (this.conns.has(conn) && conn.state === 'pending') {
+ this.markExtensionReady(conn, 'legacy');
}
}, this.handshakeGraceMs);
}, 0);
@@ -367,14 +380,15 @@ export class ExtensionBridge {
private startPingLoop(): void {
this.stopPingLoop();
this.pingTimer = setInterval(() => {
- if (!this.isConnected()) return;
- this.missedPongs++;
- if (this.missedPongs >= 3) {
- console.error('[Bridge] Extension unresponsive (3 missed pongs), closing');
- this.client?.close();
- return;
+ for (const conn of this.conns.live()) {
+ conn.missedPongs++;
+ if (conn.missedPongs >= 3) {
+ console.error(`[Bridge] Extension unresponsive (3 missed pongs), closing (browser ${conn.label})`);
+ conn.ws.close();
+ continue;
+ }
+ try { conn.ws.send(JSON.stringify({ type: 'ping' })); } catch { /* close path handles it */ }
}
- this.client?.send(JSON.stringify({ type: 'ping' }));
}, this.pingIntervalMs);
}
@@ -406,9 +420,7 @@ export class ExtensionBridge {
}
isConnected(): boolean {
- return this.client !== null
- && this.client.readyState === WebSocket.OPEN
- && (this.handshakeState === 'ready' || this.handshakeState === 'legacy');
+ return this.conns.live().length > 0;
}
/**
@@ -417,11 +429,15 @@ export class ExtensionBridge {
* forget: control messages carry no reply. Used by the daemon's close handler.
*/
sendControl(type: string, payload: Record = {}): void {
- if (!this.isConnected()) return; // extension gone — nothing to notify
- try {
- this.client!.send(JSON.stringify({ type, ...payload }));
- } catch {
- // socket gone — close path will fire
+ // A gone session no longer has a browser choice.
+ if (type === 'releaseSession' && typeof payload.sessionId === 'string') this.conns.releaseSession(payload.sessionId);
+ // Every browser gets control messages (session release, cancel of an id it may own).
+ for (const conn of this.conns.live()) {
+ try {
+ conn.ws.send(JSON.stringify({ type, ...payload }));
+ } catch {
+ // socket gone — close path will fire
+ }
}
}
@@ -451,14 +467,17 @@ export class ExtensionBridge {
}
async callTool(tool: string, params: Record, sessionId?: string, signal?: AbortSignal, agentName?: string): Promise {
+ // Browser selection is answered here, not by an extension.
+ if (tool === 'browser_list_browsers') return this.conns.list(sessionId);
+ if (tool === 'browser_select_browser') return this.conns.select(sessionId, params.browserId);
if (!this.isConnected()) {
try {
await this.waitForConnection(5_000);
} catch (error) {
- if (this.handshakeError) throw new Error(this.handshakeError);
+ if (this.handshakeError) throw new Error(this.handshakeError, { cause: error });
throw new Error(error instanceof Error && /protocol|capabilit/i.test(error.message)
? error.message
- : 'Chrome extension not connected. Make sure the Browser Controller extension is installed and enabled.');
+ : 'Chrome extension not connected. Make sure the Browser Controller extension is installed and enabled.', { cause: error });
}
}
@@ -471,6 +490,12 @@ export class ExtensionBridge {
if (signal?.aborted) {
return Promise.reject(new Error(`Call aborted before send: ${tool}`));
}
+ let conn: ExtensionConnection;
+ try {
+ conn = this.conns.forSession(sessionId);
+ } catch (err) {
+ return Promise.reject(err);
+ }
return new Promise((resolve, reject) => {
const id = String(++this.requestId);
// Timeout policy (audit M3): the tool registry is the single source of
@@ -523,14 +548,14 @@ export class ExtensionBridge {
signal.addEventListener('abort', onAbort, { once: true });
}
- this.pendingRequests.set(id, { resolve, reject, timeout, tool, retries: retryCount, params, onAbort, signal });
+ this.pendingRequests.set(id, { resolve, reject, timeout, tool, retries: retryCount, params, onAbort, signal, conn });
try {
// sessionId + agentName travel as top-level WS fields (audit M1), not
// injected into params — the daemon stays a pure {tool, params} multiplexer.
// agentName is the STABLE identity for tab locks (survives reconnects);
// sessionId is transient (s3→s4) and used only for logging/UI.
- this.client!.send(JSON.stringify({ id, tool, params, sessionId, agentName }));
+ conn.ws.send(JSON.stringify({ id, tool, params, sessionId, agentName }));
} catch (err) {
clearTimeout(timeout);
if (signal) signal.removeEventListener('abort', onAbort);
@@ -544,8 +569,10 @@ export class ExtensionBridge {
});
}
- private rejectAllPending(reason: string): void {
+ /** Fail waiting calls: all of them, or only those sent to one browser. */
+ private rejectAllPending(reason: string, onlyConn?: ExtensionConnection): void {
for (const [id, pending] of this.pendingRequests) {
+ if (onlyConn && pending.conn !== onlyConn) continue;
clearTimeout(pending.timeout);
if (pending.onAbort && pending.signal) pending.signal.removeEventListener('abort', pending.onAbort);
pending.reject(new Error(reason));
@@ -554,13 +581,15 @@ export class ExtensionBridge {
}
stop(): void {
- this.clearHandshakeTimer();
this.stopPingLoop();
this.rejectAllPending('Server shutting down');
this.connectionWaiters.forEach(w => w.reject(new Error('Server shutting down')));
this.connectionWaiters = [];
- this.client?.close();
- this.client = null;
+ for (const conn of this.conns.all()) {
+ if (conn.handshakeTimer) clearTimeout(conn.handshakeTimer);
+ try { conn.ws.close(); } catch { /* already closed */ }
+ }
+ this.conns.clear();
this.wss?.close();
this.wss = null;
this.httpServer?.close();
diff --git a/mcp-server/src/daemon-config.ts b/mcp-server/src/daemon-config.ts
index 3c6b1c2..0a06b4f 100644
--- a/mcp-server/src/daemon-config.ts
+++ b/mcp-server/src/daemon-config.ts
@@ -48,13 +48,17 @@ export function envInt(name: string, def: number, min = 1, max?: number): number
}
export const DEFAULT_WS_PORT = envInt('WS_PORT', 7225, 1, 65535);
-export const DEFAULT_WS_HOST = process.env.WS_HOST || '127.0.0.1';
-
/**
- * Directory under the user's home where daemon state lives (token, socket,
- * daemon.json, daemon.log). Override with BC_STATE_DIR for isolated tests so
- * the suite never touches the real ~/.browser-controller.
+ * The daemon's HTTP/WebSocket control plane is intentionally loopback-only.
+ *
+ * Do not make this configurable to 0.0.0.0: the extension-facing token gate is
+ * useful for local pairing, but it is not a replacement for a network trust
+ * boundary. Remote callers (including n8n) require a separately authenticated
+ * transport and must not be given direct access to this browser socket.
*/
+export const DEFAULT_WS_HOST = '127.0.0.1';
+
+/** Directory under the user's home where daemon state lives. */
export const STATE_DIR = process.env.BC_STATE_DIR || path.join(os.homedir(), '.browser-controller');
/** Local IPC socket the daemon listens on (thin clients connect here). */
@@ -63,9 +67,43 @@ export const IPC_SOCKET_PATH =
? '\\\\.\\pipe\\browser-controller'
: path.join(STATE_DIR, 'daemon.sock');
-/** Daemon metadata file: { pid, socket, port, startedAt }. */
+/** Daemon metadata file. */
export const DAEMON_INFO_FILE = path.join(STATE_DIR, 'daemon.json');
+/** Daemon ownership lock; a live PID identifies the authoritative runtime. */
+export const DAEMON_LOCK_FILE = path.join(STATE_DIR, 'daemon.lock');
+
+/** Acquire the runtime lock, removing only a demonstrably stale lock. */
+export function acquireDaemonLock(): void {
+ fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
+ for (;;) {
+ try {
+ const fd = fs.openSync(DAEMON_LOCK_FILE, 'wx', 0o600);
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
+ fs.closeSync(fd);
+ return;
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
+ let ownerPid = 0;
+ try { ownerPid = JSON.parse(fs.readFileSync(DAEMON_LOCK_FILE, 'utf8')).pid ?? 0; } catch { /* stale/corrupt */ }
+ let alive = false;
+ if (ownerPid > 0) {
+ try { process.kill(ownerPid, 0); alive = true; } catch { /* not running */ }
+ }
+ if (alive) throw new Error(`Browser Controller daemon lock is held by live process ${ownerPid}`, { cause: err });
+ try { fs.unlinkSync(DAEMON_LOCK_FILE); } catch { /* raced with cleanup */ }
+ }
+ }
+}
+
+export function releaseDaemonLock(): void {
+ try {
+ if (JSON.parse(fs.readFileSync(DAEMON_LOCK_FILE, 'utf8')).pid === process.pid) {
+ fs.unlinkSync(DAEMON_LOCK_FILE);
+ }
+ } catch { /* already gone */ }
+}
+
/** Auth token file (3.1): both IPC clients and the extension must present it. */
export const TOKEN_FILE = path.join(STATE_DIR, 'token.json');
diff --git a/mcp-server/src/daemon.ts b/mcp-server/src/daemon.ts
index 44ee91c..c5e645c 100644
--- a/mcp-server/src/daemon.ts
+++ b/mcp-server/src/daemon.ts
@@ -28,6 +28,8 @@ import {
DEFAULT_WS_HOST,
DEFAULT_WS_PORT,
DAEMON_INFO_FILE,
+ acquireDaemonLock,
+ releaseDaemonLock,
ENROLLMENT_FILE,
IPC_SOCKET_PATH,
STATE_DIR,
@@ -118,38 +120,33 @@ class Daemon {
async start(): Promise {
fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
+ acquireDaemonLock();
- // 1) extension-facing WS server (task 1.0). The bridge handles the stale-
- // port eviction already (lsof replaced by net-based probe in bridge).
- await this.bridge.start();
-
- // 1b) HTTP endpoints on the SAME port (bridge shares it). The popup uses
- // these to auto-pair the token and to show connected agents — without
- // them the extension (no fs access) could never read the token.
- this.bridge.registerHttpHandler((req, url) => this.handleHttp(req, url));
-
- // 2) IPC server for thin MCP clients.
- this.ipcServer = this.createIpcServer();
-
- // 2b) heartbeat: evict half-open IPC sockets so the popup's "Connected
- // Agents" list doesn't accumulate zombies from killed IDE processes.
- this.heartbeatTimer = this.startHeartbeat();
-
- // 3) write daemon info so thin clients can find / healthcheck us.
- this.writeDaemonInfo();
-
- console.error(`[${SERVER_NAME}] listening. WS=${DEFAULT_WS_HOST}:${DEFAULT_WS_PORT} IPC=${IPC_SOCKET_PATH}`);
- console.error(`[${SERVER_NAME}] auth token at ${TOKEN_FILE}`);
- console.error(`[${SERVER_NAME}] enrollment secret stored at ${ENROLLMENT_FILE}`);
-
- // graceful shutdown
- const shutdown = (sig: string) => {
- console.error(`[${SERVER_NAME}] ${sig} received, shutting down`);
+ try {
+ // 1) extension-facing WS server (task 1.0).
+ await this.bridge.start();
+ this.bridge.registerHttpHandler((req, url) => this.handleHttp(req, url));
+
+ // 2) IPC server for thin MCP clients.
+ this.ipcServer = this.createIpcServer();
+ this.heartbeatTimer = this.startHeartbeat();
+ this.writeDaemonInfo();
+
+ console.error(`[${SERVER_NAME}] listening. WS=${DEFAULT_WS_HOST}:${DEFAULT_WS_PORT} IPC=${IPC_SOCKET_PATH}`);
+ console.error(`[${SERVER_NAME}] auth token at ${TOKEN_FILE}`);
+ console.error(`[${SERVER_NAME}] enrollment secret stored at ${ENROLLMENT_FILE}`);
+
+ const shutdown = (sig: string) => {
+ console.error(`[${SERVER_NAME}] ${sig} received, shutting down`);
+ this.stop();
+ process.exit(0);
+ };
+ process.on('SIGINT', () => shutdown('SIGINT'));
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ } catch (err) {
this.stop();
- process.exit(0);
- };
- process.on('SIGINT', () => shutdown('SIGINT'));
- process.on('SIGTERM', () => shutdown('SIGTERM'));
+ throw err;
+ }
}
private createIpcServer(): net.Server {
@@ -459,6 +456,8 @@ class Daemon {
extension: { connected: this.bridge.isConnected(), since: null },
agents: this.agents(),
uptimeMs: Date.now() - this.startedAt,
+ // Lets lifecycle tools confirm a pid really is this daemon.
+ pid: process.pid,
};
}
return undefined; // bridge returns 404
@@ -511,6 +510,7 @@ class Daemon {
try {
fs.unlinkSync(DAEMON_INFO_FILE);
} catch {}
+ releaseDaemonLock();
}
}
diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts
index 60d1273..8770c68 100644
--- a/mcp-server/src/index.ts
+++ b/mcp-server/src/index.ts
@@ -70,6 +70,8 @@ const SERVER_VERSION = APP_VERSION;
const DAEMON_STARTUP_MS = 8_000;
const CONNECT_RETRY_MS = 250;
const MAX_CONNECT_TRIES = 32; // ~8s
+const DAEMON_MODE = (process.env.BROWSER_CONTROLLER_DAEMON_MODE ?? 'spawn').trim().toLowerCase();
+const CONNECT_ONLY_DAEMON = DAEMON_MODE === 'connect' || DAEMON_MODE === 'managed';
/**
* Daemon connection: a line-delimited JSON socket speaking the IPC protocol
@@ -306,8 +308,9 @@ async function daemonLooksAlive(): Promise {
if (!fs.existsSync(DAEMON_INFO_FILE)) return false;
const info = JSON.parse(fs.readFileSync(DAEMON_INFO_FILE, 'utf8'));
const ageMs = Date.now() - info.startedAt;
- if (ageMs > 60 * 60 * 1000) return false;
+ // The active socket probe is the source of truth. A long-lived healthy daemon
+ // must not be treated as stale solely because daemon.json is older than 1 hour.
if (await connectProbe()) return true;
// First probe failed. If the daemon is brand new (< 10s old) it may simply
@@ -412,10 +415,18 @@ async function main(): Promise {
console.error(`[${SERVER_NAME}] enrollment secret (enter in the popup once): ${enrollment}`);
}
- // 2) ensure daemon is up
+ // 2) ensure daemon is up. Desktop/stdio clients default to self-managed
+ // spawning. Long-running supervisors (systemd, launchd, containers) can set
+ // BROWSER_CONTROLLER_DAEMON_MODE=connect so this process never competes for
+ // daemon lifecycle ownership.
if (!(await daemonLooksAlive())) {
- spawnDaemon();
- await waitForDaemon();
+ if (CONNECT_ONLY_DAEMON) {
+ console.error(`[${SERVER_NAME}] daemon mode=connect; waiting for managed daemon`);
+ await waitForDaemon();
+ } else {
+ spawnDaemon();
+ await waitForDaemon();
+ }
}
// 3) connect to daemon. Resolve a human-readable agent name for the popup UI.
@@ -445,6 +456,9 @@ async function main(): Promise {
try {
if (await daemonLooksAlive()) {
console.error(`[${SERVER_NAME}] daemon connection lost — reconnecting to live daemon`);
+ } else if (CONNECT_ONLY_DAEMON) {
+ console.error(`[${SERVER_NAME}] daemon connection lost — waiting for managed daemon`);
+ await waitForDaemon();
} else {
console.error(`[${SERVER_NAME}] daemon connection lost — attempting one respawn`);
spawnDaemon();
diff --git a/mcp-server/src/register-tools.ts b/mcp-server/src/register-tools.ts
index 691b838..cb7e437 100644
--- a/mcp-server/src/register-tools.ts
+++ b/mcp-server/src/register-tools.ts
@@ -44,7 +44,7 @@ function checkPayloadLimits(value: unknown, ctx: z.RefinementCtx, path: Array {
- let bytes = 0;
+ let bytes: number;
try {
bytes = Buffer.byteLength(JSON.stringify(value), 'utf8');
} catch {
@@ -88,7 +88,7 @@ export function parseToolParams(tool: ToolDefinition, params: Record new Promise((resolve) => setTimeout(resolve, ms));
@@ -33,6 +33,19 @@ async function runStep(def: ToolDefinition, host: ToolHost, params: Record }>;
continueOnError: boolean;
output: 'all' | 'last' | 'errors';
};
+ // The default tab follows a frozen tab's replacement (navigate/reload report replacedTabId).
+ let tabId = (params as { tabId?: number }).tabId;
const content: ToolResult['content'] = [];
let failed = 0;
let ran = 0;
@@ -94,6 +109,10 @@ export const batchTool: ToolDefinition = {
}
}
ran++;
+ if (!result.isError && tabId !== undefined) {
+ const replacement = replacedBy(result, tabId);
+ if (replacement !== null) tabId = replacement;
+ }
const isLast = i === actions.length - 1;
if (output === 'all' || result.isError || (output === 'last' && isLast)) {
content.push({ type: 'text', text: `${label} ${result.isError ? 'FAILED' : 'ok'}` });
diff --git a/mcp-server/src/tools/browsers.ts b/mcp-server/src/tools/browsers.ts
new file mode 100644
index 0000000..4d46dc6
--- /dev/null
+++ b/mcp-server/src/tools/browsers.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod';
+import type { ToolDefinition } from './types.js';
+import { forwardHandler } from './types.js';
+
+// Answered by the bridge itself (it holds every extension connection), not by
+// an extension — see BRIDGE_TOOLS in bridge.ts.
+
+export const listBrowsersTool: ToolDefinition = {
+ name: 'browser_list_browsers',
+ summary: 'List the connected browsers (Chrome profiles)',
+ description:
+ 'List every browser (Chrome profile / instance with the extension) connected to Browser Controller: browserId, label, which one is the default and which one this session uses. With one browser connected you never need this.',
+ inputSchema: z.object({}),
+ idempotent: true,
+ timeoutMs: 5_000,
+ handler: forwardHandler('browser_list_browsers'),
+};
+
+export const selectBrowserTool: ToolDefinition = {
+ name: 'browser_select_browser',
+ summary: 'Send this session\'s browser calls to one browser',
+ description:
+ 'Route all of this session\'s browser tool calls to one connected browser (by browserId or label from browser_list_browsers). "auto" goes back to the default (the most recently connected browser). Tab ids belong to their browser — list tabs again after switching.',
+ inputSchema: z.object({
+ browserId: z.string().describe('browserId or label from browser_list_browsers, or "auto"'),
+ }),
+ timeoutMs: 5_000,
+ handler: forwardHandler('browser_select_browser'),
+};
diff --git a/mcp-server/src/tools/click-text.ts b/mcp-server/src/tools/click-text.ts
index c8c27ce..9c8d0c3 100644
--- a/mcp-server/src/tools/click-text.ts
+++ b/mcp-server/src/tools/click-text.ts
@@ -5,12 +5,13 @@ import { requireTabId, forwardHandler } from './types.js';
export const clickTextTool: ToolDefinition = {
name: 'browser_click_text',
summary: 'Click an element by its visible text', description:
- 'Click an element by its visible text content. Works on React dropdowns, portals, and overlays that may not appear in snapshots. CSP-safe (no eval). Prefers deepest matching element.',
+ 'Click an element by its visible text content (case-insensitive, CSS text-transform does not matter). Matches accessible names, aria-label/title and composed text including shadow DOM and same-origin iframes, then clicks the control that owns the text (e.g. the around a ) with a real (trusted) mouse click. Works on React dropdowns, portals, and overlays that may not appear in snapshots. CSP-safe (no eval).',
inputSchema: z.object({
tabId: requireTabId(),
- text: z.string().describe('Text to match against element content (first line)'),
+ text: z.string().describe('Text to match against the element name or visible text'),
index: z.number().int().min(0).optional().describe('Which match to click if multiple (0-based, default 0)'),
- exact: z.boolean().optional().describe('Require exact match instead of substring (default false)'),
+ exact: z.boolean().optional().describe('Require the whole text to match (case-insensitive) instead of a substring (default false)'),
+ trusted: z.boolean().optional().describe('Real CDP mouse click (default true). false = synthetic DOM events.'),
}),
timeoutMs: 10_000,
handler: forwardHandler('browser_click_text'),
diff --git a/mcp-server/src/tools/click.ts b/mcp-server/src/tools/click.ts
index 8e4db7d..88c52d9 100644
--- a/mcp-server/src/tools/click.ts
+++ b/mcp-server/src/tools/click.ts
@@ -5,17 +5,22 @@ import { requireTabId, forwardHandler } from './types.js';
export const clickTool: ToolDefinition = {
name: 'browser_click',
summary: 'Click an element by ref or CSS selector',
- description: 'Click an element on the page using a ref from snapshot or a CSS selector. Uses a real mouse click over CDP (isTrusted events, real focus, default actions — works in background windows); falls back to synthetic DOM events if the debugger cannot attach.',
+ description: 'Click an element on the page using a ref from snapshot or a CSS selector, or at x/y viewport coordinates (e.g. read off a screenshot). clickCount 3 = triple click (select a line); modifiers hold keys (ctrl+click opens a link in a new tab). Uses a real mouse click over CDP (isTrusted events, real focus, default actions — works in background windows); falls back to synthetic DOM events if the debugger cannot attach.',
inputSchema: z.object({
tabId: requireTabId(),
ref: z.string().optional().describe('Element reference from snapshot (e.g. "e12")'),
selector: z.string().optional().describe('CSS selector for the element'),
button: z.enum(['left', 'right', 'middle']).optional().default('left'),
doubleClick: z.boolean().optional().default(false),
+ clickCount: z.number().int().min(1).max(3).optional().describe('1 = click, 2 = double, 3 = triple click (overrides doubleClick)'),
+ modifiers: z.array(z.enum(['ctrl', 'alt', 'shift', 'meta'])).optional().describe('Keys held during the click'),
+ x: z.number().optional().describe('Viewport x in CSS px (from browser_screenshot / find bounds) — use with y instead of ref/selector'),
+ y: z.number().optional().describe('Viewport y in CSS px — use with x'),
trusted: z.boolean().optional().describe('Real (isTrusted) input over CDP — default. false = synthetic DOM events, no debugger banner.'),
}).superRefine((params, ctx) => {
- if (!params.ref && !params.selector) {
- ctx.addIssue({ code: 'custom', message: 'ref or selector is required', path: ['ref'] });
+ const point = Number.isFinite(params.x) && Number.isFinite(params.y);
+ if (!params.ref && !params.selector && !point) {
+ ctx.addIssue({ code: 'custom', message: 'ref or selector (or x and y) is required', path: ['ref'] });
}
}),
timeoutMs: 10_000,
diff --git a/mcp-server/src/tools/console.ts b/mcp-server/src/tools/console.ts
index a384eed..8418d77 100644
--- a/mcp-server/src/tools/console.ts
+++ b/mcp-server/src/tools/console.ts
@@ -4,10 +4,13 @@ import { requireTabId, forwardHandler } from './types.js';
export const consoleTool: ToolDefinition = {
name: 'browser_console',
- summary: 'Read console messages from a tab', description: 'Read console messages (log, warn, error) captured from a specific tab',
+ summary: 'Read console messages from a tab', description: 'Read console messages (log, info, warn, error, debug, uncaught errors) the page produced in a specific tab. Filter with pattern (regex) / level, and keep only the latest with limit.',
inputSchema: z.object({
tabId: requireTabId(),
clear: z.boolean().optional().default(false).describe('Clear this tab\'s messages after reading'),
+ pattern: z.string().optional().describe('Case-insensitive regex the message text must match, e.g. "error|fail"'),
+ level: z.union([z.enum(['log', 'info', 'warn', 'error', 'debug']), z.array(z.enum(['log', 'info', 'warn', 'error', 'debug']))]).optional().describe('Only these levels'),
+ limit: z.number().int().min(1).max(200).optional().describe('Return only the most recent N matching messages'),
}),
// NOT idempotent: `clear:true` mutates the buffer. A timeout-retry would
// return an empty buffer (first call already cleared it) and silently lose
diff --git a/mcp-server/src/tools/dialog.ts b/mcp-server/src/tools/dialog.ts
index 0ee5acd..3ba33a7 100644
--- a/mcp-server/src/tools/dialog.ts
+++ b/mcp-server/src/tools/dialog.ts
@@ -1,15 +1,22 @@
-import { z } from 'zod';
-import type { ToolDefinition } from './types.js';
-import { requireTabId, forwardHandler } from './types.js';
+import { z } from "zod";
+import type { ToolDefinition } from "./types.js";
+import { requireTabId, forwardHandler } from "./types.js";
export const dialogTool: ToolDefinition = {
- name: 'browser_handle_dialog',
- summary: 'Handle or dismiss browser dialogs (alert/confirm/prompt)', description: 'Handle JavaScript dialogs (alert, confirm, prompt). Dialogs block page interaction until handled.',
+ name: "browser_handle_dialog",
+ summary: "Handle or dismiss browser dialogs (alert/confirm/prompt)",
+ description:
+ "Handle JavaScript dialogs (alert, confirm, prompt). Dialogs block page interaction until handled.",
inputSchema: z.object({
tabId: requireTabId(),
- action: z.enum(['accept', 'dismiss']).describe('Accept or dismiss the dialog'),
- promptText: z.string().optional().describe('Text to enter for prompt() dialogs'),
+ action: z
+ .enum(["accept", "dismiss"])
+ .describe("Accept or dismiss the dialog"),
+ promptText: z
+ .string()
+ .optional()
+ .describe("Text to enter for prompt() dialogs"),
}),
timeoutMs: 5_000,
- handler: forwardHandler('browser_handle_dialog'),
+ handler: forwardHandler("browser_handle_dialog"),
};
diff --git a/mcp-server/src/tools/find.ts b/mcp-server/src/tools/find.ts
index 5fa78ef..f2fc613 100644
--- a/mcp-server/src/tools/find.ts
+++ b/mcp-server/src/tools/find.ts
@@ -5,11 +5,12 @@ import { requireTabId, forwardHandler } from './types.js';
export const findTool: ToolDefinition = {
name: 'browser_find',
summary: 'Find elements by natural language description', description:
- 'Find elements on the page using natural language (e.g. "login button", "search input"). Returns refs you can use with click/type.',
+ 'Find elements on the page using natural language (e.g. "login button", "search input", "Open alert dialog"). Matches every word against accessible names, labels, placeholders and attributes, understands role words (button, link, input, checkbox, tab, menu...), looks inside shadow DOM and same-origin iframes, and prefers the control over its wrappers. Returns refs you can use with click/type/every ref tool.',
inputSchema: z.object({
tabId: requireTabId(),
query: z.string().describe('Natural language description of what to find'),
limit: z.number().optional().default(10).describe('Max matches to return'),
+ role: z.string().optional().describe('Only return elements with this ARIA role (button, link, textbox, searchbox, checkbox, tab, menuitem, combobox, heading...)'),
}),
// Read-only: safe to retry on timeout. (Fixes the prior wire-name drift where
// callTool('find') disagreed with .name 'browser_find' and silently disabled
diff --git a/mcp-server/src/tools/gif.ts b/mcp-server/src/tools/gif.ts
new file mode 100644
index 0000000..9f111d3
--- /dev/null
+++ b/mcp-server/src/tools/gif.ts
@@ -0,0 +1,56 @@
+import { z } from 'zod';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import type { ToolDefinition } from './types.js';
+import { requireTabId, textResult, jsonError, payloadOf } from './types.js';
+
+/** Default place for an exported recording: ~/Downloads if it exists, else the temp dir. */
+function defaultGifPath(): string {
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ const downloads = path.join(os.homedir(), 'Downloads');
+ const dir = fs.existsSync(downloads) ? downloads : os.tmpdir();
+ return path.join(dir, `browser-recording-${stamp}.gif`);
+}
+
+export const gifTool: ToolDefinition = {
+ name: 'browser_gif',
+ summary: 'Record the agent\'s actions in a tab as an animated GIF',
+ description:
+ 'Record what happens in a tab as an animated GIF (to show the user what was done). start begins recording: a frame is captured after every page-changing action (navigate, click, type, keys, scroll, hover, select, drag, forms…), clicks are marked with a red ring. frame adds one now, stop pauses, status/clear, export writes the .gif file (default ~/Downloads) and returns its path — the image itself is not returned. A background tab is shown for a moment per frame (Chrome does not paint hidden tabs); activate:false records only while the tab is visible.',
+ inputSchema: z.object({
+ tabId: requireTabId(),
+ action: z.enum(['start', 'frame', 'stop', 'status', 'export', 'clear']).describe('Recording action'),
+ width: z.number().int().min(200).max(1600).optional().describe('start: frame width in px (default 800)'),
+ maxFrames: z.number().int().min(1).max(500).optional().describe('start: stop capturing after this many frames (default 300)'),
+ activate: z.boolean().optional().describe('start: briefly show a background tab to capture it (default true)'),
+ path: z.string().optional().describe('export: where to write the .gif (default ~/Downloads/browser-recording-.gif)'),
+ clear: z.boolean().optional().describe('export: drop the frames afterwards (default true)'),
+ }),
+ timeoutMs: 120_000,
+ async handler(host, params) {
+ const call = (p: Record) => host.callTool('browser_gif', p) as Promise>;
+ let result: Record;
+ try {
+ result = await call(params);
+ } catch (err) {
+ const payload = payloadOf(err);
+ if (payload !== undefined) return jsonError(payload);
+ throw err;
+ }
+ if (params.action !== 'export' || typeof result.gifBase64 !== 'string') return textResult(JSON.stringify(result));
+ // The GIF arrives in parts (WebSocket frames are capped at 1 MB).
+ const chunks = [Buffer.from(result.gifBase64, 'base64')];
+ const parts = typeof result.parts === 'number' ? result.parts : 1;
+ for (let part = 1; part < parts; part++) {
+ const next = await call({ ...params, part });
+ if (typeof next.gifBase64 !== 'string') throw new Error(`GIF export part ${part} returned no data`);
+ chunks.push(Buffer.from(next.gifBase64, 'base64'));
+ }
+ const file = path.resolve(typeof params.path === 'string' && params.path ? params.path : defaultGifPath());
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, Buffer.concat(chunks));
+ const { gifBase64: _drop, part: _part, parts: _parts, ...rest } = result;
+ return textResult(JSON.stringify({ ...rest, path: file }));
+ },
+};
diff --git a/mcp-server/src/tools/hover.ts b/mcp-server/src/tools/hover.ts
index 1b5180c..705f794 100644
--- a/mcp-server/src/tools/hover.ts
+++ b/mcp-server/src/tools/hover.ts
@@ -4,11 +4,13 @@ import { requireTabId, forwardHandler } from './types.js';
export const hoverTool: ToolDefinition = {
name: 'browser_hover',
- summary: 'Hover over an element', description: 'Hover over an element to trigger tooltips, dropdown menus, or hover states',
+ summary: 'Hover over an element', description: 'Hover over an element (ref/selector) or a viewport point (x/y) to trigger tooltips, dropdown menus, or hover states. Real mouse move over CDP.',
inputSchema: z.object({
tabId: requireTabId(),
ref: z.string().optional().describe('Element reference from snapshot'),
selector: z.string().optional().describe('CSS selector for the element'),
+ x: z.number().optional().describe('Viewport x in CSS px (from browser_screenshot / find bounds) — use with y instead of ref/selector'),
+ y: z.number().optional().describe('Viewport y in CSS px — use with x'),
trusted: z.boolean().optional().describe('Real (isTrusted) mouse move over CDP — default. false = synthetic DOM events, no debugger banner.'),
}),
timeoutMs: 5_000,
diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts
index daca021..0d1caf1 100644
--- a/mcp-server/src/tools/index.ts
+++ b/mcp-server/src/tools/index.ts
@@ -20,11 +20,16 @@ import { clickTextTool } from './click-text.js';
import { dialogTool } from './dialog.js';
import { uploadFileTool } from './upload-file.js';
import { runActionTool } from './run-action.js';
+import { interceptTool } from './intercept.js';
import { dragTool } from './drag.js';
import { fillFormTool } from './fill-form.js';
import { observeTool } from './observe.js';
import { actTool } from './act.js';
import { batchTool } from './batch.js';
+import { resizeWindowTool } from './resize-window.js';
+import { gifTool } from './gif.js';
+import { listBrowsersTool, selectBrowserTool } from './browsers.js';
+import { shortcutsTool } from './shortcuts.js';
export const allTools: ToolDefinition[] = [
navigateTool,
@@ -49,13 +54,19 @@ export const allTools: ToolDefinition[] = [
runActionTool,
dragTool,
fillFormTool,
+ interceptTool,
observeTool,
actTool,
batchTool,
+ resizeWindowTool,
+ gifTool,
+ listBrowsersTool,
+ selectBrowserTool,
+ shortcutsTool,
];
export const toolMap = new Map(
- allTools.map(t => [t.name, t]),
+ allTools.map((t) => [t.name, t]),
);
export type ToolCapability = 'read' | 'write' | 'mixed';
@@ -112,7 +123,7 @@ export function isIdempotent(tool: string): boolean {
*/
const timeoutByToolName = new Map(
allTools
- .filter((t) => typeof t.timeoutMs === 'number')
+ .filter((t) => typeof t.timeoutMs === "number")
.map((t) => [t.name, t.timeoutMs as number]),
);
diff --git a/mcp-server/src/tools/intercept.ts b/mcp-server/src/tools/intercept.ts
new file mode 100644
index 0000000..f86d0ba
--- /dev/null
+++ b/mcp-server/src/tools/intercept.ts
@@ -0,0 +1,63 @@
+import { z } from "zod";
+import type { ToolDefinition } from "./types.js";
+import { forwardHandler } from "./types.js";
+
+const ruleAction = z.enum(["log", "block", "redirect", "header", "mock"]);
+
+const ruleSchema = z.object({
+ id: z.string().min(1).max(64).optional(),
+ match: z.string().min(1).max(500),
+ types: z.array(z.string().min(1).max(32)).max(20).optional(),
+ tabIds: z.array(z.number().int()).max(50).optional(),
+ action: ruleAction,
+ redirectUrl: z.string().max(2000).optional(),
+ headers: z.record(z.string(), z.string()).optional(),
+ mockStatus: z.number().int().min(100).max(599).optional(),
+ mockBody: z.string().max(20_000).optional(),
+ enabled: z.boolean().optional().default(true),
+});
+
+export const interceptTool: ToolDefinition = {
+ name: "browser_intercept",
+ summary: "Manage request intercept rules and captures per tab",
+ description:
+ "Capture, block, redirect, or add request headers to network traffic per tab (Chrome session rules). Actions: set-rules, list-rules, clear-rules, list-captures, export-har. 'mock' and 'log' rules are recorded in the capture ledger only — Chrome cannot fake response bodies — and set-rules reports them under unsupported with enforcement 'partial'.",
+ inputSchema: z.object({
+ tabId: z
+ .number()
+ .int()
+ .optional()
+ .describe("Target tab id. If omitted, rules apply globally."),
+ action: z
+ .enum([
+ "set-rules",
+ "list-rules",
+ "clear-rules",
+ "list-captures",
+ "export-har",
+ ])
+ .describe("Intercept action to perform"),
+ rules: z
+ .array(ruleSchema)
+ .max(50)
+ .optional()
+ .describe("Rules for set-rules (max 50)"),
+ filter: z
+ .string()
+ .optional()
+ .describe("URL regex pattern to filter captures"),
+ limit: z
+ .number()
+ .int()
+ .min(1)
+ .max(200)
+ .optional()
+ .describe(
+ "Return only the most recent N captures (default: all buffered, up to 200)",
+ ),
+ }),
+ // Mutating (rule CRUD); list-captures shares the tool so the whole tool is non-idempotent.
+ idempotent: false,
+ timeoutMs: 10_000,
+ handler: forwardHandler("browser_intercept"),
+};
diff --git a/mcp-server/src/tools/meta.ts b/mcp-server/src/tools/meta.ts
index 4bc663f..612ebb5 100644
--- a/mcp-server/src/tools/meta.ts
+++ b/mcp-server/src/tools/meta.ts
@@ -1,7 +1,7 @@
-import { z } from 'zod';
-import type { ToolDefinition } from './types.js';
-import { textResult, jsonError } from './types.js';
-import { allTools, toolMap } from './index.js';
+import { z } from "zod";
+import type { ToolDefinition } from "./types.js";
+import { textResult, jsonError } from "./types.js";
+import { allTools, toolMap } from "./index.js";
/**
* Progressive disclosure meta tool (Anthropic "Code Execution with MCP" pattern).
@@ -61,51 +61,63 @@ const TASK_PREAMBLE =
*/
const TOOL_GUIDANCE: Record = {
browser_click:
- 'Use for ANY click — a real (trusted) mouse click over CDP with a smart-selector fallback; works in background windows. Prefer over JS .click().',
+ 'Use for ANY click — a real (trusted) mouse click over CDP with a verified smart-selector fallback; works in background windows. Also clicks at x/y read off a screenshot, triple-clicks (clickCount 3) and ctrl/shift-clicks. Prefer over JS .click().',
browser_type:
'Use for typing into inputs — real key presses over CDP, so autocomplete/lookup widgets react like for a user. change/blur fire when focus leaves: follow with browser_press_key Tab. Prefer over JS .value= .',
browser_batch:
'Use to run several known steps (click → type → Tab → wait → text) in ONE call. Stops at the first failing step. Biggest round-trip saver.',
browser_fill_form:
- 'Use to fill several fields in one call (and optionally submit). Cheaper than repeated browser_type calls.',
+ "Use to fill several fields in one call (and optionally submit). Cheaper than repeated browser_type calls.",
browser_click_text:
- 'Use to click by visible text (works on React dropdowns/portals that may not appear in a snapshot).',
+ "Use to click by visible text (works on React dropdowns/portals that may not appear in a snapshot).",
browser_navigate:
- 'Use to go to a URL. Handles hash-only routes correctly (resolves without waiting for a complete event). Returns an optional inline snapshot so you can act immediately.',
+ "Use to go to a URL. Handles hash-only routes correctly (resolves without waiting for a complete event). Returns an optional inline snapshot so you can act immediately.",
browser_snapshot:
- 'Use to understand page structure and get element refs (e1, e2…) for subsequent click/type calls. Returns the accessibility tree (semantic), not raw DOM.',
+ "Use to understand page structure and get element refs (e1, e2…) for subsequent click/type calls. Returns the accessibility tree (semantic), not raw DOM.",
browser_text:
- 'Use to read visible text on the page. Cheapest read tool. Returns {text, title, url}.',
+ 'Use to read visible text on the page (incl. shadow DOM). Cheapest read tool. mode:"article" = main content only; page long text with offset/nextOffset. Returns {text, title, url}.',
browser_find:
- 'Use to locate elements by natural-language description when you don\'t have a snapshot yet. Returns refs for click/type.',
+ 'Use to locate elements by natural-language description ("search input", "Save button") when you don\'t have a snapshot yet — cheaper than a snapshot. Sees shadow DOM and same-origin iframes. Returns refs for every ref tool.',
browser_screenshot:
- 'Use to capture a visual image (PNG/JPEG). Cannot be done via JS — this is the only way to see the page.',
+ "Use to capture a visual image (PNG/JPEG). Cannot be done via JS — this is the only way to see the page.",
browser_evaluate:
- 'Use for one-off JS in the page MAIN world (no debugger banner). CSP-RESTRICTED: on strict-CSP SPAs it may return null — fall back to browser_run_action (CDP, bypasses CSP).',
+ "Use for one-off JS in the page MAIN world (no debugger banner). CSP-RESTRICTED: on strict-CSP SPAs it may return null — fall back to browser_run_action (CDP, bypasses CSP).",
browser_run_action:
'Escape hatch: read/write DOM, fetch an internal API, or read cookies on a strict-CSP site. Runs via CDP so it bypasses CSP and returns real values. Shows a yellow "is being debugged" banner.',
browser_tabs:
- 'Use to list/create/close/focus/lock tabs. ALWAYS pass an explicit tabId to other tools so the agent doesn\'t act on the tab the user is looking at.',
+ "Use to list/create/close/focus/lock tabs. ALWAYS pass an explicit tabId to other tools so the agent doesn't act on the tab the user is looking at.",
browser_scroll:
- 'Use to scroll the page or a specific element (pixel offset, to-element, or top/bottom). Works with virtualized feeds.',
+ "Use to scroll the page or a specific element (pixel offset, to-element, or top/bottom). Works with virtualized feeds.",
browser_hover:
- 'Use to trigger tooltips / dropdown menus / hover-only UI states.',
+ 'Use to trigger tooltips / dropdown menus / hover-only UI states (ref, selector or x/y).',
+ browser_shortcuts:
+ 'Use for a workflow you repeat (login-free form fill, report export…): save it once with {{variables}}, then run it in ONE call.',
+ browser_list_browsers:
+ 'Use only when several browsers/profiles are connected: shows browserIds and which one this session uses.',
+ browser_select_browser:
+ 'Use to work in another connected browser/profile (then list its tabs). "auto" = default.',
+ browser_gif:
+ 'Use to show the user what you did: start before a flow, export after — writes an animated .gif (clicks marked) and returns its path.',
+ browser_resize_window:
+ 'Use to test responsive layouts or maximize/restore the window holding a tab. Resizes the user\'s window — prefer a separate window for experiments.',
browser_select:
'Use to pick an option in a native dropdown.',
browser_press_key:
- 'Use for keyboard input (Enter, Tab, Escape, ArrowDown, Ctrl+A, …).',
+ 'Use for keyboard input (Enter, Tab, Escape, ArrowDown, Ctrl+A, …), key sequences ("ArrowDown ArrowDown Enter") and repeat.',
browser_wait:
- 'Use to wait for an element to appear/disappear, or a fixed delay. Avoids fragile sleep loops.',
+ 'Use to wait for an element to appear/disappear, text to appear/disappear, a URL change, or a fixed delay. Avoids fragile sleep loops.',
browser_console:
- 'Use to read console messages (log/warn/error) from a tab. Useful for debugging.',
+ "Use to read console messages (log/warn/error) from a tab. Useful for debugging.",
browser_network:
- 'Use to read network requests the page made (filter by URL). Useful for seeing API calls.',
+ "Use to read network requests the page made (filter by URL). Useful for seeing API calls.",
browser_upload_file:
'Use to upload a file through an . Works even on strict-CSP pages (uses CDP).',
browser_drag:
- 'Use for drag-and-drop (ref/selector or x/y coords). Uses CDP mouse events for reliability.',
+ "Use for drag-and-drop (ref/selector or x/y coords). Uses CDP mouse events for reliability.",
browser_handle_dialog:
'Use to handle or dismiss a JS dialog (alert/confirm/prompt) that blocks the page.',
+ browser_intercept:
+ "Use to block/redirect/mock network traffic per tab (rules by URL regex) or export a redacted HAR. Check the enforcement flag — capture-only means rules are ledger-marked, not applied.",
browser_observe:
'Use as the primary AI-facing page read before browser_act. It returns compact, session-owned refs with allowed actions and geometry.',
browser_act:
@@ -114,8 +126,8 @@ const TOOL_GUIDANCE: Record = {
export function createMetaTool(deps: MetaToolDeps): ToolDefinition {
return {
- name: 'browser_tools',
- summary: 'Discover and activate browser tools (progressive disclosure)',
+ name: "browser_tools",
+ summary: "Discover and activate browser tools (progressive disclosure)",
description: `Discover, search, and activate browser control tools. Instead of loading all tool definitions upfront, use this to find the right tool for your task.
Actions:
@@ -126,49 +138,72 @@ Actions:
Workflow: call "list" or "search" first, then "details" on the tool you need, then call that tool directly.`,
inputSchema: z.object({
action: z
- .enum(['list', 'search', 'details'])
- .describe('list = all summaries; search = find by keyword; details = full schema + activate'),
+ .enum(["list", "search", "details"])
+ .describe(
+ "list = all summaries; search = find by keyword; details = full schema + activate",
+ ),
query: z
.string()
.optional()
- .describe('Search query (for action:"search"). Matches tool name + summary.'),
+ .describe(
+ 'Search query (for action:"search"). Matches tool name + summary.',
+ ),
tool: z
.string()
.optional()
.describe('Tool name (for action:"details"). e.g. "browser_click"'),
}),
async handler(_host, params) {
- const { action, query, tool } = params as { action: string; query?: string; tool?: string };
+ const { action, query, tool } = params as {
+ action: string;
+ query?: string;
+ tool?: string;
+ };
- if (action === 'list') {
+ if (action === "list") {
const tools = allTools
- .filter((t) => t.name !== 'browser_tools') // don't list the meta tool itself
+ .filter((t) => t.name !== "browser_tools") // don't list the meta tool itself
.map((t) => ({
name: t.name,
summary: t.summary,
- guidance: TOOL_GUIDANCE[t.name] ?? '',
+ guidance: TOOL_GUIDANCE[t.name] ?? "",
active: deps.isActive(t.name),
}));
return textResult(JSON.stringify({ preamble: TASK_PREAMBLE, tools }));
}
- if (action === 'search') {
+ if (action === "search") {
if (!query) {
return jsonError({ error: 'query is required for action:"search"' });
}
const q = query.toLowerCase();
const matches = allTools
- .filter((t) => t.name !== 'browser_tools')
+ .filter((t) => t.name !== "browser_tools")
.filter((t) => {
- const haystack = (t.name + ' ' + t.summary + ' ' + t.description).toLowerCase();
+ const haystack = (
+ t.name +
+ " " +
+ t.summary +
+ " " +
+ t.description
+ ).toLowerCase();
// match if ANY word in the query appears in the haystack
- return q.split(/\s+/).some((word) => word.length > 1 && haystack.includes(word));
+ return q
+ .split(/\s+/)
+ .some((word) => word.length > 1 && haystack.includes(word));
})
- .map((t) => ({ name: t.name, summary: t.summary, guidance: TOOL_GUIDANCE[t.name] ?? '', active: deps.isActive(t.name) }));
- return textResult(JSON.stringify({ query, matches, count: matches.length }));
+ .map((t) => ({
+ name: t.name,
+ summary: t.summary,
+ guidance: TOOL_GUIDANCE[t.name] ?? "",
+ active: deps.isActive(t.name),
+ }));
+ return textResult(
+ JSON.stringify({ query, matches, count: matches.length }),
+ );
}
- if (action === 'details') {
+ if (action === "details") {
if (!tool) {
return jsonError({ error: 'tool is required for action:"details"' });
}
@@ -176,7 +211,9 @@ Workflow: call "list" or "search" first, then "details" on the tool you need, th
if (!def) {
return jsonError({
error: `Unknown tool: ${tool}`,
- available: allTools.filter((t) => t.name !== 'browser_tools').map((t) => t.name),
+ available: allTools
+ .filter((t) => t.name !== "browser_tools")
+ .map((t) => t.name),
});
}
// Activate the tool so the agent can call it directly after this.
@@ -190,7 +227,7 @@ Workflow: call "list" or "search" first, then "details" on the tool you need, th
JSON.stringify({
name: def.name,
description: def.description,
- guidance: TOOL_GUIDANCE[def.name] ?? '',
+ guidance: TOOL_GUIDANCE[def.name] ?? "",
inputSchema: jsonSchema,
activated: true,
message: `Tool "${tool}" is now active. You can call it directly.`,
@@ -198,7 +235,9 @@ Workflow: call "list" or "search" first, then "details" on the tool you need, th
);
}
- return jsonError({ error: `Unknown action: ${action}. Use "list", "search", or "details".` });
+ return jsonError({
+ error: `Unknown action: ${action}. Use "list", "search", or "details".`,
+ });
},
};
}
diff --git a/mcp-server/src/tools/network.ts b/mcp-server/src/tools/network.ts
index 8401181..ff43d29 100644
--- a/mcp-server/src/tools/network.ts
+++ b/mcp-server/src/tools/network.ts
@@ -1,18 +1,20 @@
-import { z } from 'zod';
-import type { ToolDefinition } from './types.js';
-import { requireTabId, forwardHandler } from './types.js';
+import { z } from "zod";
+import type { ToolDefinition } from "./types.js";
+import { requireTabId, forwardHandler } from "./types.js";
export const networkTool: ToolDefinition = {
name: 'browser_network',
- summary: 'Read network requests captured from a tab', description: 'Read network requests made by a specific tab. Filter by URL pattern.',
+ summary: 'Read network requests captured from a tab', description: 'Read network requests made by a specific tab (method, url, status, type — failed ones carry error). Filter by urlPattern (substring) or filter (regex); failed:true = only errors and 4xx/5xx.',
inputSchema: z.object({
tabId: requireTabId(),
filter: z.string().optional().describe('URL regex pattern to filter requests'),
+ urlPattern: z.string().optional().describe('URL substring to filter requests (e.g. "/api/")'),
+ failed: z.boolean().optional().describe('Only failed requests (network errors and HTTP 4xx/5xx)'),
limit: z.number().int().min(1).max(200).optional().describe('Return only the most recent N requests (default: all buffered, up to 200)'),
clear: z.boolean().optional().default(false).describe('Clear this tab\'s requests after reading'),
}),
// NOT idempotent: `clear:true` mutates the buffer (same reasoning as console — M2).
idempotent: false,
timeoutMs: 5_000,
- handler: forwardHandler('browser_network'),
+ handler: forwardHandler("browser_network"),
};
diff --git a/mcp-server/src/tools/press-key.ts b/mcp-server/src/tools/press-key.ts
index cee74cd..668d717 100644
--- a/mcp-server/src/tools/press-key.ts
+++ b/mcp-server/src/tools/press-key.ts
@@ -5,10 +5,11 @@ import { requireTabId, forwardHandler } from './types.js';
export const pressKeyTool: ToolDefinition = {
name: 'browser_press_key',
summary: 'Press a keyboard key (Enter, Tab, Escape, etc.)', description:
- 'Press a keyboard key or combination (Enter, Escape, Tab, ArrowDown, etc). Accepts combos as "ctrl+a" or via modifiers. Real key press over CDP: Tab moves focus (fires blur/focusout), Enter submits, arrows drive autocomplete menus.',
+ 'Press a keyboard key or combination (Enter, Escape, Tab, ArrowDown, etc). Accepts combos as "ctrl+a" or via modifiers, space-separated sequences ("ArrowDown ArrowDown Enter", "ctrl+a Backspace") and repeat. Real key press over CDP: Tab moves focus (fires blur/focusout), Enter submits, arrows drive autocomplete menus.',
inputSchema: z.object({
tabId: requireTabId(),
- key: z.string().describe('Key name (e.g. "Enter", "Escape", "Tab", "ArrowDown", "a")'),
+ key: z.string().describe('Key name (e.g. "Enter", "Escape", "Tab", "ArrowDown", "a"), a combo ("ctrl+a") or a space-separated sequence ("ArrowDown ArrowDown Enter")'),
+ repeat: z.number().int().min(1).max(100).optional().describe('Press the key (or the whole sequence) this many times'),
modifiers: z
.array(z.enum(['ctrl', 'alt', 'shift', 'meta']))
.optional()
diff --git a/mcp-server/src/tools/resize-window.ts b/mcp-server/src/tools/resize-window.ts
new file mode 100644
index 0000000..666a08d
--- /dev/null
+++ b/mcp-server/src/tools/resize-window.ts
@@ -0,0 +1,22 @@
+import { z } from 'zod';
+import type { ToolDefinition } from './types.js';
+import { forwardHandler } from './types.js';
+
+export const resizeWindowTool: ToolDefinition = {
+ name: 'browser_resize_window',
+ summary: 'Resize/maximize the window that holds a tab',
+ description:
+ 'Resize the browser window that contains a tab (e.g. to test a responsive layout at 390x844), or set it to normal/maximized/minimized/fullscreen. Affects the whole window the user sees — prefer a separate window for experiments. Returns the resulting window and viewport size.',
+ inputSchema: z.object({
+ tabId: z.number().int().describe('A tab in the window to resize (from browser_tabs list)'),
+ width: z.number().int().min(200).max(10000).optional().describe('Window width in px'),
+ height: z.number().int().min(200).max(10000).optional().describe('Window height in px'),
+ state: z.enum(['normal', 'maximized', 'minimized', 'fullscreen']).optional().describe('Window state (width/height apply to "normal")'),
+ }).superRefine((p, ctx) => {
+ if (p.width === undefined && p.height === undefined && p.state === undefined) {
+ ctx.addIssue({ code: 'custom', path: ['width'], message: 'width, height or state is required' });
+ }
+ }),
+ timeoutMs: 10_000,
+ handler: forwardHandler('browser_resize_window'),
+};
diff --git a/mcp-server/src/tools/screenshot.ts b/mcp-server/src/tools/screenshot.ts
index ea72ebc..161bbda 100644
--- a/mcp-server/src/tools/screenshot.ts
+++ b/mcp-server/src/tools/screenshot.ts
@@ -5,12 +5,15 @@ import { requireTabId, imageResult, jsonError, payloadOf } from './types.js';
export const screenshotTool: ToolDefinition = {
name: 'browser_screenshot',
summary: 'Capture a screenshot of a tab',
- description: 'Capture a screenshot of a tab over CDP. Use maxWidth / scale and format:"jpeg" to shrink the image (far fewer tokens); fullPage captures the whole scrollable page. A background tab is shown for a moment and the user\'s tab is switched straight back (Chrome does not paint hidden tabs). The agent\'s blue control frame is never in the picture.',
+ description: 'Capture a screenshot of a tab over CDP. Use maxWidth / scale and format:"jpeg" to shrink the image (far fewer tokens); fullPage captures the whole scrollable page; region zooms into a rectangle. The result says how image pixels map to the x/y that click/hover/scroll take. A background tab is shown for a moment and the user\'s tab is switched straight back (Chrome does not paint hidden tabs). The agent\'s blue control frame is never in the picture.',
inputSchema: z.object({
tabId: requireTabId(),
format: z.enum(['png', 'jpeg']).optional().default('png'),
quality: z.number().min(0).max(100).optional().default(80).describe('JPEG quality (ignored for PNG)'),
- scale: z.number().min(0.05).max(1).optional().describe('Downscale factor, e.g. 0.5 = half size'),
+ scale: z.number().min(0.05).max(4).optional().describe('Downscale factor, e.g. 0.5 = half size (max 1 for the viewport/page; up to 4 to zoom into a region, default 2 there)'),
+ region: z.object({
+ x: z.number(), y: z.number(), width: z.number().positive(), height: z.number().positive(),
+ }).optional().describe('Capture only this viewport rectangle (CSS px, the same coordinates click/hover/scroll x/y use) — zoom in on small UI'),
maxWidth: z.number().int().min(100).max(4000).optional().describe('Cap the image width in pixels (keeps aspect ratio), e.g. 1024'),
fullPage: z.boolean().optional().default(false).describe('Capture the whole scrollable page, not just the viewport'),
}),
@@ -24,6 +27,9 @@ export const screenshotTool: ToolDefinition = {
success: boolean;
format: string;
data?: string;
+ width?: number;
+ height?: number;
+ frame?: { scale: number; origin: [number, number]; viewport: [number, number]; page?: boolean };
};
} catch (err) {
// Unified error channel: surface a payload-carrying rejection intact.
@@ -33,7 +39,16 @@ export const screenshotTool: ToolDefinition = {
}
if (result.data) {
const mimeType = result.format === 'jpeg' ? 'image/jpeg' : 'image/png';
- return imageResult(result.data, mimeType);
+ const image = imageResult(result.data, mimeType);
+ // Coordinate frame so a point seen in the image maps to click/hover/scroll x/y.
+ if (result.frame) {
+ const f = result.frame;
+ const map = f.page
+ ? `page coords = imagePx / ${f.scale} (scroll first; viewport y = pageY - ${(f as { scrollY?: number }).scrollY ?? 0})`
+ : `x = ${f.origin[0]} + imageX / ${f.scale}, y = ${f.origin[1]} + imageY / ${f.scale}`;
+ image.content.push({ type: 'text', text: JSON.stringify({ image: [result.width, result.height], viewport: f.viewport, toViewport: map }) });
+ }
+ return image;
}
// "Captured but no data" is a failure — the agent must not treat an
// empty screenshot as success (audit: misleading success-shaped error).
diff --git a/mcp-server/src/tools/scroll.ts b/mcp-server/src/tools/scroll.ts
index c6eca36..95b646f 100644
--- a/mcp-server/src/tools/scroll.ts
+++ b/mcp-server/src/tools/scroll.ts
@@ -5,7 +5,7 @@ import { requireTabId, forwardHandler } from './types.js';
export const scrollTool: ToolDefinition = {
name: 'browser_scroll',
summary: 'Scroll a tab up/down/left/right', description:
- 'Scroll the page or an element. Supports pixel offsets, scrolling to elements, and named positions (top/bottom). Works with virtual scroll containers used by social media sites.',
+ 'Scroll the page or an element. Supports pixel offsets, scrolling to elements, and named positions (top/bottom). Works with virtual scroll containers used by social media sites. With x/y it sends a real mouse-wheel event at that point, scrolling whatever is under it (inner panels, maps, virtual lists).',
inputSchema: z.object({
tabId: requireTabId(),
direction: z.enum(['up', 'down', 'left', 'right']).optional().default('down'),
@@ -13,6 +13,8 @@ export const scrollTool: ToolDefinition = {
selector: z.string().optional().describe('CSS selector of scroll container (for virtual scroll)'),
toElement: z.string().optional().describe('Ref or CSS selector to scroll into view'),
position: z.enum(['top', 'bottom']).optional().describe('Scroll to top or bottom of page'),
+ x: z.number().optional().describe('Viewport x in CSS px: wheel-scroll at this point (with y)'),
+ y: z.number().optional().describe('Viewport y in CSS px'),
}),
timeoutMs: 10_000,
handler: forwardHandler('browser_scroll'),
diff --git a/mcp-server/src/tools/shortcuts.ts b/mcp-server/src/tools/shortcuts.ts
new file mode 100644
index 0000000..c10e726
--- /dev/null
+++ b/mcp-server/src/tools/shortcuts.ts
@@ -0,0 +1,158 @@
+import { z } from 'zod';
+import fs from 'node:fs';
+import path from 'node:path';
+import type { ToolDefinition, ToolResult } from './types.js';
+import { optionalTabId, textResult, jsonError } from './types.js';
+import { STATE_DIR } from '../daemon-config.js';
+
+/**
+ * Saved, replayable action sequences — Browser Controller's counterpart of
+ * Claude-in-Chrome's shortcuts. A shortcut is a named browser_batch with
+ * {{variables}}: save once, then `run` it in ONE call with different values.
+ * Stored locally in ~/.browser-controller/shortcuts.json (BC_SHORTCUTS_FILE
+ * overrides); nothing leaves the machine.
+ */
+
+interface Shortcut {
+ name: string;
+ description?: string;
+ actions: Array<{ tool: string; params?: Record }>;
+ variables: string[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+const NAME = /^[\w.-]{1,64}$/;
+const VAR = /\{\{\s*([\w.-]+)\s*\}\}/g;
+
+export function shortcutsFile(): string {
+ return process.env.BC_SHORTCUTS_FILE || path.join(STATE_DIR, 'shortcuts.json');
+}
+
+function load(): Record {
+ try {
+ const data = JSON.parse(fs.readFileSync(shortcutsFile(), 'utf8')) as { shortcuts?: Record };
+ return data && typeof data.shortcuts === 'object' && data.shortcuts ? data.shortcuts : {};
+ } catch {
+ return {};
+ }
+}
+
+function save(all: Record): void {
+ const file = shortcutsFile();
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const tmp = `${file}.tmp`;
+ fs.writeFileSync(tmp, JSON.stringify({ version: 1, shortcuts: all }, null, 2));
+ fs.renameSync(tmp, file);
+}
+
+/** Every {{variable}} used anywhere in the actions. */
+export function variablesOf(actions: unknown): string[] {
+ const found = new Set();
+ const walk = (v: unknown) => {
+ if (typeof v === 'string') for (const m of v.matchAll(VAR)) found.add(m[1]!);
+ else if (Array.isArray(v)) v.forEach(walk);
+ else if (v && typeof v === 'object') Object.values(v).forEach(walk);
+ };
+ walk(actions);
+ return [...found].sort();
+}
+
+/** Replace {{variables}}. A string that is exactly one variable keeps the value's type (numbers, booleans). */
+export function substitute(value: unknown, vars: Record): unknown {
+ if (typeof value === 'string') {
+ const whole = value.match(/^\{\{\s*([\w.-]+)\s*\}\}$/);
+ if (whole && whole[1]! in vars) return vars[whole[1]!];
+ return value.replace(VAR, (m, k: string) => (k in vars ? String(vars[k]) : m));
+ }
+ if (Array.isArray(value)) return value.map((v) => substitute(v, vars));
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, substitute(v, vars)]));
+ }
+ return value;
+}
+
+const summaryOf = (s: Shortcut) => ({
+ name: s.name, ...(s.description ? { description: s.description } : {}), steps: s.actions.length,
+ ...(s.variables.length ? { variables: s.variables } : {}), updatedAt: s.updatedAt,
+});
+
+export const shortcutsTool: ToolDefinition = {
+ name: 'browser_shortcuts',
+ summary: 'Save and replay named action sequences (with {{variables}})',
+ description:
+ 'Saved, replayable browser workflows. save stores a named list of steps (same format as browser_batch actions; put {{variable}} placeholders in any string param), run replays it in ONE call with vars filled in (it runs as a browser_batch: stops at the first failing step unless continueOnError), list/show/delete manage them. Stored locally in ~/.browser-controller/shortcuts.json.',
+ inputSchema: z.object({
+ action: z.enum(['list', 'show', 'save', 'run', 'delete']).describe('Shortcut action'),
+ name: z.string().optional().describe('Shortcut name (letters, digits, _ . -)'),
+ description: z.string().max(500).optional().describe('save: what it does / when to use it'),
+ actions: z.array(z.object({
+ tool: z.string(),
+ params: z.record(z.string(), z.unknown()).optional(),
+ })).max(200).optional().describe('save: the steps, like browser_batch actions'),
+ vars: z.record(z.string(), z.unknown()).optional().describe('run: values for the {{variables}}'),
+ tabId: optionalTabId().describe('run: default tab for every step without its own tabId'),
+ continueOnError: z.boolean().optional().describe('run: keep going after a failing step'),
+ output: z.enum(['all', 'last', 'errors']).optional().describe('run: like browser_batch output (default last)'),
+ }),
+ // A run can hold up to 200 steps, like browser_batch.
+ timeoutMs: 300_000,
+ async handler(host, params): Promise {
+ const p = params as {
+ action: 'list' | 'show' | 'save' | 'run' | 'delete'; name?: string; description?: string;
+ actions?: Shortcut['actions']; vars?: Record; tabId?: number;
+ continueOnError?: boolean; output?: 'all' | 'last' | 'errors';
+ };
+ const all = load();
+ if (p.action === 'list') {
+ return textResult(JSON.stringify({ success: true, shortcuts: Object.values(all).map(summaryOf) }));
+ }
+ if (!p.name || !NAME.test(p.name)) return jsonError({ success: false, error: 'name is required (letters, digits, _ . -, max 64)' });
+ const existing = all[p.name];
+ switch (p.action) {
+ case 'show':
+ if (!existing) return jsonError({ success: false, error: `No shortcut "${p.name}"` });
+ return textResult(JSON.stringify({ success: true, shortcut: existing }));
+ case 'delete':
+ if (!existing) return jsonError({ success: false, error: `No shortcut "${p.name}"` });
+ delete all[p.name];
+ save(all);
+ return textResult(JSON.stringify({ success: true, deleted: p.name }));
+ case 'save': {
+ if (!p.actions || p.actions.length === 0) return jsonError({ success: false, error: 'actions (non-empty) are required to save a shortcut' });
+ const bad = p.actions.find((a) => a.tool === 'browser_batch' || a.tool === 'browser_shortcuts' || a.tool === 'browser_tools');
+ if (bad) return jsonError({ success: false, error: `${bad.tool} cannot be a shortcut step` });
+ const now = new Date().toISOString();
+ all[p.name] = {
+ name: p.name,
+ ...(p.description ? { description: p.description } : existing?.description ? { description: existing.description } : {}),
+ actions: p.actions,
+ variables: variablesOf(p.actions),
+ createdAt: existing?.createdAt ?? now,
+ updatedAt: now,
+ };
+ save(all);
+ return textResult(JSON.stringify({ success: true, saved: summaryOf(all[p.name]!), file: shortcutsFile() }));
+ }
+ case 'run': {
+ if (!existing) return jsonError({ success: false, error: `No shortcut "${p.name}"` });
+ const vars = p.vars ?? {};
+ const missing = existing.variables.filter((v) => !(v in vars));
+ if (missing.length) return jsonError({ success: false, error: `Missing vars: ${missing.join(', ')}`, variables: existing.variables });
+ const actions = substitute(existing.actions, vars) as Shortcut['actions'];
+ // Lazy, through the registry: batch.ts and the registry import each
+ // other, and the registry imports this module.
+ const { toolMap } = await import('./index.js');
+ const batchTool = toolMap.get('browser_batch')!;
+ return batchTool.handler(host, {
+ ...(p.tabId !== undefined ? { tabId: p.tabId } : {}),
+ actions,
+ continueOnError: p.continueOnError ?? false,
+ output: p.output ?? 'last',
+ });
+ }
+ default:
+ return jsonError({ success: false, error: `Unknown action ${String(p.action)}` });
+ }
+ },
+};
diff --git a/mcp-server/src/tools/snapshot.ts b/mcp-server/src/tools/snapshot.ts
index 1060466..3c0ef49 100644
--- a/mcp-server/src/tools/snapshot.ts
+++ b/mcp-server/src/tools/snapshot.ts
@@ -5,11 +5,15 @@ import { requireTabId, forwardHandler } from './types.js';
export const snapshotTool: ToolDefinition = {
name: 'browser_snapshot',
summary: 'Get the accessibility tree with element refs', description:
- 'Get an accessibility tree snapshot of the page. Returns element refs you can use with click, type, and other tools. Use compact mode (default) for smaller output - only interactive elements.',
+ 'Get an accessibility tree snapshot of the page, including shadow DOM (web components), slotted content and same-origin iframes. Returns element refs you can use with click, type, and every other ref tool. Use compact mode (default) for smaller output - only interactive elements, landmarks and headings. Output is capped by maxChars (default 20000); scope big pages with selector or ref.',
inputSchema: z.object({
tabId: requireTabId(),
selector: z.string().optional().describe('CSS selector to scope the snapshot'),
+ ref: z.string().optional().describe('Snapshot only the subtree of this ref (from an earlier snapshot/find)'),
compact: z.boolean().optional().default(true).describe('When true (default), returns only interactive elements with minimal nesting. Set false for full tree.'),
+ filter: z.enum(['interactive', 'all']).optional().describe('Alias of compact: "interactive" = compact, "all" = full tree. Wins over compact when given.'),
+ depth: z.number().int().min(0).optional().describe('Max nesting depth of returned nodes (0 = top level only)'),
+ maxChars: z.number().int().min(500).max(200_000).optional().describe('Cap on the serialized tree size (default 20000). The result says truncated:true when hit.'),
}),
// Read-only (refs are deterministic given a stable DOM): safe to retry.
idempotent: true,
diff --git a/mcp-server/src/tools/tabs.ts b/mcp-server/src/tools/tabs.ts
index 06dd704..d5037d1 100644
--- a/mcp-server/src/tools/tabs.ts
+++ b/mcp-server/src/tools/tabs.ts
@@ -4,20 +4,25 @@ import { forwardHandler } from './types.js';
export const tabsTool: ToolDefinition = {
name: 'browser_tabs',
- summary: 'List, create, close, focus, lock, or unlock tabs', description:
- 'Manage browser tabs: list, create, close, focus, or lock. list requires no tabId. lock/unlock claim a tab for the calling agent so other agents queue behind it instead of racing (see browser_tabs lock).',
+ summary: 'List, create, close, focus, reload, lock, or unlock tabs', description:
+ 'Manage browser tabs: list, create, close, focus, reload, or lock. list requires no tabId. reload also recovers a frozen (TAB_WEDGED) tab. lock/unlock claim a tab for the calling agent so other agents queue behind it instead of racing (see browser_tabs lock).',
inputSchema: z.object({
action: z
- .enum(['list', 'create', 'close', 'focus', 'lock', 'unlock'])
+ .enum(['list', 'create', 'close', 'focus', 'reload', 'lock', 'unlock'])
.describe('Tab action'),
- tabId: z.number().int().optional().describe('Tab ID (required for close/focus/lock/unlock)'),
+ tabId: z.number().int().optional().describe('Tab ID (required for close/focus/reload/lock/unlock)'),
+ bypassCache: z.boolean().optional().describe('reload: skip the HTTP cache'),
url: z.string().optional().describe('URL for create action'),
+ active: z.boolean().optional().describe('create: false opens the tab in the background (the user keeps their current tab)'),
+ window: z.boolean().optional().describe('focus: also bring the tab\'s window to the front'),
+ fullUrls: z.boolean().optional().describe('list: do not shorten long URLs'),
}).superRefine((params, ctx) => {
- const targetedActions = ['close', 'focus', 'lock', 'unlock'];
+ const targetedActions = ['close', 'focus', 'reload', 'lock', 'unlock'];
if (targetedActions.includes(params.action) && params.tabId === undefined) {
ctx.addIssue({ code: 'custom', path: ['tabId'], message: `tabId is required for ${params.action}` });
}
}),
- timeoutMs: 5_000,
+ // reload waits for the new document (up to 30s); the other actions return at once.
+ timeoutMs: 35_000,
handler: forwardHandler('browser_tabs'),
};
diff --git a/mcp-server/src/tools/text.ts b/mcp-server/src/tools/text.ts
index d908925..aaea2c9 100644
--- a/mcp-server/src/tools/text.ts
+++ b/mcp-server/src/tools/text.ts
@@ -4,11 +4,13 @@ import { requireTabId, forwardHandler } from './types.js';
export const textTool: ToolDefinition = {
name: 'browser_text',
- summary: 'Extract raw text content from a page', description: 'Extract raw text content from the page or a specific element',
+ summary: 'Extract raw text content from a page', description: 'Extract raw text content from the page or a specific element. Includes shadow-DOM (web component) content. mode:"article" returns only the main content (skips nav, header, footer, sidebars). Page long texts with offset.',
inputSchema: z.object({
tabId: requireTabId(),
selector: z.string().optional().describe('CSS selector to scope text extraction'),
- maxLength: z.number().optional().default(5000).describe('Max text length to return (default 5000 chars ≈ 1250 tokens; raise only when you need more)'),
+ maxLength: z.number().int().min(1).max(100_000).optional().default(5000).describe('Max text length to return (default 5000 chars ≈ 1250 tokens; raise only when you need more, max 100000)'),
+ mode: z.enum(['all', 'article']).optional().describe('"all" (default): all visible text. "article": main content only (article/main), without navigation, headers, footers, sidebars and banners.'),
+ offset: z.number().int().min(0).optional().describe('Start at this character (use nextOffset from a truncated result to read the next page).'),
}),
// Read-only: safe to retry on timeout. (Fixes prior wire-name drift — C1.)
idempotent: true,
diff --git a/mcp-server/src/tools/type.ts b/mcp-server/src/tools/type.ts
index 7fad852..b85f378 100644
--- a/mcp-server/src/tools/type.ts
+++ b/mcp-server/src/tools/type.ts
@@ -5,11 +5,11 @@ import { requireTabId, forwardHandler } from './types.js';
export const typeTool: ToolDefinition = {
name: 'browser_type',
summary: 'Type text into an input element',
- description: 'Focus an input and type text with real key presses over CDP (keydown/keypress/input/keyup per character, like a user). Like a user, `change`/blur fire only when focus leaves — follow with browser_press_key Tab to commit. Returns the field value after typing. Falls back to synthetic events if the debugger cannot attach.',
+ description: 'Focus an input (ref/selector — or, with neither, the element that already has focus) and type text with real key presses over CDP (keydown/keypress/input/keyup per character, like a user). Like a user, `change`/blur fire only when focus leaves — follow with browser_press_key Tab to commit. Returns the field value after typing. Falls back to synthetic events if the debugger cannot attach.',
inputSchema: z.object({
tabId: requireTabId(),
ref: z.string().optional().describe('Element reference from snapshot'),
- selector: z.string().optional().describe('CSS selector for the input'),
+ selector: z.string().optional().describe('CSS selector for the input (omit both ref and selector to type into the focused field)'),
text: z.string().describe('Text to type'),
clear: z.boolean().optional().default(false).describe('Clear the field before typing'),
trusted: z.boolean().optional().describe('Real (isTrusted) input over CDP — default. false = synthetic DOM events, no debugger banner.'),
diff --git a/mcp-server/src/tools/types.ts b/mcp-server/src/tools/types.ts
index d03262d..a9e7de8 100644
--- a/mcp-server/src/tools/types.ts
+++ b/mcp-server/src/tools/types.ts
@@ -1,4 +1,4 @@
-import { z } from 'zod';
+import { z } from "zod";
/**
* Minimal structural host a tool handler needs. Both {@link ExtensionBridge}
@@ -12,7 +12,10 @@ export interface ToolHost {
export interface ToolResult {
[key: string]: unknown;
- content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }>;
+ content: Array<
+ | { type: "text"; text: string }
+ | { type: "image"; data: string; mimeType: string }
+ >;
isError?: boolean;
}
@@ -25,7 +28,7 @@ export const tabIdParam = z
.number()
.int()
.describe(
- 'Target tab id (from browser_tabs list). Actions apply to THIS tab, not the active tab.',
+ "Target tab id (from browser_tabs list). Actions apply to THIS tab, not the active tab.",
);
/** Mandatory tabId (click/type/snapshot/…). */
@@ -35,7 +38,9 @@ export function requireTabId() {
/** Optional tabId — for tools like navigate where "active tab" is still acceptable. */
export function optionalTabId() {
- return tabIdParam.optional().describe('Target tab id. If omitted, uses the active tab.');
+ return tabIdParam
+ .optional()
+ .describe("Target tab id. If omitted, uses the active tab.");
}
export interface ToolDefinition {
@@ -49,7 +54,10 @@ export interface ToolDefinition {
summary: string;
description: string;
inputSchema: z.ZodObject;
- handler: (host: ToolHost, params: Record) => Promise;
+ handler: (
+ host: ToolHost,
+ params: Record,
+ ) => Promise;
/**
* Whether re-running this tool with the same params has no side effects.
* Drives the bridge's retry-on-timeout policy. Default `false` — a click must
@@ -69,7 +77,7 @@ export interface ToolDefinition {
}
export function textResult(text: string): ToolResult {
- return { content: [{ type: 'text', text }] };
+ return { content: [{ type: "text", text }] };
}
/**
@@ -78,11 +86,17 @@ export function textResult(text: string): ToolResult {
* contract wrapHandler enforces for thrown errors). Used by the meta tool.
*/
export function jsonError(payload: unknown): ToolResult {
- return { content: [{ type: 'text', text: JSON.stringify(payload) }], isError: true };
+ return {
+ content: [{ type: "text", text: JSON.stringify(payload) }],
+ isError: true,
+ };
}
export function errorResult(message: string): ToolResult {
- return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
+ return {
+ content: [{ type: "text", text: `Error: ${message}` }],
+ isError: true,
+ };
}
/**
@@ -110,7 +124,10 @@ export function payloadOf(err: unknown): unknown {
}
export function forwardHandler(name: string) {
- const handler = async (host: ToolHost, params: Record): Promise => {
+ const handler = async (
+ host: ToolHost,
+ params: Record,
+ ): Promise => {
try {
const result = await host.callTool(name, params);
return textResult(JSON.stringify(result));
@@ -124,6 +141,9 @@ export function forwardHandler(name: string) {
return handler;
}
-export function imageResult(base64: string, mimeType = 'image/png'): ToolResult {
- return { content: [{ type: 'image', data: base64, mimeType }] };
+export function imageResult(
+ base64: string,
+ mimeType = "image/png",
+): ToolResult {
+ return { content: [{ type: "image", data: base64, mimeType }] };
}
diff --git a/mcp-server/src/tools/upload-file.ts b/mcp-server/src/tools/upload-file.ts
index 3c13224..3dc04a5 100644
--- a/mcp-server/src/tools/upload-file.ts
+++ b/mcp-server/src/tools/upload-file.ts
@@ -5,7 +5,7 @@ import { requireTabId, forwardHandler } from './types.js';
export const uploadFileTool: ToolDefinition = {
name: 'browser_upload_file',
summary: 'Upload a file to a file input element', description:
- 'Upload a local file into an WITHOUT opening the file dialog: CDP DOM.setFileInputFiles sets the files as if the user picked them, then input+change events fire so React/Vue handlers react. Works even on strict-CSP pages. Paths are absolute and local to the machine running the browser. Target the input with a ref from snapshot, a CSS selector, or omit both to auto-find the first input[type="file"]. Use files (array) for multiple uploads — requires an input that allows multiple.',
+ 'Upload a local file into an WITHOUT opening the file dialog: CDP DOM.setFileInputFiles sets the files as if the user picked them, then input+change events fire so React/Vue handlers react. Works even on strict-CSP pages. Paths are absolute and local to the machine running the browser. Target the input with a ref from snapshot, a CSS selector, or omit both to auto-find the first input[type="file"]. Use files (array) for multiple uploads — requires an input that allows multiple. Without a local file: imageBase64 (+fileName/mimeType) or fromScreenshot:true uploads bytes directly — into a file input, or as a drag-and-drop onto a drop zone (ref/selector/x+y).',
inputSchema: z.object({
tabId: requireTabId(),
ref: z.string().optional().describe('Element reference from snapshot (e.g. "e12")'),
@@ -15,6 +15,15 @@ export const uploadFileTool: ToolDefinition = {
.array(z.string())
.optional()
.describe('Array of local file paths to upload (multiple files)'),
+ imageBase64: z.string().optional().describe('File bytes as base64 (no data: prefix) — uploads without a local file'),
+ fileName: z.string().optional().describe('Name for imageBase64 / screenshot uploads (default image.png / screenshot.png)'),
+ mimeType: z.string().optional().describe('MIME type for imageBase64 (default image/png)'),
+ fromScreenshot: z.boolean().optional().describe('Upload a fresh PNG screenshot (of screenshotTabId, default this tab; optional region)'),
+ screenshotTabId: z.number().int().optional().describe('Tab to screenshot for fromScreenshot'),
+ region: z.object({ x: z.number(), y: z.number(), width: z.number().positive(), height: z.number().positive() }).optional()
+ .describe('fromScreenshot: only this viewport rectangle'),
+ x: z.number().optional().describe('Drop target at viewport x (with y) when there is no ref/selector'),
+ y: z.number().optional().describe('Drop target at viewport y'),
}),
timeoutMs: 15_000,
handler: forwardHandler('browser_upload_file'),
diff --git a/mcp-server/src/tools/wait.ts b/mcp-server/src/tools/wait.ts
index db9688d..323f4ca 100644
--- a/mcp-server/src/tools/wait.ts
+++ b/mcp-server/src/tools/wait.ts
@@ -5,7 +5,7 @@ import { requireTabId, forwardHandler } from './types.js';
export const waitTool: ToolDefinition = {
name: 'browser_wait',
summary: 'Wait for a duration or condition', description:
- 'Wait for a condition: element to appear, element to disappear, or a fixed delay. Useful for SPAs and dynamic content.',
+ 'Wait for a condition: element to appear (any visible match, including inside shadow DOM / same-origin iframes), element to disappear, text to appear/disappear, the URL to change, or a fixed delay. Useful for SPAs and dynamic content.',
inputSchema: z.object({
tabId: requireTabId(),
selector: z.string().optional().describe('CSS selector to wait for'),
@@ -15,7 +15,9 @@ export const waitTool: ToolDefinition = {
.default('visible')
.describe('Wait until element is visible, hidden, or attached to DOM'),
timeout: z.number().optional().default(10000).describe('Max wait time in ms'),
- delay: z.number().optional().describe('Fixed delay in ms (ignores selector)'),
+ delay: z.number().optional().describe('Fixed delay in ms (ignores selector/text/urlIncludes)'),
+ text: z.string().optional().describe('Wait until this text is on the page (state "hidden": until it is gone). Case-insensitive.'),
+ urlIncludes: z.string().optional().describe('Wait until the tab URL contains this string (e.g. after a client-side navigation).'),
}),
timeoutMs: 60_000,
handler: forwardHandler('browser_wait'),
diff --git a/package-lock.json b/package-lock.json
index 4906e7a..5d34447 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "browser-controller",
- "version": "2.3.0",
+ "version": "2.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "browser-controller",
- "version": "2.3.0",
+ "version": "2.4.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@babel/core": "^8.0.6",
- "@babel/eslint-parser": "^7.29.9",
+ "@babel/eslint-parser": "^8.0.6",
"@babel/preset-typescript": "^8.0.1",
"@eslint/js": "^10.0.1",
"@types/node": "^26.6.2",
@@ -151,22 +151,22 @@
}
},
"node_modules/@babel/eslint-parser": {
- "version": "7.29.9",
- "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.29.9.tgz",
- "integrity": "sha512-GmrJTAtiRbNip+eKFqeuSvoHMJhKlH36j8U0yE3Gqn45B+SIO7NvsEiHtsmzkBqV1hLSMqYr41dI1aZLTkJ37g==",
+ "version": "8.0.6",
+ "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-8.0.6.tgz",
+ "integrity": "sha512-mXx93HLovamLnDDLQ4i48gm4HHtaoqJrbMAuJqrMfpCGJkJ5M4sXhRgKGTrxykg1P8tAN5myrJxsRZuMuTq+OQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
- "eslint-visitor-keys": "^2.1.0",
- "semver": "^6.3.1"
+ "eslint-scope": "^9.1.0",
+ "eslint-visitor-keys": "^5.0.0",
+ "verkit": "^0.3.2"
},
"engines": {
- "node": "^10.13.0 || ^12.13.0 || >=14.0.0"
+ "node": "^22.18.0 || >=24.11.0"
},
"peerDependencies": {
- "@babel/core": "^7.11.0",
- "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0"
+ "@babel/core": "^8.0.0",
+ "eslint": "^9.0.0 || ^10.0.0"
}
},
"node_modules/@babel/generator": {
@@ -1240,16 +1240,6 @@
}
}
},
- "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
- "version": "5.1.1-v1",
- "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
- "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-scope": "5.1.1"
- }
- },
"node_modules/@oxc-project/types": {
"version": "0.149.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
@@ -2627,47 +2617,6 @@
}
},
"node_modules/eslint-scope": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
- "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "esrecurse": "^4.3.0",
- "estraverse": "^4.1.1"
- },
- "engines": {
- "node": ">=8.0.0"
- }
- },
- "node_modules/eslint-visitor-keys": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
- "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/eslint/node_modules/ajv": {
- "version": "6.15.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
- "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
- "uri-js": "^4.2.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
- "node_modules/eslint/node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
@@ -2686,7 +2635,7 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/eslint/node_modules/eslint-visitor-keys": {
+ "node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
@@ -2699,14 +2648,21 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/eslint/node_modules/estraverse": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
- "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "node_modules/eslint/node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/eslint/node_modules/json-schema-traverse": {
@@ -2734,19 +2690,6 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/espree/node_modules/eslint-visitor-keys": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
- "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -2760,16 +2703,6 @@
"node": ">=0.10"
}
},
- "node_modules/esquery/node_modules/estraverse": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
- "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
- }
- },
"node_modules/esrecurse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
@@ -2783,7 +2716,7 @@
"node": ">=4.0"
}
},
- "node_modules/esrecurse/node_modules/estraverse": {
+ "node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
@@ -2793,16 +2726,6 @@
"node": ">=4.0"
}
},
- "node_modules/estraverse": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
- "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
- }
- },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -4244,16 +4167,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
- "node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- }
- },
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
diff --git a/package.json b/package.json
index 4c6064f..c267009 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "browser-controller",
"mcpName": "io.github.noiemany/browser-controller",
- "version": "2.3.0",
+ "version": "2.4.0",
"description": "MCP server + Chrome extension that gives AI agents control of your real browser with existing sessions and logins",
"type": "module",
"bin": {
@@ -12,9 +12,14 @@
"build": "tsc -p mcp-server/tsconfig.json",
"dev": "tsc -p mcp-server/tsconfig.json --watch",
"start": "node mcp-server/dist/index.js",
+ "daemon:start": "node scripts/daemon.mjs start",
+ "daemon:stop": "node scripts/daemon.mjs stop",
+ "daemon:restart": "node scripts/daemon.mjs restart",
+ "daemon:status": "node scripts/daemon.mjs status",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest",
+ "smoke:real-chrome": "node scripts/smoke-real-chrome.mjs",
"lint": "eslint mcp-server/src tests extension vitest.config.ts eslint.config.js",
"security:audit": "npm audit --omit=dev --audit-level=high",
"typecheck": "tsc -p mcp-server/tsconfig.json --noEmit",
@@ -71,7 +76,7 @@
},
"devDependencies": {
"@babel/core": "^8.0.6",
- "@babel/eslint-parser": "^7.29.9",
+ "@babel/eslint-parser": "^8.0.6",
"@babel/preset-typescript": "^8.0.1",
"@eslint/js": "^10.0.1",
"@types/node": "^26.6.2",
diff --git a/scripts/daemon.mjs b/scripts/daemon.mjs
new file mode 100644
index 0000000..8796d2c
--- /dev/null
+++ b/scripts/daemon.mjs
@@ -0,0 +1,131 @@
+#!/usr/bin/env node
+/**
+ * Explicit lifecycle CLI for the single authoritative Browser Controller daemon.
+ * MCP clients may still auto-start it, but deployment/restart should use these
+ * commands so only this process manager is responsible for the runtime.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import { spawn } from 'node:child_process';
+import http from 'node:http';
+import net from 'node:net';
+import { fileURLToPath } from 'node:url';
+
+const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+const entry = path.join(root, 'mcp-server', 'dist', 'daemon.js');
+const stateDir = process.env.BC_STATE_DIR || path.join(os.homedir(), '.browser-controller');
+const infoFile = path.join(stateDir, 'daemon.json');
+const lockFile = path.join(stateDir, 'daemon.lock');
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+function readInfo() {
+ try { return JSON.parse(fs.readFileSync(infoFile, 'utf8')); } catch { return null; }
+}
+function pidAlive(pid) {
+ if (!pid) return false;
+ try { process.kill(pid, 0); return true; } catch { return false; }
+}
+function enrollmentSecret() {
+ try { return JSON.parse(fs.readFileSync(path.join(stateDir, 'enrollment.json'), 'utf8')).secret || null; } catch { return null; }
+}
+/** GET /status from the daemon's HTTP port (enrollment-gated); null when nothing answers. */
+function daemonStatus(info, timeoutMs = 1500) {
+ const secret = enrollmentSecret();
+ if (!info?.port || !secret) return Promise.resolve(null);
+ return new Promise((resolve) => {
+ const req = http.get({ host: info.host || '127.0.0.1', port: info.port, path: '/status', headers: { 'X-BC-Enrollment': secret }, timeout: timeoutMs }, (res) => {
+ let body = '';
+ res.on('data', (c) => { body += c; });
+ res.on('end', () => { try { resolve(res.statusCode === 200 ? JSON.parse(body) : null); } catch { resolve(null); } });
+ });
+ req.on('timeout', () => { req.destroy(); resolve(null); });
+ req.on('error', () => resolve(null));
+ });
+}
+/** Does a Browser Controller daemon answer on the IPC socket? (Any protocol frame back proves it.) */
+function ipcAnswers(socketPath, timeoutMs = 1500) {
+ if (!socketPath) return Promise.resolve(false);
+ return new Promise((resolve) => {
+ let done = false;
+ const finish = (v) => { if (!done) { done = true; clearTimeout(t); sock.destroy(); resolve(v); } };
+ const sock = net.createConnection(socketPath);
+ const t = setTimeout(() => finish(false), timeoutMs);
+ let buf = '';
+ sock.setEncoding('utf8');
+ // Not a hello: the daemon answers {kind:"denied", reason:"first frame must be hello"}.
+ sock.on('connect', () => sock.write(JSON.stringify({ kind: 'probe' }) + '\n'));
+ sock.on('data', (c) => {
+ buf += c;
+ const line = buf.split('\n')[0];
+ try { const msg = JSON.parse(line); finish(!!msg && typeof msg.kind === 'string'); } catch { /* wait for the rest */ }
+ });
+ sock.on('error', () => finish(false));
+ sock.on('close', () => finish(false));
+ });
+}
+/**
+ * Is the pid in daemon.json really the daemon? A live pid alone is not proof
+ * (pids are reused; the file can be stale): the daemon itself must answer —
+ * its /status reports its pid; an older daemon without that field must at
+ * least answer on the recorded IPC socket.
+ */
+async function live(info) {
+ if (!pidAlive(info?.pid)) return false;
+ const status = await daemonStatus(info);
+ if (status && typeof status.pid === 'number') return status.pid === info.pid;
+ return ipcAnswers(info.socket);
+}
+async function waitForStart(timeoutMs = 8000) {
+ const end = Date.now() + timeoutMs;
+ while (Date.now() < end) {
+ const info = readInfo();
+ if (await live(info)) return info;
+ await sleep(100);
+ }
+ throw new Error('daemon did not become ready; see daemon.log');
+}
+function staleNote(info) {
+ return info?.pid && pidAlive(info.pid)
+ ? ` (daemon.json names pid ${info.pid}, which is alive but is not a Browser Controller daemon — left untouched)`
+ : '';
+}
+async function start() {
+ if (!fs.existsSync(entry)) throw new Error('daemon build missing; run `npm run build` first');
+ const existing = readInfo();
+ if (await live(existing)) {
+ console.log(`daemon already running (pid ${existing.pid}) on ${existing.host}:${existing.port}`);
+ return;
+ }
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
+ const log = fs.openSync(path.join(stateDir, 'daemon.log'), 'a', 0o600);
+ const child = spawn(process.execPath, [entry], { detached: true, stdio: ['ignore', log, log], env: process.env });
+ child.unref();
+ const info = await waitForStart();
+ console.log(`daemon started (pid ${info.pid})`);
+ console.log(`MCP stdio entry: ${path.join(root, 'mcp-server', 'dist', 'index.js')}`);
+ console.log(`daemon endpoints: http/ws://${info.host}:${info.port} (WS + /pair /status /kill); IPC ${info.socket}`);
+}
+async function stop() {
+ const info = readInfo();
+ if (!(await live(info))) {
+ for (const file of [infoFile, lockFile]) { try { fs.unlinkSync(file); } catch {} }
+ console.log(`daemon is not running${staleNote(info)}`);
+ return;
+ }
+ // Identity verified above: only now is it safe to signal this pid.
+ process.kill(info.pid, 'SIGTERM');
+ const end = Date.now() + 5000;
+ while (Date.now() < end && pidAlive(info.pid)) await sleep(100);
+ if (pidAlive(info.pid)) throw new Error(`daemon pid ${info.pid} did not stop`);
+ console.log('daemon stopped');
+}
+const command = process.argv[2] || 'status';
+if (command === 'start') await start();
+else if (command === 'stop') await stop();
+else if (command === 'restart') { await stop(); await start(); }
+else if (command === 'status') {
+ const info = readInfo();
+ if (!(await live(info))) { console.log(`daemon is not running${staleNote(info)}`); process.exitCode = 1; }
+ else console.log(JSON.stringify({ ...info, running: true }, null, 2));
+} else throw new Error(`unknown command: ${command}`);
diff --git a/scripts/smoke-real-chrome.mjs b/scripts/smoke-real-chrome.mjs
new file mode 100644
index 0000000..e06f072
--- /dev/null
+++ b/scripts/smoke-real-chrome.mjs
@@ -0,0 +1,211 @@
+#!/usr/bin/env node
+/**
+ * Real-profile smoke test for extension reconnect and core tools.
+ * Requires the unpacked extension enrolled in the user's normal Chrome profile.
+ */
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import net from 'node:net';
+import http from 'node:http';
+import { spawn, execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const STATE = path.join(os.homedir(), '.browser-controller');
+const SOCKET = path.join(STATE, 'daemon.sock');
+const TOKEN = JSON.parse(fs.readFileSync(path.join(STATE, 'token.json'), 'utf8')).token;
+const DAEMON = path.join(ROOT, 'mcp-server', 'dist', 'daemon.js');
+const UPLOAD = path.join(STATE, 'smoke-upload.txt');
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+const execFileAsync = promisify(execFile);
+const SYSTEMD_SERVICE = 'browser-controller-daemon.service';
+const userSystemdEnv = {
+ ...process.env,
+ XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid?.() ?? 1000}`,
+};
+if (!userSystemdEnv.DBUS_SESSION_BUS_ADDRESS) {
+ userSystemdEnv.DBUS_SESSION_BUS_ADDRESS = `unix:path=${userSystemdEnv.XDG_RUNTIME_DIR}/bus`;
+}
+const log = (...args) => console.log('[smoke]', ...args);
+let server, daemon, tabId, client, startedByTest = false;
+
+function textOf(result) {
+ if (result?.content) return result.content.map(x => x.text || '').join('\n');
+ return typeof result === 'string' ? result : JSON.stringify(result);
+}
+function parseText(result) { try { return JSON.parse(textOf(result)); } catch { return null; } }
+function refsIn(result, role) {
+ const refs = [];
+ const walk = node => {
+ if (!node || typeof node !== 'object') return;
+ if (node.role === role && node.ref) refs.push(node.ref);
+ for (const child of node.children || []) walk(child);
+ if (node.tree) walk(node.tree);
+ };
+ walk(parseText(result));
+ return refs;
+}
+function assert(value, message) { if (!value) throw new Error(message); }
+function daemonAlive() {
+ return new Promise(resolve => {
+ const s = net.createConnection(SOCKET);
+ s.once('connect', () => { s.destroy(); resolve(true); });
+ s.once('error', () => resolve(false));
+ });
+}
+function startDaemon() {
+ if (!fs.existsSync(DAEMON)) throw new Error(`Run npm run build first (missing ${DAEMON})`);
+ daemon = spawn(process.execPath, [DAEMON], { cwd: ROOT, stdio: 'ignore', detached: false });
+ daemon.on('error', error => console.error('[daemon]', error.message));
+}
+async function waitForDaemon() {
+ for (let i = 0; i < 300; i++) { if (await daemonAlive()) return; await sleep(100); }
+ throw new Error(`daemon did not become ready: ${SOCKET}`);
+}
+async function stopDaemon() {
+ if (!daemon) {
+ try {
+ const info = JSON.parse(fs.readFileSync(path.join(STATE, 'daemon.json'), 'utf8'));
+ daemon = { kill: () => process.kill(info.pid, 'SIGTERM'), once: (_event, done) => done() };
+ } catch { return; }
+ }
+ const exited = new Promise(resolve => daemon.once('exit', resolve));
+ daemon.kill('SIGTERM');
+ await Promise.race([exited, sleep(1500)]);
+ daemon = undefined;
+ for (let i = 0; i < 30 && await daemonAlive(); i++) await sleep(100);
+}
+async function call(tool, params) { return client.call(tool, params); }
+
+async function hasManagedDaemon() {
+ try {
+ const { stdout } = await execFileAsync('systemctl', ['--user', 'show', '--property=LoadState', '--value', SYSTEMD_SERVICE], { env: userSystemdEnv });
+ return stdout.trim() === 'loaded';
+ } catch {
+ return false;
+ }
+}
+
+async function restartManagedDaemon() {
+ await execFileAsync('systemctl', ['--user', 'restart', SYSTEMD_SERVICE], { env: userSystemdEnv });
+ await waitForDaemon();
+}
+
+async function startManagedDaemon() {
+ await execFileAsync('systemctl', ['--user', 'start', SYSTEMD_SERVICE], { env: userSystemdEnv });
+ await waitForDaemon();
+}
+
+
+async function connect() {
+ const socket = net.createConnection(SOCKET);
+ socket.setEncoding('utf8');
+ let buffer = '', sequence = 0;
+ const pending = new Map();
+ let readyResolve, readyReject;
+ const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
+ socket.on('connect', () => socket.write(JSON.stringify({ kind: 'hello', token: TOKEN, agentName: 'RealChromeSmoke' }) + '\n'));
+ socket.on('data', chunk => {
+ buffer += chunk;
+ let end;
+ while ((end = buffer.indexOf('\n')) >= 0) {
+ const line = buffer.slice(0, end).trim(); buffer = buffer.slice(end + 1);
+ if (!line) continue;
+ const msg = JSON.parse(line);
+ if (msg.kind === 'welcome') readyResolve(msg);
+ if (msg.kind === 'ping') socket.write(JSON.stringify({ kind: 'pong' }) + '\n');
+ if (msg.kind === 'result' && pending.has(msg.id)) {
+ const done = pending.get(msg.id); pending.delete(msg.id); done(msg);
+ }
+ }
+ });
+ socket.on('error', error => { readyReject(error); });
+ await ready;
+ return {
+ socket,
+ call(tool, params) {
+ return new Promise((resolve, reject) => {
+ const id = String(++sequence);
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`${tool} timed out`)); }, 30000);
+ pending.set(id, msg => { clearTimeout(timer); msg.success ? resolve(msg.result) : reject(new Error(msg.error || tool)); });
+ socket.write(JSON.stringify({ kind: 'call', id, tool, params }) + '\n');
+ });
+ },
+ close() { socket.destroy(); },
+ };
+}
+
+async function main() {
+ fs.writeFileSync(UPLOAD, 'browser-controller smoke upload\n');
+ server = http.createServer((req, res) => {
+ if (req.url.startsWith('/api')) { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); return; }
+ res.writeHead(200, { 'content-type': 'text/html' });
+ res.end(`Browser Controller Smoke Smoke page Click me not clicked
`);
+ });
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ const page = `http://127.0.0.1:${server.address().port}/`;
+ const managedDaemon = await hasManagedDaemon();
+ const wasRunning = await daemonAlive();
+ if (managedDaemon) {
+ if (!wasRunning) await startManagedDaemon();
+ log(wasRunning ? 'systemd daemon already running; restarting service during test' : 'started systemd daemon for test');
+ } else if (!wasRunning) {
+ startDaemon();
+ startedByTest = true;
+ } else {
+ log('unmanaged daemon already running; restarting it during test');
+ }
+ await waitForDaemon();
+ client = await connect();
+ await call('browser_tabs', { action: 'list' });
+ client.close();
+ if (managedDaemon) {
+ await restartManagedDaemon();
+ } else {
+ await stopDaemon();
+ if (!(await daemonAlive())) startDaemon();
+ await waitForDaemon();
+ }
+ client = await connect();
+ assert(Array.isArray((await call('browser_tabs', { action: 'list' }))?.tabs), 'tab list failed after daemon restart');
+ log('daemon restart/reconnect verified');
+
+ const created = await call('browser_tabs', { action: 'create', url: page });
+ tabId = created.tabId ?? created.tabs?.[0]?.id;
+ assert(Number.isInteger(tabId), `could not determine created tabId: ${JSON.stringify(created)}`);
+ assert(Array.isArray((await call('browser_tabs', { action: 'list' }))?.tabs), 'tab list failed');
+ const snapshot = await call('browser_snapshot', { tabId, compact: false });
+ assert(textOf(snapshot).includes('Smoke page'), 'snapshot did not contain smoke page');
+ const buttonRef = refsIn(snapshot, 'button')[0];
+ const inputRef = refsIn(snapshot, 'textbox')[0];
+ assert(buttonRef && inputRef, 'snapshot did not provide button/textbox refs');
+ log(`snapshot ok (tab ${tabId})`);
+ await call('browser_click', { tabId, ref: buttonRef });
+ const clicked = await call('browser_evaluate', { tabId, expression: 'document.querySelector("#result").textContent' });
+ assert(textOf(clicked).includes('clicked'), `click verification failed: ${textOf(clicked)}`);
+ await call('browser_type', { tabId, ref: inputRef, text: 'typed by smoke', clear: true });
+ const value = await call('browser_evaluate', { tabId, expression: 'document.querySelector("#text").value' });
+ assert(textOf(value).includes('typed by smoke'), `type verification failed: ${textOf(value)}`);
+ log('click and type ok');
+ const consoleResult = await call('browser_console', { tabId });
+ assert(textOf(consoleResult).includes('SMOKE_CONSOLE_MARKER'), 'console marker was not captured');
+ const networkResult = await call('browser_network', { tabId, filter: '/api' });
+ assert(textOf(networkResult).includes('/api'), 'network request was not captured');
+ log('console and network ok');
+ await call('browser_upload_file', { tabId, selector: '#file', filePath: UPLOAD });
+ const files = await call('browser_evaluate', { tabId, expression: 'document.querySelector("#file").files[0]?.name' });
+ assert(textOf(files).includes('smoke-upload.txt'), `upload verification failed: ${textOf(files)}`);
+ log('upload ok');
+ await call('browser_tabs', { action: 'close', tabId }); tabId = undefined;
+ log('PASS: real Chrome profile, reconnect, and all requested tools verified');
+}
+
+main().catch(error => { console.error('[smoke] FAIL:', error.message); process.exitCode = 1; }).finally(async () => {
+ if (client) client.close();
+ if (tabId !== undefined) { try { await call('browser_tabs', { action: 'close', tabId }); } catch {} }
+ if (server) await new Promise(resolve => server.close(resolve));
+ if (startedByTest) await stopDaemon();
+ try { fs.unlinkSync(UPLOAD); } catch {}
+});
diff --git a/tests/bridge-multibrowser.test.ts b/tests/bridge-multibrowser.test.ts
new file mode 100644
index 0000000..49d198e
--- /dev/null
+++ b/tests/bridge-multibrowser.test.ts
@@ -0,0 +1,107 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { WebSocket } from 'ws';
+import { ExtensionBridge } from '../mcp-server/src/bridge.js';
+import { buildExtensionHelloAck } from '../mcp-server/src/protocol.js';
+import { buildExtensionHelloAck as extensionHelloAck } from '../extension/lib/protocol.js';
+
+let port = 26_000 + (process.pid % 3_000);
+const sockets: WebSocket[] = [];
+const bridges: ExtensionBridge[] = [];
+
+afterEach(async () => {
+ sockets.forEach((s) => { try { s.close(); } catch { /* closed */ } });
+ sockets.length = 0;
+ bridges.forEach((b) => b.stop());
+ bridges.length = 0;
+ await new Promise((r) => setTimeout(r, 30));
+});
+
+async function startBridge() {
+ const bridge = new ExtensionBridge({ port: ++port, maxRetries: 0, pingIntervalMs: 60_000, handshakeGraceMs: 500 });
+ bridges.push(bridge);
+ await bridge.start();
+ return { bridge, port };
+}
+
+/** A fake extension: answers the hello with its browser identity and echoes tool calls with its name. */
+async function fakeBrowser(p: number, browserId: string, opts: { silent?: boolean } = {}) {
+ const ws = new WebSocket(`ws://localhost:${p}`);
+ sockets.push(ws);
+ const calls: string[] = [];
+ ws.on('message', (data) => {
+ const msg = JSON.parse(data.toString());
+ if (msg.type === 'hello') ws.send(JSON.stringify({ ...buildExtensionHelloAck('test'), browserId, browserLabel: `Chrome ${browserId}` }));
+ if (msg.tool) {
+ calls.push(msg.tool);
+ if (!opts.silent) ws.send(JSON.stringify({ id: msg.id, success: true, result: { from: browserId } }));
+ }
+ });
+ await new Promise((resolve, reject) => { ws.on('open', () => resolve()); ws.on('error', reject); });
+ await new Promise((r) => setTimeout(r, 60));
+ return { ws, calls };
+}
+
+describe('multi-browser bridge', () => {
+ it('keeps several browsers connected; the newest is the default', async () => {
+ const { bridge, port: p } = await startBridge();
+ await fakeBrowser(p, 'work');
+ await fakeBrowser(p, 'home');
+ const list = bridge.callTool('browser_list_browsers', {}, 's1') as Promise;
+ const { browsers } = await list;
+ expect(browsers.map((b: any) => b.browserId).sort()).toEqual(['home', 'work']);
+ expect(browsers.find((b: any) => b.default).browserId).toBe('home');
+ expect(await bridge.callTool('browser_tabs', { action: 'list' }, 's1')).toEqual({ from: 'home' });
+ });
+
+ it('routes a session to the browser it selected, other sessions keep the default', async () => {
+ const { bridge, port: p } = await startBridge();
+ await fakeBrowser(p, 'work');
+ await fakeBrowser(p, 'home');
+ expect(await bridge.callTool('browser_select_browser', { browserId: 'Chrome work' }, 's1')).toMatchObject({ selected: 'work' });
+ expect(await bridge.callTool('browser_tabs', { action: 'list' }, 's1')).toEqual({ from: 'work' });
+ expect(await bridge.callTool('browser_tabs', { action: 'list' }, 's2')).toEqual({ from: 'home' });
+ await bridge.callTool('browser_select_browser', { browserId: 'auto' }, 's1');
+ expect(await bridge.callTool('browser_tabs', { action: 'list' }, 's1')).toEqual({ from: 'home' });
+ await expect(bridge.callTool('browser_select_browser', { browserId: 'nope' }, 's1')).rejects.toThrow(/No connected browser/);
+ });
+
+ it('a reconnect of the same browser replaces its old socket; a different browser is untouched', async () => {
+ const { bridge, port: p } = await startBridge();
+ const first = await fakeBrowser(p, 'work');
+ await fakeBrowser(p, 'home');
+ const firstClosed = new Promise((r) => first.ws.once('close', () => r()));
+ await fakeBrowser(p, 'work');
+ await firstClosed;
+ const { browsers } = await (bridge.callTool('browser_list_browsers', {}, 's1') as Promise);
+ expect(browsers.map((b: any) => b.browserId).sort()).toEqual(['home', 'work']);
+ });
+
+ it('one browser disconnecting fails only its own calls', async () => {
+ const { bridge, port: p } = await startBridge();
+ const work = await fakeBrowser(p, 'work', { silent: true });
+ await fakeBrowser(p, 'home');
+ await bridge.callTool('browser_select_browser', { browserId: 'work' }, 's1');
+ const hanging = bridge.callTool('browser_wait', { delay: 10 }, 's1');
+ await new Promise((r) => setTimeout(r, 50));
+ work.ws.close();
+ await expect(hanging).rejects.toThrow(/disconnected/);
+ expect(await bridge.callTool('browser_tabs', { action: 'list' }, 's2')).toEqual({ from: 'home' });
+ // The session that picked the gone browser gets a clear error, not the wrong browser.
+ await expect(bridge.callTool('browser_tabs', { action: 'list' }, 's1')).rejects.toThrow(/not connected/);
+ });
+
+ it('releasing a session forgets its browser choice', async () => {
+ const { bridge, port: p } = await startBridge();
+ await fakeBrowser(p, 'work');
+ await fakeBrowser(p, 'home');
+ await bridge.callTool('browser_select_browser', { browserId: 'work' }, 's1');
+ bridge.sendControl('releaseSession', { sessionId: 's1' });
+ expect(await bridge.callTool('browser_tabs', { action: 'list' }, 's1')).toEqual({ from: 'home' });
+ });
+
+ it('the extension announces its browser identity in helloAck', () => {
+ expect(extensionHelloAck('2.4.0', { browserId: 'abc123', browserLabel: 'Chrome on Windows (abc1)' }))
+ .toMatchObject({ type: 'helloAck', browserId: 'abc123', browserLabel: 'Chrome on Windows (abc1)' });
+ expect(extensionHelloAck('2.4.0')).not.toHaveProperty('browserId');
+ });
+});
diff --git a/tests/daemon-config.test.ts b/tests/daemon-config.test.ts
index ad539c6..d360266 100644
--- a/tests/daemon-config.test.ts
+++ b/tests/daemon-config.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, afterEach } from 'vitest';
-import { envInt } from '../mcp-server/src/daemon-config.js';
+import { envInt, DEFAULT_WS_HOST } from '../mcp-server/src/daemon-config.js';
/**
* envInt (critical audit #9): bare `parseInt(process.env.X || '…')` yielded
@@ -11,6 +11,11 @@ import { envInt } from '../mcp-server/src/daemon-config.js';
const SET_KEYS = ['BC_TEST_INT', 'WS_PORT'] as const;
describe('envInt', () => {
+ it('keeps the control plane loopback-only', () => {
+ expect(DEFAULT_WS_HOST).toBe('127.0.0.1');
+ });
+
+
afterEach(() => {
for (const k of SET_KEYS) delete process.env[k];
});
diff --git a/tests/daemon-lifecycle-identity.test.ts b/tests/daemon-lifecycle-identity.test.ts
new file mode 100644
index 0000000..27d5cb8
--- /dev/null
+++ b/tests/daemon-lifecycle-identity.test.ts
@@ -0,0 +1,49 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+const SCRIPT = path.join(ROOT, 'scripts', 'daemon.mjs');
+const helpers: ChildProcess[] = [];
+
+afterEach(() => {
+ for (const h of helpers) { try { h.kill(); } catch { /* gone */ } }
+ helpers.length = 0;
+});
+
+const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
+
+/** A state dir whose daemon.json names a live process that is NOT a daemon. */
+function impostorState() {
+ const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bc-lifecycle-'));
+ const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1e9)'], { stdio: 'ignore' });
+ helpers.push(helper);
+ const socket = process.platform === 'win32' ? `\\\\.\\pipe\\bc-test-none-${process.pid}-${Date.now()}` : path.join(stateDir, 'none.sock');
+ fs.writeFileSync(path.join(stateDir, 'daemon.json'), JSON.stringify({ pid: helper.pid, socket, port: 1, host: '127.0.0.1', startedAt: Date.now() }));
+ return { stateDir, helper };
+}
+
+const run = (cmd: string, stateDir: string) => spawnSync(process.execPath, [SCRIPT, cmd], {
+ env: { ...process.env, BC_STATE_DIR: stateDir }, encoding: 'utf8', timeout: 20_000,
+});
+
+describe('scripts/daemon.mjs verifies daemon identity before trusting a pid', () => {
+ it('status does not report an unrelated live pid as a running daemon', () => {
+ const { stateDir, helper } = impostorState();
+ const res = run('status', stateDir);
+ expect(res.status).toBe(1);
+ expect(res.stdout).toMatch(/not running/);
+ expect(res.stdout).toContain(`pid ${helper.pid}`);
+ });
+
+ it('stop never signals a process that is not the daemon', () => {
+ const { stateDir, helper } = impostorState();
+ const res = run('stop', stateDir);
+ expect(res.stdout).toMatch(/not running/);
+ expect(alive(helper.pid!)).toBe(true);
+ expect(fs.existsSync(path.join(stateDir, 'daemon.json'))).toBe(false); // stale metadata cleaned
+ });
+});
diff --git a/tests/extension-agent-api.test.ts b/tests/extension-agent-api.test.ts
index dec7bd2..3e2156f 100644
--- a/tests/extension-agent-api.test.ts
+++ b/tests/extension-agent-api.test.ts
@@ -32,6 +32,8 @@ let queryNodeId = 2;
debuggerCommands.push(method);
if (method === 'DOM.getDocument') return { root: { nodeId: 1 } };
if (method === 'DOM.querySelector') return { nodeId: queryNodeId };
+ // upload_file finds the marked input in the page and hands CDP its objectId.
+ if (method === 'Runtime.evaluate') return { result: queryNodeId ? { objectId: 'obj-1' } : { type: 'object', subtype: 'null' } };
return {};
},
},
@@ -196,7 +198,7 @@ describe('observe/act extension handlers', () => {
}, 'session-a');
expect(result).toMatchObject({ success: true, ok: true, action: 'upload', files: ['/tmp/resume.pdf'] });
- expect(debuggerCommands.filter((m) => m.startsWith('DOM.'))).toEqual(['DOM.enable', 'DOM.getDocument', 'DOM.querySelector', 'DOM.setFileInputFiles']);
+ expect(debuggerCommands.filter((m) => m.startsWith('DOM.'))).toEqual(['DOM.setFileInputFiles']);
expect(result.metrics).toMatchObject({ protocolCalls: 8 });
});
diff --git a/tests/extension-intercept-dnr.test.ts b/tests/extension-intercept-dnr.test.ts
new file mode 100644
index 0000000..4727b40
--- /dev/null
+++ b/tests/extension-intercept-dnr.test.ts
@@ -0,0 +1,111 @@
+import { beforeEach, describe, expect, it } from "vitest";
+
+/**
+ * Enforcement against a STATEFUL declarativeNetRequest mock: what Chrome
+ * actually has installed must match what browser_intercept reports.
+ */
+type DnrRule = { id: number; action: Record; condition: Record };
+const sessionRules = new Map();
+const dynamicRules = new Map();
+
+(globalThis as unknown as { chrome: unknown }).chrome = {
+ tabs: { get: async (id: number) => ({ id, url: "https://x.test/", windowId: 1 }), query: async () => [] },
+ scripting: { executeScript: async () => [{ result: null }] },
+ storage: { session: { get: async () => ({}), set: async () => {} } },
+ declarativeNetRequest: {
+ getSessionRules: async () => [...sessionRules.values()],
+ updateSessionRules: async ({ removeRuleIds = [], addRules = [] }: { removeRuleIds?: number[]; addRules?: DnrRule[] }) => {
+ for (const id of removeRuleIds) sessionRules.delete(id);
+ for (const r of addRules) {
+ if (sessionRules.has(r.id)) throw new Error(`Rule with id ${r.id} does not have a unique ID.`);
+ sessionRules.set(r.id, r);
+ }
+ },
+ getDynamicRules: async () => [...dynamicRules.values()],
+ updateDynamicRules: async ({ removeRuleIds = [], addRules = [] }: { removeRuleIds?: number[]; addRules?: DnrRule[] }) => {
+ for (const id of removeRuleIds) dynamicRules.delete(id);
+ for (const r of addRules) dynamicRules.set(r.id, r);
+ },
+ },
+};
+
+const { handleIntercept, rulesByScope, enrichCapture } = await import("../extension/handlers/intercept.js");
+
+describe("browser_intercept enforcement (stateful DNR)", () => {
+ beforeEach(async () => {
+ sessionRules.clear();
+ dynamicRules.clear();
+ rulesByScope.clear();
+ });
+
+ it("a tab-scoped block is installed as a tab-scoped session rule with its resource types", async () => {
+ const res = await handleIntercept({
+ action: "set-rules", tabId: 15,
+ rules: [{ id: "api", match: "https://api\\.x\\.test/", action: "block", types: ["xmlhttprequest"] }],
+ });
+ expect(res).toMatchObject({ success: true, enforcement: "full", scope: "tabs:15", enforced: 1 });
+ expect(dynamicRules.size).toBe(0); // never browser-wide dynamic rules
+ const [rule] = [...sessionRules.values()];
+ expect(rule.condition).toEqual({ regexFilter: "https://api\\.x\\.test/", tabIds: [15], resourceTypes: ["xmlhttprequest"] });
+ });
+
+ it("clearing removes the rules from Chrome too (not just from the list)", async () => {
+ await handleIntercept({ action: "set-rules", tabId: 15, rules: [{ match: "https://ads\\.test/", action: "block" }] });
+ await handleIntercept({ action: "set-rules", rules: [{ match: "https://old\\.test/", action: "redirect", redirectUrl: "https://new.test/" }] });
+ expect(sessionRules.size).toBe(2);
+ await handleIntercept({ action: "clear-rules", tabId: 15 });
+ expect([...sessionRules.values()].map((r) => r.condition.regexFilter)).toEqual(["https://old\\.test/"]);
+ await handleIntercept({ action: "clear-rules" });
+ expect(sessionRules.size).toBe(0);
+ expect((await handleIntercept({ action: "list-rules" })).rules).toEqual([]);
+ });
+
+ it("replacing a tab's rules replaces them (scope keys are deduplicated)", async () => {
+ await handleIntercept({
+ action: "set-rules", tabId: 15,
+ rules: [{ id: "a", match: "https://a\\.test/", action: "block" }, { id: "b", match: "https://b\\.test/", action: "block" }],
+ });
+ expect([...rulesByScope.keys()]).toEqual(["tabs:15"]);
+ await handleIntercept({ action: "set-rules", tabId: 15, rules: [{ id: "c", match: "https://c\\.test/", action: "block" }] });
+ expect([...rulesByScope.keys()]).toEqual(["tabs:15"]);
+ expect([...sessionRules.values()].map((r) => r.condition.regexFilter)).toEqual(["https://c\\.test/"]);
+ });
+
+ it("header rules are enforced as request-header modifications", async () => {
+ const res = await handleIntercept({
+ action: "set-rules", tabId: 15,
+ rules: [{ id: "h", match: "https://api\\.x\\.test/", action: "header", headers: { "X-Test": "1" } }],
+ });
+ expect(res).toMatchObject({ enforcement: "full", enforced: 1 });
+ expect([...sessionRules.values()][0].action).toEqual({
+ type: "modifyHeaders", requestHeaders: [{ header: "X-Test", operation: "set", value: "1" }],
+ });
+ });
+
+ it("mock rules are not claimed as enforced", async () => {
+ const res = await handleIntercept({
+ action: "set-rules", tabId: 15,
+ rules: [
+ { id: "m", match: "https://api\\.x\\.test/v1", action: "mock", mockStatus: 200, mockBody: "{}" },
+ { id: "b", match: "https://ads\\.test/", action: "block" },
+ ],
+ });
+ expect(res).toMatchObject({ enforcement: "partial", enforced: 1 });
+ expect(res.unsupported).toEqual([expect.objectContaining({ id: "m", action: "mock" })]);
+ expect(sessionRules.size).toBe(1);
+ const entry = enrichCapture(15, { url: "https://api.x.test/v1/users", type: "xmlhttprequest", method: "GET" }) as { intercept: { applied: Array<{ ruleId: string; outcome: string }> } };
+ expect(entry.intercept.applied).toEqual([{ ruleId: "m", outcome: "ledger-only" }]);
+ const blocked = enrichCapture(15, { url: "https://ads.test/x.js", type: "script", method: "GET" }) as { intercept: { applied: Array<{ outcome: string }> } };
+ expect(blocked.intercept.applied[0].outcome).toBe("enforced");
+ const listed = await handleIntercept({ action: "list-rules", tabId: 15 });
+ expect(listed.enforcement).toBe("partial");
+ expect(listed.rules.map((r: { id: string; enforced: boolean }) => [r.id, r.enforced])).toEqual([["m", false], ["b", true]]);
+ });
+
+ it("rules installed by a previous service worker are removed on the next sync", async () => {
+ sessionRules.set(1003, { id: 1003, action: { type: "block" }, condition: { regexFilter: "stale" } });
+ sessionRules.set(42, { id: 42, action: { type: "block" }, condition: { regexFilter: "not ours" } });
+ await handleIntercept({ action: "set-rules", tabId: 15, rules: [{ match: "https://a\\.test/", action: "block" }] });
+ expect([...sessionRules.keys()].sort((a, b) => a - b)).toEqual([42, 1000]);
+ });
+});
diff --git a/tests/extension-intercept.test.ts b/tests/extension-intercept.test.ts
new file mode 100644
index 0000000..71c9bea
--- /dev/null
+++ b/tests/extension-intercept.test.ts
@@ -0,0 +1,309 @@
+import { vi, describe, it, expect, beforeEach } from "vitest";
+
+/**
+ * Intercept plane: pure engine (S2) + handler CRUD/degrade/HAR (S3/S4).
+ * chrome mock has NO declarativeNetRequest -> every enforcement assertion
+ * must expect capture-only degradation, never a throw.
+ */
+
+const tabStore = new Map<
+ number,
+ { id: number; windowId: number; url: string; title: string; active: boolean }
+>();
+(globalThis as unknown as { chrome: unknown }).chrome = {
+ tabs: {
+ get: async (id: number) =>
+ tabStore.get(id) ?? Promise.reject(new Error(`No tab ${id}`)),
+ query: async () => [],
+ update: async () => ({}),
+ remove: async () => ({}),
+ create: async () => ({}),
+ },
+ scripting: { executeScript: async () => [{ result: null }] },
+ storage: { session: { get: async () => ({}), set: async () => {} } },
+};
+
+const { validateRule, validateRuleSet, matchRule, evaluateRules } =
+ await import("../extension/lib/intercept.js");
+const { handleIntercept, enrichCapture, rulesByScope, interceptLedgerByTab } =
+ await import("../extension/handlers/intercept.js");
+const { networkByTab } = await import("../extension/lib/state.js");
+
+describe("pure engine (S2)", () => {
+ it("rejects invalid regex", () => {
+ expect(() => validateRule({ match: "([", action: "log" }, 0)).toThrow(
+ /invalid match regex/,
+ );
+ });
+
+ it("rejects match-all patterns", () => {
+ expect(() => validateRule({ match: ".*", action: "block" }, 0)).toThrow(
+ /match-all/,
+ );
+ expect(() => validateRule({ match: ".+", action: "block" }, 0)).toThrow(
+ /match-all/,
+ );
+ });
+
+ it("rejects empty match and unknown action", () => {
+ expect(() => validateRule({ match: "", action: "log" }, 0)).toThrow(
+ /match must be/,
+ );
+ expect(() =>
+ validateRule({ match: "https://x\\.com/", action: "nuke" }, 0),
+ ).toThrow(/action must be/);
+ });
+
+ it("rejects redirect without redirectUrl and oversize mock", () => {
+ expect(() =>
+ validateRule({ match: "https://x\\.com/", action: "redirect" }, 0),
+ ).toThrow(/redirectUrl/);
+ expect(() =>
+ validateRule(
+ {
+ match: "https://x\\.com/",
+ action: "mock",
+ mockBody: "x".repeat(20_001),
+ },
+ 0,
+ ),
+ ).toThrow(/mockBody exceeds/);
+ });
+
+ it("rejects 51 rules and duplicate ids", () => {
+ const many = Array.from({ length: 51 }, (_, i) => ({
+ id: `r${i}`,
+ match: `https://x${i}\\.com/`,
+ action: "log",
+ }));
+ expect(() => validateRuleSet(many)).toThrow(/Too many rules/);
+ expect(() =>
+ validateRuleSet([
+ { id: "dup", match: "https://a\\.com/", action: "log" },
+ { id: "dup", match: "https://b\\.com/", action: "log" },
+ ]),
+ ).toThrow(/Duplicate rule id/);
+ });
+
+ it("match respects regex + type + tab scope; disabled never matches", () => {
+ const rule = {
+ id: "r1",
+ match: "api\\.example\\.com",
+ types: ["xmlhttprequest"],
+ tabIds: [15],
+ action: "log",
+ };
+ expect(
+ matchRule(rule, {
+ url: "https://api.example.com/v1",
+ type: "xmlhttprequest",
+ tabId: 15,
+ }),
+ ).toBe(true);
+ expect(
+ matchRule(rule, {
+ url: "https://api.example.com/v1",
+ type: "image",
+ tabId: 15,
+ }),
+ ).toBe(false);
+ expect(
+ matchRule(rule, {
+ url: "https://api.example.com/v1",
+ type: "xmlhttprequest",
+ tabId: 16,
+ }),
+ ).toBe(false);
+ expect(
+ matchRule(
+ { ...rule, enabled: false },
+ {
+ url: "https://api.example.com/v1",
+ type: "xmlhttprequest",
+ tabId: 15,
+ },
+ ),
+ ).toBe(false);
+ expect(
+ evaluateRules([rule], {
+ url: "https://other.com/",
+ type: "xmlhttprequest",
+ tabId: 15,
+ }),
+ ).toEqual([]);
+ });
+});
+
+describe("handler CRUD + degrade (S3)", () => {
+ beforeEach(() => {
+ rulesByScope.clear();
+ interceptLedgerByTab.clear();
+ networkByTab.clear();
+ tabStore.clear();
+ tabStore.set(15, {
+ id: 15,
+ windowId: 1,
+ url: "https://app.example.com/",
+ title: "App",
+ active: true,
+ });
+ });
+
+ it("set/list/clear round-trips with capture-only enforcement (no DNR in tests)", async () => {
+ const set = await handleIntercept({
+ action: "set-rules",
+ tabId: 15,
+ rules: [{ match: "tracker\\.example\\.com", action: "block" }],
+ });
+ expect(set.success).toBe(true);
+ expect(set.enforcement).toBe("capture-only");
+ expect(set.ruleCount).toBe(1);
+
+ const list = await handleIntercept({ action: "list-rules", tabId: 15 });
+ expect(list.rules.length).toBe(1);
+
+ const clear = await handleIntercept({ action: "clear-rules", tabId: 15 });
+ expect(clear.success).toBe(true);
+ const after = await handleIntercept({ action: "list-rules", tabId: 15 });
+ expect(after.rules.length).toBe(0);
+ });
+
+ it("rejects invalid rules without persisting", async () => {
+ await expect(
+ handleIntercept({
+ action: "set-rules",
+ rules: [{ match: "([", action: "log" }],
+ }),
+ ).rejects.toThrow(/invalid match regex/);
+ const list = await handleIntercept({ action: "list-rules" });
+ expect(list.rules.length).toBe(0);
+ });
+
+ it("unknown action throws honestly", async () => {
+ await expect(handleIntercept({ action: "nuke" })).rejects.toThrow(
+ /Unknown intercept action/,
+ );
+ });
+});
+
+describe("capture + HAR (S4)", () => {
+ beforeEach(() => {
+ rulesByScope.clear();
+ interceptLedgerByTab.clear();
+ networkByTab.clear();
+ tabStore.clear();
+ tabStore.set(15, {
+ id: 15,
+ windowId: 1,
+ url: "https://app.example.com/",
+ title: "App",
+ active: true,
+ });
+ });
+
+ it("enrichCapture marks matched entries and ledgers them", async () => {
+ await handleIntercept({
+ action: "set-rules",
+ rules: [{ id: "blk-track", match: "tracker\\.example", action: "block" }],
+ });
+ const entry: Record = {
+ method: "GET",
+ url: "https://tracker.example/ping",
+ status: 200,
+ type: "script",
+ };
+ const out = enrichCapture(15, entry);
+ expect(
+ (out.intercept as { matchedRuleIds: string[] }).matchedRuleIds,
+ ).toContain("blk-track");
+ expect(interceptLedgerByTab.get(15)?.length).toBe(1);
+ });
+
+ it("list-captures rejects invalid filter regex honestly", async () => {
+ await expect(
+ handleIntercept({ action: "list-captures", tabId: 15, filter: "([" }),
+ ).rejects.toThrow(/Invalid filter regex/);
+ });
+
+ it("export-har returns valid HAR 1.2 with redaction flag", async () => {
+ networkByTab.set(15, [
+ {
+ method: "GET",
+ url: "https://app.example.com/",
+ status: 200,
+ type: "main_frame",
+ timestamp: Date.now(),
+ },
+ ]);
+ const res = await handleIntercept({ action: "export-har", tabId: 15 });
+ expect(res.success).toBe(true);
+ expect(res.har.log.version).toBe("1.2");
+ expect(res.har.log.creator.name).toBe("browser-controller");
+ expect(res.entries).toBe(1);
+ expect(res.har.log.entries[0].response._redacted).toBe(true);
+ });
+
+ it("list-captures requires tabId", async () => {
+ await expect(handleIntercept({ action: "list-captures" })).rejects.toThrow(
+ /tabId required/,
+ );
+ });
+});
+
+describe("capture dedupe (fix)", () => {
+ beforeEach(() => {
+ rulesByScope.clear();
+ interceptLedgerByTab.clear();
+ networkByTab.clear();
+ tabStore.clear();
+ tabStore.set(15, {
+ id: 15,
+ windowId: 1,
+ url: "https://app.example.com/",
+ title: "App",
+ active: true,
+ });
+ });
+
+ it("a matched request appears once in list-captures (not ledger + buffer)", async () => {
+ await handleIntercept({
+ action: "set-rules",
+ rules: [{ id: "blk-track", match: "tracker\\.example", action: "block" }],
+ });
+ // Simulate the events.js flow: buffer push, then enrich (mutates + ledgers).
+ const buf = networkByTab.get(15) ?? [];
+ networkByTab.set(15, buf);
+ const entry = {
+ method: "GET",
+ url: "https://tracker.example/ping",
+ status: 200,
+ type: "script",
+ timestamp: 1234567890,
+ };
+ buf.push(entry);
+ enrichCapture(15, entry);
+ const res = await handleIntercept({ action: "list-captures", tabId: 15 });
+ expect(res.captures.length).toBe(1);
+ expect(res.captures[0].intercept.matchedRuleIds).toContain("blk-track");
+ });
+
+ it("export-har counts each request once", async () => {
+ await handleIntercept({
+ action: "set-rules",
+ rules: [{ id: "blk-track", match: "tracker\\.example", action: "block" }],
+ });
+ const buf: Record[] = [];
+ networkByTab.set(15, buf);
+ const entry = {
+ method: "GET",
+ url: "https://tracker.example/ping",
+ status: 200,
+ type: "script",
+ timestamp: 1234567890,
+ };
+ buf.push(entry);
+ enrichCapture(15, entry);
+ const res = await handleIntercept({ action: "export-har", tabId: 15 });
+ expect(res.entries).toBe(1);
+ });
+});
diff --git a/tests/extension-router.test.ts b/tests/extension-router.test.ts
index 1a973ce..c87fcfc 100644
--- a/tests/extension-router.test.ts
+++ b/tests/extension-router.test.ts
@@ -1,4 +1,4 @@
-import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { vi, describe, it, expect, beforeEach } from "vitest";
/**
* First behavior tests for the extension side (architecture item: background
@@ -9,8 +9,10 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
const sent = vi.hoisted(() => [] as Array>);
-vi.mock('../extension/lib/connection.js', () => ({
- sendJson: (obj: Record) => { sent.push(obj); },
+vi.mock("../extension/lib/connection.js", () => ({
+ sendJson: (obj: Record) => {
+ sent.push(obj);
+ },
updateBadge: () => {},
broadcastStatus: async () => {},
isWsConnected: () => true,
@@ -19,10 +21,14 @@ vi.mock('../extension/lib/connection.js', () => ({
// chrome.* mock — must exist before any handler runs (module evaluation of the
// handler modules never touches chrome; only function bodies do).
-const tabStore = new Map();
+const tabStore = new Map<
+ number,
+ { id: number; windowId: number; url: string; title: string; active: boolean }
+>();
(globalThis as unknown as { chrome: unknown }).chrome = {
tabs: {
- get: async (id: number) => tabStore.get(id) ?? Promise.reject(new Error(`No tab ${id}`)),
+ get: async (id: number) =>
+ tabStore.get(id) ?? Promise.reject(new Error(`No tab ${id}`)),
query: async () => [] as unknown[],
update: async () => ({}),
remove: async () => ({}),
@@ -34,14 +40,21 @@ const tabStore = new Map ({}), set: async () => {} },
local: { get: async () => ({}), set: async () => {} },
},
- runtime: { sendMessage: async () => {}, onMessage: { addListener: () => {} } },
+ runtime: {
+ sendMessage: async () => {},
+ onMessage: { addListener: () => {} },
+ },
alarms: { create: () => {}, onAlarm: { addListener: () => {} } },
webRequest: { onCompleted: { addListener: () => {} } },
- debugger: { attach: async () => {}, detach: async () => {}, sendCommand: async () => ({}) },
+ debugger: {
+ attach: async () => {},
+ detach: async () => {},
+ sendCommand: async () => ({}),
+ },
};
const { handleMessage, dispatchedTools } = await import('../extension/lib/router.js');
-const { tabLocks, observationSnapshots } = await import('../extension/lib/state.js');
+const { tabLocks, observationSnapshots, wedgedTabs } = await import('../extension/lib/state.js');
const { allTools } = await import('../mcp-server/src/tools/index.js');
function lastFrame(): Record {
@@ -54,7 +67,7 @@ async function flush(ms = 60): Promise {
await new Promise((r) => setTimeout(r, ms));
}
-describe('extension router (handleMessage)', () => {
+describe("extension router (handleMessage)", () => {
beforeEach(() => {
sent.length = 0;
tabStore.clear();
@@ -64,52 +77,103 @@ describe('extension router (handleMessage)', () => {
tabStore.set(3, { id: 3, windowId: 1, url: 'https://example.com/page', title: 'Page', active: true });
});
- it('answers an unknown tool with a wire-level error', async () => {
- await handleMessage({ id: 'n1', tool: 'browser_nope', params: {} });
- expect(lastFrame()).toMatchObject({ id: 'n1', success: false });
- expect((lastFrame().error as string)).toContain('Unknown tool');
+ it("answers an unknown tool with a wire-level error", async () => {
+ await handleMessage({ id: "n1", tool: "browser_nope", params: {} });
+ expect(lastFrame()).toMatchObject({ id: "n1", success: false });
+ expect(lastFrame().error as string).toContain("Unknown tool");
});
- it('marks in-band {success:false} results as wire failures WITH the payload (unified error channel)', async () => {
+ it("marks in-band {success:false} results as wire failures WITH the payload (unified error channel)", async () => {
// browser_wait without selector/delay returns in-band {success:false} —
// the router must send success:false + the payload as `result`, not wrap
// it in a success envelope.
- await handleMessage({ id: 'w1', tool: 'browser_wait', params: { tabId: 3 }, sessionId: 's1' });
+ await handleMessage({
+ id: "w1",
+ tool: "browser_wait",
+ params: { tabId: 3 },
+ sessionId: "s1",
+ });
await flush();
const frame = lastFrame();
- expect(frame.id).toBe('w1');
+ expect(frame.id).toBe("w1");
expect(frame.success).toBe(false);
- expect(frame.error).toBe('Need selector or delay');
- expect(frame.result).toEqual({ success: false, error: 'Need selector or delay' });
+ expect(frame.error).toBe('Need selector, text, urlIncludes or delay');
+ expect(frame.result).toEqual({ success: false, error: 'Need selector, text, urlIncludes or delay' });
+ });
+
+ it('frozen-tab navigate cannot replace a tab locked by another session', async () => {
+ const created: unknown[] = [];
+ const removed: number[] = [];
+ (globalThis as any).chrome.tabs.create = async (o: unknown) => { created.push(o); return { id: 99 }; };
+ (globalThis as any).chrome.tabs.remove = async (id: number) => { removed.push(id); };
+ tabLocks.lock(3, 'session-a');
+ wedgedTabs.set(3, Date.now());
+ await handleMessage({ id: 'fz1', tool: 'browser_navigate', params: { tabId: 3, url: 'https://example.com/x', snapshot: false }, sessionId: 'session-b' });
+ await flush();
+ expect(lastFrame()).toMatchObject({ id: 'fz1', success: false });
+ expect(String(lastFrame().error)).toMatch(/locked by session-a/);
+ expect(created).toEqual([]);
+ expect(removed).toEqual([]);
+ expect(tabLocks.owner(3)).toBe('session-a');
+ wedgedTabs.clear();
});
- it('converts a THROWN handler error into a wire-level error', async () => {
+ it('the lock owner recovering its own frozen tab keeps the lock on the replacement', async () => {
+ const { replaceFrozenTab } = await import('../extension/lib/page-exec.js');
+ (globalThis as any).chrome.tabs.create = async () => ({ id: 99 });
+ (globalThis as any).chrome.tabs.remove = async () => {};
+ tabLocks.lock(3, 'session-a');
+ wedgedTabs.set(3, Date.now());
+ await expect(replaceFrozenTab({ id: 3, windowId: 1, index: 0, active: true }, null, 'session-b')).rejects.toThrow(/locked by session-a/);
+ const fresh = await replaceFrozenTab({ id: 3, windowId: 1, index: 0, active: true }, null, 'session-a');
+ expect(fresh.id).toBe(99);
+ expect(tabLocks.owner(99)).toBe('session-a');
+ expect(tabLocks.owner(3)).toBeUndefined();
+ wedgedTabs.clear();
+ });
+
+ it("converts a THROWN handler error into a wire-level error", async () => {
// click with neither ref nor selector throws in requireTarget.
- await handleMessage({ id: 'c1', tool: 'browser_click', params: { tabId: 3 }, sessionId: 's1' });
+ await handleMessage({
+ id: "c1",
+ tool: "browser_click",
+ params: { tabId: 3 },
+ sessionId: "s1",
+ });
await flush();
const frame = lastFrame();
- expect(frame.id).toBe('c1');
+ expect(frame.id).toBe("c1");
expect(frame.success).toBe(false);
- expect((frame.error as string)).toContain('ref or selector is required');
+ expect(frame.error as string).toContain("ref or selector is required");
});
- it('routes a successful tool through the mutex and replies success', async () => {
- await handleMessage({ id: 'k1', tool: 'browser_console', params: { tabId: 3 }, sessionId: 's1' });
+ it("routes a successful tool through the mutex and replies success", async () => {
+ await handleMessage({
+ id: "k1",
+ tool: "browser_console",
+ params: { tabId: 3 },
+ sessionId: "s1",
+ });
await flush();
const frame = lastFrame();
- expect(frame.id).toBe('k1');
+ expect(frame.id).toBe("k1");
expect(frame.success).toBe(true);
expect((frame.result as { messages: unknown[] }).messages).toEqual([]);
});
- it('honors tab-lock ownership: a non-owner call waits instead of running (TOCTOU fix, end-to-end)', async () => {
- tabLocks.lock(3, 'ownerA');
- await handleMessage({ id: 'k2', tool: 'browser_console', params: { tabId: 3 }, sessionId: 'sessionB' });
+ it("honors tab-lock ownership: a non-owner call waits instead of running (TOCTOU fix, end-to-end)", async () => {
+ tabLocks.lock(3, "ownerA");
+ await handleMessage({
+ id: "k2",
+ tool: "browser_console",
+ params: { tabId: 3 },
+ sessionId: "sessionB",
+ });
// No reply yet — B is queued behind owner A's lock.
- expect(sent.filter((f) => f.id === 'k2')).toEqual([]);
- tabLocks.unlock(3, 'ownerA');
+ expect(sent.filter((f) => f.id === "k2")).toEqual([]);
+ tabLocks.unlock(3, "ownerA");
await new Promise((r) => setTimeout(r, 120));
- const frame = sent.find((f) => f.id === 'k2');
+ const frame = sent.find((f) => f.id === "k2");
expect(frame?.success).toBe(true);
});
});
@@ -239,7 +303,8 @@ describe('observe/act concurrency integration', () => {
describe('dispatch registry ↔ MCP tool registry (drift guard)', () => {
// Server-local tools never reach the extension: browser_batch runs other
// tools' handlers in the MCP process (the meta tool isn't in allTools).
- const wireTools = allTools.filter((t) => t.name !== 'browser_batch');
+ // browser_batch runs in the MCP process; browser selection is answered by the bridge.
+ const wireTools = allTools.filter((t) => !['browser_batch', 'browser_shortcuts', 'browser_list_browsers', 'browser_select_browser'].includes(t.name));
it('every registered MCP tool has an extension handler', () => {
for (const tool of wireTools) {
diff --git a/tests/gif-encoder.test.ts b/tests/gif-encoder.test.ts
new file mode 100644
index 0000000..ec980f0
--- /dev/null
+++ b/tests/gif-encoder.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest';
+import { encodeGif, lzwEncode, indexPixels, buildPalette, drawMarker } from '../extension/lib/gif-encoder.js';
+
+/** Reference GIF LZW decoder (spec algorithm) for round-trip checks. */
+function lzwDecode(data: number[]): number[] {
+ const min = data[0];
+ const bytes: number[] = [];
+ let i = 1;
+ while (data[i] !== 0) { const n = data[i]; bytes.push(...data.slice(i + 1, i + 1 + n)); i += n + 1; }
+ const clear = 1 << min;
+ const eoi = clear + 1;
+ let size = min + 1;
+ let dict: number[][] = [];
+ const reset = () => { dict = []; for (let k = 0; k < clear; k++) dict[k] = [k]; dict[clear] = []; dict[eoi] = []; size = min + 1; };
+ reset();
+ const out: number[] = [];
+ let bitPos = 0;
+ const read = () => {
+ let code = 0;
+ for (let b = 0; b < size; b++) {
+ const byte = bytes[(bitPos + b) >> 3];
+ if (((byte >> ((bitPos + b) & 7)) & 1) === 1) code |= 1 << b;
+ }
+ bitPos += size;
+ return code;
+ };
+ let prev: number[] | null = null;
+ for (;;) {
+ const code = read();
+ if (code === clear) { reset(); prev = null; continue; }
+ if (code === eoi) break;
+ let entry: number[];
+ if (dict[code]) entry = dict[code];
+ else if (prev) entry = [...prev, prev[0]];
+ else throw new Error('bad code');
+ out.push(...entry);
+ if (prev) {
+ dict.push([...prev, entry[0]]);
+ if (dict.length === (1 << size) && size < 12) size++;
+ }
+ prev = entry;
+ }
+ return out;
+}
+
+describe('GIF encoder', () => {
+ it('LZW round-trips short, repetitive and dictionary-overflowing data', () => {
+ const cases = [
+ [5],
+ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
+ Array.from({ length: 5000 }, (_, i) => (i * 7) % 256),
+ Array.from({ length: 60000 }, (_, i) => ((i * 2654435761) >>> 24) & 0xff), // forces dictionary resets
+ ];
+ for (const c of cases) expect(lzwDecode(lzwEncode(Uint8Array.from(c)))).toEqual(c);
+ });
+
+ it('maps greys to the grey ramp and colours to the cube', () => {
+ const pal = buildPalette();
+ const idx = indexPixels(Uint8Array.from([255, 255, 255, 255, 0, 0, 0, 255, 255, 0, 0, 255, 128, 128, 128, 255]), 4);
+ expect([pal[idx[0] * 3], pal[idx[0] * 3 + 1]]).toEqual([255, 255]);
+ expect(pal[idx[1] * 3]).toBe(0);
+ expect([pal[idx[2] * 3], pal[idx[2] * 3 + 1], pal[idx[2] * 3 + 2]]).toEqual([255, 0, 0]);
+ expect(idx[3]).toBeGreaterThanOrEqual(216);
+ });
+
+ it('writes a well-formed looping GIF89a with one image per frame', () => {
+ const w = 4; const h = 3;
+ const frame = (v: number) => ({ rgba: new Uint8Array(w * h * 4).fill(v), delayMs: 400 });
+ const f2 = frame(200);
+ drawMarker(f2.rgba, w, h, 1, 1, 1);
+ const gif = encodeGif(w, h, [frame(0), f2]);
+ const text = String.fromCharCode(...gif.slice(0, 6));
+ expect(text).toBe('GIF89a');
+ expect(gif[6] | (gif[7] << 8)).toBe(w);
+ expect(gif[8] | (gif[9] << 8)).toBe(h);
+ expect(String.fromCharCode(...gif.slice(13 + 768 + 3, 13 + 768 + 14))).toBe('NETSCAPE2.0');
+ // one graphic-control extension (21 F9 04) per frame
+ expect(gif.filter((b, i) => b === 0x21 && gif[i + 1] === 0xf9 && gif[i + 2] === 0x04).length).toBe(2);
+ expect(gif[gif.length - 1]).toBe(0x3b);
+ });
+});
diff --git a/tests/helpers/fake-dom.ts b/tests/helpers/fake-dom.ts
new file mode 100644
index 0000000..9842048
--- /dev/null
+++ b/tests/helpers/fake-dom.ts
@@ -0,0 +1,173 @@
+/**
+ * Tiny DOM for exercising the injected page runtime (extension/lib/page-dom.js)
+ * in node: elements, text nodes, open shadow roots, a handful of selector
+ * forms, visibility via a `hidden` flag / inline display, fixed-size layout.
+ * Deliberately small — enough for resolver / find / click_text semantics.
+ */
+export class FakeText {
+ nodeType = 3;
+ parentNode: FakeNode | null = null;
+ constructor(public nodeValue: string) {}
+ get textContent() { return this.nodeValue; }
+}
+
+type FakeNode = FakeElement | FakeShadowRoot;
+
+export class FakeShadowRoot {
+ nodeType = 11;
+ childNodes: Array = [];
+ constructor(public host: FakeElement) {}
+ get children() { return this.childNodes.filter((c): c is FakeElement => c instanceof FakeElement); }
+ append(...nodes: Array) { for (const n of nodes) { n.parentNode = this; if (n instanceof FakeElement) n.parentElement = null; this.childNodes.push(n); } return this; }
+ querySelectorAll(sel: string) { return this.children.flatMap((c) => c.selfAndDescendants()).filter((e) => e.matches(sel)); }
+ querySelector(sel: string) { return this.querySelectorAll(sel)[0] ?? null; }
+ getElementById(id: string) { return this.querySelectorAll(`#${id}`)[0] ?? null; }
+ get activeElement() { return null; }
+ elementFromPoint() { return null; }
+}
+
+export class FakeElement {
+ nodeType = 1;
+ tagName: string;
+ attrs = new Map();
+ childNodes: Array = [];
+ parentElement: FakeElement | null = null;
+ parentNode: FakeNode | null = null;
+ shadowRoot: FakeShadowRoot | null = null;
+ hidden = false;
+ value: string | undefined;
+ type: string | undefined;
+ disabled = false;
+ isConnected = true;
+ onclick = null;
+ isContentEditable = false;
+ events: string[] = [];
+ ownerDocument: FakeDocument;
+
+ constructor(doc: FakeDocument, tag: string, attrs: Record = {}) {
+ this.ownerDocument = doc;
+ this.tagName = tag.toUpperCase();
+ for (const [k, v] of Object.entries(attrs)) this.setAttribute(k, v);
+ }
+
+ get children() { return this.childNodes.filter((c): c is FakeElement => c instanceof FakeElement); }
+ get childElementCount() { return this.children.length; }
+ get id() { return this.attrs.get('id') ?? ''; }
+ get className() { return this.attrs.get('class') ?? ''; }
+ get textContent(): string { return this.childNodes.map((c) => c.textContent).join(''); }
+ get innerText(): string { return this.textContent; }
+ get labels() { return []; }
+ get multiple() { return false; }
+
+ append(...nodes: Array) {
+ for (const raw of nodes) {
+ const n = typeof raw === 'string' ? new FakeText(raw) : raw;
+ n.parentNode = this;
+ if (n instanceof FakeElement) n.parentElement = this;
+ this.childNodes.push(n);
+ }
+ return this;
+ }
+ attachShadow() { this.shadowRoot = new FakeShadowRoot(this); return this.shadowRoot; }
+ getAttribute(n: string) { if (n === 'type' && this.type) return this.type; return this.attrs.get(n) ?? null; }
+ setAttribute(n: string, v: string) { this.attrs.set(n, v); if (n === 'type') this.type = v; if (n === 'value') this.value = v; }
+ removeAttribute(n: string) { this.attrs.delete(n); }
+ hasAttribute(n: string) { return this.attrs.has(n); }
+ getRootNode(): unknown {
+ let cur: FakeElement = this;
+ while (cur.parentElement) cur = cur.parentElement;
+ if (cur.parentNode instanceof FakeShadowRoot) return cur.parentNode;
+ return this.ownerDocument;
+ }
+ closest() { return null; }
+ get isHiddenInTree(): boolean {
+ let cur: FakeElement | null = this;
+ while (cur) {
+ if (cur.hidden || cur.attrs.get('style')?.includes('display:none')) return true;
+ const p: FakeNode | null = cur.parentNode;
+ cur = cur.parentElement ?? (p instanceof FakeShadowRoot ? p.host : null);
+ }
+ return false;
+ }
+ getBoundingClientRect() {
+ const w = this.isHiddenInTree ? 0 : 100;
+ return { x: 10, y: 10, left: 10, top: 10, width: w, height: w ? 20 : 0, right: 10 + w, bottom: 30 };
+ }
+ checkVisibility() { return !this.isHiddenInTree; }
+ scrollIntoView() {}
+ focus() { this.ownerDocument.activeElement = this; }
+ dispatchEvent(e: { type: string }) { this.events.push(e.type); return true; }
+ selfAndDescendants(): FakeElement[] {
+ return [this, ...this.children.flatMap((c) => c.selfAndDescendants())];
+ }
+ querySelectorAll(sel: string) { return this.children.flatMap((c) => c.selfAndDescendants()).filter((e) => e.matches(sel)); }
+ querySelector(sel: string) { return this.querySelectorAll(sel)[0] ?? null; }
+ /** Supports: *, tag, #id, .cls, tag.cls, [attr], [attr="v"], tag[attr="v"], comma lists. */
+ matches(sel: string): boolean {
+ return sel.split(',').some((part) => {
+ const s = part.trim();
+ if (s === '*') return true;
+ const m = s.match(/^([a-zA-Z0-9-]*)((?:[#.][\w-]+)*)((?:\[[^\]]+\])*)$/);
+ if (!m) throw new Error(`fake-dom: unsupported selector ${s}`);
+ const [, tag, idcls, attrPart] = m;
+ if (tag && tag.toUpperCase() !== this.tagName) return false;
+ for (const t of idcls.match(/[#.][\w-]+/g) ?? []) {
+ if (t[0] === '#' && this.id !== t.slice(1)) return false;
+ if (t[0] === '.' && !this.className.split(/\s+/).includes(t.slice(1))) return false;
+ }
+ for (const a of attrPart.match(/\[[^\]]+\]/g) ?? []) {
+ const am = a.match(/^\[([\w-]+)(?:=["']?([^"'\]]*)["']?)?\]$/);
+ if (!am) return false;
+ const have = this.getAttribute(am[1]);
+ if (have == null) return false;
+ if (am[2] !== undefined && have !== am[2]) return false;
+ }
+ return true;
+ });
+ }
+}
+
+export class FakeDocument {
+ nodeType = 9;
+ title = 'Fixture';
+ activeElement: FakeElement | null = null;
+ body: FakeElement;
+ defaultView: { getComputedStyle: (el: FakeElement) => Record; frameElement: FakeElement | null } = { getComputedStyle: (el: FakeElement) => ({ display: el.attrs.get('style')?.includes('display:contents') ? 'contents' : el.isHiddenInTree ? 'none' : 'block', visibility: 'visible', opacity: '1' }), frameElement: null };
+ constructor() { this.body = new FakeElement(this, 'BODY'); }
+ el(tag: string, attrs: Record = {}, ...kids: Array) {
+ return new FakeElement(this, tag, attrs).append(...kids);
+ }
+ querySelectorAll(sel: string) { return this.body.selfAndDescendants().filter((e) => e.matches(sel)); }
+ querySelector(sel: string) { return this.querySelectorAll(sel)[0] ?? null; }
+ getElementById(id: string) { return this.querySelector(`#${id}`); }
+ elementFromPoint() { return null; }
+ createTreeWalker() { throw new Error('fake-dom: tree walkers are not supported'); }
+}
+
+/** Give an