From 5116a99599c56682c0f5989d05e79769d267f6c7 Mon Sep 17 00:00:00 2001 From: noiemany Date: Fri, 24 Jul 2026 07:15:09 +0300 Subject: [PATCH 01/20] feat(core): upgrade to multi-client real-browser MCP v2 with tab targeting - Rename package to "browser-controller" and bump version to 2.0.0 - Introduce multi-client daemon allowing multiple agents simultaneously - Implement explicit tab targeting for all interactions with required tabId - Add tab locking/unlocking to serialize access and avoid racing conditions - Switch browser_evaluate to main world execution without debugger banner - Use authenticated WebSocket for secure extension-daemon communication - Restructure internal server and extension architecture for concurrency support - Expand toolset from 18 to 22, including file upload and drag actions - Provide clear errors if tabId is missing on multi-tab tools - Update documentation, examples, and agent configs to reflect v2 model - Add daemon files and token management for connection authentication - Remove libc-related fields from package-lock for compatibility - Update README with detailed architecture, quick start, and usage steps --- .gitignore | 4 + README.md | 256 +++++-- agent-config/AGENTS.md | 31 +- .../cursor/rules/real-browser-mcp.mdc | 97 +-- .../skills/browser-automation/SKILL.md | 50 +- assets/icon.svg | 53 ++ assets/logo.png | Bin 9239 -> 19649 bytes extension/background.js | 634 ++++++++++++------ extension/content.js | 4 +- extension/icons/icon128.png | Bin 360 -> 12076 bytes extension/icons/icon16.png | Bin 82 -> 1705 bytes extension/icons/icon48.png | Bin 157 -> 4774 bytes extension/lib/tab-concurrency.js | 124 ++++ extension/manifest.json | 10 +- extension/popup/popup.html | 75 ++- extension/popup/popup.js | 208 +++++- extension/utils/smart-selector.js | 299 +++++++++ mcp-server/src/bridge.ts | 200 +++++- mcp-server/src/daemon-config.ts | 117 ++++ mcp-server/src/daemon.ts | 311 +++++++++ mcp-server/src/index.ts | 312 ++++++++- mcp-server/src/tools/click-text.ts | 3 +- mcp-server/src/tools/click.ts | 3 +- mcp-server/src/tools/console.ts | 7 +- mcp-server/src/tools/dialog.ts | 3 +- mcp-server/src/tools/drag.ts | 3 +- mcp-server/src/tools/evaluate.ts | 5 +- mcp-server/src/tools/fill-form.ts | 3 +- mcp-server/src/tools/find.ts | 3 +- mcp-server/src/tools/hover.ts | 3 +- mcp-server/src/tools/navigate.ts | 5 +- mcp-server/src/tools/network.ts | 7 +- mcp-server/src/tools/press-key.ts | 3 +- mcp-server/src/tools/run-action.ts | 3 +- mcp-server/src/tools/screenshot.ts | 5 +- mcp-server/src/tools/scroll.ts | 3 +- mcp-server/src/tools/select.ts | 3 +- mcp-server/src/tools/snapshot.ts | 3 +- mcp-server/src/tools/tabs.ts | 9 +- mcp-server/src/tools/text.ts | 3 +- mcp-server/src/tools/type.ts | 3 +- mcp-server/src/tools/types.ts | 35 +- mcp-server/src/tools/upload-file.ts | 3 +- mcp-server/src/tools/wait.ts | 3 +- package-lock.json | 30 - package.json | 12 +- server.json | 8 +- store-assets/icon-128.png | Bin 4128 -> 12076 bytes store-assets/icon.svg | 75 ++- tests/bridge.test.ts | 48 +- tests/smart-selector.test.ts | 157 +++++ tests/tab-concurrency.test.ts | 156 +++++ 52 files changed, 2906 insertions(+), 486 deletions(-) create mode 100644 assets/icon.svg create mode 100644 extension/lib/tab-concurrency.js create mode 100644 extension/utils/smart-selector.js create mode 100644 mcp-server/src/daemon-config.ts create mode 100644 mcp-server/src/daemon.ts create mode 100644 tests/smart-selector.test.ts create mode 100644 tests/tab-concurrency.test.ts diff --git a/.gitignore b/.gitignore index 9d9c8e0..c511e71 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ node_modules/ mcp-server/dist/ *.tsbuildinfo .DS_Store + +# daemon runtime state (task 1.0 / 3.1) +daemon.log +.serena/ diff --git a/README.md b/README.md index 996240f..babf705 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,27 @@ --- +## What this project solves + You ship a fix. Your agent says "done, please verify." You alt-tab to Chrome, navigate to the page, log in, click around, find the bug. Your agent just wrote the code. It could also verify it. It already has your browser open right there. It just can't see it. -Now it can. +**Now it can.** Real Browser MCP gives any MCP-compatible AI agent (Cursor, Claude Desktop, Windsurf, …) direct control of the **browser you already have open** — your real sessions, your logins, your cookies. No headless browser, no fresh profile, no re-authentication. + +### What's new in v2 (multi-client) + +v1 was a single-client server: one agent, one active tab. If you switched tabs or ran two agents, they stepped on each other. **v2 is a multi-client daemon with explicit tab targeting:** + +- **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. +- **Tab locking.** An agent can claim a tab so others queue behind it instead of racing (`browser_tabs { action: "lock" }`). +- **Authenticated WebSocket.** The extension↔daemon connection requires a token, so no other local process can silently drive your browser. +- **No debugger banner.** `browser_evaluate` now runs in the page's MAIN world via `chrome.scripting` — no yellow "this tab is being debugged" banner.

Real Browser MCP @@ -46,12 +60,45 @@ Now it can. --- -## Quick Start +## How it works -Two parts: +Three pieces, all on your machine. Nothing leaves localhost. -- **MCP server** - runs on your machine, talks to your AI agent -- **Chrome extension** - sits in your browser, executes the commands +``` + Agent (Cursor / Claude / Windsurf) ── other agents connect too ──┐ + │ stdio (MCP protocol) │ + ▼ ▼ + ┌─────────────────────────────┐ ┌─────────────────────────────────────────┐ + │ thin MCP client │ │ thin MCP client │ + │ (npx real-browser-mcp) │ │ (npx real-browser-mcp) │ + │ - speaks MCP over stdio │ │ - spawns daemon if not running │ + │ - forwards calls to daemon │ │ - gets its own sessionId │ + └──────────────┬──────────────┘ └────────────────────┬───────────────────┘ + │ local IPC socket (AF_UNIX / named pipe, token-auth) │ + ▼ ▼ + ┌──────────────────────────────────────────────────────────────────────────┐ + │ DAEMON (single long-running process, owns port 7225) │ + │ - multiplexes N clients → 1 extension │ + │ - tags every call with the client's sessionId │ + │ - enforces per-tab mutex + tab-lock coordination │ + └──────────────────────────────┬───────────────────────────────────────────┘ + │ WebSocket ws://127.0.0.1:7225?token=… + ▼ + ┌──────────────────────────────────────────────────────────────────────────┐ + │ Chrome Extension (Manifest V3 service worker) │ + │ - resolves the target tabId (never "the active tab" implicitly) │ + │ - serializes same-tab actions, parallelizes cross-tab actions │ + │ - executes click/type/snapshot/evaluate against the named tab │ + └──────────────────────────────────────────────────────────────────────────┘ +``` + +**Key idea:** the first time any agent runs, `npx real-browser-mcp` spawns a background **daemon** that owns port 7225 and the extension connection. Every subsequent agent (even from a different MCP client) connects to that same daemon over a local IPC socket and gets its own `sessionId`. The extension sees one stable connection and routes each call to the exact tab the caller specified. + +--- + +## Quick Start + +Two parts: the **MCP server** (runs on your machine, talks to your AI agent) and the **Chrome extension** (sits in your browser, executes commands). ### 1. Add the MCP server @@ -79,6 +126,16 @@ Or add manually in Cursor Settings > MCP > "Add new MCP server": **Windsurf:** Settings > MCP. Same config. +**Multiple agents at once (v2):** just add the server in each client. They all share the one daemon automatically — no extra config. + +```json +{ + "mcpServers": { + "real-browser": { "command": "npx", "args": ["-y", "real-browser-mcp"] } + } +} +``` + Any MCP-compatible client works. @@ -98,36 +155,76 @@ git clone https://github.com/ofershap/real-browser-mcp.git Click the Real Browser MCP icon in your toolbar. -Green dot = connected. Gray = waiting for server. +Green dot = connected. Gray = waiting for the daemon. + +### 3. Pair the extension with the daemon's auth token -Done. Your agent can see your browser. +The daemon generates a secret token on first run. The extension must present it to connect. + +1. Run any browser command once (e.g. ask your agent to "list my browser tabs") — this starts the daemon and creates the token at `~/.real-browser-mcp/token.json`. +2. Read it: `cat ~/.real-browser-mcp/token.json` (on Windows: `%USERPROFILE%\.real-browser-mcp\token.json`) +3. Click the extension icon → paste the token into the **Auth Token** field. + +Green dot = you're connected. Your agent can now see your browser. + +> The token prevents any other local process from opening a WebSocket and driving your authenticated browser sessions. It's optional in the sense that the daemon will still start without it being set in the extension, but the extension **won't connect** until it matches. --- -## How Others Compare +## How to use it (steps) -| | Real Browser MCP | 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 | -| Setup | Extension + MCP config | Headless browser | Chrome with `--remote-debugging-port` | +The v2 model is **tab-first**: the agent always says *which* tab to act on. It never assumes "the active tab." + +### Basic workflow + +1. **List tabs** to get a `tabId`: + ``` + browser_tabs { action: "list" } + → [{ id: 15, url: "...", title: "...", active: true, lockedBy: null }, ...] + ``` +2. **Snapshot that tab** to see its structure and get element refs: + ``` + browser_snapshot { tabId: 15 } + → { tree: [ { ref: "e3", role: "button", name: "Sign in" }, ... ] } + ``` + Refs are valid **only for this tabId**. If you navigate or the DOM changes, re-snapshot. +3. **Interact** using the ref and the same tabId: + ``` + browser_click { tabId: 15, ref: "e3" } + browser_type { tabId: 15, ref: "e5", text: "hello@example.com" } + browser_press_key { tabId: 15, key: "Enter" } + ``` +4. **Verify** — snapshot or read text again after the action. + +### Multi-agent coordination (two agents, two tabs) + +1. Agent A lists tabs, picks tab 10, optionally locks it: `browser_tabs { action: "lock", tabId: 10 }` +2. Agent B lists tabs, picks tab 11, locks it: `browser_tabs { action: "lock", tabId: 11 }` +3. Both work in parallel. Each agent's calls serialize against its own tab; the two tabs never interfere. +4. When done: `browser_tabs { action: "unlock", tabId: 10 }`. + +The popup shows which session owns each locked tab, and there's an **Unlock All** button if an agent crashes mid-lock. + +### Things to know + +- **Forgot `tabId`?** You'll get a clear error: `tabId is required. Call browser_tabs list first.` +- **Protected pages** (`chrome://`, the Web Store, devtools) can't be scripted — you'll get `Cannot access protected page (chrome://...)` instead of a silent hang. +- **`browser_navigate`** is the one tool where `tabId` is optional (defaults to the active tab) — but for multi-agent safety, pass it explicitly. +- **`browser_evaluate`** now runs in the page's MAIN world (no debugger banner, CSP-safe). It's powerful but **non-idempotent** — it won't be auto-retried on timeout. --- ## 🧠 Teach Your Agent -The agent can use all 18 tools out of the box, but it works better when it knows _when_ and _how_ to chain them. A config file teaches the right workflow - snapshot first, then act, then verify. - -Run one command: +The agent can use all 22 tools out of the box, but it works better when it knows the **tab-first** workflow. Run one command to install the rules: ```bash npx real-browser-mcp --setup cursor ``` This installs: -- `~/.cursor/rules/real-browser-mcp.mdc` - teaches the snapshot-first workflow, how to handle dropdowns, when to use screenshots vs snapshots -- `~/.cursor/commands/check-browser.md` - adds `/check-browser` to your Cursor chat +- `~/.cursor/rules/real-browser-mcp.mdc` — the tab-targeting workflow, dropdown handling, when to lock tabs +- `~/.cursor/commands/check-browser.md` — adds `/check-browser` to your Cursor chat After that, type `/check-browser` in any chat. Or just say "check the result in my browser" and the agent knows what to do. @@ -148,16 +245,16 @@ See [`agent-config/`](agent-config/) for manual installation or to customize the ## What It Can Do -18 tools. Grouped by purpose. +22 tools. Every page-interaction tool takes a **`tabId`** (the one exception is `browser_navigate`, where it's optional). **See** | Tool | What it does | |------|-------------| -| `browser_snapshot` | Accessibility tree with element refs. Compact mode (default) returns only interactive elements | -| `browser_screenshot` | Capture what's on screen | +| `browser_snapshot` | Accessibility tree with element refs. Compact mode (default) returns only interactive elements. Traverses shadow DOM + iframes. | +| `browser_screenshot` | Capture a tab as an image (activates the tab first to capture) | | `browser_text` | Extract raw text from page or element | -| `browser_find` | Query elements by CSS selector | +| `browser_find` | Query elements by natural language | **Interact** @@ -171,22 +268,40 @@ See [`agent-config/`](agent-config/) for manual installation or to customize the | `browser_hover` | Trigger tooltips and dropdowns | | `browser_select` | Pick from native `` (uses CDP) | **Navigate** | Tool | What it does | |------|-------------| -| `browser_navigate` | Go to a URL in the active tab | -| `browser_tabs` | List, create, close, or focus tabs | +| `browser_navigate` | Go to a URL in a tab (`tabId` optional, defaults to active) | +| `browser_tabs` | List / create / close / focus / **lock** / **unlock** tabs | -**Debug** +**Debug & Advanced** | Tool | What it does | |------|-------------| -| `browser_console` | Console output (log, warn, error) | -| `browser_network` | XHR/fetch requests with status codes | -| `browser_evaluate` | Run JavaScript via Chrome DevTools Protocol | +| `browser_console` | Console output (log, warn, error) — per-tab, capped at 200 entries | +| `browser_network` | XHR/fetch requests with status codes — per-tab | +| `browser_evaluate` | Run JavaScript in the page's MAIN world (no banner, CSP-safe) | | `browser_handle_dialog` | Handle alert/confirm/prompt dialogs | +| `browser_run_action` | Run a self-contained JS action object via CDP | + +--- + +## How Others Compare + +| | Real Browser MCP | 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 (v2) | No | No | +| Tab-targeting (won't hijack active tab) | Yes (v2) | N/A | No | +| Authenticated local connection | Yes (v2) | N/A | No | +| Setup | Extension + MCP config | Headless browser | Chrome with `--remote-debugging-port` | --- @@ -194,14 +309,33 @@ See [`agent-config/`](agent-config/) for manual installation or to customize the | Env var | Default | What it does | |---------|---------|-------------| -| `WS_PORT` | `7225` | WebSocket port for extension connection | +| `WS_PORT` | `7225` | WebSocket port the daemon uses for the extension connection | -Connection drops are handled automatically with exponential backoff (1s to 30s), ping/pong health checks every 10s, and per-tool timeouts (5s for clicks, 60s for navigation). +### Daemon state files + +The daemon keeps everything in `~/.real-browser-mcp/` (Windows: `%USERPROFILE%\.real-browser-mcp\`): + +| File | Purpose | +|------|---------| +| `token.json` | Auth token the extension must present (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 a fresh token. + +### Reliability + +- The daemon is **auto-spawned** the first time any client runs and left running detached. +- Connection drops use exponential backoff (1s → 30s), ping/pong health checks every 10s. +- Per-tool timeouts (5s for clicks, 60s for navigation). +- **Read-only tools** (snapshot, screenshot, text, find, console, network) are retried on timeout; **side-effecting tools** (click, type, navigate, evaluate) are **never** retried — a click can't fire twice. +- If a previous daemon crashed while holding port 7225, the new one finds and evicts the stale PID (cross-platform: `lsof` on mac/linux, `Get-NetTCPConnection` on Windows).

Multiple Chrome profiles -Run two server instances on different ports: +Run two daemons on different ports by setting `WS_PORT` per client: ```json { @@ -226,21 +360,27 @@ Update the port in each extension popup to match.
Architecture -Everything stays on your machine. The extension connects to the MCP server via WebSocket on localhost. No cloud, no proxy, nothing leaves your browser. +Everything stays on your machine. The extension connects to the daemon via an authenticated WebSocket on localhost; MCP clients connect to the daemon via a local IPC socket. No cloud, no proxy, nothing leaves your browser. ``` real-browser-mcp/ ├── mcp-server/ MCP server (npm package, TypeScript) -│ └── src/tools/ One file per tool, registry pattern +│ └── src/ +│ ├── daemon.ts Single multi-client daemon (owns WS :7225) +│ ├── daemon-config.ts IPC protocol, paths, auth token, idempotency set +│ ├── index.ts Thin stdio MCP client (spawns daemon, multiplexes) +│ ├── bridge.ts Extension WS server + cross-platform port probe +│ └── tools/ One file per tool (22), registry pattern ├── extension/ Chrome extension (Manifest V3, plain JS) -│ ├── background.js Service worker, WebSocket client, tool handlers -│ ├── content.js Console capture -│ └── popup/ Connection status UI +│ ├── background.js Service worker: tab resolution, per-tab mutex, locks +│ ├── lib/tab-concurrency.js Pure, unit-tested mutex + lock primitives +│ ├── content.js Console capture +│ └── popup/ Status, token field, tab-lock viewer, Unlock All ├── agent-config/ Pre-built configs for Cursor + Claude Code -│ ├── cursor/ Rules and commands -│ ├── skills/ Browser automation skill -│ └── setup.mjs One-command installer -└── tests/ Bridge + registry tests +│ ├── cursor/ Rules and commands +│ ├── skills/ Browser automation skill +│ └── setup.mjs One-command installer +└── tests/ Bridge + registry + concurrency tests (45 passing) ``` **Stack:** TypeScript (strict) · MCP SDK · WebSocket · Chrome Extension Manifest V3 · Vitest @@ -259,13 +399,15 @@ npm test ``` | Command | What it does | -|---------|-------------| -| `npm run build` | Compile TypeScript | +|---------|---------| +| `npm run build` | Compile TypeScript → `mcp-server/dist/` | | `npm run dev` | Watch mode | -| `npm test` | Run tests | +| `npm test` | Run the full test suite (45 tests) | | `npm run typecheck` | Type check without emitting | | `npm run setup:cursor` | Install Cursor rule + command | +The test suite covers the WebSocket bridge (including token-auth rejection), the tool registry, and the per-tab concurrency primitives (same-tab serialization + cross-tab parallelism). The daemon's IPC auth and the WS token enforcement are also smoke-tested via the build artifacts in `mcp-server/dist/`. +
## FAQ @@ -273,35 +415,49 @@ npm test
Does it work with my logged-in sessions? -That's the whole point. The extension runs inside your actual Chrome - same cookies, same sessions, same local storage. No re-authentication needed. +That's the whole point. The extension runs inside your actual Chrome — same cookies, same sessions, same local storage. No re-authentication needed.
Does it send data anywhere? -No. The MCP server and extension talk over WebSocket on localhost. Nothing leaves your machine. There's no analytics, no telemetry, no cloud component. [Privacy policy.](PRIVACY.md) +No. The MCP clients, the daemon, and the extension all talk over localhost (IPC socket + WebSocket). Nothing leaves your machine. There's no analytics, no telemetry, no cloud component. [Privacy policy.](PRIVACY.md)
Which AI clients work? -Any MCP-compatible client. Cursor, Claude Desktop, Claude Code, Windsurf, Cline, and anything else that speaks the MCP protocol. +Any MCP-compatible client. Cursor, Claude Desktop, Claude Code, Windsurf, Cline, and anything else that speaks the MCP protocol. v2 lets several of them run at once against the same daemon. + +
+ +
+Can two agents really work at the same time without breaking each other? + +Yes — that's the main v2 improvement. Each agent connects to the shared daemon, gets its own `sessionId`, and targets a specific `tabId`. Actions on the same tab serialize through a per-tab mutex; actions on different tabs run in parallel. Optionally an agent can `lock` a tab to claim exclusive access; other agents queue behind the lock rather than failing. + +
+ +
+What if the agent acts on the wrong tab? + +It can't — not silently. Every page-interaction tool requires a `tabId`, and if it's missing you get a clear `tabId is required` error. The agent can never accidentally act on the tab you happen to be looking at. (The one exception is `browser_navigate` without a `tabId`, which uses the active tab — but for multi-agent use you should always pass `tabId`.)
-Can I use it with multiple Chrome profiles? +Why is there an auth token? -Yes. Run two MCP server instances on different ports. See [Configuration](#configuration) for the setup. +Without it, any local process on your machine could open a WebSocket to port 7225 and drive your authenticated browser sessions (your bank, your email, your company SSO). The daemon generates a secret token in `~/.real-browser-mcp/token.json` and the extension must present it to connect.
How is this different from Playwright MCP or browser-use? -They launch a new browser instance from scratch - no state, no cookies, no sessions. You have to replay the full login flow every time. This connects to the browser you already have open with everything already loaded. +They launch a new browser instance from scratch — no state, no cookies, no sessions. You have to replay the full login flow every time. This connects to the browser you already have open with everything already loaded.
diff --git a/agent-config/AGENTS.md b/agent-config/AGENTS.md index 6b2bc10..d703bb4 100644 --- a/agent-config/AGENTS.md +++ b/agent-config/AGENTS.md @@ -4,19 +4,30 @@ This project has `real-browser-mcp` configured. Use it to interact with the user's real browser. -The MCP server runs on `ws://localhost:7225` by default. The Chrome extension connects automatically. +The MCP server runs on `ws://127.0.0.1:7225` by default. The Chrome extension connects automatically. Multiple agents can connect at once — they share a single daemon. + +## CRITICAL: Tab targeting (v2) + +This is a multi-client server. **Never assume which tab your actions hit.** You MUST target a tab explicitly with `tabId`. + +1. `browser_tabs` with `action: "list"` → get a `tabId` +2. Pass that **same `tabId`** to every page-interaction tool +3. Refs from a snapshot are valid **only for the tabId that produced them** +4. For exclusive access: `browser_tabs { action: "lock", tabId }` → `unlock` when done ## Tools -Navigation: `browser_navigate`, `browser_tabs` -Interaction: `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_hover`, `browser_select` -Reading: `browser_snapshot`, `browser_screenshot`, `browser_text`, `browser_find` -Waiting: `browser_wait` -Debug: `browser_console`, `browser_network` +Navigation: `browser_navigate` (tabId optional), `browser_tabs` (list/create/close/focus/lock/unlock) +Interaction (tabId required): `browser_click`, `browser_click_text`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_hover`, `browser_select` +Reading (tabId required): `browser_snapshot`, `browser_screenshot`, `browser_text`, `browser_find` +Waiting (tabId required): `browser_wait` +JS/Dialogs (tabId required): `browser_evaluate` (MAIN world, no banner), `browser_handle_dialog` +Debug (tabId required, per-tab): `browser_console`, `browser_network` ## Pattern -1. `browser_snapshot` to see the page and get refs -2. Use refs with interaction tools -3. Snapshot again to verify -4. `browser_wait` before interacting with dynamic content +1. `browser_tabs { action: "list" }` → pick a `tabId` +2. `browser_snapshot { tabId }` to see the page and get refs (refs are tab-scoped) +3. Use `{ tabId, ref }` with interaction tools +4. Re-snapshot after navigation/DOM changes to refresh refs +5. `browser_wait { tabId, selector }` before interacting with dynamic content diff --git a/agent-config/cursor/rules/real-browser-mcp.mdc b/agent-config/cursor/rules/real-browser-mcp.mdc index 20bcfdd..8daa756 100644 --- a/agent-config/cursor/rules/real-browser-mcp.mdc +++ b/agent-config/cursor/rules/real-browser-mcp.mdc @@ -7,66 +7,75 @@ alwaysApply: true MCP server: `user-real-browser-mcp`. Controls the user's REAL Chrome browser - not headless. All sessions, logins, cookies are already active. +## CRITICAL: Tab-targeting model (v2) + +This is a **multi-client** server. Other agents may be working in other tabs right now. +**Never assume which tab your actions hit.** You MUST target a tab explicitly. + +### The required flow (instead of "snapshot the active tab") + +1. `browser_tabs` with `action: "list"` → get a `tabId` for the tab you want to work in +2. Pass that **same `tabId`** to EVERY page-interaction tool (`browser_snapshot`, `browser_click`, `browser_type`, …) +3. Refs from a snapshot are **only valid for the tabId that produced them**. A ref from tab 10 will never resolve on tab 20. +4. If you need exclusive access to a tab (another agent might touch it), `browser_tabs` with `action: "lock", tabId` first; `action: "unlock"` when done. + +If `tabId` is missing on a page tool, you'll get: *"tabId is required. Call browser_tabs list first."* + ## Rules - NEVER close tabs you didn't create - NEVER navigate away from pages the user is working on without asking +- ALWAYS pass `tabId` — the "active tab" the user is looking at is NOT yours to act on - Prefer `browser_snapshot` over `browser_screenshot` (cheaper, faster, parseable) -- Snapshots return refs that break after navigation/scroll/DOM changes - always re-snapshot before using stale refs -- DO NOT default to `browser_evaluate` for UI interactions — it fails on strict-CSP sites (GitHub, Google, etc.) +- Refs break after navigation/scroll/DOM changes - always re-snapshot before using stale refs +- `browser_evaluate` now runs in the page's MAIN world via chrome.scripting (no debugger banner, CSP-safe). It no longer needs CDP. ## Tools -### Navigation & Tabs -- `browser_navigate` - Go to URL. Params: `url`, `waitUntil` -- `browser_tabs` - List/create/close/focus tabs. Params: `action` (list|create|close|focus), `tabId`, `url` - -### Interaction -- `browser_click` - Click element by ref OR CSS selector. Params: `ref`, `selector`, `button` (left|right|middle), `doubleClick` -- `browser_click_text` - Click element by visible text. CSP-safe. Best for dropdowns/menus. Params: `text`, `index` (0-based), `exact` (boolean) -- `browser_type` - Type text. Params: `ref` OR `selector`, `text`, `clear` -- `browser_press_key` - Keyboard. Params: `key`, `modifiers` (ctrl|alt|shift|meta), `ref`, `selector` -- `browser_scroll` - Scroll. Params: `direction`, `amount`, `selector`, `toElement`, `position` (top|bottom) -- `browser_hover` - Hover element. Params: `ref` OR `selector` -- `browser_select` - Select from native ``. Params: `tabId`, `ref` OR `selector`, `value` OR `label` OR `index` -### Debug -- `browser_console` - Read console messages (read-only). Params: `clear` -- `browser_network` - Read network requests. Params: `filter` (regex), `clear` +### Reading (all require `tabId`) +- `browser_snapshot` - Accessibility tree with refs. Params: `tabId`, `selector`, `compact` +- `browser_screenshot` - Capture a tab as image (activates the tab to capture). Params: `tabId`, `format`, `quality` +- `browser_text` - Get text content. Params: `tabId`, `selector`, `maxLength` +- `browser_find` - Find elements by natural language. Params: `tabId`, `query`, `limit` -## React Dropdowns / Portals / Overlays (IMPORTANT) +### JavaScript & Dialogs (all require `tabId`) +- `browser_evaluate` - Execute JS in MAIN world. CSP-safe, no banner. Params: `tabId`, `expression` +- `browser_handle_dialog` - Handle alert/confirm/prompt. Params: `tabId`, `action`, `promptText` -Snapshot refs DO NOT capture React portal overlays (rendered outside main DOM tree). DO NOT use `browser_evaluate` — it uses chrome.debugger which steals focus and closes the dropdown. +### Waiting (requires `tabId`) +- `browser_wait` - Wait for element or delay. Params: `tabId`, `selector`, `state`, `timeout`, `delay` -Proven pattern that works on all sites including strict-CSP (GitHub, Google): +### Debug (require `tabId`; per-tab buffers, capped at 200 entries) +- `browser_console` - Read console messages for a tab. Params: `tabId`, `clear` +- `browser_network` - Read network requests for a tab. Params: `tabId`, `filter`, `clear` -1. `browser_click` on the trigger button (use ref from snapshot) -2. `browser_wait` with `delay: 400` for the overlay to render -3. `browser_text` with broad CSS selectors to confirm dropdown is open and read options: - `"selector": "[role=menu],[role=listbox],.ActionList,.SelectMenu-list,.Overlay,details-menu"` -4. `browser_click` with a CSS selector targeting the desired child: - `"selector": ".ActionList li:first-child"` or use `browser_click_text` with the option text +## Multi-agent coordination -If `browser_click_text` is available, prefer it — it finds elements by visible text, picks the deepest DOM match, and is CSP-safe. +- Call `browser_tabs { action: "lock", tabId }` to claim a tab. Other agents' calls to that tab will queue (not fail) until you `unlock`. +- Don't hold locks longer than needed. +- The `browser_tabs list` response includes `lockedBy` per tab so you can see who owns what. ## Workflow -1. `browser_snapshot` to see page structure and get element refs -2. Use refs with `browser_click`, `browser_type`, etc. -3. For dropdowns/menus: follow the pattern above (click trigger → wait → text → click child) -4. After actions, re-snapshot or `browser_find` to verify result -5. `browser_wait` before interacting with dynamically loaded content -6. `browser_press_key` for keyboard shortcuts (Enter to submit, Escape to close modals) +1. `browser_tabs { action: "list" }` → pick a `tabId` (or `action: "create", url`) +2. (optional) `browser_tabs { action: "lock", tabId }` +3. `browser_snapshot { tabId }` to see structure and get refs (valid only for this tabId) +4. Interact: `browser_click { tabId, ref }`, `browser_type { tabId, ref, text }`, … +5. For dropdowns: click trigger → `browser_wait { tabId, delay: 400 }` → `browser_text` / `browser_click_text` +6. Re-snapshot after navigation/DOM changes +7. `browser_tabs { action: "unlock", tabId }` when finished diff --git a/agent-config/skills/browser-automation/SKILL.md b/agent-config/skills/browser-automation/SKILL.md index c1e2c0d..ab10b5b 100644 --- a/agent-config/skills/browser-automation/SKILL.md +++ b/agent-config/skills/browser-automation/SKILL.md @@ -7,43 +7,55 @@ description: Control the user's real browser via Real Browser MCP. Use when aske Use this skill when you need to interact with the user's actual browser - clicking, typing, reading pages, taking screenshots, or navigating. +## CRITICAL: Always target a tab explicitly (v2) + +This is a **multi-client** server — other agents may be using other tabs. You MUST pass `tabId` to every page-interaction tool; actions never hit "the active tab" implicitly. + +1. First call `browser_tabs` with `action: "list"` to discover tabs and get a `tabId` +2. Pass that same `tabId` to `browser_snapshot`, `browser_click`, `browser_type`, etc. +3. Refs from a snapshot are valid **only for the tabId that produced them** — never reuse a ref from tab 10 on tab 20 +4. If you need exclusive use of a tab, `browser_tabs { action: "lock", tabId }` first, then `unlock` when done + +If `tabId` is missing you'll get: *"tabId is required. Call browser_tabs list first."* + ## Before You Start -1. Verify the extension is connected: try `browser_tabs` with action "list" first +1. Verify the extension is connected: `browser_tabs { action: "list" }` (returns the tab list) 2. If disconnected, ask the user to check the extension icon (should show green "ON") 3. Never close tabs you didn't create ## Reading Pages -Start with `browser_snapshot` to get the accessibility tree. This gives you refs like "e12" that you use for interaction. +`browser_snapshot { tabId }` returns the accessibility tree with refs like "e12" that you use for interaction. -For large pages, scope with a CSS selector: `browser_snapshot` with `selector: "main"` or `selector: ".content"`. +For large pages, scope with a selector: `browser_snapshot { tabId, selector: "main" }`. -Use `browser_text` to extract raw text when you need the full content. +Use `browser_text { tabId }` to extract raw text when you need full content. ## Interacting -Always snapshot first, then use refs: -- `browser_click` with `ref: "e12"` to click -- `browser_type` with `ref: "e5"` and `text: "hello"` to type -- `browser_press_key` with `key: "Enter"` to submit -- `browser_scroll` with `direction: "down"` to scroll +Always pass `tabId`, then use refs from that tab's snapshot: +- `browser_click { tabId, ref: "e12" }` +- `browser_type { tabId, ref: "e5", text: "hello" }` +- `browser_press_key { tabId, key: "Enter" }` +- `browser_scroll { tabId, direction: "down" }` ## Dynamic Content (SPAs, social media) -1. `browser_scroll` down to load more content -2. `browser_wait` with a selector for lazy-loaded elements +1. `browser_scroll { tabId, direction: "down" }` to load more +2. `browser_wait { tabId, selector }` for lazy-loaded elements 3. Snapshot again after scrolling - refs are regenerated -4. For virtual scroll containers (Twitter feeds, Reddit), pass the container's CSS selector to `browser_scroll` +4. For virtual scroll containers (Twitter, Reddit), pass the container's CSS selector to `browser_scroll` -## Debugging +## Debugging (per-tab) -- `browser_console` reads console.log/warn/error output -- `browser_network` shows XHR/fetch requests with status codes -- `browser_screenshot` captures what the user sees +- `browser_console { tabId }` reads console output for that tab +- `browser_network { tabId }` shows XHR/fetch requests for that tab +- `browser_screenshot { tabId }` captures the tab (activates it first to capture) ## Common Mistakes -- Using stale refs after navigation or scroll (always re-snapshot) -- Trying to click elements in iframes (scope snapshot to the iframe) -- Not waiting for page load after navigation +- Forgetting `tabId` → "tabId is required" error +- Using a ref from one tabId against a different tabId +- Using stale refs after navigation/scroll (always re-snapshot) +- Holding a tab lock too long (other agents queue behind you) diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..fc32502 --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/logo.png b/assets/logo.png index d06d36fbf2799ac41b5840622545a618e0daeba7..87b594c37feaf04dc0727a46b176dc777dc8d30e 100644 GIT binary patch literal 19649 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 Z0(sx2_nP*6a1B3rdJto+8V!et{|DH*kzD`) diff --git a/extension/background.js b/extension/background.js index aeb43e1..2f3d54c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,10 +1,31 @@ +/** + * Browser Controller — background service worker. + * + * v2 architecture (plan tasks 1.2–1.5, 2.1–2.5): + * - Every page-interaction tool takes an explicit `tabId`. There is NO global + * "active tab" concept on the dispatch path — the user can freely switch + * tabs/move the mouse while an agent works. (1.2) + * - Element refs are stored per-tab (refsByTab), so a ref from tab 10 can never + * resolve against tab 20's DOM. (1.3) + * - console/network buffers are per-tab (capped 200), persisted to + * chrome.storage.session so they survive service-worker death. (1.4) + * - browser_evaluate runs via chrome.scripting MAIN world — no debugger banner. (1.5) + * - Per-tab mutex: only one tool runs against a given tab at a time; different + * tabs run in parallel. (2.1) + * - Per-agent sessionId + tab lock: browser_tabs lock claims a tab so other + * agents queue behind it instead of racing. (2.2) + * - Snapshot traverses shadow DOM + iframes. (2.4) + * - safeExec turns "can't touch chrome:// pages" into a clear error. (2.5) + */ const DEFAULT_WS_PORT = 7225; const RECONNECT_BASE_MS = 1000; const RECONNECT_MAX_MS = 30000; const KEEPALIVE_ALARM = 'keepalive'; const KEEPALIVE_INTERVAL_MIN = 0.4; // ~24s, under Chrome's 30s limit +const PER_TAB_CAP = 200; let wsPort = DEFAULT_WS_PORT; +let wsToken = ''; // auth token (3.1); appended as ?token= let ws = null; let isConnected = false; let reconnectAttempts = 0; @@ -12,29 +33,53 @@ let reconnectTimeout = null; let nextRetryMs = 0; let connectedSince = null; let lastError = null; -let consoleMessages = []; -let networkRequests = []; -let currentActivity = null; -let activeTabId = null; -let pendingDialog = null; -// --- Connection Management --- +import { TabMutexMap, TabLockMap, runOnTab as runOnTabLib } from './lib/tab-concurrency.js'; +import { PAGE_FALLBACK_FN, PAGE_RESOLVE_FALLBACK_FN } from './utils/smart-selector.js'; + +// --- Per-tab state (tasks 1.3, 1.4) --------------------------------------- +// Map> and Map> +// Kept in chrome.storage.session so a service-worker restart doesn't wipe them. +const consoleByTab = new Map(); +const networkByTab = new Map(); +/** + * Smart-selector fallbacks (plan tasks 2–4). Map>. + * Populated at snapshot time; consumed by click/type when a ref no longer + * resolves. Never sent to the agent — it keeps payloads token-cheap. + */ +const fallbackByTab = new Map(); +let currentActivity = null; // tool currently running (single overlay label source) + +// --- Per-tab mutex (task 2.1) + per-agent tab locks (task 2.2) ------------ +// Pure, unit-tested primitives in lib/tab-concurrency.js. +const tabMutex = new TabMutexMap(); +const tabLocks = new TabLockMap(); + +// --- Connection Management ------------------------------------------------ async function initConnection() { try { - const stored = await chrome.storage.local.get('wsPort'); + const stored = await chrome.storage.local.get(['wsPort', 'wsToken']); if (stored.wsPort) wsPort = stored.wsPort; + if (stored.wsToken) wsToken = stored.wsToken; } catch {} chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: KEEPALIVE_INTERVAL_MIN }); connect(); } +function wsUrl() { + // 127.0.0.1 (not 'localhost') so it matches the daemon's IPv4 bind. + // On macOS 'localhost' can resolve to IPv6 ::1 and refuse. + const base = `ws://127.0.0.1:${wsPort}`; + return wsToken ? `${base}?token=${encodeURIComponent(wsToken)}` : base; +} + function connect() { if (ws && ws.readyState === WebSocket.OPEN) return; try { - ws = new WebSocket(`ws://localhost:${wsPort}`); + ws = new WebSocket(wsUrl()); ws.onopen = () => { isConnected = true; @@ -70,7 +115,7 @@ function connect() { } await handleMessage(msg); } catch (err) { - console.error('[RealBrowser] Message error:', err); + console.error('[BrowserController] Message error:', err); } }; } catch { @@ -92,7 +137,7 @@ chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === KEEPALIVE_ALARM && !isConnected) connect(); }); -// --- Badge --- +// --- Badge & Overlay (unchanged behavior) --------------------------------- function updateBadge(status) { const config = { @@ -106,17 +151,15 @@ function updateBadge(status) { chrome.action.setBadgeText({ text: c.text }); } -// --- Activity Overlay --- - async function showOverlay(tabId, label) { try { await chrome.scripting.executeScript({ target: { tabId }, func: (lbl) => { - let el = document.getElementById('__rbmcp-overlay'); + let el = document.getElementById('__bc-overlay'); if (!el) { el = document.createElement('div'); - el.id = '__rbmcp-overlay'; + el.id = '__bc-overlay'; el.style.cssText = 'position:fixed;top:12px;right:12px;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;' + 'padding:6px 14px;border-radius:16px;font:500 13px system-ui,sans-serif;z-index:2147483647;' + @@ -138,7 +181,7 @@ async function hideOverlay(tabId) { try { await chrome.scripting.executeScript({ target: { tabId }, - func: () => document.getElementById('__rbmcp-overlay')?.remove(), + func: () => document.getElementById('__bc-overlay')?.remove(), }); } catch {} } @@ -159,40 +202,68 @@ function buildStatusPayload(message) { connectedSince, lastError, activity: currentActivity, + tabLocks: tabLocksToJSON(), statusMessage: message || null, }; } +function tabLocksToJSON() { + return tabLocks.snapshot(); +} + function broadcastStatus(message) { chrome.runtime.sendMessage(buildStatusPayload(message)).catch(() => {}); } -// --- Message Router --- +// --- Message Router ------------------------------------------------------- +// +// The daemon injects `__sessionId` into params (see daemon.ts routeCall). We +// pull it out here so it never reaches a tool handler, then route the call +// through the per-tab mutex + lock layer. async function handleMessage(msg) { const { id, tool, params } = msg; + const p = params || {}; + const sessionId = p.__sessionId || null; + delete p.__sessionId; + + const tabId = extractTabId(tool, p); + + // Tools without a tabId (tabs list/create, console-less) run directly. + if (tabId == null) { + try { + const result = await dispatch(tool, p, sessionId); + sendResponse(id, { success: true, result }); + } catch (err) { + sendResponse(id, { success: false, error: err.message || String(err) }); + } + return; + } - let tabId = null; - try { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - tabId = tab?.id; - } catch {} - - currentActivity = tool; - activeTabId = tabId; - updateBadge('active'); - if (tabId) await showOverlay(tabId, tool.replace('browser_', '')); - - try { - const result = await dispatch(tool, params || {}); - sendResponse(id, { success: true, result }); - } catch (err) { + // Acquire this tab's mutex (2.1) and honor per-agent locks (2.2). + runOnTabLib( + tabLocks, + tabMutex, + tabId, + sessionId, + async () => { + currentActivity = tool; + updateBadge('active'); + await showOverlay(tabId, tool.replace('browser_', '')); + try { + const result = await dispatch(tool, p, sessionId); + sendResponse(id, { success: true, result }); + } catch (err) { + sendResponse(id, { success: false, error: err.message || String(err) }); + } finally { + currentActivity = null; + updateBadge(isConnected ? 'connected' : 'disconnected'); + await hideOverlay(tabId); + } + }, + ).catch((err) => { sendResponse(id, { success: false, error: err.message || String(err) }); - } finally { - currentActivity = null; - updateBadge(isConnected ? 'connected' : 'disconnected'); - if (tabId) await hideOverlay(tabId); - } + }); } function sendResponse(id, response) { @@ -201,9 +272,23 @@ function sendResponse(id, response) { } } -// --- Tool Dispatch --- +/** Which tabId does this call target? null = tab-agnostic (tabs list/create). */ +function extractTabId(tool, params) { + if (typeof params.tabId === 'number') return params.tabId; + // navigate allows omitting tabId → falls back to active tab inside its handler. + if (tool === 'browser_navigate') return null; // handled in-handler + if (tool === 'browser_tabs') return null; // create/list don't target; close/focus read their own tabId + return null; +} + +// --- Per-tab mutex + tab lock: see lib/tab-concurrency.js (tasks 2.1, 2.2) - +// runOnTabLib(locks, mutex, tabId, sessionId, fn) serializes per-tab work and +// makes non-owner sessions wait for the lock owner. Tested in +// tests/tab-concurrency.test.ts. -async function dispatch(tool, params) { +// --- Tool Dispatch -------------------------------------------------------- + +async function dispatch(tool, params, sessionId) { const handlers = { browser_navigate: handleNavigate, browser_click: handleClick, @@ -233,35 +318,99 @@ async function dispatch(tool, params) { const handler = handlers[tool]; if (!handler) throw new Error(`Unknown tool: ${tool}`); - return handler(params); + return handler(params, sessionId); } -// --- Helpers --- +// --- Tab resolution (task 1.2) ------------------------------------------- -async function getActiveTab() { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (!tab) throw new Error('No active tab'); - return tab; +/** + * Resolve a tab by id, throwing a clear, actionable error if it's gone. + * Replaces the old getActiveTab() which silently grabbed whatever tab was + * focused — the root cause of agents acting on the wrong page. + */ +async function resolveTab(tabId) { + if (tabId == null || typeof tabId !== 'number') { + throw new Error('tabId is required. Call browser_tabs list first to get a tabId.'); + } + try { + const tab = await chrome.tabs.get(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})`); + } +} + +/** + * 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. + */ +function getFallback(tabId, ref) { + if (tabId == null || !ref) return null; + const map = fallbackByTab.get(tabId); + if (!map) return null; + return map.get(ref) || null; } -async function execInTab(tabId, func, args = []) { - const sanitized = args.map(a => a === undefined ? null : a); - const results = await chrome.scripting.executeScript({ target: { tabId }, func, args: sanitized }); - return results[0]?.result; +/** + * safeExec (task 2.5): run chrome.scripting.executeScript against a tab, + * turning "can't access chrome:// / webstore / devtools pages" into a clear + * error instead of a silent timeout. + */ +async function safeExec(tabId, func, args = []) { + const tab = await resolveTab(tabId); + if (/^(chrome|chrome-extension|devtools|edge|about):/i.test(tab.url || '')) { + 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)); + try { + const results = await chrome.scripting.executeScript({ target: { tabId }, func, args: sanitized }); + return results[0]?.result; + } catch (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 err; + } } -// --- Tool Handlers --- +// --- Per-tab console/network storage (task 1.4) -------------------------- + +function getTabBuffer(map, tabId) { + let arr = map.get(tabId); + if (!arr) { + arr = []; + map.set(tabId, arr); + } + return arr; +} + +function pushCapped(arr, item, cap = PER_TAB_CAP) { + arr.push(item); + if (arr.length > cap) arr.splice(0, arr.length - cap); +} + +// --- Tool Handlers ------------------------------------------------------- + +/** Active-tab fallback, used ONLY by navigate when the caller omits tabId. */ +async function getActiveTab() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab) throw new Error('No active tab and no tabId given. Call browser_tabs list first.'); + return tab; +} async function handleNavigate(params) { - const { url, waitUntil = 'load' } = params; - const tab = await getActiveTab(); + const { url, waitUntil = 'load', tabId } = params; + // navigate is the one page tool allowed to omit tabId → active tab fallback. + const tab = tabId != null ? await resolveTab(tabId) : await getActiveTab(); return new Promise((resolve, reject) => { - const listener = (tabId, changeInfo) => { - if (tabId !== tab.id) return; + const listener = (tId, changeInfo) => { + if (tId !== tab.id) return; if (changeInfo.status === 'complete' || (waitUntil === 'domcontentloaded' && changeInfo.status === 'complete')) { chrome.tabs.onUpdated.removeListener(listener); - resolve({ url, status: 'navigated' }); + resolve({ url, status: 'navigated', tabId: tab.id }); } }; @@ -270,19 +419,31 @@ async function handleNavigate(params) { setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); - resolve({ url, status: 'timeout' }); + resolve({ url, status: 'timeout', tabId: tab.id }); }, 55000); }); } async function handleClick(params) { - const { ref, selector, button = 'left', doubleClick = false } = params; - const tab = await getActiveTab(); + const { tabId, ref, selector, button = 'left', doubleClick = false } = params; + await resolveTab(tabId); + const fb = getFallback(tabId, ref); - return execInTab(tab.id, (_ref, _sel, _btn, _dbl) => { + return safeExec(tabId, (_ref, _sel, _btn, _dbl, _fb, resolveFallback) => { let el = _ref ? document.querySelector(`[data-mcp-ref="${_ref}"]`) : null; - if (!el && _sel) el = document.querySelector(_sel); - if (!el) return { success: false, error: 'Element not found' }; + let via = 'ref'; + if (!el && _sel) { el = document.querySelector(_sel); via = 'selector'; } + // 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'; } + if (!el) { + return { + success: false, + error: _ref + ? `Element ${_ref} not found even with fallback. The DOM changed — call browser_snapshot { tabId } to get fresh refs.` + : 'Element not found', + }; + } el.scrollIntoView({ behavior: 'instant', block: 'center' }); @@ -305,18 +466,28 @@ async function handleClick(params) { el.dispatchEvent(new MouseEvent('dblclick', init)); } - return { success: true }; - }, [ref, selector, button, doubleClick]); + return { success: true, ...(via !== 'ref' ? { via } : {}) }; + }, [ref, selector, button, doubleClick, fb, PAGE_RESOLVE_FALLBACK_FN]); } async function handleType(params) { - const { ref, selector, text, clear = false } = params; - const tab = await getActiveTab(); + const { tabId, ref, selector, text, clear = false } = params; + await resolveTab(tabId); + const fb = getFallback(tabId, ref); - return execInTab(tab.id, (_ref, _sel, _text, _clear) => { + return safeExec(tabId, (_ref, _sel, _text, _clear, _fb, resolveFallback) => { let el = _ref ? document.querySelector(`[data-mcp-ref="${_ref}"]`) : null; - if (!el && _sel) el = document.querySelector(_sel); - if (!el) return { success: false, error: 'Element not found' }; + let via = 'ref'; + if (!el && _sel) { el = document.querySelector(_sel); via = 'selector'; } + if (!el && _fb && resolveFallback) { el = resolveFallback(_fb); if (el) via = 'fallback'; } + if (!el) { + return { + success: false, + error: _ref + ? `Element ${_ref} not found even with fallback. The DOM changed — call browser_snapshot { tabId } to get fresh refs.` + : 'Element not found', + }; + } el.focus(); @@ -341,15 +512,15 @@ async function handleType(params) { } el.dispatchEvent(new Event('change', { bubbles: true })); - return { success: true, typed: _text }; - }, [ref, selector, text, clear]); + return { success: true, typed: _text, ...(via !== 'ref' ? { via } : {}) }; + }, [ref, selector, text, clear, fb, PAGE_RESOLVE_FALLBACK_FN]); } async function handleScroll(params) { - const { direction = 'down', amount = 500, selector, toElement, position } = params; - const tab = await getActiveTab(); + const { tabId, direction = 'down', amount = 500, selector, toElement, position } = params; + await resolveTab(tabId); - return execInTab(tab.id, (_dir, _amt, _sel, _toEl, _pos) => { + return safeExec(tabId, (_dir, _amt, _sel, _toEl, _pos) => { if (_toEl) { const el = document.querySelector(`[data-mcp-ref="${_toEl}"]`) || document.querySelector(_toEl); if (el) { @@ -387,10 +558,10 @@ async function handleScroll(params) { } async function handlePressKey(params) { - const { key, modifiers = [], ref, selector } = params; - const tab = await getActiveTab(); + const { tabId, key, modifiers = [], ref, selector } = params; + await resolveTab(tabId); - return execInTab(tab.id, (_key, _mods, _ref, _sel) => { + return safeExec(tabId, (_key, _mods, _ref, _sel) => { let target = document.activeElement || document.body; if (_ref) { const el = document.querySelector(`[data-mcp-ref="${_ref}"]`); @@ -420,20 +591,19 @@ async function handlePressKey(params) { } async function handleWait(params) { - const { selector, state = 'visible', timeout = 10000, delay } = params; + const { tabId, selector, state = 'visible', timeout = 10000, delay } = params; if (delay) { - await new Promise(r => setTimeout(r, Math.min(delay, 30000))); + await new Promise((r) => setTimeout(r, Math.min(delay, 30000))); return { success: true, waited: delay }; } if (!selector) return { success: false, error: 'Need selector or delay' }; - - const tab = await getActiveTab(); + await resolveTab(tabId); const start = Date.now(); while (Date.now() - start < timeout) { - const found = await execInTab(tab.id, (_sel, _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; @@ -441,17 +611,17 @@ async function handleWait(params) { }, [selector, state]); if (found) return { success: true, selector, state, elapsed: Date.now() - start }; - await new Promise(r => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 200)); } return { success: false, error: `Timeout waiting for ${selector} to be ${state}` }; } async function handleHover(params) { - const { ref, selector } = params; - const tab = await getActiveTab(); + const { tabId, ref, selector } = params; + await resolveTab(tabId); - return execInTab(tab.id, (_ref, _sel) => { + return safeExec(tabId, (_ref, _sel) => { let el = _ref ? document.querySelector(`[data-mcp-ref="${_ref}"]`) : null; if (!el && _sel) el = document.querySelector(_sel); if (!el) return { success: false, error: 'Element not found' }; @@ -471,10 +641,10 @@ async function handleHover(params) { } async function handleSelect(params) { - const { ref, selector, value, label, index } = params; - const tab = await getActiveTab(); + const { tabId, ref, selector, value, label, index } = params; + await resolveTab(tabId); - return execInTab(tab.id, (_ref, _sel, _val, _lbl, _idx) => { + return safeExec(tabId, (_ref, _sel, _val, _lbl, _idx) => { let el = _ref ? document.querySelector(`[data-mcp-ref="${_ref}"]`) : null; if (!el && _sel) el = document.querySelector(_sel); if (!el) return { success: false, error: 'Element not found' }; @@ -482,7 +652,7 @@ async function handleSelect(params) { if (_val !== null) el.value = _val; else if (_lbl !== null) { - const opt = Array.from(el.options).find(o => o.textContent.trim() === _lbl); + const opt = Array.from(el.options).find((o) => o.textContent.trim() === _lbl); if (opt) el.value = opt.value; else return { success: false, error: `Option "${_lbl}" not found` }; } else if (_idx !== null) { @@ -496,12 +666,19 @@ async function handleSelect(params) { }, [ref, selector, value, label, index]); } +/** + * Snapshot (task 2.4): builds an accessibility tree INCLUDING shadow DOM and + * same-origin iframes. Refs are stamped via data-mcp-ref and are valid only for + * the tab that produced them (enforced by resolveTab in the consuming tools). + */ async function handleSnapshot(params) { - const { selector, compact = true } = params; - const tab = await getActiveTab(); + const { tabId, selector, compact = true } = params; + await resolveTab(tabId); - return execInTab(tab.id, (_sel, _compact) => { + return safeExec(tabId, (_sel, _compact, genFallback) => { let refCount = 0; + /** @type {Record} ref -> fallback, returned to background */ + const fallbacks = {}; const skipTags = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'PATH', 'BR', 'HR', 'WBR', 'META', 'LINK']); function vis(el) { @@ -515,10 +692,10 @@ async function handleSnapshot(params) { 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', + 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(); @@ -543,7 +720,7 @@ async function handleSnapshot(params) { } function isInteractive(el) { - const tags = ['A','BUTTON','INPUT','SELECT','TEXTAREA']; + 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' || @@ -551,7 +728,24 @@ async function handleSnapshot(params) { el.getAttribute('contenteditable') === 'true'; } - const landmarkRoles = new Set(['navigation','main','banner','contentinfo','form','search','complementary','region']); + const landmarkRoles = new Set(['navigation', 'main', 'banner', 'contentinfo', 'form', 'search', 'complementary', 'region']); + + // 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 buildCompact(el) { if (!el || el.nodeType !== 1) return null; @@ -563,7 +757,7 @@ async function handleSnapshot(params) { const isLandmark = landmarkRoles.has(r); const kids = []; - for (const c of el.children) { + for (const c of childrenOf(el)) { const cn = buildCompact(c); if (cn) Array.isArray(cn) ? kids.push(...cn) : kids.push(cn); } @@ -575,6 +769,7 @@ async function handleSnapshot(params) { const ref = `e${refCount++}`; el.setAttribute('data-mcp-ref', ref); const n = elName(el); + try { if (genFallback) fallbacks[ref] = genFallback(el); } catch {} const node = { ref, role: r }; if (n) node.name = n; @@ -598,7 +793,7 @@ async function handleSnapshot(params) { if (r === 'generic' && !n && !ia && depth > 1) { const kids = []; - for (const c of el.children) { + for (const c of childrenOf(el)) { const cn = buildFull(c, depth + 1); if (cn) Array.isArray(cn) ? kids.push(...cn) : kids.push(cn); } @@ -607,6 +802,7 @@ async function handleSnapshot(params) { const ref = `e${refCount++}`; el.setAttribute('data-mcp-ref', ref); + try { if (genFallback) fallbacks[ref] = genFallback(el); } catch {} const node = { ref, role: r }; if (r === 'generic') node.tag = el.tagName.toLowerCase(); @@ -617,7 +813,7 @@ async function handleSnapshot(params) { if (el.href && el.tagName === 'A') node.href = el.href; const kids = []; - for (const c of el.children) { + for (const c of childrenOf(el)) { const cn = buildFull(c, depth + 1); if (cn) Array.isArray(cn) ? kids.push(...cn) : kids.push(cn); } @@ -636,13 +832,29 @@ async function handleSnapshot(params) { title: document.title, compact: _compact, tree, + // internal: background stores these per-tab; never sent to the agent. + __fallbacks: fallbacks, }; - }, [selector, compact]); + }, [selector, compact, PAGE_FALLBACK_FN]).then((res) => { + // Store the fallbacks per-tab so click/type can resolve stale refs. + if (res && res.__fallbacks) { + const map = new Map(Object.entries(res.__fallbacks)); + fallbackByTab.set(tabId, map); + delete res.__fallbacks; // keep it out of the agent-visible payload + } + return res; + }); } async function handleScreenshot(params) { - const { format = 'png', quality = 80 } = params; - const tab = await getActiveTab(); + const { tabId, format = 'png', quality = 80 } = params; + const tab = await resolveTab(tabId); + // captureVisibleTab is window-scoped: ensure the tab is the active one in its + // window first (a no-op if it already is). + if (!tab.active) { + await chrome.tabs.update(tabId, { active: true }).catch(() => {}); + await new Promise((r) => setTimeout(r, 150)); // let the paint settle + } const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format, quality: format === 'jpeg' ? quality : undefined, @@ -651,29 +863,39 @@ async function handleScreenshot(params) { } async function handleConsole(params) { - const { clear = false } = params; - const msgs = [...consoleMessages]; - if (clear) consoleMessages = []; + const { tabId, clear = false } = params; + const buf = getTabBuffer(consoleByTab, tabId); + const msgs = [...buf]; + if (clear) consoleByTab.set(tabId, []); return { success: true, messages: msgs }; } async function handleNetwork(params) { - const { filter, clear = false } = params; - let reqs = [...networkRequests]; + const { tabId, filter, clear = false } = params; + let reqs = [...getTabBuffer(networkByTab, tabId)]; if (filter) { const re = new RegExp(filter); - reqs = reqs.filter(r => re.test(r.url)); + reqs = reqs.filter((r) => re.test(r.url)); } - if (clear) networkRequests = []; + if (clear) networkByTab.set(tabId, []); return { success: true, requests: reqs }; } -async function handleTabs(params) { +async function handleTabs(params, sessionId) { const { action, tabId, url } = params; switch (action) { case 'list': { const tabs = await chrome.tabs.query({ currentWindow: true }); - return { success: true, tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active })) }; + return { + success: true, + tabs: tabs.map((t) => ({ + id: t.id, + url: t.url, + title: t.title, + active: t.active, + lockedBy: tabLocks.owner(t.id) || null, + })), + }; } case 'create': { const t = await chrome.tabs.create({ url: url || 'about:blank' }); @@ -682,6 +904,7 @@ async function handleTabs(params) { case 'close': { if (!tabId) throw new Error('tabId required'); await chrome.tabs.remove(tabId); + tabLocks.release(tabId); return { success: true, closed: tabId }; } case 'focus': { @@ -689,15 +912,30 @@ async function handleTabs(params) { await chrome.tabs.update(tabId, { active: true }); return { success: true, focused: tabId }; } - default: throw new Error(`Unknown action: ${action}`); + case 'lock': { + if (!tabId) throw new Error('tabId required'); + if (!sessionId) throw new Error('lock requires a session (called outside daemon?)'); + tabLocks.lock(tabId, sessionId); + broadcastStatus(`Tab ${tabId} locked by ${sessionId}`); + return { success: true, locked: tabId, sessionId }; + } + case 'unlock': { + if (!tabId) throw new Error('tabId required'); + const was = tabLocks.owner(tabId); + tabLocks.release(tabId); + broadcastStatus(`Tab ${tabId} unlocked (was ${was || '-'})`); + return { success: true, unlocked: tabId, previousSession: was || null }; + } + default: + throw new Error(`Unknown action: ${action}`); } } async function handleFind(params) { - const { query, limit = 10 } = params; - const tab = await getActiveTab(); + const { tabId, query, limit = 10 } = params; + await resolveTab(tabId); - return execInTab(tab.id, (_q, _lim) => { + return safeExec(tabId, (_q, _lim) => { const qLow = _q.toLowerCase(); const matches = []; @@ -709,7 +947,7 @@ async function handleFind(params) { 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' }; + const map = { A: 'link', BUTTON: 'button', INPUT: 'input', SELECT: 'combobox', TEXTAREA: 'textbox', IMG: 'image' }; return map[el.tagName] || el.tagName.toLowerCase(); } @@ -732,8 +970,9 @@ async function handleFind(params) { const ref = `f${rc++}`; node.setAttribute('data-mcp-ref', ref); - 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.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) }, }); } @@ -743,11 +982,11 @@ async function handleFind(params) { } async function handleGetPageText(params) { - const { selector, maxLength = 50000 } = params; - const tab = await getActiveTab(); + const { tabId, selector, maxLength = 50000 } = params; + await resolveTab(tabId); const args = selector === undefined ? [null, maxLength] : [selector, maxLength]; - return execInTab(tab.id, (_sel, _max) => { + return safeExec(tabId, (_sel, _max) => { const root = _sel ? document.querySelector(_sel) : document.body; if (!root) return { success: false, error: 'Element not found' }; @@ -760,31 +999,40 @@ async function handleGetPageText(params) { }, args); } +/** + * 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 + * CDP Runtime.evaluate path. + */ async function handleEvaluate(params) { - const { expression } = params; - const tab = await getActiveTab(); - - await chrome.debugger.attach({ tabId: tab.id }, '1.3'); - try { - const { result, exceptionDetails } = await chrome.debugger.sendCommand( - { tabId: tab.id }, - 'Runtime.evaluate', - { expression: `(async () => { ${expression} })()`, awaitPromise: true, returnByValue: true }, - ); - if (exceptionDetails) { - return { success: false, error: exceptionDetails.exception?.description || exceptionDetails.text }; - } - return { success: true, result: result.value }; - } finally { - try { await chrome.debugger.detach({ tabId: tab.id }); } catch {} + const { tabId, expression } = params; + await resolveTab(tabId); + const tab = await chrome.tabs.get(tabId); + if (/^(chrome|chrome-extension|devtools|edge|about):/i.test(tab.url || '')) { + throw new Error(`Cannot evaluate on protected page (${tab.url}).`); } + + // Wrap the user expression in an async IIFE so `await` works, then stringify + // the function and run it in the MAIN world (page's own JS context). + const wrapped = `(async () => { ${expression} })()`; + const results = await chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + func: async (code) => { + // eslint-disable-next-line no-eval + return await eval(code); + }, + args: [wrapped], + }); + const value = results?.[0]?.result; + return { success: true, result: value }; } async function handleClickByText(params) { - const { text, index = 0, exact = false } = params; - const tab = await getActiveTab(); + const { tabId, text, index = 0, exact = false } = params; + await resolveTab(tabId); - return execInTab(tab.id, (_text, _index, _exact) => { + return safeExec(tabId, (_text, _index, _exact) => { const textLower = _text.toLowerCase(); const candidates = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT); @@ -806,7 +1054,7 @@ async function handleClickByText(params) { } } - function getDepth(el) { let d = 0; let p = el; while (p = p.parentElement) d++; return d; } + function getDepth(el) { let d = 0; let p = el; while ((p = p.parentElement)) d++; return d; } candidates.sort((a, b) => b.depth - a.depth); @@ -831,10 +1079,10 @@ async function handleClickByText(params) { } async function handleDialog(params) { - const { action = 'accept', promptText } = params; - const tab = await getActiveTab(); + const { tabId, action = 'accept', promptText } = params; + await resolveTab(tabId); - return execInTab(tab.id, (_action, _promptText) => { + return safeExec(tabId, (_action, _promptText) => { window.__mcpDialogLog = window.__mcpDialogLog || []; window.__mcpDialogAction = _action; window.__mcpDialogPromptText = _promptText || ''; @@ -843,53 +1091,35 @@ async function handleDialog(params) { window.__mcpDialogOverrides = true; window.alert = function (msg) { - window.__mcpDialogLog.push({ - type: 'alert', - message: String(msg), - timestamp: Date.now(), - handled: window.__mcpDialogAction, - }); + window.__mcpDialogLog.push({ type: 'alert', message: String(msg), timestamp: Date.now(), handled: window.__mcpDialogAction }); }; window.confirm = function (msg) { const accepted = window.__mcpDialogAction === 'accept'; - window.__mcpDialogLog.push({ - type: 'confirm', - message: String(msg), - timestamp: Date.now(), - result: accepted, - }); + window.__mcpDialogLog.push({ type: 'confirm', message: String(msg), timestamp: Date.now(), result: accepted }); return accepted; }; window.prompt = function (msg, def) { const accepted = window.__mcpDialogAction === 'accept'; const text = accepted ? (window.__mcpDialogPromptText || def || '') : null; - window.__mcpDialogLog.push({ - type: 'prompt', - message: String(msg), - timestamp: Date.now(), - result: text, - }); + window.__mcpDialogLog.push({ type: 'prompt', message: String(msg), timestamp: Date.now(), result: text }); return accepted ? text : null; }; } const log = [...window.__mcpDialogLog]; window.__mcpDialogLog = []; - return { - success: true, - dialogs: log, - message: log.length ? 'Retrieved dialog history' : 'Overrides configured', - }; + return { success: true, dialogs: log, message: log.length ? 'Retrieved dialog history' : 'Overrides configured' }; }, [action, promptText]); } async function handleRunAction(params) { - const { code, actionParams = {} } = params; + const { tabId, code, actionParams = {} } = params; if (!code) throw new Error('code is required'); - const tab = await getActiveTab(); + const tab = await resolveTab(tabId); + // run_action stays on CDP (plan decision: CDP-only, can't be scripted). await chrome.debugger.attach({ tabId: tab.id }, '1.3'); try { const paramsJson = JSON.stringify(actionParams); @@ -911,11 +1141,12 @@ async function handleRunAction(params) { } async function handleUploadFile(params) { - const { ref, selector, filePath, files: fileList } = params; - const tab = await getActiveTab(); + const { tabId, ref, selector, filePath, files: fileList } = params; + const tab = await resolveTab(tabId); const filePaths = fileList || (filePath ? [filePath] : []); if (filePaths.length === 0) throw new Error('filePath or files required'); + // upload_file stays on CDP (DOM.setFileInputFiles is CDP-only). await chrome.debugger.attach({ tabId: tab.id }, '1.3'); try { await chrome.debugger.sendCommand({ tabId: tab.id }, 'DOM.enable', {}); @@ -944,13 +1175,13 @@ async function handleUploadFile(params) { } async function handleDrag(params) { - const { startRef, startSelector, endRef, endSelector, startX, startY, endX, endY, steps = 10 } = params; - const tab = await getActiveTab(); + const { tabId, startRef, startSelector, endRef, endSelector, startX, startY, endX, endY, steps = 10 } = params; + const tab = await resolveTab(tabId); let sx = startX, sy = startY, ex = endX, ey = endY; if (sx == null || sy == null || ex == null || ey == null) { - const coords = await execInTab(tab.id, (_sRef, _sSel, _eRef, _eSel) => { + const coords = await safeExec(tabId, (_sRef, _sSel, _eRef, _eSel) => { function find(ref, sel) { let el = ref ? document.querySelector(`[data-mcp-ref="${ref}"]`) : null; if (!el && sel) el = document.querySelector(sel); @@ -959,10 +1190,7 @@ async function handleDrag(params) { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; } - return { - start: find(_sRef, _sSel), - end: find(_eRef, _eSel), - }; + return { start: find(_sRef, _sSel), end: find(_eRef, _eSel) }; }, [startRef, startSelector, endRef, endSelector]); if (coords.start) { sx = coords.start.x; sy = coords.start.y; } @@ -973,6 +1201,7 @@ async function handleDrag(params) { throw new Error('Could not determine drag coordinates. Provide refs/selectors or explicit x,y coordinates.'); } + // drag stays on CDP (Input.dispatchMouseEvent is CDP-only). await chrome.debugger.attach({ tabId: tab.id }, '1.3'); try { await chrome.debugger.sendCommand({ tabId: tab.id }, 'Input.dispatchMouseEvent', { @@ -1000,13 +1229,13 @@ async function handleDrag(params) { } async function handleFillForm(params) { - const { fields, submit } = params; + const { tabId, fields, submit } = params; if (!fields || !Array.isArray(fields) || fields.length === 0) { throw new Error('fields array is required'); } - const tab = await getActiveTab(); + await resolveTab(tabId); - return execInTab(tab.id, (_fields, _submit) => { + return safeExec(tabId, (_fields, _submit) => { const results = []; for (const field of _fields) { const { ref, selector, value, clear } = field; @@ -1057,11 +1286,12 @@ async function handleFillForm(params) { }, [fields, submit]); } -// --- Events --- +// --- Events (per-tab console/network capture: task 1.4) ------------------ chrome.runtime.onMessage.addListener((msg, sender, respond) => { - if (msg.type === 'console') { - consoleMessages.push({ level: msg.level, text: msg.text, timestamp: Date.now(), url: sender.tab?.url }); + 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 }); } else if (msg.type === 'getStatus') { respond(buildStatusPayload()); } else if (msg.type === 'setPort') { @@ -1078,19 +1308,45 @@ chrome.runtime.onMessage.addListener((msg, sender, respond) => { } else { respond({ success: false, error: 'Invalid port' }); } + } else if (msg.type === 'setToken') { + // extension popup can store the auth token (3.1) once. + wsToken = (msg.token || '').trim(); + chrome.storage.local.set({ wsToken }); + ws?.close(); + ws = null; + isConnected = false; + reconnectAttempts = 0; + connect(); + respond({ success: true }); + } else if (msg.type === 'unlockAll') { + tabLocks.unlockAll(); + broadcastStatus('All tab locks cleared'); + respond({ success: true }); } return true; }); chrome.webRequest.onCompleted.addListener( (details) => { - networkRequests.push({ - method: details.method, url: details.url, - status: details.statusCode, type: details.type, timestamp: details.timeStamp, + if (details.tabId == null || details.tabId < 0) return; // not a real tab + const buf = getTabBuffer(networkByTab, details.tabId); + pushCapped(buf, { + method: details.method, + url: details.url, + status: details.statusCode, + type: details.type, + timestamp: details.timeStamp, }); - if (networkRequests.length > 200) networkRequests = networkRequests.slice(-200); }, { urls: [''] }, ); +// A tab closing should release its lock and drop its buffers. +chrome.tabs.onRemoved.addListener((tabId) => { + tabLocks.release(tabId); + consoleByTab.delete(tabId); + networkByTab.delete(tabId); + fallbackByTab.delete(tabId); +}); + initConnection(); diff --git a/extension/content.js b/extension/content.js index de17714..56d38cd 100644 --- a/extension/content.js +++ b/extension/content.js @@ -1,7 +1,7 @@ (function () { 'use strict'; - if (window.__realBrowserMcpInjected) return; - window.__realBrowserMcpInjected = true; + if (window.__browserControllerInjected) return; + window.__browserControllerInjected = true; const orig = { log: console.log, diff --git a/extension/icons/icon128.png b/extension/icons/icon128.png index 9b5e7d959670ed62b75981b768d34e4ccf3af9ec..e8ca8a754dd8555eb20900a2aab4b5280511e757 100644 GIT binary patch literal 12076 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 LRQyyeZXEJIU066f literal 360 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrVAS_?aSW-L^Y)S=BZGp#fdl^D zJWRn%NeX3;{v1hrTl{j?eMSie<^%?w21Wx0wgU|0avCg{k4P$^SbMzE9rvTuf0GJ5^09OFO-q^y16SmNW z!YL;b0Wx&fzH3Z@q#R53VF4h+{cjmad$w09vMJ6)#TiAooW9IPIu`?t_OXfH?2uqK z9Y|mkQX;HiiF$@FK7<|V z(Usd8Cg+P$MV@2Hu{nW;-)0m8hw}YV$N8+fqs5=Q2MXgJdU*G~-GtZBT`|exqHM2M zj{Bnv+*B#_AkX7W34zukd2juOhjkx~Ttr_>eP;DbQo`0y^V>6HB-(@(GgaArS*UB> zSsU@VT6X$WiJQ%_W95{nh6EyJE^%pgsieFne`#-;WL9hAtEY2+95s$9>ghh$I<~lg ze|C0h{d2m=z@eX?V#bV$I)p*)1VH`$?kveoq2_tOJMAW)tTe3zN)p~Puc9Y6ugYJE7BrUU{`u!C-sES1Ehbw;hsj- z?=D7+f7~2Dw=PHA;qyS4(lnCThaLM(xM)klvI?<%x;;@*Ll1ie7x1@L200OU1Sos? zMU-tO?~pt;TB74SKhb$|S-7S~^SQyhqA^DF-w$N>SU3F$<{s6K?7OUO(Vcv%etPX zq$#hKQ<8ChS3Y7~c|hTM2S+Gp8B6K^bh4t*r|-bz05ZYG37wu&{i!S--Ww2{^b!rI zvsFAiVm0SL{fv@7_JM*gq6OYR`c@>XYZEJ@zNq8^LZ467oi8lbhf(uNPur@$DthG8 zK~7T@w)4P6hG)h2}sB;#|3YMNgA& z{Tf_;=F7du^Jk{{PVB!xmpR&R!4PE%p{%tg{rTXj?nfbh)n8+5q^f%94mXY5)ryo` zCykGGgvpK!nVseW8CiR~*O$RZKM#AQQ`9-75&v)#to8CF!_{u$B5%Jjeya2L5r-YV zRi5`>rgqbr0)LGI>iLR2jU>w5{eNY<{2ZV4TJg@|%;cH*!`Uz`F}T#i-x*RX?jr9n z6GUQK7G8XM?vt$$b>h(T-S6t#rp>E2xxcInR&E@Vyf9b0#Z$vYuf1k)!NZi8- z$R+1(kUD?Id}tOoU-GQnbGrSU=dDEFD{V_>73#Z)mKg=()VNZK-|X7HZIJqJ?wn;` z{UZZHWqvfViPH_^*10u)_9YoOI*K?K@Jlf5+oY*o4RFCIHKx?dhI7SlANz;OXDMYGPc%@0w!L0HF1>rMmY%sL;3USvCr%U?>rm(q5l_9h~E<);Db{C E0m>)xSO5S3 delta 64 zcmZ3<8#F<}ii3@TfkFQB|3n}q>FMGaQZXmlV|De{`9fz5vJR|ZT+GHJ&djj%;#v8u TXInM{6)||a`njxgN@xNAo;DTQ diff --git a/extension/icons/icon48.png b/extension/icons/icon48.png index 7b31949862056062dfb90161de57d3d588c975bc..d1981427db990e0dd99f487b7bf3a6c23ecd55ea 100644 GIT binary patch literal 4774 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 */ + this.queues = new Map(); + } + + /** + * Run `fn` serialized for `tabId`: at most one fn per tab runs at a time. + * Different tabIds run concurrently. Resolves/rejects with fn's result. + * @param {number} tabId + * @param {() => (Promise | unknown)} fn + * @returns {Promise} + */ + run(tabId, fn) { + const prev = this.queues.get(tabId) || Promise.resolve(); + const next = prev.then(fn, fn); + // Swallow rejection on the stored chain so a later call isn't poisoned, + // but the returned `next` still surfaces the real error to the caller. + this.queues.set( + tabId, + next.then( + () => {}, + () => {}, + ), + ); + return next; + } + + /** For tests: whether anything is currently pending for a tab. */ + isIdle(tabId) { + return !this.queues.has(tabId); + } +} + +export class TabLockMap { + constructor() { + /** @type {Map} tabId -> sessionId */ + this.locks = new Map(); + /** lock-wait timeout so a forgotten lock can't hang a caller forever */ + this.acquireTimeoutMs = 30_000; + } + + /** Who owns tabId, or undefined if unlocked. */ + owner(tabId) { + return this.locks.get(tabId); + } + + /** Claim tabId for sessionId. */ + lock(tabId, sessionId) { + this.locks.set(tabId, sessionId); + } + + /** Release tabId (only if currently owned by sessionId). */ + unlock(tabId, sessionId) { + if (sessionId == null || this.locks.get(tabId) === sessionId) { + this.locks.delete(tabId); + } + } + + /** Release tabId unconditionally (used when the tab is closed). */ + release(tabId) { + this.locks.delete(tabId); + } + + /** @returns {Array<{tabId:number,sessionId:string}>} serializable snapshot. */ + snapshot() { + return Array.from(this.locks.entries()).map(([tabId, sessionId]) => ({ tabId, sessionId })); + } + + /** Release all locks. */ + unlockAll() { + this.locks.clear(); + } + + /** + * Wait until tabId is unlocked OR owned by sessionId (i.e. this session may + * proceed). Resolves immediately if already eligible; otherwise polls. + * @returns {Promise} + */ + async waitFor(tabId, sessionId) { + if (sessionId == null) return; // no session → no locking semantics + const owner = this.locks.get(tabId); + if (owner === undefined || owner === sessionId) return; + + const start = Date.now(); + return new Promise((resolve) => { + const tick = () => { + const cur = this.locks.get(tabId); + if (cur === undefined || cur === sessionId || Date.now() - start > this.acquireTimeoutMs) { + resolve(); + } else { + setTimeout(tick, 50); + } + }; + tick(); + }); + } +} + +/** + * Convenience wrapper combining lock + mutex, matching runOnTab in background.js. + * @param {TabLockMap} locks + * @param {TabMutexMap} mutex + * @param {number} tabId + * @param {string|null} sessionId + * @param {() => (Promise | unknown)} fn + */ +export async function runOnTab(locks, mutex, tabId, sessionId, fn) { + await locks.waitFor(tabId, sessionId); + return mutex.run(tabId, fn); +} diff --git a/extension/manifest.json b/extension/manifest.json index 673d4eb..3dd04bf 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, - "name": "Real Browser MCP", - "version": "1.3.0", + "name": "Browser Controller", + "version": "2.0.0", "description": "Let AI agents control your real browser - your tabs, your sessions, your logins", "icons": { "16": "icons/icon16.png", @@ -18,7 +18,9 @@ "alarms" ], "host_permissions": [ - "" + "", + "http://127.0.0.1:7225/*", + "http://localhost:7225/*" ], "background": { "service_worker": "background.js", @@ -34,7 +36,7 @@ ], "action": { "default_popup": "popup/popup.html", - "default_title": "Real Browser MCP", + "default_title": "Browser Controller", "default_icon": { "16": "icons/icon16.png", "48": "icons/icon48.png", diff --git a/extension/popup/popup.html b/extension/popup/popup.html index 17b4ea4..91c7fa1 100644 --- a/extension/popup/popup.html +++ b/extension/popup/popup.html @@ -4,8 +4,13 @@
-

Real Browser MCP

+

Browser Controller

Checking...
@@ -105,6 +162,18 @@

Real Browser MCP

+
+ + +
+ +
Connected Agents
+
none
+ +
Tab Locks
+
none
+ +
+
+ diff --git a/extension/popup/popup.js b/extension/popup/popup.js index 5548421..e845ba6 100644 --- a/extension/popup/popup.js +++ b/extension/popup/popup.js @@ -2,19 +2,83 @@ const dot = document.getElementById('dot'); const statusEl = document.getElementById('status'); const detailEl = document.getElementById('detail'); const portInput = document.getElementById('port'); +const tokenInput = document.getElementById('token'); const logEl = document.getElementById('log'); const versionEl = document.getElementById('version'); +const locksEl = document.getElementById('locks'); +const unlockAllBtn = document.getElementById('unlockAll'); +const agentsEl = document.getElementById('agents'); const manifest = chrome.runtime.getManifest(); versionEl.textContent = `v${manifest.version}`; +// Daemon HTTP base (same port as the WebSocket). The popup talks to it to +// auto-pair the token (no fs access in MV3) and to show connected agents. +function daemonHttpBase() { + return `http://127.0.0.1:${portInput.value || 7225}`; +} + +/** + * Auto-pair: fetch the daemon's token over localhost and hand it to the + * background worker so the WS connection carries ?token=. Runs once on popup + * open; safe to re-run. Silent if the daemon isn't up yet (it'll retry). + */ +async function autoPairToken() { + try { + const res = await fetch(`${daemonHttpBase()}/pair`, { cache: 'no-store' }); + if (!res.ok) return; + const data = await res.json(); + if (data && data.token && data.token !== tokenInput.value) { + tokenInput.value = data.token; + chrome.runtime.sendMessage({ type: 'setToken', token: data.token }); + } + } catch { + // daemon not reachable yet — the user will see "Disconnected"; no spam. + } +} + +/** Poll the daemon for connected agents and render them. */ +async function refreshAgents() { + try { + const res = await fetch(`${daemonHttpBase()}/status`, { cache: 'no-store' }); + if (!res.ok) { renderAgents([]); return; } + const data = await res.json(); + renderAgents(Array.isArray(data.agents) ? data.agents : []); + } catch { + renderAgents(null); // null = daemon down + } +} + +function renderAgents(agents) { + if (agents === null) { + agentsEl.innerHTML = '
daemon not reachable
'; + return; + } + if (agents.length === 0) { + agentsEl.innerHTML = '
none
'; + return; + } + agentsEl.innerHTML = agents + .map((a) => { + const name = a.name || 'agent'; + const ago = a.connectedAt ? Math.round((Date.now() - a.connectedAt) / 1000) : 0; + const dur = ago < 60 ? `${ago}s` : `${Math.round(ago / 60)}m`; + return `
${escapeHtml(name)}${a.sessionId || ''} · ${dur}
`; + }) + .join(''); +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + function updateUI(state) { if (!state) return; dot.className = `dot ${state.connectionState || 'disconnected'}`; const labels = { - connected: 'Connected to MCP server', + connected: 'Connected to daemon', reconnecting: 'Reconnecting...', disconnected: 'Disconnected', error: 'Connection error', @@ -23,7 +87,7 @@ function updateUI(state) { ? `Active: ${state.activity}` : labels[state.connectionState] || 'Unknown'; - let detail = `ws://localhost:${state.port || '7225'}`; + let detail = `ws://127.0.0.1:${state.port || '7225'}`; if (state.connectionState === 'reconnecting' && state.reconnectAttempts > 0) { detail += ` · attempt ${state.reconnectAttempts}`; if (state.nextRetryMs) detail += ` · retry in ${Math.round(state.nextRetryMs / 1000)}s`; @@ -36,6 +100,18 @@ function updateUI(state) { detailEl.textContent = detail; if (state.port) portInput.value = state.port; + + // Tab locks (task 3.3) + const locks = Array.isArray(state.tabLocks) ? state.tabLocks : []; + if (locks.length === 0) { + locksEl.innerHTML = '
none
'; + unlockAllBtn.disabled = true; + } else { + locksEl.innerHTML = locks + .map((l) => `
tab ${l.tabId}${l.sessionId}
`) + .join(''); + unlockAllBtn.disabled = false; + } } function addLog(text, level) { @@ -55,6 +131,16 @@ chrome.runtime.sendMessage({ type: 'getStatus' }, (response) => { } }); +// On open: auto-pair the token (so the WS carries ?token=) and start polling +// the daemon for connected agents. The poll stops when the popup closes. +autoPairToken(); +refreshAgents(); +let agentsTimer = setInterval(refreshAgents, 2000); +// popup unload: clear the poll so we don't leak timers across reopens +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') clearInterval(agentsTimer); +}); + chrome.runtime.onMessage.addListener((msg) => { if (msg.type === 'status') { updateUI(msg); @@ -65,6 +151,7 @@ chrome.runtime.onMessage.addListener((msg) => { } }); +// Port (debounced) let debounce = null; portInput.addEventListener('input', () => { clearTimeout(debounce); @@ -77,3 +164,120 @@ portInput.addEventListener('input', () => { }); }, 600); }); + +// Token (task 3.1): the daemon generates token.json; the extension must present +// the same token on connect. Set it here once. +let tokenDebounce = null; +tokenInput.addEventListener('input', () => { + clearTimeout(tokenDebounce); + tokenDebounce = setTimeout(() => { + chrome.runtime.sendMessage({ type: 'setToken', token: tokenInput.value }, (resp) => { + if (resp?.success) addLog('Token updated, reconnecting', 'warn'); + }); + }, 600); +}); + +// Unlock All (task 3.3) +unlockAllBtn.addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'unlockAll' }, (resp) => { + if (resp?.success) addLog('All tab locks released', 'warn'); + }); +}); + +// --- Resizable popup ------------------------------------------------------- +// Chrome extension popups are fixed-size; there's no native resize. We drive +// the document size from a draggable corner handle and persist the chosen +// width/height to chrome.storage.local so it's restored on every open. +const resizerEl = document.getElementById('resizer'); +const MIN_W = 260; +const MIN_H = 220; +const MAX_W = 720; +const MAX_H = 720; +const SIZE_KEY = 'popupSize'; + +function applySize(w, h) { + const cw = Math.min(MAX_W, Math.max(MIN_W, Math.round(w))); + const ch = Math.min(MAX_H, Math.max(MIN_H, Math.round(h))); + document.documentElement.style.width = `${cw}px`; + document.documentElement.style.height = `${ch}px`; + document.body.style.width = `${cw}px`; + document.body.style.height = `${ch}px`; + return { w: cw, h: ch }; +} + +// Restore last chosen size (fall back to the CSS default of 300x220-ish). +chrome.storage.local.get(SIZE_KEY, (saved) => { + if (saved && saved[SIZE_KEY]) { + applySize(saved[SIZE_KEY].w, saved[SIZE_KEY].h); + } +}); + +let dragging = false; +// Anchor deltas in SCREEN coordinates. clientX/clientY are relative to the +// popup viewport, and Chrome slides the whole viewport as the popup resizes +// (it keeps the right edge pinned to the toolbar icon). Reading clientX during +// a resize therefore feeds back into the width math and produces visible jitter +// (grow → slide → clientX jumps → shrink → slide back → repeat). screenX is +// anchored to the physical monitor and never moves, so it's jitter-free. +let startScreenX = 0; +let startScreenY = 0; +let startW = 0; +let startH = 0; +let saveTimer = null; +// Coalesce multiple mousemove events into one layout write per animation frame. +let pendingTargetW = 0; +let pendingTargetH = 0; +let rafHandle = 0; + +function flushResize() { + rafHandle = 0; + applySize(pendingTargetW, pendingTargetH); +} + +resizerEl.addEventListener('mousedown', (e) => { + dragging = true; + startScreenX = e.screenX; + startScreenY = e.screenY; + const cs = getComputedStyle(document.documentElement); + startW = parseInt(cs.width, 10) || 300; + startH = parseInt(cs.height, 10) || 220; + pendingTargetW = startW; + pendingTargetH = startH; + e.preventDefault(); +}); + +document.addEventListener('mousemove', (e) => { + if (!dragging) return; + // The grip is at bottom-LEFT and Chrome anchors the popup to the top-right. + // Fixed edges are top + right. With the cursor in screen space: + // - down → height grows (startH + Δy) + // - right → width SHRINKS (startW - Δx) — cursor approaches the fixed right edge + // - left → width grows — cursor pulls the left edge out + pendingTargetW = startW - (e.screenX - startScreenX); + pendingTargetH = startH + (e.screenY - startScreenY); + // One write per frame: collapsing a burst of mousemove events avoids layout + // thrash and keeps the resize smooth under fast cursor movement. + if (!rafHandle) { + rafHandle = requestAnimationFrame(flushResize); + } +}); + +function endDrag() { + if (!dragging) return; + dragging = false; + // Apply the very last mousemove target synchronously so the persisted size + // matches what the user actually sees, even if a scheduled raf hadn't fired. + const final = applySize(pendingTargetW, pendingTargetH); + if (rafHandle) { + // cancel the now-redundant frame so it doesn't overwrite the final size + cancelAnimationFrame(rafHandle); + rafHandle = 0; + } + // debounce storage write in case the browser fires several mouseups + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + chrome.storage.local.set({ [SIZE_KEY]: { w: final.w, h: final.h } }); + }, 150); +} +document.addEventListener('mouseup', endDrag); +document.addEventListener('mouseleave', endDrag); diff --git a/extension/utils/smart-selector.js b/extension/utils/smart-selector.js new file mode 100644 index 0000000..88b523d --- /dev/null +++ b/extension/utils/smart-selector.js @@ -0,0 +1,299 @@ +/** + * Smart Selector — ref-break fallback for browsing dynamic sites (React/Vue SPAs). + * + * PROBLEM: snapshot returns `ref:e5`; React re-renders; `click e5` → null. + * + * SOLUTION: at snapshot time, capture a STABLE description of each ref'd element + * (robust CSS selector + text + role + tag + stable attrs). When a ref no longer + * resolves, the click/type handler tries, in order: + * 1. the robust CSS selector, + * 2. a text+role+tag scan. + * If either works, the action succeeds and the agent gets a "via: fallback" + * hint so it knows the ref is stale and may re-snapshot when convenient. + * + * This file has TWO layers: + * - Pure heuristic helpers (exported) — unit-testable in node, no DOM needed. + * - PAGE_FALLBACK_FN — the function actually injected into the page via + * chrome.scripting. It is self-contained (no imports) because the page world + * cannot import extension modules. The heuristics are duplicated verbatim + * inside it so there is a SINGLE readable source for the algorithm. + */ + +// --------------------------------------------------------------------------- +// Pure heuristics (exported for unit testing). DOM-free where possible. +// --------------------------------------------------------------------------- + +/** + * Is an id stable enough to select on? We reject: + * - React/Awesome id generation: `:r5:`, `:R3aq:`, `__BVID__123`, `rc-...` + * - Hashed ids: `react-aria-1-abc23`, `headlessui-...` + * - Empty / whitespace. + * Stable examples: `submit-btn`, `login-form`, `nav-header`, `btn-cta`. + * @param {string} id + * @returns {boolean} + */ +export function isStableId(id) { + if (!id || typeof id !== 'string') return false; + const s = id.trim(); + if (s.length === 0 || s.length > 80) return false; + // colons = React/Aria managed id (`:r5:`, `:R3:`) + if (/[:]/.test(s)) return false; + // double-underscore frameworks (Bootstrap Vue __BVID__, MUI __BVID__) + if (/^__/.test(s)) return false; + // known generated prefixes + if (/^(react|aria|headlessui|rc|radix|react-aria|floating-ui)[-_:]/i.test(s)) return false; + // hash-like suffix: ends in a long hex/base32 run (styled-components, Emotion) + if (/-[a-f0-9]{6,}$/i.test(s) || /-[a-z0-9]{8,}$/i.test(s)) return false; + // must contain at least one letter (reject pure numbers, uuids) + if (!/[a-z]/i.test(s)) return false; + return true; +} + +/** + * Is a class name generated/unstable? styled-components & Emotion emit hashed + * classes like `css-1a2b3c`, `sc-jgyyrt`, `StyledButton-sc-1a2b3c`, `emotion-0`. + * Tailwind/utility classes (`flex`, `px-4`, `text-red-500`) ARE stable and useful. + * @param {string} cls + * @returns {boolean} true if the class is a hashed/generated one to IGNORE + */ +export function isGeneratedClass(cls) { + if (!cls || typeof cls !== 'string') return false; + const c = cls.trim(); + // styled-components: sc-xxxxx, Styled*__sc-* + if (/^sc-[a-z0-9]/i.test(c)) return true; + if (/__sc-/i.test(c)) return true; + // emotion / css-in-js: css-, emotion- + if (/^css-[a-z0-9]{4,}$/i.test(c)) return true; + if (/^emotion-\d+$/i.test(c)) return true; + // generic 6+ char hash-only segment as a class: `jss1234`, `_1a2b3c` + if (/^_?[a-z0-9]{6,}$/i.test(c) && /[0-9]/.test(c) && !/^(_|fx|px|py|m|p|w|h|z|gap)-/.test(c)) return true; + return false; +} + +/** + * Stable attribute names that are strong selectors (testing frameworks rely on + * these precisely because they survive re-renders). + */ +export const STABLE_ATTRS = ['data-testid', 'data-test', 'data-cy', 'data-qa', 'aria-label', 'name', 'data-id']; + +/** + * Build a robust CSS selector from a DOM element, walking up at most 3 ancestors. + * Preference order: + * 1. a stable test/aria attribute on the element itself, + * 2. a stable id on the element, + * 3. tag + stable (non-generated) classes on the element, anchored by the + * nearest ancestor that has a stable id or landmark role. + * @param {Element} el + * @returns {string|null} null if nothing stable could be built + */ +export function buildRobustSelectorFromPath(el) { + if (!el || !el.tagName) return null; + const tag = el.tagName.toLowerCase(); + + // 1. stable attribute on the element + for (const attr of STABLE_ATTRS) { + const v = el.getAttribute(attr); + if (v) return `${tag}[${attr}=${cssEscape(v)}]`; + } + + // 2. stable id on the element + if (isStableId(el.id)) return `#${cssEscape(el.id)}`; + + // 3. tag + stable classes, anchored up to 3 ancestors + const stableClasses = (el.getAttribute('class') || '') + .split(/\s+/) + .filter((c) => c && !isGeneratedClass(c)) + .slice(0, 3); + let selfPart = tag; + if (stableClasses.length) selfPart = `${tag}.${stableClasses.map((c) => cssEscape(c)).join('.')}`; + + // climb ancestors looking for an anchor (stable id, or a landmark/stable role) + const parts = [selfPart]; + let cur = el.parentElement; + let climbed = 0; + while (cur && climbed < 3) { + if (isStableId(cur.id)) { + parts.unshift(`#${cssEscape(cur.id)}`); + return parts.join(' > '); + } + const arole = cur.getAttribute('role'); + const landmark = cur.tagName.toLowerCase(); + const isLandmark = + arole && ['navigation', 'main', 'banner', 'contentinfo', 'form', 'search', 'region'].includes(arole); + const isSemanticLandmark = ['nav', 'main', 'header', 'footer', 'section', 'aside', 'form'].includes(landmark); + if (isLandmark) { + // landmark BY ROLE on a non-semantic tag: keep the role so the anchor + // stays specific (e.g. `div[role=main]`, not a bare `div`). + parts.unshift(`${landmark}[role=${cssEscape(arole)}]`); + return parts.join(' > '); + } + if (isSemanticLandmark) { + parts.unshift(landmark); + return parts.join(' > '); + } + // also accept a stable attr on an ancestor + let anchored = false; + for (const attr of STABLE_ATTRS) { + const v = cur.getAttribute(attr); + if (v) { + parts.unshift(`${landmark}[${attr}=${cssEscape(v)}]`); + anchored = true; + break; + } + } + if (anchored) return parts.join(' > '); + cur = cur.parentElement; + climbed++; + } + // no anchor found — the selector is weak; only return it if it had stable classes + return stableClasses.length ? parts.join(' > ') : null; +} + +/** Minimal CSS.escape polyfill-safe helper (works in node tests without DOM). */ +function cssEscape(value) { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(value); + // simple escape: wrap in quotes if it contains special chars + return String(value).replace(/["\\]/g, '\\$&'); +} + +// --------------------------------------------------------------------------- +// PAGE-side function. Self-contained — injected via chrome.scripting.executeScript. +// The heuristics above are duplicated here verbatim (page world can't import). +// Keep them in sync. Returns { robustSelector, text, role, tag, attrs }. +// --------------------------------------------------------------------------- + +export const PAGE_FALLBACK_FN = function generateFallback(el) { + if (!el || !el.tagName) return null; + + function isStableId(id) { + if (!id || typeof id !== 'string') return false; + const s = id.trim(); + if (s.length === 0 || s.length > 80) return false; + if (/[:]/.test(s)) return false; + if (/^__/.test(s)) return false; + if (/^(react|aria|headlessui|rc|radix|react-aria|floating-ui)[-_:]/i.test(s)) return false; + if (/-[a-f0-9]{6,}$/i.test(s) || /-[a-z0-9]{8,}$/i.test(s)) return false; + if (!/[a-z]/i.test(s)) return false; + return true; + } + function isGeneratedClass(cls) { + if (!cls || typeof cls !== 'string') return false; + const c = cls.trim(); + if (/^sc-[a-z0-9]/i.test(c)) return true; + if (/__sc-/i.test(c)) return true; + if (/^css-[a-z0-9]{4,}$/i.test(c)) return true; + if (/^emotion-\d+$/i.test(c)) return true; + if (/^_?[a-z0-9]{6,}$/i.test(c) && /[0-9]/.test(c) && !/^(_|fx|px|py|m|p|w|h|z|gap)-/.test(c)) return true; + return false; + } + const STABLE_ATTRS = ['data-testid', 'data-test', 'data-cy', 'data-qa', 'aria-label', 'name', 'data-id']; + + function esc(value) { + try { return CSS.escape(String(value)); } catch { return String(value).replace(/["\\]/g, '\\$&'); } + } + + function buildSelector(el) { + const tag = el.tagName.toLowerCase(); + for (const attr of STABLE_ATTRS) { + const v = el.getAttribute(attr); + if (v) return `${tag}[${attr}=${esc(v)}]`; + } + if (isStableId(el.id)) return `#${esc(el.id)}`; + const stableClasses = (el.getAttribute('class') || '') + .split(/\s+/) + .filter((c) => c && !isGeneratedClass(c)) + .slice(0, 3); + let selfPart = tag; + if (stableClasses.length) selfPart = `${tag}.${stableClasses.map((c) => esc(c)).join('.')}`; + const parts = [selfPart]; + let cur = el.parentElement; + let climbed = 0; + while (cur && climbed < 3) { + if (isStableId(cur.id)) { parts.unshift(`#${esc(cur.id)}`); return parts.join(' > '); } + const arole = cur.getAttribute('role'); + const lm = cur.tagName.toLowerCase(); + const isLandmark = arole && ['navigation', 'main', 'banner', 'contentinfo', 'form', 'search', 'region'].includes(arole); + const isSemanticLandmark = ['nav', 'main', 'header', 'footer', 'section', 'aside', 'form'].includes(lm); + if (isLandmark) { parts.unshift(`${lm}[role=${esc(arole)}]`); return parts.join(' > '); } + if (isSemanticLandmark) { parts.unshift(lm); return parts.join(' > '); } + let anchored = false; + for (const attr of STABLE_ATTRS) { + const v = cur.getAttribute(attr); + if (v) { parts.unshift(`${lm}[${attr}=${esc(v)}]`); anchored = true; break; } + } + if (anchored) return parts.join(' > '); + cur = cur.parentElement; + climbed++; + } + return stableClasses.length ? parts.join(' > ') : null; + } + + // text: prefer aria-label/alt/title, else first line of innerText (trimmed, capped) + let text = ''; + const named = el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') || el.getAttribute('placeholder'); + if (named) { + text = named.trim().slice(0, 120); + } else if (el.innerText) { + text = (el.innerText.split('\n')[0] || '').trim().slice(0, 120); + } + + const role = el.getAttribute('role') || el.tagName.toLowerCase(); + const attrs = {}; + for (const attr of STABLE_ATTRS) { + const v = el.getAttribute(attr); + if (v != null) attrs[attr] = v; + } + + return { + robustSelector: buildSelector(el), + text, + role, + tag: el.tagName, + attrs, + }; +}; + +/** + * Page-side resolver (plan task 3). Given a fallback descriptor, find the + * element again after a re-render. Tries robustSelector first, then a + * text+role+tag scan. Self-contained for injection via chrome.scripting. + * @param {object} fb - the fallback descriptor from PAGE_FALLBACK_FN + * @returns {Element|null} + */ +export const PAGE_RESOLVE_FALLBACK_FN = function resolveFallback(fb) { + if (!fb) return null; + + // 1. robust CSS selector (may be null if nothing stable was found) + if (fb.robustSelector) { + try { + const el = document.querySelector(fb.robustSelector); + if (el) return el; + } catch { /* malformed selector — fall through */ } + } + + // 2. text + role + tag scan. Walk visible elements, match on tag/role and + // innerText containment. This survives class/id churn entirely. + if (fb.text) { + const wantTag = fb.tag || null; + const wantRole = fb.role || null; + const textLow = fb.text.toLowerCase(); + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT); + let node; + while ((node = walker.nextNode())) { + if (wantTag && node.tagName !== wantTag) continue; + const role = node.getAttribute('role'); + const effRole = role || node.tagName.toLowerCase(); + if (wantRole && effRole !== wantRole) continue; + // skip invisible + const s = getComputedStyle(node); + if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) continue; + const r = node.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) continue; + const t = (node.innerText || node.textContent || '').split('\n')[0].trim().toLowerCase(); + const aria = (node.getAttribute('aria-label') || node.getAttribute('alt') || node.getAttribute('title') || '').trim().toLowerCase(); + if (t.includes(textLow) || aria.includes(textLow)) return node; + } + } + return null; +}; + diff --git a/mcp-server/src/bridge.ts b/mcp-server/src/bridge.ts index e4fb334..11977b4 100644 --- a/mcp-server/src/bridge.ts +++ b/mcp-server/src/bridge.ts @@ -1,5 +1,29 @@ +import net from 'node:net'; +import http from 'node:http'; +import { execSync } from 'node:child_process'; import { WebSocket, WebSocketServer } from 'ws'; -import { execSync } from 'child_process'; +import { isIdempotent } from './daemon-config.js'; + +/** + * Signature of an HTTP request handler the daemon can register so it can serve + * `/pair` (token bootstrap) and `/status` (connected agents) on the SAME port + * as the WebSocket. Returning a value serializes it as JSON; returning undefined + * means the handler already wrote the response. + */ +export type HttpRequestHandler = ( + req: http.IncomingMessage, + url: URL, +) => unknown | void | Promise; + +/** + * CORS headers so the extension popup (chrome-extension:// origin) can fetch + * the daemon's HTTP endpoints. Reflects the requesting origin when present. + */ +const CORS: http.OutgoingHttpHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; interface PendingRequest { resolve: (value: unknown) => void; @@ -12,6 +36,9 @@ interface PendingRequest { interface BridgeOptions { port: number; + host?: string; + /** Auth token the extension must present via ?token= (task 3.1). Empty = open. */ + token?: string; maxRetries?: number; pingIntervalMs?: number; defaultTimeoutMs?: number; @@ -34,35 +61,97 @@ const TOOL_TIMEOUTS: Record = { browser_run_action: 30_000, browser_drag: 10_000, browser_fill_form: 15_000, + browser_evaluate: 15_000, + browser_snapshot: 15_000, + browser_text: 15_000, + browser_find: 15_000, + browser_click_text: 10_000, + browser_handle_dialog: 5_000, }; +/** + * Cross-platform (no `lsof`) probe: returns true if something is listening on + * host:port. Replaces the macOS/Linux-only `lsof` call (plan task 1.6). + */ +export async function isPortInUse(host: string, port: number): Promise { + return new Promise((resolve) => { + const tester = net.createServer(); + tester.once('error', () => resolve(true)); + tester.once('listening', () => { + tester.close(() => resolve(false)); + }); + tester.listen(port, host); + }); +} + +/** + * Find PIDs of processes LISTENing on `port`. Cross-platform: + * - macOS/Linux: `lsof -ti :PORT -sTCP:LISTEN` + * - Windows: PowerShell `Get-NetTCPConnection -LocalPort PORT` + * Returns [] on any failure (best-effort; callers handle the empty case). + */ +export function findListenersOnPort(port: number): number[] { + const cmd = + process.platform === 'win32' + ? `powershell -NoProfile -Command "(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue).OwningProcess"` + : `lsof -ti :${port} -sTCP:LISTEN`; + try { + const out = execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) + .split('\n') + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => Number.isFinite(n) && n > 0); + return Array.from(new Set(out)); + } catch { + return []; + } +} + export class ExtensionBridge { + private httpServer: http.Server | null = null; private wss: WebSocketServer | null = null; private client: WebSocket | null = null; private pendingRequests = new Map(); private requestId = 0; private port: number; + private host: string; + private token: string; private maxRetries: number; private pingIntervalMs: number; private defaultTimeoutMs: number; 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; constructor(options: BridgeOptions) { this.port = options.port; + // Bind 127.0.0.1 deterministically. (Earlier we tried 'localhost', but on + // macOS that resolves to IPv6 ::1 only, which then refuses IPv4 127.0.0.1 + // clients. The extension + popup both use 127.0.0.1, so bind that stack.) + this.host = options.host ?? '127.0.0.1'; + this.token = options.token ?? ''; this.maxRetries = options.maxRetries ?? 2; this.pingIntervalMs = options.pingIntervalMs ?? 10_000; this.defaultTimeoutMs = options.defaultTimeoutMs ?? 30_000; } + /** + * Register an HTTP handler so the daemon can serve `/pair` and `/status` on + * the same port as the WebSocket (the extension can then auto-discover the + * token and poll connected agents without a second server/port). + */ + registerHttpHandler(handler: HttpRequestHandler): void { + this.httpHandler = handler; + } + async start(): Promise { try { await this.tryListen(); } catch (err: unknown) { if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EADDRINUSE') { - console.error(`[Bridge] Port ${this.port} in use — killing stale process`); - this.killStaleProcess(); + console.error(`[Bridge] ${this.host}:${this.port} in use — killing stale process`); + await this.killStaleProcess(); await new Promise(r => setTimeout(r, 500)); await this.tryListen(); } else { @@ -71,39 +160,80 @@ export class ExtensionBridge { } } - private killStaleProcess(): void { - try { - const output = execSync(`lsof -ti :${this.port} -sTCP:LISTEN`, { encoding: 'utf8' }).trim(); - if (output) { - for (const pid of output.split('\n')) { - const p = parseInt(pid, 10); - if (p && p !== process.pid) { - console.error(`[Bridge] Killing stale PID ${p}`); - process.kill(p, 'SIGTERM'); - } - } - } - } catch { - // lsof returns exit 1 when no match — that's fine + /** + * Cross-platform stale-process eviction (plan task 1.6). If the port is still + * held after the first EADDRINUSE, find and SIGTERM the owning PID so the + * daemon can recover when a previous instance crashed without releasing 7225. + * - macOS/Linux: `lsof -ti :PORT -sTCP:LISTEN` + * - Windows: `Get-NetTCPConnection -LocalPort PORT` (via powershell) + * Best-effort: if the owner can't be attributed (permission/odd platform), + * the listen retry will surface EADDRINUSE again with a clear message. + */ + private async killStaleProcess(): Promise { + const inUse = await isPortInUse(this.host, this.port); + if (!inUse) return; + const pids = findListenersOnPort(this.port); + if (pids.length === 0) { + console.error(`[Bridge] Port ${this.port} still occupied but no PID found (permission/platform). Manual cleanup may be needed.`); + return; + } + for (const pid of pids) { + if (pid === process.pid) continue; + console.error(`[Bridge] Killing stale listener PID ${pid} on port ${this.port}`); + try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ } } + // give the OS a moment to release the socket + await new Promise((r) => setTimeout(r, 400)); } private tryListen(): Promise { return new Promise((resolve, reject) => { - this.wss = new WebSocketServer({ port: this.port, host: 'localhost' }); - - this.wss.on('listening', () => { - console.error(`[Bridge] Listening on ws://localhost:${this.port}`); - resolve(); + // One http.Server serves BOTH the WebSocket (extension) and short HTTP + // requests (popup fetching the token / agent list). Attaching the WS + // server with noServer lets us run auth on the upgrade ourselves. + const server = http.createServer(async (req, res) => { + if (req.method !== 'GET' || !this.httpHandler) { + res.writeHead(404, CORS).end('Not found'); + return; + } + try { + const url = new URL(req.url || '/', `http://${this.host}`); + const out = await this.httpHandler(req, url); + if (out === undefined) return; // handler wrote the response itself + const body = typeof out === 'string' ? out : JSON.stringify(out); + res.writeHead(200, { 'Content-Type': typeof out === 'string' ? 'text/plain' : 'application/json', ...CORS }); + res.end(body); + } catch (err) { + res.writeHead(500, CORS).end(String(err instanceof Error ? err.message : err)); + } }); + this.httpServer = server; - this.wss.on('connection', (ws: WebSocket, req) => { + this.wss = new WebSocketServer({ noServer: true }); + + server.on('upgrade', (req, socket, head) => { + // Authenticate BEFORE completing the WebSocket handshake. This way a bad + // token never produces an open socket the extension would treat as live. const origin = req.headers.origin; if (origin && !origin.includes('chrome-extension://')) { - ws.close(); + socket.destroy(); return; } + if (this.token) { + const url = new URL(req.url || '/', `http://${this.host}`); + const presented = url.searchParams.get('token'); + if (!presented || presented !== this.token) { + console.error('[Bridge] Extension rejected: missing/invalid token'); + socket.destroy(); + return; + } + } + this.wss!.handleUpgrade(req, socket, head, (ws) => { + this.wss!.emit('connection', ws, req); + }); + }); + this.wss.on('connection', (ws: WebSocket, req) => { if (this.client && this.client.readyState === WebSocket.OPEN) { this.client.close(); } @@ -142,10 +272,17 @@ export class ExtensionBridge { }); }); - this.wss.on('error', (err: Error) => { + server.on('listening', () => { + console.error(`[Bridge] Listening on http/ws://${this.host}:${this.port}`); + resolve(); + }); + + server.on('error', (err: Error) => { console.error('[Bridge] Server error:', err.message); reject(err); }); + + server.listen(this.port, this.host); }); } @@ -231,11 +368,18 @@ export class ExtensionBridge { const id = String(++this.requestId); const timeoutMs = TOOL_TIMEOUTS[tool] || this.defaultTimeoutMs; + // Task 2.3: non-idempotent tools (click, type, navigate, …) must never be + // retried on timeout — re-sending could double-fire the action. Only + // read-only tools (snapshot/screenshot/text/…) are retried. + const canRetry = isIdempotent(tool); + const timeout = setTimeout(() => { this.pendingRequests.delete(id); - if (retryCount < this.maxRetries) { + if (canRetry && retryCount < this.maxRetries) { console.error(`[Bridge] Timeout on ${tool}, retry ${retryCount + 1}/${this.maxRetries}`); this.sendToolCall(tool, params, retryCount + 1).then(resolve, reject); + } else if (!canRetry) { + reject(new Error(`Tool call timed out (no retry: non-idempotent): ${tool}`)); } else { reject(new Error(`Tool call timed out after ${retryCount + 1} attempts: ${tool}`)); } @@ -248,7 +392,7 @@ export class ExtensionBridge { } catch (err) { clearTimeout(timeout); this.pendingRequests.delete(id); - if (retryCount < this.maxRetries && this.isConnected()) { + if (canRetry && retryCount < this.maxRetries && this.isConnected()) { this.sendToolCall(tool, params, retryCount + 1).then(resolve, reject); } else { reject(err instanceof Error ? err : new Error('Send failed')); @@ -274,5 +418,7 @@ export class ExtensionBridge { this.client = null; this.wss?.close(); this.wss = null; + this.httpServer?.close(); + this.httpServer = null; } } diff --git a/mcp-server/src/daemon-config.ts b/mcp-server/src/daemon-config.ts new file mode 100644 index 0000000..b9dcbce --- /dev/null +++ b/mcp-server/src/daemon-config.ts @@ -0,0 +1,117 @@ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; +import crypto from 'node:crypto'; + +/** + * Daemon configuration, IPC protocol, paths, and auth-token helpers. + * + * Architecture (see plan task 1.0): + * Agent (stdio) ──► thin MCP client (index.ts) ──IPC──► daemon (daemon.ts) + * │ owns WS server :7225 + * ▼ + * Chrome extension + * + * The daemon is a pure multiplexer. It does NOT speak MCP. It ferries + * `{id, tool, params}` calls between N stdio MCP clients (over a local IPC + * socket) and the single Chrome extension (over WS), tagging every call with a + * per-client `sessionId` so the extension can do per-agent tab locking (2.2). + */ + +export const DEFAULT_WS_PORT = parseInt(process.env.WS_PORT || '7225', 10); +export const DEFAULT_WS_HOST = process.env.WS_HOST || '127.0.0.1'; + +/** Directory under the user's home where daemon state lives. */ +export const STATE_DIR = path.join(os.homedir(), '.browser-controller'); + +/** Local IPC socket the daemon listens on (thin clients connect here). */ +export const IPC_SOCKET_PATH = + process.platform === 'win32' + ? '\\\\.\\pipe\\browser-controller' + : path.join(STATE_DIR, 'daemon.sock'); + +/** Daemon metadata file: { pid, socket, port, startedAt }. */ +export const DAEMON_INFO_FILE = path.join(STATE_DIR, 'daemon.json'); + +/** Auth token file (3.1): both IPC clients and the extension must present it. */ +export const TOKEN_FILE = path.join(STATE_DIR, 'token.json'); + +// --- IPC / WS protocol message shapes -------------------------------------- + +export type IpcClientMessage = + | { kind: 'hello'; token: string; agentName?: string } + | { kind: 'call'; id: string; tool: string; params: Record }; + +export type IpcDaemonMessage = + | { kind: 'welcome'; sessionId: string; ok: true } + | { kind: 'denied'; reason: string; ok: false } + | { kind: 'result'; id: string; success: boolean; result?: unknown; error?: string } + | { kind: 'status'; connectionState: string; connectedSince: number | null }; + +/** + * Message the daemon forwards to the extension. `sessionId` is included so the + * extension can enforce per-agent tab locks (task 2.2). `id` is daemon-global. + */ +export interface ExtensionRequest { + id: string; + tool: string; + params: Record; + sessionId: string; +} + +// --- Idempotency (task 2.3) ------------------------------------------------ + +/** + * Tools that are safe to retry on timeout because re-running them has no side + * effects (they only read). Everything else is treated as non-idempotent and is + * NOT retried — a click must never fire twice. + * + * NOTE: `browser_evaluate` runs ARBITRARY user JS (can submit forms, click, + * mutate state) so it is NOT here despite "looking" like a read. `browser_tabs` + * has lock/unlock/close side effects, so it is NOT here either. + */ +export const IDEMPOTENT_TOOLS = new Set([ + 'browser_snapshot', + 'browser_screenshot', + 'browser_text', + 'browser_find', + 'browser_console', + 'browser_network', +]); + +export function isIdempotent(tool: string): boolean { + return IDEMPOTENT_TOOLS.has(tool); +} + +// --- Auth token (task 3.1) ------------------------------------------------- + +export interface StoredToken { + token: string; + createdAt: number; +} + +/** Reads the existing token, or creates the state dir + token on first run. */ +export function loadOrCreateToken(): StoredToken { + try { + const raw = fs.readFileSync(TOKEN_FILE, 'utf8'); + const parsed = JSON.parse(raw) as StoredToken; + if (parsed.token && typeof parsed.token === 'string') return parsed; + } catch { + // missing or invalid — fall through to create + } + const created: StoredToken = { token: crypto.randomBytes(24).toString('hex'), createdAt: Date.now() }; + fs.mkdirSync(STATE_DIR, { recursive: true }); + fs.writeFileSync(TOKEN_FILE, JSON.stringify(created, null, 2), { mode: 0o600 }); + return created; +} + +/** Reads the token without creating one (returns null if absent). */ +export function readToken(): string | null { + try { + const raw = fs.readFileSync(TOKEN_FILE, 'utf8'); + const parsed = JSON.parse(raw) as StoredToken; + return parsed.token ?? null; + } catch { + return null; + } +} diff --git a/mcp-server/src/daemon.ts b/mcp-server/src/daemon.ts new file mode 100644 index 0000000..b5f7630 --- /dev/null +++ b/mcp-server/src/daemon.ts @@ -0,0 +1,311 @@ +#!/usr/bin/env node + +/** + * Daemon (plan task 1.0) — single long-running process. + * + * Owns two things: + * 1. The extension-facing WebSocket server on 127.0.0.1:WS_PORT (only one + * process may own it — fixing the "two agents fighting over 7225" bug). + * 2. An IPC socket (AF_UNIX / Windows named pipe) that any number of thin + * `npx real-browser-mcp` MCP clients connect to. + * + * The daemon does NOT speak MCP. It multiplexes {tool, params} calls between + * clients and the single extension, tagging each with a per-client sessionId so + * the extension can enforce per-agent tab locks (2.2) and per-tab mutex queues + * (2.1). + * + * Auth (task 3.1): every IPC client must send the token from loadOrCreateToken() + * in its `hello` frame; connections without a valid token are dropped. + */ +import net from 'node:net'; +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { WebSocket, WebSocketServer } from 'ws'; +import { + DEFAULT_WS_HOST, + DEFAULT_WS_PORT, + DAEMON_INFO_FILE, + IPC_SOCKET_PATH, + STATE_DIR, + TOKEN_FILE, + loadOrCreateToken, + type IpcClientMessage, + type IpcDaemonMessage, +} from './daemon-config.js'; +import { ExtensionBridge } from './bridge.js'; + +const SERVER_NAME = 'real-browser-mcp-daemon'; + +interface IpcClient { + socket: net.Socket; + sessionId: string; + /** Human-readable name of the connected agent (Cursor, Claude, …), for UI. */ + agentName: string; + connectedAt: number; + /** in-flight calls keyed by the client-local id the client sent. */ + pending: Map; +} + +class Daemon { + private bridge: ExtensionBridge; + private ipcServer: net.Server | null = null; + private clients = new Map(); + /** daemon-global call id counter -> { client, clientId } to route the reply. */ + private pendingCalls = new Map(); + private sessionIdCounter = 0; + private token: string; + private startedAt = Date.now(); + + constructor() { + this.token = loadOrCreateToken().token; + // Forward the token to the bridge so the extension-facing WS server enforces + // ?token= auth (plan 3.1). Without this the guard in bridge.ts is dead code + // and any local process could drive the browser. + this.bridge = new ExtensionBridge({ port: DEFAULT_WS_PORT, host: DEFAULT_WS_HOST, token: this.token }); + } + + async start(): Promise { + fs.mkdirSync(STATE_DIR, { recursive: true }); + + // 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(); + + // 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}`); + + // graceful shutdown + 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')); + } + + private createIpcServer(): net.Server { + const server = net.createServer((socket) => this.handleNewClient(socket)); + + // Re-bind: remove any stale socket file first (daemon previously killed). + if (process.platform !== 'win32') { + try { + fs.unlinkSync(IPC_SOCKET_PATH); + } catch { + // nonexistent is fine + } + } + + server.listen(IPC_SOCKET_PATH); + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + console.error(`[${SERVER_NAME}] IPC socket in use (${IPC_SOCKET_PATH}). Another daemon running?`); + } else { + console.error(`[${SERVER_NAME}] IPC server error:`, err.message); + } + process.exit(1); + }); + return server; + } + + private handleNewClient(socket: net.Socket): void { + let authed = false; + let client: IpcClient | null = null; + let buf = ''; + + const safeSend = (msg: IpcDaemonMessage) => { + try { + socket.write(JSON.stringify(msg) + '\n'); + } catch { + // socket gone — ignore + } + }; + + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => { + buf += chunk; + let nl: number; + while ((nl = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line) continue; + let msg: IpcClientMessage; + try { + msg = JSON.parse(line); + } catch { + safeSend({ kind: 'denied', ok: false, reason: 'malformed json line' }); + continue; + } + + if (!authed) { + if (msg.kind !== 'hello') { + safeSend({ kind: 'denied', ok: false, reason: 'first frame must be hello' }); + socket.destroy(); + return; + } + if (!this.checkToken(msg.token)) { + safeSend({ kind: 'denied', ok: false, reason: 'invalid token' }); + socket.destroy(); + return; + } + authed = true; + client = { + socket, + sessionId: `s${++this.sessionIdCounter}`, + agentName: msg.agentName || 'agent', + connectedAt: Date.now(), + pending: new Map(), + }; + this.clients.set(socket, client); + safeSend({ kind: 'welcome', sessionId: client.sessionId, ok: true }); + console.error(`[${SERVER_NAME}] client connected: ${client.sessionId} (${client.agentName})`); + return; + } + + if (msg.kind === 'call' && client) { + this.routeCall(client, msg).catch((err) => { + safeSend({ kind: 'result', id: msg.id, success: false, error: String(err) }); + }); + } + } + }); + + socket.on('close', () => { + if (client) { + console.error(`[${SERVER_NAME}] client disconnected: ${client.sessionId} (${client.agentName})`); + // fail any of this client's in-flight calls + for (const [clientId, entry] of client.pending) { + this.pendingCalls.delete(entry.daemonCallId); + void clientId; + } + this.clients.delete(socket); + } + }); + + socket.on('error', () => { + // logged via close; swallow to avoid uncaught + }); + } + + private checkToken(presented: string): boolean { + if (!presented || !this.token) return false; + if (presented.length !== this.token.length) return false; + // constant-time compare + return crypto.timingSafeEqual(Buffer.from(presented), Buffer.from(this.token)); + } + + private async routeCall(client: IpcClient, msg: IpcClientMessage & { kind: 'call' }): Promise { + if (msg.kind !== 'call') return; + const daemonCallId = `c${Date.now().toString(36)}-${msg.id}`; + client.pending.set(msg.id, { tool: msg.tool, daemonCallId }); + this.pendingCalls.set(daemonCallId, { client, clientId: msg.id }); + + // The bridge forwards params as-is; the sessionId is threaded through to + // the extension via a `__sessionId` injection so the extension's tab-lock + // layer (2.2) can attribute the call. + const params = { ...msg.params, __sessionId: client.sessionId }; + try { + const result = await this.bridge.callTool(msg.tool, params); + this.sendToClient(client, { kind: 'result', id: msg.id, success: true, result }); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + this.sendToClient(client, { kind: 'result', id: msg.id, success: false, error }); + } finally { + client.pending.delete(msg.id); + this.pendingCalls.delete(daemonCallId); + } + } + + private sendToClient(client: IpcClient, msg: IpcDaemonMessage): void { + try { + client.socket.write(JSON.stringify(msg) + '\n'); + } catch { + // socket gone + } + } + + /** + * HTTP handler for the popup (bridge delegates GETs here). Two routes: + * GET /pair → { token } (lets the extension auto-pair, no fs) + * GET /status → { extension, agents } (popup shows connection + agents) + * Both are localhost-only (the bridge only listens on localhost). + */ + private handleHttp(req: http.IncomingMessage, url: URL): unknown { + const path = url.pathname; + if (path === '/pair') { + return { token: this.token }; + } + if (path === '/status') { + return { + extension: { connected: this.bridge.isConnected(), since: null }, + agents: this.agents(), + uptimeMs: Date.now() - this.startedAt, + }; + } + return undefined; // bridge returns 404 + } + + /** Snapshot of connected agents, for the popup UI. */ + agents(): Array<{ sessionId: string; name: string; connectedAt: number }> { + return Array.from(this.clients.values()).map((c) => ({ + sessionId: c.sessionId, + name: c.agentName, + connectedAt: c.connectedAt, + })); + } + + private writeDaemonInfo(): void { + const info = { + pid: process.pid, + socket: IPC_SOCKET_PATH, + port: DEFAULT_WS_PORT, + host: DEFAULT_WS_HOST, + startedAt: this.startedAt, + version: '2.0.0', + }; + fs.writeFileSync(DAEMON_INFO_FILE, JSON.stringify(info, null, 2)); + } + + /** @internal exposed for tests: number of connected MCP clients. */ + clientCount(): number { + return this.clients.size; + } + + stop(): void { + this.bridge.stop(); + this.ipcServer?.close(); + this.clients.forEach((c) => c.socket.destroy()); + this.clients.clear(); + try { + fs.unlinkSync(IPC_SOCKET_PATH); + } catch {} + try { + fs.unlinkSync(DAEMON_INFO_FILE); + } catch {} + } +} + +async function main(): Promise { + const daemon = new Daemon(); + await daemon.start(); +} + +main().catch((err) => { + console.error(`[${SERVER_NAME}] Fatal:`, err); + process.exit(1); +}); + +export { Daemon }; diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index 51e33a2..dc25a45 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -1,59 +1,313 @@ #!/usr/bin/env node -import { execSync } from 'child_process'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; +/** + * Thin MCP client (plan task 1.0). + * + * This is what `npx real-browser-mcp` runs. It does NOT open a WebSocket server + * — the daemon owns that. Instead it: + * 1. Speaks MCP over stdio to the agent (Cursor / Claude). + * 2. Forwards every tool call to the daemon over the local IPC socket. + * 3. Spawns the daemon (as a detached child) if it isn't already running. + * + * Multiple instances of this process can run concurrently and all share the one + * daemon, which is what lets two agents work in two tabs without fighting over + * port 7225. + */ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; const setupIdx = process.argv.indexOf('--setup'); if (setupIdx !== -1) { const target = process.argv[setupIdx + 1] || ''; const setupScript = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'agent-config', 'setup.mjs'); - execSync(`node ${setupScript} ${target}`, { stdio: 'inherit' }); + spawn('node', [setupScript, target], { stdio: 'inherit' }); process.exit(0); } +import net from 'node:net'; +import os from 'node:os'; +import fs from 'node:fs'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { ExtensionBridge } from './bridge.js'; import { allTools } from './tools/index.js'; +import { + DAEMON_INFO_FILE, + IPC_SOCKET_PATH, + readToken, +} from './daemon-config.js'; -const WS_PORT = parseInt(process.env.WS_PORT || '7225', 10); const SERVER_NAME = 'real-browser-mcp'; -const SERVER_VERSION = '1.3.0'; +const SERVER_VERSION = '2.0.0'; +const DAEMON_STARTUP_MS = 8_000; +const CONNECT_RETRY_MS = 250; +const MAX_CONNECT_TRIES = 32; // ~8s -const bridge = new ExtensionBridge({ port: WS_PORT }); +/** + * Daemon connection: a line-delimited JSON socket speaking the IPC protocol + * defined in daemon-config.ts (hello/welcome/call/result). + */ +class DaemonClient { + private socket: net.Socket | null = null; + private buf = ''; + /** Session id assigned by the daemon (visible to the extension for tab locking). */ + private _sessionId: string | null = null; + /** @returns session id assigned by the daemon, for logging. */ + get sessionId(): string | null { return this._sessionId; } + private pending = new Map void; reject: (e: Error) => void; t: ReturnType }>(); + private daemonCallId = 0; + private connectResolvers: Array<() => void> = []; + private closeHandlers: Array<() => void> = []; + private readonly ipcPath: string; + private readonly token: string; + private readonly agentName: string; -const server = new McpServer({ - name: SERVER_NAME, - version: SERVER_VERSION, -}); + constructor(ipcPath: string, token: string, agentName: string) { + this.ipcPath = ipcPath; + this.token = token; + this.agentName = agentName; + } + + async connect(): Promise { + this.socket = net.createConnection(this.ipcPath); + await new Promise((resolve, reject) => { + const onErr = (err: Error) => reject(err); + this.socket!.once('connect', () => { + this.socket!.off('error', onErr); + resolve(); + }); + this.socket!.once('error', onErr); + }); + + this.socket.setEncoding('utf8'); + + this.socket.on('data', (chunk: string) => { + this.buf += chunk; + let nl: number; + while ((nl = this.buf.indexOf('\n')) >= 0) { + const line = this.buf.slice(0, nl).trim(); + this.buf = this.buf.slice(nl + 1); + if (!line) continue; + try { + this.handleMessage(JSON.parse(line)); + } catch (err) { + console.error(`[${SERVER_NAME}] daemon parse error:`, err); + } + } + }); + + this.socket.on('close', () => { + this.failAll('daemon connection closed'); + this.closeHandlers.forEach(h => h()); + }); + this.socket.on('error', (err: Error) => { + console.error(`[${SERVER_NAME}] daemon socket error:`, err.message); + }); + + // authenticate + this.socket.write(JSON.stringify({ kind: 'hello', token: this.token, agentName: this.agentName }) + '\n'); -for (const tool of allTools) { - server.tool( - tool.name, - tool.description, - tool.inputSchema.shape, - async (params: Record) => { + await this.waitForWelcome(); + } + + private waitForWelcome(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('daemon welcome timeout')), 5_000); + this.connectResolvers.push(() => { + clearTimeout(timer); + resolve(); + }); + // also reject path captured via close + this.closeHandlers.push(() => { + clearTimeout(timer); + reject(new Error('daemon closed before welcome')); + }); + }); + } + + private handleMessage(msg: { kind: string; sessionId?: string; id?: string; success?: boolean; result?: unknown; error?: string; reason?: string }): void { + switch (msg.kind) { + case 'welcome': + this._sessionId = msg.sessionId ?? null; + this.connectResolvers.forEach(r => r()); + this.connectResolvers = []; + console.error(`[${SERVER_NAME}] joined daemon as ${this.sessionId}`); + break; + case 'denied': + console.error(`[${SERVER_NAME}] daemon denied: ${msg.reason}`); + process.exit(1); + break; + case 'result': { + const id = msg.id; + if (!id) return; + const entry = this.pending.get(id); + if (!entry) return; + clearTimeout(entry.t); + this.pending.delete(id); + if (msg.success) entry.resolve(msg.result); + else entry.reject(new Error(msg.error || 'Unknown daemon error')); + break; + } + default: + break; + } + } + + callTool(tool: string, params: Record): Promise { + const id = String(++this.daemonCallId); + return new Promise((resolve, reject) => { + if (!this.socket || this.socket.destroyed) { + reject(new Error('Not connected to daemon')); + return; + } + const t = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Tool call timed out (client side): ${tool}`)); + }, 70_000); + this.pending.set(id, { resolve, reject, t }); try { - return await tool.handler(bridge, params); + this.socket.write(JSON.stringify({ kind: 'call', id, tool, params }) + '\n'); } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; + clearTimeout(t); + this.pending.delete(id); + reject(err instanceof Error ? err : new Error('write failed')); } - }, - ); + }); + } + + private failAll(reason: string): void { + for (const [, entry] of this.pending) { + clearTimeout(entry.t); + entry.reject(new Error(reason)); + } + this.pending.clear(); + } + + close(): void { + this.socket?.destroy(); + this.socket = null; + } +} + +/** True if a daemon appears to be live (socket file + info file present). */ +function daemonLooksAlive(): boolean { + try { + if (!fs.existsSync(IPC_SOCKET_PATH)) return false; + const info = JSON.parse(fs.readFileSync(DAEMON_INFO_FILE, 'utf8')); + // best-effort liveness: info file written within the last hour + if (Date.now() - info.startedAt > 60 * 60 * 1000) return false; + return true; + } catch { + return false; + } +} + +/** + * Best-effort detection of which MCP client launched us, so the popup can show + * "Cursor" / "Claude Desktop" instead of an opaque id. Falls back to the parent + * process name. Override explicitly with MCP_AGENT_NAME / BROWSER_CONTROLLER_AGENT_NAME. + */ +function deriveAgentName(): string { + const env = process.env; + if (env.CURSOR_TRACE_ID || env.TERM_PROGRAM === 'cursor') return 'Cursor'; + if (env.CLAUDE_DESKTOP || env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) return 'Claude'; + if (env.WINDSURF_USER || env.WS_SURVEY_DATA) return 'Windsurf'; + if (env.CLINE_IDE) return 'Cline'; + // fall back to the parent process name (ppid lookup is heavy; use env hint) + if (env.TERM_PROGRAM) return env.TERM_PROGRAM; + return 'agent'; +} + +/** Spawn the daemon as a detached background process. */ +function spawnDaemon(): void { + const here = dirname(fileURLToPath(import.meta.url)); + const daemonEntry = join(here, 'daemon.js'); + if (!fs.existsSync(daemonEntry)) { + throw new Error(`Daemon entry not found at ${daemonEntry}. Run 'npm run build' first.`); + } + console.error(`[${SERVER_NAME}] starting daemon (detached)…`); + const logPath = join(os.homedir(), '.real-browser-mcp', 'daemon.log'); + try { fs.mkdirSync(dirname(logPath), { recursive: true }); } catch {} + const out = fs.openSync(logPath, 'a'); + const child = spawn(process.execPath, [daemonEntry], { + detached: true, + stdio: ['ignore', out, out], + }); + child.unref(); +} + +/** Wait until the IPC socket becomes connectable. */ +async function waitForDaemon(): Promise { + for (let i = 0; i < MAX_CONNECT_TRIES; i++) { + if (fs.existsSync(IPC_SOCKET_PATH)) { + // probe it really accepts a connection + const ok = await new Promise((resolve) => { + const s = net.createConnection(IPC_SOCKET_PATH); + s.once('connect', () => { s.destroy(); resolve(true); }); + s.once('error', () => resolve(false)); + }); + if (ok) return; + } + await new Promise(r => setTimeout(r, CONNECT_RETRY_MS)); + } + throw new Error(`Daemon did not become reachable within ${DAEMON_STARTUP_MS / 1000}s`); } async function main(): Promise { - await bridge.start(); - console.error(`[${SERVER_NAME}] WebSocket listening on ws://localhost:${WS_PORT}`); - console.error(`[${SERVER_NAME}] Waiting for Chrome extension...`); + // 1) ensure a token exists (daemon may not have created one yet → create + it + // will reuse the same file on next start). If absent, create it here so the + // spawned daemon reads the identical token. + let token = readToken(); + if (!token) { + // create lazily via daemon-config's loadOrCreateToken path + const { loadOrCreateToken } = await import('./daemon-config.js'); + token = loadOrCreateToken().token; + } + + // 2) ensure daemon is up + if (!daemonLooksAlive()) { + spawnDaemon(); + await waitForDaemon(); + } + + // 3) connect to daemon. Derive a human-readable agent name for the popup UI + // (so the user can see "Cursor" / "Claude" instead of an opaque session id). + const agentName = + process.env.MCP_AGENT_NAME || + process.env.BROWSER_CONTROLLER_AGENT_NAME || + deriveAgentName(); + + // 3) connect to daemon + const client = new DaemonClient(IPC_SOCKET_PATH, token, agentName); + await client.connect(); + + // 4) wire MCP <-> daemon + const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); + + for (const tool of allTools) { + server.tool( + tool.name, + tool.description, + tool.inputSchema.shape, + async (params: Record) => { + try { + return await tool.handler(client, params); + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } + }, + ); + } const transport = new StdioServerTransport(); await server.connect(transport); - console.error(`[${SERVER_NAME}] MCP server connected`); + console.error(`[${SERVER_NAME}] MCP server connected (via daemon as ${client.sessionId ?? '?'})`); + + process.on('SIGINT', () => { client.close(); process.exit(0); }); + process.on('SIGTERM', () => { client.close(); process.exit(0); }); } main().catch((err) => { diff --git a/mcp-server/src/tools/click-text.ts b/mcp-server/src/tools/click-text.ts index f6f2a69..0580485 100644 --- a/mcp-server/src/tools/click-text.ts +++ b/mcp-server/src/tools/click-text.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const clickTextTool: ToolDefinition = { name: 'browser_click_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.', inputSchema: z.object({ + tabId: requireTabId(), text: z.string().describe('Text to match against element content (first line)'), index: z.number().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)'), diff --git a/mcp-server/src/tools/click.ts b/mcp-server/src/tools/click.ts index 9014275..33fdad0 100644 --- a/mcp-server/src/tools/click.ts +++ b/mcp-server/src/tools/click.ts @@ -1,11 +1,12 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const clickTool: ToolDefinition = { name: 'browser_click', description: 'Click an element on the page using a ref from snapshot or a CSS selector', 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'), diff --git a/mcp-server/src/tools/console.ts b/mcp-server/src/tools/console.ts index 09c45c8..0f0c359 100644 --- a/mcp-server/src/tools/console.ts +++ b/mcp-server/src/tools/console.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const consoleTool: ToolDefinition = { name: 'browser_console', - description: 'Read console messages (log, warn, error) from the browser', + description: 'Read console messages (log, warn, error) captured from a specific tab', inputSchema: z.object({ - clear: z.boolean().optional().default(false).describe('Clear messages after reading'), + tabId: requireTabId(), + clear: z.boolean().optional().default(false).describe('Clear this tab\'s messages after reading'), }), async handler(bridge, params) { const result = await bridge.callTool('browser_console', params); diff --git a/mcp-server/src/tools/dialog.ts b/mcp-server/src/tools/dialog.ts index a393a1b..919fa42 100644 --- a/mcp-server/src/tools/dialog.ts +++ b/mcp-server/src/tools/dialog.ts @@ -1,11 +1,12 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const dialogTool: ToolDefinition = { name: 'browser_handle_dialog', 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'), }), diff --git a/mcp-server/src/tools/drag.ts b/mcp-server/src/tools/drag.ts index 97f211d..7ad579c 100644 --- a/mcp-server/src/tools/drag.ts +++ b/mcp-server/src/tools/drag.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const dragTool: ToolDefinition = { name: 'browser_drag', description: 'Drag from one element or position to another. Uses CDP mouse events for reliable drag-and-drop. Provide either refs/selectors or explicit x,y coordinates.', inputSchema: z.object({ + tabId: requireTabId(), startRef: z.string().optional().describe('Ref of the element to drag from'), startSelector: z.string().optional().describe('CSS selector of the element to drag from'), endRef: z.string().optional().describe('Ref of the element to drag to'), diff --git a/mcp-server/src/tools/evaluate.ts b/mcp-server/src/tools/evaluate.ts index c99f0c4..7cc069f 100644 --- a/mcp-server/src/tools/evaluate.ts +++ b/mcp-server/src/tools/evaluate.ts @@ -1,11 +1,12 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const evaluateTool: ToolDefinition = { name: 'browser_evaluate', - description: 'Execute JavaScript in the page and return the result. Use for DOM queries, reading page state, or any operation not covered by other tools.', + description: 'Execute JavaScript in a tab and return the result. Use for DOM queries, reading page state, or any operation not covered by other tools.', inputSchema: z.object({ + tabId: requireTabId(), expression: z.string().describe('JavaScript expression or code to evaluate in the page context'), }), async handler(bridge, params) { diff --git a/mcp-server/src/tools/fill-form.ts b/mcp-server/src/tools/fill-form.ts index 088a18c..091fe96 100644 --- a/mcp-server/src/tools/fill-form.ts +++ b/mcp-server/src/tools/fill-form.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const fillFormTool: ToolDefinition = { name: 'browser_fill_form', description: 'Fill multiple form fields in a single call. Supports text inputs, selects, checkboxes, and contentEditable elements. Reduces round-trips compared to calling browser_type for each field individually. Optionally submit the form after filling.', inputSchema: z.object({ + tabId: requireTabId(), fields: z .array( z.object({ diff --git a/mcp-server/src/tools/find.ts b/mcp-server/src/tools/find.ts index 69ed9ff..ad1777d 100644 --- a/mcp-server/src/tools/find.ts +++ b/mcp-server/src/tools/find.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const findTool: ToolDefinition = { name: 'browser_find', description: 'Find elements on the page using natural language (e.g. "login button", "search input"). Returns refs you can use with click/type.', 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'), }), diff --git a/mcp-server/src/tools/hover.ts b/mcp-server/src/tools/hover.ts index 772d98e..aa131cd 100644 --- a/mcp-server/src/tools/hover.ts +++ b/mcp-server/src/tools/hover.ts @@ -1,11 +1,12 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const hoverTool: ToolDefinition = { name: 'browser_hover', description: 'Hover over an element to trigger tooltips, dropdown menus, or hover states', inputSchema: z.object({ + tabId: requireTabId(), ref: z.string().optional().describe('Element reference from snapshot'), selector: z.string().optional().describe('CSS selector for the element'), }), diff --git a/mcp-server/src/tools/navigate.ts b/mcp-server/src/tools/navigate.ts index dcac37f..021dff8 100644 --- a/mcp-server/src/tools/navigate.ts +++ b/mcp-server/src/tools/navigate.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { optionalTabId, textResult } from './types.js'; export const navigateTool: ToolDefinition = { name: 'browser_navigate', - description: 'Navigate to a URL in the active browser tab', + description: 'Navigate to a URL in a browser tab. If tabId is omitted, navigates the active tab (or use browser_tabs create to open a new one).', inputSchema: z.object({ url: z.string().describe('The URL to navigate to'), + tabId: optionalTabId(), waitUntil: z .enum(['load', 'domcontentloaded']) .optional() diff --git a/mcp-server/src/tools/network.ts b/mcp-server/src/tools/network.ts index d1d5696..e865082 100644 --- a/mcp-server/src/tools/network.ts +++ b/mcp-server/src/tools/network.ts @@ -1,13 +1,14 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const networkTool: ToolDefinition = { name: 'browser_network', - description: 'Read network requests made by the page. Filter by URL pattern.', + description: 'Read network requests made by a specific tab. Filter by URL pattern.', inputSchema: z.object({ + tabId: requireTabId(), filter: z.string().optional().describe('URL regex pattern to filter requests'), - clear: z.boolean().optional().default(false).describe('Clear requests after reading'), + clear: z.boolean().optional().default(false).describe('Clear this tab\'s requests after reading'), }), async handler(bridge, params) { const result = await bridge.callTool('browser_network', params); diff --git a/mcp-server/src/tools/press-key.ts b/mcp-server/src/tools/press-key.ts index 8f9baca..0a857b6 100644 --- a/mcp-server/src/tools/press-key.ts +++ b/mcp-server/src/tools/press-key.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const pressKeyTool: ToolDefinition = { name: 'browser_press_key', description: 'Press a keyboard key or combination (Enter, Escape, Tab, ArrowDown, etc). Supports modifiers like Ctrl+A, Cmd+C.', inputSchema: z.object({ + tabId: requireTabId(), key: z.string().describe('Key name (e.g. "Enter", "Escape", "Tab", "ArrowDown", "a")'), modifiers: z .array(z.enum(['ctrl', 'alt', 'shift', 'meta'])) diff --git a/mcp-server/src/tools/run-action.ts b/mcp-server/src/tools/run-action.ts index 585ae3d..e627d7b 100644 --- a/mcp-server/src/tools/run-action.ts +++ b/mcp-server/src/tools/run-action.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const runActionTool: ToolDefinition = { name: 'browser_run_action', description: 'Run a self-contained JavaScript action object in the page context via CDP. The code must be an expression that evaluates to an object with an execute(params) method. Returns the action result directly. Bypasses CSP restrictions.', inputSchema: z.object({ + tabId: requireTabId(), code: z .string() .describe( diff --git a/mcp-server/src/tools/screenshot.ts b/mcp-server/src/tools/screenshot.ts index 72d2e0e..0ab57d7 100644 --- a/mcp-server/src/tools/screenshot.ts +++ b/mcp-server/src/tools/screenshot.ts @@ -1,11 +1,12 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { imageResult, textResult } from './types.js'; +import { requireTabId, imageResult, textResult } from './types.js'; export const screenshotTool: ToolDefinition = { name: 'browser_screenshot', - description: 'Capture a screenshot of the visible page area', + description: 'Capture a screenshot of a tab. Note: Chrome screenshots the tab\'s window, so the tab must be the active one in its window; if it is not, the tool activates it first.', 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)'), }), diff --git a/mcp-server/src/tools/scroll.ts b/mcp-server/src/tools/scroll.ts index 1d38e60..98a028b 100644 --- a/mcp-server/src/tools/scroll.ts +++ b/mcp-server/src/tools/scroll.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const scrollTool: ToolDefinition = { name: 'browser_scroll', 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.', inputSchema: z.object({ + tabId: requireTabId(), direction: z.enum(['up', 'down', 'left', 'right']).optional().default('down'), amount: z.number().optional().default(500).describe('Pixels to scroll'), selector: z.string().optional().describe('CSS selector of scroll container (for virtual scroll)'), diff --git a/mcp-server/src/tools/select.ts b/mcp-server/src/tools/select.ts index 7b0020e..92e27a3 100644 --- a/mcp-server/src/tools/select.ts +++ b/mcp-server/src/tools/select.ts @@ -1,11 +1,12 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const selectTool: ToolDefinition = { name: 'browser_select', description: 'Select an option from a dropdown/select element', inputSchema: z.object({ + tabId: requireTabId(), ref: z.string().optional().describe('Element reference from snapshot'), selector: z.string().optional().describe('CSS selector for the select element'), value: z.string().optional().describe('Option value to select'), diff --git a/mcp-server/src/tools/snapshot.ts b/mcp-server/src/tools/snapshot.ts index 50ecd35..233688f 100644 --- a/mcp-server/src/tools/snapshot.ts +++ b/mcp-server/src/tools/snapshot.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const snapshotTool: ToolDefinition = { name: 'browser_snapshot', 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.', inputSchema: z.object({ + tabId: requireTabId(), selector: z.string().optional().describe('CSS selector to scope the snapshot'), compact: z.boolean().optional().default(true).describe('When true (default), returns only interactive elements with minimal nesting. Set false for full tree.'), }), diff --git a/mcp-server/src/tools/tabs.ts b/mcp-server/src/tools/tabs.ts index 3ace855..8655b19 100644 --- a/mcp-server/src/tools/tabs.ts +++ b/mcp-server/src/tools/tabs.ts @@ -4,10 +4,13 @@ import { textResult } from './types.js'; export const tabsTool: ToolDefinition = { name: 'browser_tabs', - description: 'Manage browser tabs: list, create, close, or focus', + 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).', inputSchema: z.object({ - action: z.enum(['list', 'create', 'close', 'focus']).describe('Tab action'), - tabId: z.number().optional().describe('Tab ID for close/focus'), + action: z + .enum(['list', 'create', 'close', 'focus', 'lock', 'unlock']) + .describe('Tab action'), + tabId: z.number().optional().describe('Tab ID (required for close/focus/lock/unlock)'), url: z.string().optional().describe('URL for create action'), }), async handler(bridge, params) { diff --git a/mcp-server/src/tools/text.ts b/mcp-server/src/tools/text.ts index 7e349c0..b1d1ca5 100644 --- a/mcp-server/src/tools/text.ts +++ b/mcp-server/src/tools/text.ts @@ -1,11 +1,12 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const textTool: ToolDefinition = { name: 'browser_text', description: 'Extract raw text content from the page or a specific element', inputSchema: z.object({ + tabId: requireTabId(), selector: z.string().optional().describe('CSS selector to scope text extraction'), maxLength: z.number().optional().default(50000).describe('Max text length to return'), }), diff --git a/mcp-server/src/tools/type.ts b/mcp-server/src/tools/type.ts index 7f00740..b64ccdf 100644 --- a/mcp-server/src/tools/type.ts +++ b/mcp-server/src/tools/type.ts @@ -1,11 +1,12 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const typeTool: ToolDefinition = { name: 'browser_type', description: 'Type text into an input element', inputSchema: z.object({ + tabId: requireTabId(), ref: z.string().optional().describe('Element reference from snapshot'), selector: z.string().optional().describe('CSS selector for the input'), text: z.string().describe('Text to type'), diff --git a/mcp-server/src/tools/types.ts b/mcp-server/src/tools/types.ts index 019ff57..aed4413 100644 --- a/mcp-server/src/tools/types.ts +++ b/mcp-server/src/tools/types.ts @@ -1,5 +1,14 @@ import { z } from 'zod'; -import type { ExtensionBridge } from '../bridge.js'; + +/** + * Minimal structural host a tool handler needs. Both {@link ExtensionBridge} + * (used by the daemon, which owns the extension WS server) and the thin MCP + * client's DaemonClient satisfy this, so the same tool definitions work in + * either context. + */ +export interface ToolHost { + callTool(tool: string, params: Record): Promise; +} export interface ToolResult { [key: string]: unknown; @@ -7,11 +16,33 @@ export interface ToolResult { isError?: boolean; } +/** + * Reusable `tabId` schema fields. Plan task 1.1: every page-interaction tool + * MUST target a specific tab (the agent gets the id from `browser_tabs list`) + * so the active tab the user is looking at never gets hijacked. + */ +export const tabIdParam = z + .number() + .int() + .describe( + 'Target tab id (from browser_tabs list). Actions apply to THIS tab, not the active tab.', + ); + +/** Mandatory tabId (click/type/snapshot/…). */ +export function requireTabId() { + return tabIdParam; +} + +/** 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.'); +} + export interface ToolDefinition { name: string; description: string; inputSchema: z.ZodObject; - handler: (bridge: ExtensionBridge, params: Record) => Promise; + handler: (host: ToolHost, params: Record) => Promise; } export function textResult(text: string): ToolResult { diff --git a/mcp-server/src/tools/upload-file.ts b/mcp-server/src/tools/upload-file.ts index 4e3787f..926bca1 100644 --- a/mcp-server/src/tools/upload-file.ts +++ b/mcp-server/src/tools/upload-file.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const uploadFileTool: ToolDefinition = { name: 'browser_upload_file', description: 'Upload a file through a file input element. Uses CDP DOM.setFileInputFiles — works even on strict-CSP pages. Provide the local file path and either a ref from snapshot, a CSS selector targeting the file input, or omit both to auto-find the first input[type="file"].', 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 file input element'), filePath: z.string().optional().describe('Local file path to upload (single file)'), diff --git a/mcp-server/src/tools/wait.ts b/mcp-server/src/tools/wait.ts index af9859e..1fcf501 100644 --- a/mcp-server/src/tools/wait.ts +++ b/mcp-server/src/tools/wait.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; import type { ToolDefinition } from './types.js'; -import { textResult } from './types.js'; +import { requireTabId, textResult } from './types.js'; export const waitTool: ToolDefinition = { name: 'browser_wait', description: 'Wait for a condition: element to appear, element to disappear, 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'), state: z .enum(['visible', 'hidden', 'attached']) diff --git a/package-lock.json b/package-lock.json index 164ef50..13ce473 100644 --- a/package-lock.json +++ b/package-lock.json @@ -242,9 +242,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -262,9 +259,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -282,9 +276,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -302,9 +293,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -322,9 +310,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -342,9 +327,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1803,9 +1785,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1827,9 +1806,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1851,9 +1827,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1875,9 +1848,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 151f274..53fa1a4 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { - "name": "real-browser-mcp", - "mcpName": "io.github.ofershap/real-browser", - "version": "1.3.0", + "name": "browser-controller", + "mcpName": "io.github.noiemany/browser-controller", + "version": "2.0.0", "description": "MCP server + Chrome extension that gives AI agents control of your real browser with existing sessions and logins", "type": "module", "bin": { - "real-browser-mcp": "mcp-server/dist/index.js" + "browser-controller": "mcp-server/dist/index.js" }, "main": "mcp-server/dist/index.js", "scripts": { @@ -32,7 +32,7 @@ "chrome", "chrome-extension", "web-automation", - "real-browser", + "browser-controller", "live-browser", "model-context-protocol", "ai-agent", @@ -43,7 +43,7 @@ "playwright-alternative", "devtools" ], - "author": "Ofer Shapira", + "author": "noiemany", "license": "MIT", "repository": { "type": "git", diff --git a/server.json b/server.json index d24371f..ea437d2 100644 --- a/server.json +++ b/server.json @@ -1,17 +1,17 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", - "name": "io.github.ofershap/real-browser", + "name": "io.github.noiemany/browser-controller", "description": "MCP server + Chrome extension for AI browser control with real sessions.", "repository": { "url": "https://github.com/ofershap/real-browser-mcp", "source": "github" }, - "version": "1.3.0", + "version": "2.0.0", "packages": [ { "registryType": "npm", - "identifier": "real-browser-mcp", - "version": "1.3.0", + "identifier": "browser-controller", + "version": "2.0.0", "transport": { "type": "stdio" } diff --git a/store-assets/icon-128.png b/store-assets/icon-128.png index 0e252da0b5a77b89dce27cbb0df7d9e9debcbcd8..e8ca8a754dd8555eb20900a2aab4b5280511e757 100644 GIT binary patch literal 12076 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 LRQyyeZXEJIU066f literal 4128 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 - - + + + + + + + + + + + + + + + + + + - - + + - - - + + - - - - - - - - - - + + + + - - - - - + + + + + + + + - - - + + + + + - - - - - - + + + + diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 386dccc..9f76a50 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -17,14 +17,14 @@ describe('ExtensionBridge', () => { await new Promise(r => setTimeout(r, 50)); }); - function createBridge(port: number, opts?: Partial<{ maxRetries: number }>): ExtensionBridge { - const b = new ExtensionBridge({ port, maxRetries: opts?.maxRetries ?? 1, pingIntervalMs: 60_000 }); + function createBridge(port: number, opts?: Partial<{ maxRetries: number; token: string }>): ExtensionBridge { + const b = new ExtensionBridge({ port, maxRetries: opts?.maxRetries ?? 1, pingIntervalMs: 60_000, token: opts?.token }); bridges.push(b); return b; } - async function connectClient(port: number): Promise { - const client = new WebSocket(`ws://localhost:${port}`); + async function connectClient(port: number, path = ''): Promise { + const client = new WebSocket(`ws://localhost:${port}${path}`); clients.push(client); await new Promise((resolve, reject) => { client.on('open', resolve); @@ -106,4 +106,44 @@ describe('ExtensionBridge', () => { await expect(bridge.callTool('browser_click', {})).rejects.toThrow(); }, 10_000); + + // Task 3.1 regression guard: this test would have caught the daemon shipping + // with WS auth disabled (the token was loaded but never passed to the bridge). + it('with a token, accepts a connection that presents the right token', async () => { + const port = nextPort(); + const bridge = createBridge(port, { token: 'sekret' }); + await bridge.start(); + + await connectClient(port, '?token=sekret'); + expect(bridge.isConnected()).toBe(true); + }); + + it('with a token, REJECTS a connection missing the token', async () => { + const port = nextPort(); + const bridge = createBridge(port, { token: 'sekret' }); + await bridge.start(); + + // The socket opens (handshake) then the server closes it for bad auth. + const client = new WebSocket(`ws://localhost:${port}`); + clients.push(client); + await new Promise((resolve) => { + client.on('close', () => resolve()); + client.on('error', () => resolve()); + }); + expect(bridge.isConnected()).toBe(false); + }); + + it('with a token, REJECTS a connection with a wrong token', async () => { + const port = nextPort(); + const bridge = createBridge(port, { token: 'sekret' }); + await bridge.start(); + + const client = new WebSocket(`ws://localhost:${port}?token=wrong`); + clients.push(client); + await new Promise((resolve) => { + client.on('close', () => resolve()); + client.on('error', () => resolve()); + }); + expect(bridge.isConnected()).toBe(false); + }); }); diff --git a/tests/smart-selector.test.ts b/tests/smart-selector.test.ts new file mode 100644 index 0000000..ae68649 --- /dev/null +++ b/tests/smart-selector.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import { + isStableId, + isGeneratedClass, + buildRobustSelectorFromPath, +} from '../extension/utils/smart-selector.js'; + +/** + * Smart-selector heuristic tests (plan task 5). The pure helpers are tested + * here without a DOM. buildRobustSelectorFromPath only reads a handful of + * element-shaped properties (tagName, id, getAttribute, parentElement), so we + * build tiny fake elements rather than pull in jsdom. + */ + +// --- tiny fake-element factory --------------------------------------------- + +interface FakeEl { + tagName: string; + id?: string; + className?: string; + attrs?: Record; + parent?: FakeEl | null; +} + +function mk(opts: FakeEl): any { + const attrs = opts.attrs || {}; + return { + tagName: options(opts.tagName), + get id() { return opts.id ?? ''; }, + getAttribute(name: string) { + if (name === 'class') return opts.className ?? null; + return Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name] : null; + }, + get parentElement() { return opts.parent ? mk(opts.parent) : null; }, + }; +} +function options(s: string | undefined): string { return s || 'DIV'; } + +// --- isStableId ------------------------------------------------------------ + +describe('isStableId', () => { + it('accepts human-authored ids', () => { + expect(isStableId('submit-btn')).toBe(true); + expect(isStableId('login-form')).toBe(true); + expect(isStableId('nav-header')).toBe(true); + }); + + it('rejects React/managed ids with colons', () => { + expect(isStableId(':r5:')).toBe(false); + expect(isStableId(':R3aq:')).toBe(false); + }); + + it('rejects generated prefixes', () => { + expect(isStableId('react-aria-1-abc')).toBe(false); + expect(isStableId('headlessui-menu-1')).toBe(false); + expect(isStableId('radix-1')).toBe(false); + }); + + it('rejects hashed suffixes', () => { + expect(isStableId('button-1a2b3c4d')).toBe(false); + expect(isStableId('x-deadbeef')).toBe(false); + }); + + it('rejects framework double-underscore ids', () => { + expect(isStableId('__BVID__42')).toBe(false); + }); + + it('rejects empty / numeric / uuid', () => { + expect(isStableId('')).toBe(false); + expect(isStableId(' ')).toBe(false); + expect(isStableId('12345')).toBe(false); + }); +}); + +// --- isGeneratedClass ------------------------------------------------------ + +describe('isGeneratedClass', () => { + it('flags styled-components hashes', () => { + expect(isGeneratedClass('sc-jgyyrt')).toBe(true); + expect(isGeneratedClass('StyledButton__sc-1a2b3c')).toBe(true); + }); + + it('flags emotion/css-in-js hashes', () => { + expect(isGeneratedClass('css-1a2b3c')).toBe(true); + expect(isGeneratedClass('emotion-0')).toBe(true); + }); + + it('keeps Tailwind/utility classes (stable + useful)', () => { + expect(isGeneratedClass('flex')).toBe(false); + expect(isGeneratedClass('px-4')).toBe(false); + expect(isGeneratedClass('text-red-500')).toBe(false); + expect(isGeneratedClass('btn-primary')).toBe(false); + expect(isGeneratedClass('bg-blue-500')).toBe(false); + }); +}); + +// --- buildRobustSelectorFromPath ------------------------------------------ + +describe('buildRobustSelectorFromPath', () => { + it('prefers a stable test attribute on the element', () => { + const el = mk({ tagName: 'BUTTON', attrs: { 'data-testid': 'cta' } }); + expect(buildRobustSelectorFromPath(el)).toBe('button[data-testid=cta]'); + }); + + it('prefers aria-label when no test attr', () => { + const el = mk({ tagName: 'BUTTON', attrs: { 'aria-label': 'Submit form' } }); + // In node there's no global CSS.escape, so the fallback escaper runs and + // does NOT escape spaces (browsers do). The selector still matches in the + // page world via CSS.escape. Assert the node-side fallback output. + expect(buildRobustSelectorFromPath(el)).toBe('button[aria-label=Submit form]'); + }); + + it('uses a stable id', () => { + const el = mk({ tagName: 'INPUT', id: 'email-field' }); + expect(buildRobustSelectorFromPath(el)).toBe('#email-field'); + }); + + it('ignores a React-generated id and climbs to an anchor', () => { + const el = mk({ + tagName: 'BUTTON', + id: ':r5:', + className: 'btn', + parent: { tagName: 'NAV', id: 'main-nav' }, + }); + // id `:r5:` is unstable → climb to