diff --git a/UPSTREAM.md b/UPSTREAM.md index 1ac46130e..1da96bf6a 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -92,6 +92,7 @@ Each upstream has its own append-only table. Add a row every time you pull. | 2026-04-29 | `2125cea` | `997ee45` | bcode | 6 upstream commits (PRs #241, #244, #245). `src/browser_harness/_ipc.py`: when `BH_TMP_DIR` is set, drop the `bu-` filename prefix (caller-isolated dir means no shared-tmpdir disambiguation needed); without `BH_TMP_DIR` the original `bu-` scheme is unchanged. `src/browser_harness/admin.py`: `_daemon_endpoint_names` short-circuits to the local NAME when `BH_TMP_DIR` is set (no glob); plus catch `SystemError` from `os.kill` on Windows during `restart_daemon`. `src/browser_harness/daemon.py`: discover DevToolsActivePort in Comet and Arc profiles on macOS. `tests/unit/test_admin.py`: 2 new tests for the `BH_TMP_DIR` discovery path. All in protected `src/browser_harness/*.py` + tests — taken verbatim. Smoke test + 12 admin unit tests pass. The `_ipc` filename change pairs with our recent per-session BH_TMP_DIR work (browsercode PR #22) — caller isolation now extends to filenames as well as the dir. Divergences touched: none. | | 2026-04-30 | `997ee45` | `660827d` | bcode | 11 upstream commits (PRs #246, #247, #251, #254, #256, #260). `src/browser_harness/daemon.py`: resolve WS via `/json/version` to avoid stale `DevToolsActivePort` path (PR #260) + report `cdp_disconnected` on stale CDP probe in `connection_status` (PR #254) + cleanup remote browser when daemon startup fails (PR #251). `src/browser_harness/admin.py`: companion changes for the daemon fixes. `tests/unit/test_admin.py`: 7 new tests. New domain skills: `agent-workspace/domain-skills/xiaohongshu/scraping.md` (PR #246), and a top-level `domain-skills/shopify-admin/` tree (PR #247: README, embedded-apps, knowledge-base, polaris-inputs). Note: PR #247 added skills at the top-level `domain-skills/` path, not under `agent-workspace/domain-skills/` as the post-#229 layout would suggest — vendored verbatim to match upstream layout. Doc updates: README operator framing (PR #255), install.md heredoc → `-c` flag (PR #256), profile-sync.md same. All files outside divergences — taken verbatim. Smoke test + 19 admin unit tests pass. Divergences touched: none. | | 2026-05-01 | `660827d` | `013097a` | bcode | 8 upstream commits (PRs #261, #265, #266). `src/browser_harness/daemon.py` (PR #265): split `DevToolsActivePort` into port + ws-path lines and fall back to `ws://127.0.0.1:` when `/json/version` returns 404 (Chrome 147+ disables `/json/*` HTTP discovery on the default user-data-dir). `src/browser_harness/run.py` (PR #266): when no daemon is alive, no local Chrome is listening on 9222/9223 (probed via `/json/version`, not bare TCP), and `BROWSER_USE_API_KEY` is set, auto-bootstrap a cloud daemon. `tests/unit/test_run.py`: 2 new tests for the cloud bootstrap path. PR #261 moved `domain-skills/shopify-admin/` → `agent-workspace/domain-skills/shopify-admin/` upstream — both paths are excluded from the vendored tree per §3, so this rename is a no-op for browsercode (`script/check-harness-diff.sh` filters both via `IGNORED_PATHS_REGEX`). All in protected `src/browser_harness/*.py` + tests — taken verbatim. Smoke test + 23 unit tests pass. Divergences touched: none. | +| 2026-05-03 | `013097a` | `59a166f` | bcode | 62 upstream commits. **Helper additions** (PRs #258, #279): `helpers.py` adds `fill_input` (raises on missing element, optional timeout for SPA rendering, dispatches select-all without char event so Cmd/Ctrl+A fires on macOS), `wait_for_element` (prefers `checkVisibility`, falls back to computed style), `wait_for_network_idle`. `tests/unit/test_helpers.py`: +253 lines covering the new helpers. `daemon.py`: discover Dia browser profile on macOS. **Windows IPC hardening** (PR #276): `_ipc.py` adds ping handshake, token auth, atomic port file. **Domain-skills opt-in** (PR #274): `helpers.py` gates auto-injected domain skills behind `BH_DOMAIN_SKILLS=1` (default off). Aligns upstream default with browsercode's exclusion policy — no behavior change for us, but the `BH_DOMAIN_SKILLS` env name is now the canonical knob if we ever decide to ship a curated set. **Cloud bootstrap opt-in** (PR #277): `run.py` makes cloud auto-bootstrap opt-in via `BU_AUTOSPAWN` instead of triggering on any `BROWSER_USE_API_KEY` presence. Plus admin tweaks (`tests/unit/test_admin.py` +10 lines), doc canonicalization (`README.md`, `SKILL.md`, `install.md`, `interaction-skills/profile-sync.md` PR #280), and new top-level scaffolding: `AGENTS.md` (repo orientation for coding agents), `.github/ISSUE_TEMPLATE/{bug-report,feature-request,config}.yml`, `.github/VOUCHED.td`, `docs/allow-remote-debugging.png`. All non-excluded paths taken verbatim. **Excluded paths** (per §3): 14 new domain-skills directories added upstream (aa, alaska, articulate-rise, bigbang-hr, bilibili, BOSS-zhipin, claude-ai, ctrip, flipkart, ly-com, manus, perplexity, wehotel, plus amazon under top-level `domain-skills/`) — skipped. **Divergence update**: `.gitignore` now also includes upstream's new `.idea/` and `.claude/` entries while preserving our `.venv/`. Smoke test (imports + `--version`) clean. Divergences touched: `.gitignore` (extended, same intent). | --- diff --git a/packages/bcode-browser/harness/.github/ISSUE_TEMPLATE/bug-report.yml b/packages/bcode-browser/harness/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 000000000..27dec6ace --- /dev/null +++ b/packages/bcode-browser/harness/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,49 @@ +name: Bug report +description: Report a reproducible bug in browser-harness. +labels: [bug] +body: + - type: checkboxes + id: preflight + attributes: + label: Before submitting + options: + - label: I searched existing issues for duplicates. + required: true + - label: I ran `browser-harness --doctor` and read the output. + required: true + - label: I read the troubleshooting section of `install.md`. + required: true + - label: This is a reproducible bug in browser-harness — not a question, feature request, or `cloud.browser-use.com` issue. + required: true + + - type: textarea + id: summary + attributes: + label: Summary + description: What's broken, in one or two sentences. + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Repro + description: Numbered steps. Include the exact command and the output you saw. + placeholder: | + 1. Chrome 147 on default profile, remote debugging on + 2. browser-harness -c 'print(page_info())' + 3. RuntimeError: DevTools is not live yet on 127.0.0.1:9222 + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + placeholder: | + OS: + Chrome version: + browser-harness --version: + browser-harness --doctor output: + validations: + required: true diff --git a/packages/bcode-browser/harness/.github/ISSUE_TEMPLATE/config.yml b/packages/bcode-browser/harness/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..dba8f5aab --- /dev/null +++ b/packages/bcode-browser/harness/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Question or how-to + url: https://github.com/browser-use/browser-harness/discussions/categories/q-a + about: Ask in Discussions Q&A, not Issues. + - name: Install or setup troubleshooting + url: https://github.com/browser-use/browser-harness/blob/main/install.md + about: Most install and "DevTools not live" errors are covered here. diff --git a/packages/bcode-browser/harness/.github/ISSUE_TEMPLATE/feature-request.yml b/packages/bcode-browser/harness/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 000000000..0953f68b3 --- /dev/null +++ b/packages/bcode-browser/harness/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,37 @@ +name: Feature request +description: Propose a new feature or change. +labels: [feature-request] +body: + - type: checkboxes + id: preflight + attributes: + label: Before submitting + options: + - label: I searched existing issues and discussions. + required: true + - label: This is a feature request, not a bug. + required: true + + - type: textarea + id: problem + attributes: + label: Problem + description: What user pain or limitation motivates this? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposal + description: What you'd like to happen. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: What else you tried, or why other approaches fall short. + validations: + required: true diff --git a/packages/bcode-browser/harness/.github/VOUCHED.td b/packages/bcode-browser/harness/.github/VOUCHED.td new file mode 100644 index 000000000..b51d0cd73 --- /dev/null +++ b/packages/bcode-browser/harness/.github/VOUCHED.td @@ -0,0 +1,13 @@ +# Vouched (or denounced) users for browser-harness. +# +# See https://github.com/mitchellh/vouch for details. +# +# Syntax: +# - One handle per line (without @), sorted alphabetically. +# - Optional platform prefix: platform:username (e.g., github:user). +# - Denounce by prefixing with minus: -username +# - Optional reason after a space following the handle. + +molesza +rohitdutt108 +shaunandrewjackson1977 diff --git a/packages/bcode-browser/harness/.gitignore b/packages/bcode-browser/harness/.gitignore index b4e44e2a2..04d11a358 100644 --- a/packages/bcode-browser/harness/.gitignore +++ b/packages/bcode-browser/harness/.gitignore @@ -5,3 +5,5 @@ __pycache__/ .venv/ uv.lock *.egg-info/ +.idea/ +.claude/ diff --git a/packages/bcode-browser/harness/AGENTS.md b/packages/bcode-browser/harness/AGENTS.md new file mode 100644 index 000000000..546075ebb --- /dev/null +++ b/packages/bcode-browser/harness/AGENTS.md @@ -0,0 +1,24 @@ +browser-harness is a thin layer that connects agents to browsers via an editable CDP harness. + +# Code priorities +- Clarity +- Precision +- Low verbosity +- Versatility + +# Overview +Core code lives in `src/browser_harness/`: +- `admin.py` — daemon lifecycle, diagnostics, updates, profile management +- `daemon.py` — the long-lived middleman process between the browser and the agent +- `helpers.py` — CDP wrapper and core browser primitives auto-imported into `-c` scripts +- `run.py` — the `browser-harness` CLI + +`SKILL.md` tells agents how to use the harness and CLI. +`install.md` tells agents how to install it, attach a browser, and troubleshoot. + +An agent operating the harness only edits inside `agent-workspace/`: +- `agent_helpers.py` — task-specific browser helpers the agent adds +- `domain-skills/` — skills the agent writes and reads + +# Contributing +Consider what is really needed. Prefer the smallest diff that fixes the bug. diff --git a/packages/bcode-browser/harness/README.md b/packages/bcode-browser/harness/README.md index ccfc32942..704b5efd2 100644 --- a/packages/bcode-browser/harness/README.md +++ b/packages/bcode-browser/harness/README.md @@ -25,24 +25,28 @@ Paste into Claude Code or Codex: ```text Set up https://github.com/browser-use/browser-harness for me. -Read `install.md` first to install and connect this repo to my real browser. Then read `SKILL.md` for normal usage. Use `agent-workspace/agent_helpers.py` and `agent-workspace/domain-skills/` for task-specific edits. When you open a setup or verification tab, activate it so I can see the active browser tab. After it is installed, open this repository in my browser and, if I am logged in to GitHub, ask me whether you should star it for me as a quick demo that the interaction works — only click the star if I say yes. If I am not logged in, just go to browser-use.com. +Read `install.md` and follow the steps to install browser-harness and connect it to my browser. ``` -When this page appears, tick the checkbox so the agent can connect to your browser: +The agent will open `chrome://inspect/#remote-debugging`. Tick the checkbox so the agent can connect to your browser: Remote debugging setup +Click Allow when the per-attach popup appears (Chrome 144+): + +Allow remote debugging popup + See [agent-workspace/domain-skills/](agent-workspace/domain-skills/) for example tasks. -## Free remote browsers +## Free Browser Use Cloud browsers -Useful for stealth, sub-agents, or deployment.
-**Free tier: 3 concurrent browsers, proxies, captcha solving, and more. No card required.** +Stealth, sub-agents, or headless deployment.
+**Browser Use Cloud free tier: 3 concurrent browsers, proxies, captcha solving, and more. No card required.** - Grab a key at [cloud.browser-use.com/new-api-key](https://cloud.browser-use.com/new-api-key) - Or let the agent sign up itself via [docs.browser-use.com/llms.txt](https://docs.browser-use.com/llms.txt) (setup flow + challenge context included). -## How simple is it? (~592 lines of Python) +## Architecture (~1k lines across 4 core files) - `install.md` — first-time install and browser bootstrap - `SKILL.md` — day-to-day usage @@ -61,6 +65,10 @@ PRs and improvements welcome. The best way to help: **contribute a new domain sk If you're not sure where to start, open an issue and we'll point you somewhere useful. +## Domain skills + +Set `BH_DOMAIN_SKILLS=1` to enable [agent-workspace/domain-skills/](agent-workspace/domain-skills/) — community-contributed per-site playbooks `goto_url` surfaces by domain. Contribute via PR. + --- [The Bitter Lesson of Agent Harnesses](https://browser-use.com/posts/bitter-lesson-agent-harnesses) · [Web Agents That Actually Learn](https://browser-use.com/posts/web-agents-that-actually-learn) diff --git a/packages/bcode-browser/harness/SKILL.md b/packages/bcode-browser/harness/SKILL.md index 420726d42..7c3153683 100644 --- a/packages/bcode-browser/harness/SKILL.md +++ b/packages/bcode-browser/harness/SKILL.md @@ -5,7 +5,9 @@ description: Direct browser control via CDP. Use when the user wants to automate # browser-harness -Direct browser control via CDP. For task-specific edits, use `agent-workspace/agent_helpers.py` and `agent-workspace/domain-skills/`. For setup, install, or connection problems, read install.md. +Direct browser control via CDP. For task-specific edits, use `agent-workspace/agent_helpers.py`. For setup, install, or connection problems, read install.md. + +Domain skills (community-contributed per-site playbooks under `agent-workspace/domain-skills/`) are off by default. Set `BH_DOMAIN_SKILLS=1` to enable them; see the bottom section. ## Usage @@ -20,13 +22,6 @@ print(page_info()) - Invoke as browser-harness — it's on $PATH. No cd, no uv run. - First navigation is new_tab(url), not goto_url(url) — goto runs in the user's active tab and clobbers their work. -Available interaction skills: -- interaction-skills/connection.md — startup sequence, tab visibility, omnibox popup fix - -Available domain skills: -- tiktok/upload.md -- polymarket/scraping.md - ## Tool call shape ```bash @@ -60,11 +55,10 @@ start_remote_daemon prints liveUrl and auto-opens it in the local browser (if a Profiles (cookies-only login state) live in interaction-skills/profile-sync.md — covers list_cloud_profiles(), the chat-driven "which profile?" pattern, and sync_local_profile() for uploading a local Chrome profile. -## Search first +## Interaction skills -Search `agent-workspace/domain-skills/` first for the domain you are working on before inventing a new approach. - -Only if you start struggling with a specific mechanic while navigating, look in interaction-skills/ for helpers. The available interaction skills are: +If you start struggling with a specific mechanic while navigating, look in interaction-skills/ for helpers. They cover reusable UI mechanics like dialogs, tabs, dropdowns, iframes, and uploads. The available interaction skills are: +- connection.md - cookies.md - cross-origin-iframes.md - dialogs.md @@ -82,44 +76,6 @@ Only if you start struggling with a specific mechanic while navigating, look in - uploads.md - viewport.md -Useful commands: - -```bash -rg --files agent-workspace/domain-skills -rg -n "tiktok|upload" agent-workspace/domain-skills -``` - -## Always contribute back - -If you learned anything non-obvious about how a site works, open a PR to `agent-workspace/domain-skills//` before you finish. Default to contributing. The harness gets better only because agents file what they learn. If figuring something out cost you a few steps, the next run should not pay the same tax. - -Examples of what's worth a PR: - -- A private API the page calls (XHR/fetch endpoint, request shape, auth) — often 10× faster than DOM scraping. -- A stable selector that beats the obvious one, or an obfuscated CSS-module class to avoid. -- A framework quirk — "the dropdown is a React combobox that only commits on Escape", "this Vue list only renders rows inside its own scroll container, so scrollIntoView on the row doesn't work — you have to scroll the container". -- A URL pattern — direct route, required query params (?lang=en, ?th=1), a variant that skips a loader. -- A wait that wait_for_load() misses, with the reason. -- A trap — stale drafts, legacy IDs that now return null, unicode quirks, beforeunload dialogs, CAPTCHA surfaces. - -### What a domain skill should capture - -The *durable* shape of the site — the map, not the diary. Focus on what the next agent on this site needs to know before it starts: - -- URL patterns and query params. -- Private APIs and their payload shape. -- Stable selectors (data-*, aria-*, role, semantic classes). -- Site structure — containers, items per page, framework, where state lives. -- Framework/interaction quirks unique to this site. -- Waits and the reasons they're needed. -- Traps and the selectors that *don't* work. - -### Do not write - -- Raw pixel coordinates. They break on viewport, zoom, and layout changes. Describe how to *locate* the target (selector, scrollIntoView, aria-label, visible text) — never where it happened to be on your screen. -- Run narration or step-by-step of the specific task you just did. -- Secrets, cookies, session tokens, user-specific state. `agent-workspace/domain-skills/` is shared and public. - ## What actually works - Screenshots first: use capture_screenshot() to understand the current page quickly, find visible targets, and decide whether you need a click, a selector, or more navigation. @@ -155,7 +111,10 @@ The *durable* shape of the site — the map, not the diary. Focus on what the ne - Prefer compositor-level actions over framework hacks. Try screenshots, coordinate clicks, and raw key input before adding DOM-specific workarounds. - If you need framework-specific DOM tricks, check interaction-skills/ first. That is where dropdown, dialog, iframe, shadow DOM, and form-specific guidance belongs. -## Interaction notes +## Domain skills (opt-in) + +Only applies when `BH_DOMAIN_SKILLS=1`. Otherwise ignore — `agent-workspace/domain-skills/` is dormant and `goto_url` won't surface skill files. + +When enabled, search `agent-workspace/domain-skills//` before inventing an approach. `goto_url` returns up to 10 skill filenames for the navigated host. -- interaction-skills/ holds reusable UI mechanics such as dialogs, tabs, dropdowns, iframes, and uploads. -- `agent-workspace/domain-skills/` holds site-specific workflows and should be updated when you discover reusable patterns for a website. +If you learn anything non-obvious — a private API, stable selector, framework quirk, URL pattern, hidden wait, or site-specific trap — open a PR to `agent-workspace/domain-skills//`. Capture the durable shape of the site (the map, not the diary). Don't write pixel coordinates (break on layout), task narration, or secrets — the directory is public. diff --git a/packages/bcode-browser/harness/docs/allow-remote-debugging.png b/packages/bcode-browser/harness/docs/allow-remote-debugging.png new file mode 100644 index 000000000..77186c2d2 Binary files /dev/null and b/packages/bcode-browser/harness/docs/allow-remote-debugging.png differ diff --git a/packages/bcode-browser/harness/install.md b/packages/bcode-browser/harness/install.md index 589ebe15a..21cc3f51a 100644 --- a/packages/bcode-browser/harness/install.md +++ b/packages/bcode-browser/harness/install.md @@ -1,17 +1,13 @@ --- name: browser-install -description: Install and bootstrap browser-harness into the current agent, then connect it to the user's real Chrome with minimal prompting. +description: Install browser-harness into the current agent and connect it to a browser with minimal prompting. --- -# browser-harness install +# `browser-harness` installation -Use this file only for first-time install, reconnect, or cold-start browser bootstrap. For day-to-day browser work, read `SKILL.md`. Task-specific edits belong in `agent-workspace/agent_helpers.py` and `agent-workspace/domain-skills/`. +Use this file only for browser-harness install, browser connection setup, and connection troubleshooting. For day-to-day browser work, read `SKILL.md`. Task-specific edits belong in `agent-workspace/agent_helpers.py` and `agent-workspace/domain-skills/`. -## Install prompt contract - -When you open a setup or verification tab, activate it so the user can actually see the active browser tab. - -## Best everyday setup +## Recommended `browser-harness` setup Clone the repo once into a durable location, then install it as an editable tool so `browser-harness` works from any directory: @@ -24,119 +20,114 @@ command -v browser-harness That keeps the command global while still pointing at the real repo checkout, so when the agent edits `agent-workspace/agent_helpers.py` the next `browser-harness` uses the new code immediately. Prefer a stable path like `~/Developer/browser-harness`, not `/tmp`. -## Make it global for the current agent +## Make browser-harness global for the current agent After the repo is installed, register this repo's `SKILL.md` with the agent you are using: - **Codex**: add this file as a global skill at `$CODEX_HOME/skills/browser-harness/SKILL.md` (often `~/.codex/skills/browser-harness/SKILL.md`). A symlink to this repo's `SKILL.md` is fine. -- **Claude Code**: add an import to `~/.claude/CLAUDE.md` that points at this repo's `SKILL.md`, for example `@~/src/browser-harness/SKILL.md`. -Codex command: + ```bash + mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills/browser-harness" && ln -sf "$PWD/SKILL.md" "${CODEX_HOME:-$HOME/.codex}/skills/browser-harness/SKILL.md" + ``` -```bash -mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills/browser-harness" && ln -sf "$PWD/SKILL.md" "${CODEX_HOME:-$HOME/.codex}/skills/browser-harness/SKILL.md" -``` +- **Claude Code**: add an import to `~/.claude/CLAUDE.md` that points at this repo's `SKILL.md`, for example `@~/Developer/browser-harness/SKILL.md`. + +This makes new Codex or Claude Code sessions in other folders load the runtime browser harness instructions automatically. + +## Keeping the harness current -That makes new Codex or Claude Code sessions in other folders load the runtime browser harness instructions automatically. An empty `~/.codex/skills/browser-harness/` directory is fine; the symlink command above populates it. +- On each run, `browser-harness` prints `[browser-harness] update available: X -> Y` (once per day) when a newer GitHub release exists. +- When you see that banner, run `browser-harness --update -y` yourself — don't ask the user. It pulls the new code (`git pull --ff-only` for editable clones, `uv tool upgrade browser-harness` for PyPI installs) and stops the running daemon so the next call picks up the new code. With `-y` it won't prompt. +- `--update` refuses to run on an editable clone with uncommitted changes. If that happens, tell the user and let them resolve the dirty worktree. -## Browser bootstrap +## Maintenance commands -Prefer `browser-harness --setup` — it runs the full attach-and-escalate flow below as one interactive command. The manual steps that follow are only for when `--setup` is unavailable or you need to debug a specific failure. +- browser-harness --doctor — show version, install mode, daemon and Chrome state, and whether an update is pending. -1. Run `uv sync`. - If `browser-harness` is still missing after that, run `command -v browser-harness >/dev/null || uv tool install -e .`. -2. First try the harness directly. If this works, skip manual browser setup: +## Architecture -```bash -uv run browser-harness -c 'print(page_info())' +```text +Chrome / Browser Use cloud -> CDP WS -> browser_harness.daemon -> IPC -> browser_harness.run ``` - Reuse an existing healthy daemon if it is already responding. Do not kill it during setup unless the attach is clearly stale and you are confident no other agent is using the same `BU_NAME`. For parallel agents, use distinct `BU_NAME`s so they do not fight over the same default session. +- Protocol is one JSON line each way. +- Requests are {method, params, session_id} for CDP or {meta: ...} for daemon control. +- Responses are {result} / {error} / {events} / {session_id}. +- IPC: Unix socket at `/tmp/bu-.sock` on POSIX, TCP loopback + port file on Windows. +- BU_NAME namespaces the daemon's IPC, pid, and log files. +- BU_CDP_WS overrides local Chrome discovery for remote browsers. +- BU_CDP_URL overrides local Chrome discovery with a specific DevTools HTTP endpoint (used for Way 2). +- BU_BROWSER_ID + BROWSER_USE_API_KEY lets the daemon stop a Browser Use cloud browser on shutdown. -3. If it failed, **read the error and escalate from there — do not assume you need `chrome://inspect`**. The remote-debugging checkbox is per-profile sticky in Chrome, so any profile that has had it toggled on once will auto-enable CDP on every future launch; the inspect page is only needed the first time per profile. +# Browser connection setup and troubleshooting - - **No Chrome process running** → just start Chrome and re-run the harness. On macOS: `open -a "Google Chrome"`. Do *not* navigate to `chrome://inspect` yet — if the user has ever ticked the checkbox on this profile, the harness will attach on its own. - - **`DevToolsActivePort` missing or empty after Chrome is up** → remote-debugging has never been enabled on this profile. *This* is when you open `chrome://inspect/#remote-debugging` and ask the user to tick the checkbox and click `Allow`. Once ticked, the setting sticks. - - **Port present but `connection refused` / `DevTools not live yet` / `/json/version` 404** → Chrome is mid-startup. Just keep polling for up to 30 seconds; do not restart Chrome and do not open the inspect page. - - **`no close frame received or sent` / stale websocket** → the daemon (not Chrome) is the problem. Run `restart_daemon()` once and retry — see step 7 below. +## Browser connection reference - When you do need to open the inspect page on macOS and Chrome is already running, prefer AppleScript so it reuses the current profile instead of going through the picker: +This section is the source of truth for how browser-harness connects to a browser. It is the canonical reference for every agent and user of this repo. Every statement here is intended to be verifiable against either an official Chrome source or this repo's own code, and is held to that standard deliberately. If anything below is incorrect, incomplete, or misleading, open an issue on the browser-harness repository immediately with clear evidence and explanation so it can be corrected. Do not silently work around an error in this document; the cost of one user being misled is much higher than the cost of one issue. -```bash -osascript -e 'tell application "Google Chrome" to activate' \ - -e 'tell application "Google Chrome" to open location "chrome://inspect/#remote-debugging"' -``` +Browser-harness can connect to any Chrome or Chromium-based browser on your computer, or to a Browser Use cloud browser. - On Linux: open that URL manually in the existing Chrome window. - If Chrome shows the profile picker first, tell the user to choose their normal profile, *then* (only if `DevToolsActivePort` is still missing) open the inspect page in that profile. Keep polling instead of waiting for the user to type a follow-up. -4. Be explicit with the user about the two possible Chrome actions: choose their normal profile if the profile picker is open, and in the remote-debugging tab tick the checkbox and click `Allow` once if Chrome shows it. -5. Try to do everything yourself. Only ask the user to do something if it is truly necessary, like selecting the Chrome profile or clicking `Allow`. While the user is doing that, sleep and check every 3 seconds whether it is completed. After asking, keep retrying for at least 30 seconds even if you see connection-refused, stale websocket, or other weird transient attach errors. -6. If setup still lands on the profile picker, have the user choose their normal profile, then (only if `DevToolsActivePort` is still missing) open `chrome://inspect/#remote-debugging` in that profile and keep polling instead of restarting the explanation. As soon as attach succeeds, continue immediately with the verification task without asking again. -7. Verify with: +**Cloud browsers** are managed by the Browser Use cloud API. Start one in Python with `start_remote_daemon("work", ...)`. Authentication is via the `BROWSER_USE_API_KEY` environment variable; the harness handles the WebSocket URL itself. To carry your local Chrome cookies into a cloud browser, install `profile-use` once (`curl -fsSL https://browser-use.com/profile.sh | sh`), then call `uuid = sync_local_profile("MyChromeProfile")` followed by `start_remote_daemon("work", profileId=uuid)`. Cookies are the only thing synced — not localStorage, not extensions, not history. -```bash -uv run browser-harness -c "$(cat <<'PY' -goto_url("https://github.com/browser-use/browser-harness") -wait_for_load() -print(page_info()) -PY -)" -``` +**Local browsers** require remote debugging to be enabled. There are two ways, and they suit different use cases. -If that fails with a stale websocket or stale socket, restart the daemon once and retry: +*Way 1: chrome://inspect/#remote-debugging checkbox — uses your real profile.* In your running Chrome, navigate to `chrome://inspect/#remote-debugging` and tick the "Allow remote debugging for this browser instance" checkbox. This setting is per-profile and sticky: tick it once and it persists across every future Chrome launch of that profile. Then run any `browser-harness` command. On Chrome 144 and later, the first attach by the harness triggers an in-browser "Allow remote debugging?" popup that you must click Allow on. The popup may reappear on later attaches under conditions that are not fully characterized.[^1] This path inherits your everyday Chrome's logins, extensions, history, and bookmarks, which makes it the right choice for an agent helping you with tasks in your real browser. -```bash -uv run python - <<'PY' -from browser_harness.admin import restart_daemon -restart_daemon() -PY -``` +*Way 2: command-line flag — uses an isolated profile, no popups ever.* Launch Chrome with `--remote-debugging-port=9222 --user-data-dir=`. Two precisions: -If restart_daemon() also hangs, kill Chrome entirely, clean sockets, and reopen: +- The path must be a directory that is **not** Chrome's platform default (`%LOCALAPPDATA%\Google\Chrome\User Data` on Windows, `~/Library/Application Support/Google/Chrome` on macOS, `~/.config/google-chrome` on Linux). On Chrome 136 and later, the port flag is silently no-opped when the user-data-dir is the platform default, even if you pass it explicitly. An empty or new path gives a fresh clean profile that Chrome will persist there across future runs. +- This path does **not** let you reuse your everyday Chrome profile. Copying the default profile's files into a custom directory makes Chrome accept the flag, but cookies are encrypted under a key bound to the original directory and will not survive the copy — so you carry over bookmarks and extensions but lose every logged-in session. If you want your real logins, use Way 1. -```bash -pkill -9 -f "Google Chrome" -rm -f /tmp/bu-default.sock /tmp/bu-default.pid -open -a "Google Chrome" -``` +Tell the harness which port you launched on by setting `BU_CDP_URL=http://127.0.0.1:9222` before running `browser-harness`. -Wait 5 seconds, then reconnect. This resets all CDP state. +For most tasks where the agent acts on your behalf in your normal browser, use Way 1. For automation that runs without you watching, or any case where popup interruptions are unacceptable, use Way 2 or a cloud browser. -8. After install and browser bootstrap succeed, navigate to `https://github.com/browser-use/browser-harness` so the user can see the harness has attached to their browser. If the user is logged in to GitHub, **ask the user whether you should star the repo for them** as a quick demo that the interaction works — only click the star if they say yes. Then ask the user what they want to do next. +[^1]: The conditions that cause Chrome to re-show the "Allow remote debugging?" popup on a subsequent attach (time elapsed since previous Allow, daemon restart, browser restart, new CDP session, version-dependent options like "Allow for N hours") are not fully characterized. Way 2 sidesteps this entirely. -## Maintenance commands +## First time setup -- browser-harness --doctor — show version, install mode, daemon and Chrome state, and whether an update is pending. -- browser-harness --setup — re-run the full interactive browser-attach flow. -- browser-harness --update -y — pull the latest version and restart the daemon. Run this yourself when you see the `[browser-harness] update available: X -> Y` banner — don't ask the user. The banner is rate-limited to once per day. +Try yourself before asking the user to do anything. Retry transient errors briefly. Only ask the user when a step genuinely needs them — ticking a checkbox, clicking Allow. -## Architecture +If the user hasn't said which connection method to use, default to Way 1 if Chrome is already running, Way 2 if not. Cloud is only used when the user opts in. -```text -Chrome / Browser Use cloud -> CDP WS -> browser_harness.daemon -> /tmp/bu-.sock -> browser_harness.run -``` +1. Try the harness: -- Protocol is one JSON line each way. -- Requests are {method, params, session_id} for CDP or {meta: ...} for daemon control. -- Responses are {result} / {error} / {events} / {session_id}. -- BU_NAME namespaces socket, pid, and log files. -- BU_CDP_WS overrides local Chrome discovery for remote browsers. -- BU_BROWSER_ID + BROWSER_USE_API_KEY lets the daemon stop a Browser Use cloud browser on shutdown. + ```bash + browser-harness -c 'print(page_info())' + ``` -## Keeping the harness current + If it prints page info, you're done. + +2. Otherwise run `browser-harness --doctor`. The two lines that matter for connection are `chrome running` and `daemon alive`. + +3. Match the output to a case: + + - **chrome FAIL** → no Chrome process detected. + - **Way 1**: ask the user to open their target Chrome themselves. + - **Way 2**: launch Chrome yourself with `--remote-debugging-port=9222 --user-data-dir=`, then set `BU_CDP_URL=http://127.0.0.1:9222` for the harness (see the Browser connection reference). + + - **chrome ok, daemon FAIL** → Way 1 setup is incomplete. Tell the user to: + - navigate to `chrome://inspect/#remote-debugging` in their Chrome and tick "Allow remote debugging for this browser instance" if not yet ticked (one-time per profile) + - click Allow on the in-browser popup if it appears (every attach on Chrome 144+) + + On macOS, you can open the inspect page in their running Chrome yourself instead of asking them to navigate: + + ```bash + osascript -e 'tell application "Google Chrome" to activate' \ + -e 'tell application "Google Chrome" to open location "chrome://inspect/#remote-debugging"' + ``` + + - **chrome ok, daemon ok, but step 1 still failed** → stale daemon. Restart it: + + ```bash + browser-harness -c 'restart_daemon()' + ``` + + If that hangs, escalate: kill all Chrome and daemon processes, then reopen Chrome and retry. On macOS/Linux, also remove `/tmp/bu-default.sock` and `/tmp/bu-default.pid` if they linger. + +4. After any fix, retry step 1. + +If Way 1 fails repeatedly or the user's task is unattended, move to Way 2 or a cloud browser per the Browser connection reference (these have no popups). + +If you are testing browser connection for the first time, run this demo: open `https://github.com/browser-use/browser-harness` in a new tab and activate it (`switch_tab`) so the user sees the harness has attached. If they are logged into GitHub, ask whether to star the repo for them — only click if they say yes. If they are not logged in, navigate to `https://browser-use.com` instead. Then ask what they want to do next. -- On each run, `browser-harness` prints `[browser-harness] update available: X -> Y` (once per day) when a newer GitHub release exists. -- When you see that banner, run `browser-harness --update -y` yourself — don't ask the user. It pulls the new code (`git pull --ff-only` for editable clones, `uv tool upgrade browser-harness` for PyPI installs) and stops the running daemon so the next call picks up the new code. With `-y` it won't prompt. -- `--update` refuses to run on an editable clone with uncommitted changes. If that happens, tell the user and let them resolve the dirty worktree. -- Use `browser-harness --doctor` any time to see version, install mode, daemon and Chrome state, and whether an update is pending. - -## Cold-start reminders - -- Try attaching before asking the user to change anything. Decide what to escalate based on the harness's error message, not on whether Chrome is visibly running. -- The remote-debugging checkbox is per-profile sticky in Chrome. If it has ever been ticked on a profile, just launching Chrome is enough — only navigate to `chrome://inspect/#remote-debugging` when `DevToolsActivePort` is genuinely missing. -- The first connect may block on Chrome's `Allow` dialog, and Chrome may also stop first on the profile picker. -- `DevToolsActivePort` can exist before the port is actually listening. Treat connection refused as "still enabling" and keep polling briefly. -- If the port is listening but `/json/version` returns `404`, treat that as expected on newer Chrome builds and retry `browser-harness`. -- Chrome may open the profile picker before any real tab exists. -- On macOS, prefer AppleScript `open location` over `open -a ... URL` when Chrome is already running. -- Microsoft Edge (including Beta/Dev/Canary) works too — substitute the app name; steps are identical. diff --git a/packages/bcode-browser/harness/interaction-skills/profile-sync.md b/packages/bcode-browser/harness/interaction-skills/profile-sync.md index 961695a76..19bb08f81 100644 --- a/packages/bcode-browser/harness/interaction-skills/profile-sync.md +++ b/packages/bcode-browser/harness/interaction-skills/profile-sync.md @@ -8,7 +8,7 @@ Make a remote Browser Use browser start already logged in, by uploading cookies curl -fsSL https://browser-use.com/profile.sh | sh ``` -Downloads `profile-use` (macOS / Linux / Windows, x64 / arm64). The Python helpers shell out to it; you don't run `profile-use` directly. +Downloads `profile-use` (macOS / Linux, x64 / arm64). The Python helpers shell out to it; you don't run `profile-use` directly. ## Python API (pre-imported in `browser-harness -c`) @@ -19,7 +19,7 @@ list_cloud_profiles() list_local_profiles() # [{BrowserName, ProfileName, DisplayName, ProfilePath, ...}, ...] — detected on this machine -sync_local_profile(local_profile_name, browser=None, +sync_local_profile(profile_name, browser=None, cloud_profile_id=None, # update an existing cloud profile instead of creating new include_domains=None, # only these domains (and subdomains); leading dot optional exclude_domains=None) # drop these domains; applied before include @@ -81,7 +81,7 @@ Cookies mutated during a remote session only persist on a clean `PATCH /browsers - API: `GET /profiles`, `GET/PATCH/DELETE /profiles/{id}` (paths are relative to `BU_API = "https://api.browser-use.com/api/v3"` in `admin.py`). Fields: `id`, `name`, `userId`, `lastUsedAt`, `cookieDomains[]`. `list_cloud_profiles()` wraps this. - Name → UUID: `profileName=` on `start_remote_daemon` resolves client-side; no API change needed. - Need the UUID for an existing profile? `matches = [p["id"] for p in list_cloud_profiles() if p["name"] == ""]` — then verify `len(matches) == 1` before using it. Profile names are not unique; syncs create duplicates unless you pass `cloud_profile_id=`. -- Lower-level raw calls: `from admin import _browser_use; _browser_use("/profiles/", "DELETE")`. Pass the path *without* the `/api/v3` prefix — it's already on `BU_API`. +- Lower-level raw calls: `from browser_harness.admin import _browser_use; _browser_use("/profiles/", "DELETE")`. Pass the path *without* the `/api/v3` prefix — it's already on `BU_API`. ## Traps diff --git a/packages/bcode-browser/harness/src/browser_harness/_ipc.py b/packages/bcode-browser/harness/src/browser_harness/_ipc.py index e47c31572..71acddd0e 100644 --- a/packages/bcode-browser/harness/src/browser_harness/_ipc.py +++ b/packages/bcode-browser/harness/src/browser_harness/_ipc.py @@ -1,5 +1,5 @@ """Daemon IPC plumbing. AF_UNIX socket on POSIX, TCP loopback on Windows.""" -import asyncio, os, re, socket, subprocess, sys, tempfile +import asyncio, json, os, re, secrets, socket, subprocess, sys, tempfile from pathlib import Path IS_WINDOWS = sys.platform == "win32" @@ -12,6 +12,12 @@ _TMP.mkdir(parents=True, exist_ok=True) _NAME_RE = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") +# Set by serve() on Windows. Daemon's handle() requires every request to carry +# this token (TCP loopback has no chmod-equivalent so any local process could +# otherwise issue CDP commands). Stays None on POSIX where AF_UNIX + chmod 600 +# is the boundary. +_server_token = None + def _check(name): # path-traversal guard for BU_NAME if not _NAME_RE.match(name or ""): @@ -26,14 +32,23 @@ def _stem(name): # "bu" when BH_TMP_DIR isolates us, else "bu-" def log_path(name): return _TMP / f"{_stem(name)}.log" def pid_path(name): return _TMP / f"{_stem(name)}.pid" -def port_path(name): return _TMP / f"{_stem(name)}.port" # Windows-only: holds the daemon's TCP port +def port_path(name): return _TMP / f"{_stem(name)}.port" # Windows-only: holds {"port","token"} JSON def _sock_path(name): return _TMP / f"{_stem(name)}.sock" +def _read_port_file(name): + """(port, token) from the Windows port file, or (None, None) on any failure.""" + try: + d = json.loads(port_path(name).read_text()) + return int(d["port"]), d["token"] + except (FileNotFoundError, ValueError, KeyError, TypeError, OSError): + return None, None + + def sock_addr(name): # display-only, used in log lines if not IS_WINDOWS: return str(_sock_path(name)) - try: return f"127.0.0.1:{port_path(name).read_text().strip()}" - except FileNotFoundError: return f"tcp:{_stem(name)}" + port, _ = _read_port_file(name) + return f"127.0.0.1:{port}" if port else f"tcp:{_stem(name)}" def spawn_kwargs(): # subprocess.Popen flags so the daemon detaches from this terminal @@ -48,29 +63,67 @@ def spawn_kwargs(): # subprocess.Popen flags so the daemon detaches from this t def connect(name, timeout=1.0): - """Blocking client. Raises FileNotFoundError if no daemon, TimeoutError on connect timeout.""" + """Blocking client. Returns (sock, token); token is None on POSIX, hex string on Windows. + Callers sending JSON requests MUST include the token as req["token"] on Windows.""" if not IS_WINDOWS: # uv-Python on Windows lacks socket.AF_UNIX, so this branch must be gated. s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - s.settimeout(timeout); s.connect(str(_sock_path(name))); return s - try: port = int(port_path(name).read_text().strip()) - except (FileNotFoundError, ValueError): raise FileNotFoundError(str(port_path(name))) + s.settimeout(timeout); s.connect(str(_sock_path(name))); return s, None + port, token = _read_port_file(name) + if port is None: raise FileNotFoundError(str(port_path(name))) s = socket.create_connection(("127.0.0.1", port), timeout=timeout) - s.settimeout(timeout); return s + s.settimeout(timeout); return s, token + + +def request(c, token, req): + """One-shot send + recv + parse on an open socket. Injects token on Windows. + Returns the parsed JSON response. Caller closes the socket.""" + if token: req = {**req, "token": token} + c.sendall((json.dumps(req) + "\n").encode()) + data = b"" + while not data.endswith(b"\n"): + chunk = c.recv(1 << 16) + if not chunk: break + data += chunk + return json.loads(data or b"{}") + + +def ping(name, timeout=1.0): + """True iff a live daemon answers our ping. Defends against stale .port files + + port reuse: a bare TCP connect can succeed against an unrelated process that + grabbed the port after our daemon crashed; only our daemon answers {"pong":true}.""" + try: + c, token = connect(name, timeout=timeout) + except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError): + return False + try: + return request(c, token, {"meta": "ping"}).get("pong") is True + except (OSError, ValueError): + return False + finally: + try: c.close() + except OSError: pass async def serve(name, handler): """Run the server until cancelled. handler(reader, writer) sees the same interface either way.""" + global _server_token if not IS_WINDOWS: path = str(_sock_path(name)) if os.path.exists(path): os.unlink(path) server = await asyncio.start_unix_server(handler, path=path) os.chmod(path, 0o600) + _server_token = None async with server: await asyncio.Event().wait() return server = await asyncio.start_server(handler, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + _server_token = secrets.token_hex(32) pf = port_path(name) - pf.write_text(str(server.sockets[0].getsockname()[1])) # so clients can find us + # Atomic write so a concurrent reader never sees a half-written file. + tmp = pf.with_name(pf.name + ".tmp") + tmp.write_text(json.dumps({"port": port, "token": _server_token})) + os.replace(tmp, pf) try: async with server: await asyncio.Event().wait() finally: @@ -78,6 +131,11 @@ async def serve(name, handler): except FileNotFoundError: pass +def expected_token(): + """The token the running daemon will accept, or None on POSIX.""" + return _server_token + + def cleanup_endpoint(name): # best-effort; silent if already gone p = _sock_path(name) if not IS_WINDOWS else port_path(name) try: p.unlink() diff --git a/packages/bcode-browser/harness/src/browser_harness/admin.py b/packages/bcode-browser/harness/src/browser_harness/admin.py index 6a387c0da..83109c419 100644 --- a/packages/bcode-browser/harness/src/browser_harness/admin.py +++ b/packages/bcode-browser/harness/src/browser_harness/admin.py @@ -69,10 +69,9 @@ def _is_local_chrome_mode(env=None): def daemon_alive(name=None): - try: - c = ipc.connect(name or NAME, timeout=1.0); c.close(); return True - except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError): - return False + # Ping handshake (not a bare connect) so a stale .port file + port reuse + # after a daemon crash doesn't make us mistake an unrelated listener for ours. + return ipc.ping(name or NAME, timeout=1.0) def _daemon_endpoint_names(): @@ -97,15 +96,8 @@ def _daemon_endpoint_names(): def _daemon_browser_connection(name): c = None try: - c = ipc.connect(name, timeout=1.0) - c.sendall(b'{"meta":"connection_status"}\n') - data = b"" - while not data.endswith(b"\n"): - chunk = c.recv(1 << 16) - if not chunk: - break - data += chunk - response = json.loads(data) + c, token = ipc.connect(name, timeout=1.0) + response = ipc.request(c, token, {"meta": "connection_status"}) if "error" in response: return None page = response.get("page") @@ -140,7 +132,7 @@ def _doctor_short_text(value, limit=None): return value if len(value) <= limit else value[:limit - 3] + "..." -def ensure_daemon(wait=60.0, name=None, env=None, _open_inspect=True): +def ensure_daemon(wait=60.0, name=None, env=None): """Idempotent. Self-heals stale daemon, cold Chrome, and missing Allow on chrome://inspect.""" if daemon_alive(name): # Stale daemons accept connects AND reply to meta:* (pure Python) even when the @@ -148,14 +140,9 @@ def ensure_daemon(wait=60.0, name=None, env=None, _open_inspect=True): # Must go through ipc.connect so this works on Windows (TCP loopback) too; # raw AF_UNIX here would fail on every warm call and churn the daemon. try: - s = ipc.connect(name or NAME, timeout=3.0) - s.sendall(b'{"method":"Target.getTargets","params":{}}\n') - data = b"" - while not data.endswith(b"\n"): - chunk = s.recv(1 << 16) - if not chunk: break - data += chunk - if b'"result"' in data: return + s, token = ipc.connect(name or NAME, timeout=3.0) + resp = ipc.request(s, token, {"method": "Target.getTargets", "params": {}}) + if "result" in resp: return except Exception: pass restart_daemon(name) @@ -174,9 +161,8 @@ def ensure_daemon(wait=60.0, name=None, env=None, _open_inspect=True): time.sleep(0.2) msg = _log_tail(name) or "" if local and attempt == 0 and _needs_chrome_remote_debugging_prompt(msg): - if _open_inspect: - _open_chrome_inspect() - print("browser-harness: click Allow on chrome://inspect (and tick the checkbox if shown)", file=sys.stderr) + _open_chrome_inspect() + print('browser-harness: at chrome://inspect/#remote-debugging, tick "Allow remote debugging for this browser instance" and click Allow on the popup that appears', file=sys.stderr) restart_daemon(name) continue raise RuntimeError(msg or f"daemon {name or NAME} didn't come up -- check {ipc.log_path(name or NAME)}") @@ -206,9 +192,8 @@ def restart_daemon(name=None): pid_path = str(ipc.pid_path(name or NAME)) try: - c = ipc.connect(name or NAME, timeout=5.0) - c.sendall(b'{"meta":"shutdown"}\n') - c.recv(1024) + c, token = ipc.connect(name or NAME, timeout=5.0) + ipc.request(c, token, {"meta": "shutdown"}) c.close() except Exception: pass @@ -373,9 +358,9 @@ def sync_local_profile(profile_name, browser=None, cloud_profile_id=None, include_domains=None, exclude_domains=None): """Sync a local profile's cookies to a cloud profile. Returns the cloud UUID. - Shells out to `profile-use sync` (v1.0.4+). Requires BROWSER_USE_API_KEY and the - target local Chrome profile to be closed (profile-use needs an exclusive lock on - the Cookies DB). + Shells out to `profile-use sync` (v1.0.5+). Requires BROWSER_USE_API_KEY. + profile-use copies the profile dir to a temp and syncs from the copy, so Chrome + can stay open. Args: profile_name: local Chrome profile name (as shown by `list_local_profiles`). @@ -548,56 +533,6 @@ def _open_chrome_inspect(): pass -def run_setup(): - """Interactive bootstrap: attach to the running browser, guiding the user through chrome://inspect if needed. - - Exit code 0 on success, 1 on failure.""" - import sys - print("browser-harness setup: attaching to your browser...") - - if daemon_alive(): - print("daemon already running; nothing to do.") - return 0 - - if not _chrome_running(): - print("no Chrome/Edge process detected. please start your browser and rerun `browser-harness --setup`.") - return 1 - - # First attach attempt. - try: - ensure_daemon(wait=20.0) - print("daemon is up.") - return 0 - except RuntimeError as e: - first_err = str(e) - - needs_inspect = _is_local_chrome_mode() and _needs_chrome_remote_debugging_prompt(first_err) - if needs_inspect: - print("chrome remote-debugging is not enabled on the current profile.") - print("opening chrome://inspect/#remote-debugging -- in the tab that opens:") - print(" 1. if chrome shows the profile picker, pick your normal profile;") - print(" 2. tick 'Discover network targets' and click Allow if prompted.") - _open_chrome_inspect() - else: - print(f"attach failed: {first_err}") - print("retrying for up to 60s (chrome may still be starting up)...") - - deadline = time.time() + 60 - last = first_err - while time.time() < deadline: - try: - ensure_daemon(wait=5.0, _open_inspect=False) - print("daemon is up.") - return 0 - except RuntimeError as e: - last = str(e) - time.sleep(2) - - print(f"setup failed: {last}", file=sys.stderr) - print("run `browser-harness --doctor` for diagnostics.", file=sys.stderr) - return 1 - - def run_doctor(): """Read-only diagnostics. Exit 0 iff everything looks healthy.""" import platform, shutil, sys @@ -626,8 +561,8 @@ def row(label, ok, detail=""): print(f" latest release {latest}" + (" (update available)" if newer else "")) else: print(" latest release (could not reach github)") - row("chrome running", chrome, "" if chrome else "start chrome/edge and rerun `browser-harness --setup`") - row("daemon alive", daemon, "" if daemon else "run `browser-harness --setup` to attach") + row("chrome running", chrome, "" if chrome else "start chrome/edge") + row("daemon alive", daemon, "" if daemon else "see install.md") row("active browser connections", bool(connections), str(len(connections))) for conn in connections: page = conn.get("page") diff --git a/packages/bcode-browser/harness/src/browser_harness/daemon.py b/packages/bcode-browser/harness/src/browser_harness/daemon.py index a8631406d..01edad1ce 100644 --- a/packages/bcode-browser/harness/src/browser_harness/daemon.py +++ b/packages/bcode-browser/harness/src/browser_harness/daemon.py @@ -36,6 +36,7 @@ def _load_env_file(p): Path.home() / "Library/Application Support/Google/Chrome", Path.home() / "Library/Application Support/Comet", Path.home() / "Library/Application Support/Arc/User Data", + Path.home() / "Library/Application Support/Dia/User Data", Path.home() / "Library/Application Support/Microsoft Edge", Path.home() / "Library/Application Support/Microsoft Edge Beta", Path.home() / "Library/Application Support/Microsoft Edge Dev", @@ -210,7 +211,16 @@ async def tap(method, params, session_id=None): self.cdp._event_registry.handle_event = tap async def handle(self, req): + # Token guard for Windows TCP loopback: any local process can otherwise + # connect and issue CDP commands. expected_token() is None on POSIX so + # this check is a no-op there (AF_UNIX + chmod 600 is the boundary). + expected = ipc.expected_token() + if expected is not None and req.get("token") != expected: + return {"error": "unauthorized"} meta = req.get("meta") + # Liveness probe — lets clients confirm the listener is actually this + # daemon and not an unrelated process that reused our port post-crash. + if meta == "ping": return {"pong": True} if meta == "drain_events": out = list(self.events); self.events.clear() return {"events": out} @@ -297,10 +307,9 @@ async def main(): def already_running(): - try: - c = ipc.connect(NAME, timeout=1.0); c.close(); return True - except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError): - return False + # Ping handshake (not a bare connect) so a stale .port file + port reuse + # after a daemon crash doesn't make us mistake an unrelated listener for ours. + return ipc.ping(NAME, timeout=1.0) if __name__ == "__main__": diff --git a/packages/bcode-browser/harness/src/browser_harness/helpers.py b/packages/bcode-browser/harness/src/browser_harness/helpers.py index 516d1837b..aa897da99 100644 --- a/packages/bcode-browser/harness/src/browser_harness/helpers.py +++ b/packages/bcode-browser/harness/src/browser_harness/helpers.py @@ -3,7 +3,7 @@ Core helpers live here. Agent-editable helpers live in BH_AGENT_WORKSPACE/agent_helpers.py. """ -import base64, importlib.util, json, math, os, time, urllib.request +import base64, importlib.util, json, math, os, sys, time, urllib.request from pathlib import Path from urllib.parse import urlparse @@ -40,15 +40,11 @@ def _load_env_file(p): def _send(req): - c = ipc.connect(NAME, timeout=5.0) - c.sendall((json.dumps(req) + "\n").encode()) - data = b"" - while not data.endswith(b"\n"): - chunk = c.recv(1 << 20) - if not chunk: break - data += chunk - c.close() - r = json.loads(data) + c, token = ipc.connect(NAME, timeout=5.0) + try: + r = ipc.request(c, token, req) + finally: + c.close() if "error" in r: raise RuntimeError(r["error"]) return r @@ -162,6 +158,8 @@ def _has_return_statement(expression): # --- navigation / page --- def goto_url(url): r = cdp("Page.navigate", url=url) + if os.environ.get("BH_DOMAIN_SKILLS") != "1": + return r d = (AGENT_WORKSPACE / "domain-skills" / (urlparse(url).hostname or "").removeprefix("www.").split(".")[0]) return {**r, "domain_skills": sorted(p.name for p in d.rglob("*.md"))[:10]} if d.is_dir() else r @@ -205,6 +203,45 @@ def click_at_xy(x, y, button="left", clicks=1): def type_text(text): cdp("Input.insertText", text=text) +def fill_input(selector, text, clear_first=True, timeout=0.0): + """Fill a framework-managed input (React controlled, Vue v-model, Ember tracked). + + type_text() uses Input.insertText which bypasses framework event listeners and leaves + submit buttons disabled. This helper focuses the element, clears it, types via real + key events, then fires synthetic input+change events so the framework sees the update. + + Raises RuntimeError if the element is not found. Pass timeout>0 to wait for + late-rendered elements (e.g. after a route change) before typing. + """ + if timeout > 0: + if not wait_for_element(selector, timeout=timeout): + raise RuntimeError(f"fill_input: element not found: {selector!r}") + focused = js( + f"(()=>{{const e=document.querySelector({json.dumps(selector)});" + f"if(!e)return false;e.focus();return true;}})()" + ) + if not focused: + raise RuntimeError(f"fill_input: element not found: {selector!r}") + if clear_first: + # Dispatch select-all directly — NOT via press_key, which always emits a + # `char` event for single-char keys. With Ctrl/Cmd held, that `char` + # makes Chrome treat the input as a printable "a" instead of firing the + # select-all shortcut, leaving the field uncleared. + mods = 4 if sys.platform == "darwin" else 2 # Cmd on macOS, Ctrl elsewhere + select_all = {"key": "a", "code": "KeyA", "modifiers": mods, + "windowsVirtualKeyCode": 65, "nativeVirtualKeyCode": 65} + cdp("Input.dispatchKeyEvent", type="rawKeyDown", **select_all) + cdp("Input.dispatchKeyEvent", type="keyUp", **select_all) + press_key("Backspace") + for ch in text: + press_key(ch) + js( + f"(()=>{{const e=document.querySelector({json.dumps(selector)});" + f"if(!e)return;" + f"e.dispatchEvent(new Event('input',{{bubbles:true}}));" + f"e.dispatchEvent(new Event('change',{{bubbles:true}}));}})();" + ) + _KEYS = { # key → (windowsVirtualKeyCode, code, text) "Enter": (13, "Enter", "\r"), "Tab": (9, "Tab", "\t"), "Backspace": (8, "Backspace", ""), "Escape": (27, "Escape", ""), "Delete": (46, "Delete", ""), " ": (32, "Space", " "), @@ -320,6 +357,63 @@ def wait_for_load(timeout=15.0): time.sleep(0.3) return False +def wait_for_element(selector, timeout=10.0, visible=False): + """Poll until querySelector(selector) exists in the DOM, or timeout. + + wait_for_load() misses SPAs — the document is 'complete' before the framework renders. + Use this after actions that trigger async rendering (route changes, data fetches). + Set visible=True to also require the element to be non-hidden and in-layout. + Returns True if found, False on timeout. + """ + if visible: + # checkVisibility walks the ancestor chain and respects display:none / + # visibility:hidden / opacity:0 on parents, which a getComputedStyle + # check on the element alone misses (it returns the descendant's own + # style, not the inherited "is this rendered" state). Falls back to + # the per-element CSS check on older Chrome that lacks checkVisibility. + check = ( + f"(()=>{{const e=document.querySelector({json.dumps(selector)});" + f"if(!e)return false;" + f"if(typeof e.checkVisibility==='function')" + f"return e.checkVisibility({{checkOpacity:true,checkVisibilityCSS:true}});" + f"const s=getComputedStyle(e);" + f"return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'}})()" + ) + else: + check = f"!!document.querySelector({json.dumps(selector)})" + deadline = time.time() + timeout + while time.time() < deadline: + if js(check): return True + time.sleep(0.3) + return False + +def wait_for_network_idle(timeout=10.0, idle_ms=500): + """Wait until all in-flight requests finish and no Network.* events arrive for idle_ms ms. + + Useful after form submits, SPA route transitions, and any action that triggers + XHR/fetch without a visible DOM change. Builds on drain_events() — no daemon changes. + Returns True if idle window reached, False on timeout. + """ + deadline = time.time() + timeout + last_activity = time.time() + inflight = set() + while time.time() < deadline: + for e in drain_events(): + method = e.get("method", "") + params = e.get("params", {}) + if method == "Network.requestWillBeSent": + inflight.add(params.get("requestId")) + last_activity = time.time() + elif method in ("Network.loadingFinished", "Network.loadingFailed"): + inflight.discard(params.get("requestId")) + last_activity = time.time() + elif method.startswith("Network."): + last_activity = time.time() + if not inflight and (time.time() - last_activity) * 1000 >= idle_ms: + return True + time.sleep(0.1) + return False + def js(expression, target_id=None): """Run JS in the attached tab (default) or inside an iframe target (via iframe_target()). diff --git a/packages/bcode-browser/harness/src/browser_harness/run.py b/packages/bcode-browser/harness/src/browser_harness/run.py index ba7b8309d..2c6ac9573 100644 --- a/packages/bcode-browser/harness/src/browser_harness/run.py +++ b/packages/bcode-browser/harness/src/browser_harness/run.py @@ -17,7 +17,6 @@ print_update_banner, restart_daemon, run_doctor, - run_setup, run_update, start_remote_daemon, stop_remote_daemon, @@ -40,7 +39,6 @@ Commands: browser-harness --version print the installed version browser-harness --doctor diagnose install, daemon, and browser state - browser-harness --setup interactively attach to your running browser browser-harness --update [-y] pull the latest version (agents: pass -y) browser-harness --reload stop the daemon so next call picks up code changes """ @@ -68,8 +66,6 @@ def main(): return if args and args[0] == "--doctor": sys.exit(run_doctor()) - if args and args[0] == "--setup": - sys.exit(run_setup()) if args and args[0] == "--update": yes = any(a in {"-y", "--yes"} for a in args[1:]) sys.exit(run_update(yes=yes)) @@ -85,7 +81,15 @@ def main(): if len(args) < 2: sys.exit("Usage: browser-harness -c \"print(page_info())\"") print_update_banner() - if not daemon_alive() and not _local_chrome_listening() and os.environ.get("BROWSER_USE_API_KEY"): + # Auto-bootstrap a cloud browser is opt-in via BU_AUTOSPAWN — BROWSER_USE_API_KEY alone + # is not enough, since the key is commonly set for unrelated reasons (profile sync, + # cloud API calls, parent agents managing their own session). + if ( + not daemon_alive() + and not _local_chrome_listening() + and os.environ.get("BROWSER_USE_API_KEY") + and os.environ.get("BU_AUTOSPAWN") + ): start_remote_daemon(NAME) ensure_daemon() exec(args[1], globals()) diff --git a/packages/bcode-browser/harness/tests/unit/test_admin.py b/packages/bcode-browser/harness/tests/unit/test_admin.py index ee298086c..70be8afa2 100644 --- a/packages/bcode-browser/harness/tests/unit/test_admin.py +++ b/packages/bcode-browser/harness/tests/unit/test_admin.py @@ -86,8 +86,8 @@ def fake_connect(name, timeout=1.0): if name == "stale": raise ConnectionRefusedError() if name == "remote": - return FakeSocket(b'{"error":"no close frame received or sent"}\n') - return FakeSocket() + return FakeSocket(b'{"error":"no close frame received or sent"}\n'), None + return FakeSocket(), None monkeypatch.setattr(admin.ipc, "connect", fake_connect) @@ -99,8 +99,8 @@ def test_active_browser_connections_skips_daemons_reporting_cdp_disconnected(mon def fake_connect(name, timeout=1.0): if name == "stale": - return FakeSocket(b'{"error":"cdp_disconnected"}\n') - return FakeSocket() + return FakeSocket(b'{"error":"cdp_disconnected"}\n'), None + return FakeSocket(), None monkeypatch.setattr(admin.ipc, "connect", fake_connect) @@ -113,7 +113,7 @@ def test_browser_connections_returns_attached_page(monkeypatch): b'{"target_id":"target-1","session_id":"session-1",' b'"page":{"targetId":"target-1","title":"Cat - Wikipedia","url":"https://en.wikipedia.org/wiki/Cat"}}\n' ) - monkeypatch.setattr(admin.ipc, "connect", lambda name, timeout=1.0: FakeSocket(response)) + monkeypatch.setattr(admin.ipc, "connect", lambda name, timeout=1.0: (FakeSocket(response), None)) assert admin.browser_connections() == [ { diff --git a/packages/bcode-browser/harness/tests/unit/test_helpers.py b/packages/bcode-browser/harness/tests/unit/test_helpers.py index 013649143..e90602b99 100644 --- a/packages/bcode-browser/harness/tests/unit/test_helpers.py +++ b/packages/bcode-browser/harness/tests/unit/test_helpers.py @@ -1,5 +1,6 @@ import os import tempfile +import time from unittest.mock import patch import pytest @@ -28,6 +29,30 @@ def test_max_dim_default_is_no_resize(fake_png): assert _run(fake_png, 4592, 2286) == (4592, 2286) +def _seed_skill(tmp_path): + site = tmp_path / "domain-skills" / "example" + site.mkdir(parents=True) + (site / "scraping.md").write_text("hi") + + +def test_goto_url_omits_domain_skills_by_default(tmp_path, monkeypatch): + monkeypatch.delenv("BH_DOMAIN_SKILLS", raising=False) + monkeypatch.setattr(helpers, "AGENT_WORKSPACE", tmp_path) + _seed_skill(tmp_path) + with patch("browser_harness.helpers.cdp", return_value={"frameId": "f"}): + result = helpers.goto_url("https://www.example.com/") + assert result == {"frameId": "f"} + + +def test_goto_url_includes_domain_skills_when_enabled(tmp_path, monkeypatch): + monkeypatch.setenv("BH_DOMAIN_SKILLS", "1") + monkeypatch.setattr(helpers, "AGENT_WORKSPACE", tmp_path) + _seed_skill(tmp_path) + with patch("browser_harness.helpers.cdp", return_value={"frameId": "f"}): + result = helpers.goto_url("https://www.example.com/") + assert result == {"frameId": "f", "domain_skills": ["scraping.md"]} + + def test_page_info_raises_clear_error_on_js_exception(): def fake_send(req): return {} @@ -50,3 +75,231 @@ def fake_cdp(method, **kwargs): patch("browser_harness.helpers.cdp", side_effect=fake_cdp): with pytest.raises(RuntimeError, match="ReferenceError"): helpers.page_info() + + +# --- fill_input --- + +def test_fill_input_focuses_types_and_fires_events(): + cdp_calls = [] + js_calls = [] + + def fake_cdp(method, **kwargs): + cdp_calls.append((method, kwargs)) + return {} + + def fake_js(expr, **kwargs): + js_calls.append(expr) + return True # focus call must return True (element found) + + with patch("browser_harness.helpers.cdp", side_effect=fake_cdp), \ + patch("browser_harness.helpers.js", side_effect=fake_js): + helpers.fill_input("#my-input", "hello") + + assert any("#my-input" in e for e in js_calls) + key_downs = [m for m, _ in cdp_calls if m == "Input.dispatchKeyEvent"] + assert len(key_downs) > 0 + assert any("input" in e and "change" in e for e in js_calls) + + +def test_fill_input_raises_when_element_not_found(): + def fake_js(expr, **kwargs): + return False # element not found + + with patch("browser_harness.helpers.js", side_effect=fake_js): + with pytest.raises(RuntimeError, match="element not found"): + helpers.fill_input("#missing", "hello") + + +def test_fill_input_clear_first_sends_select_all_then_backspace(): + import sys + + key_events = [] + + def fake_cdp(method, **kwargs): + if method == "Input.dispatchKeyEvent": + key_events.append(kwargs) + return {} + + def fake_js(expr, **kwargs): + return True # element found + + with patch("browser_harness.helpers.cdp", side_effect=fake_cdp), \ + patch("browser_harness.helpers.js", side_effect=fake_js): + helpers.fill_input("#inp", "x", clear_first=True) + + # The "a" must be dispatched with the platform-correct modifier (Meta=4 on + # macOS, Ctrl=2 elsewhere). Without the modifier, the field would never get + # selected — it would just receive a literal "a". + expected_mod = 4 if sys.platform == "darwin" else 2 + a_events = [e for e in key_events if e.get("key") == "a"] + assert a_events, "expected an 'a' key event for select-all" + assert all(e.get("modifiers") == expected_mod for e in a_events), \ + f"select-all 'a' must carry modifiers={expected_mod}; got {[e.get('modifiers') for e in a_events]}" + + # Crucial: no `char` event for the "a" — emitting one makes Chrome treat + # Cmd/Ctrl+A as a printable letter instead of a shortcut. + assert not any(e.get("type") == "char" and e.get("text") == "a" for e in key_events), \ + "select-all must not emit a 'char' event with text='a' (would cancel the shortcut)" + + # Backspace still fires (via press_key, which uses keyDown). + keys_down = [e.get("key") for e in key_events if e.get("type") in ("keyDown", "rawKeyDown")] + assert "Backspace" in keys_down + + +def test_fill_input_no_clear_skips_ctrl_a(): + key_events = [] + + def fake_cdp(method, **kwargs): + if method == "Input.dispatchKeyEvent": + key_events.append(kwargs) + return {} + + def fake_js(expr, **kwargs): + return True # element found + + with patch("browser_harness.helpers.cdp", side_effect=fake_cdp), \ + patch("browser_harness.helpers.js", side_effect=fake_js): + helpers.fill_input("#inp", "x", clear_first=False) + + keys_seen = [e.get("key") for e in key_events if e.get("type") == "keyDown"] + assert "Backspace" not in keys_seen + + +# --- wait_for_element --- + +def test_wait_for_element_returns_true_when_found_immediately(): + def fake_js(expr, **kwargs): + return True + + with patch("browser_harness.helpers.js", side_effect=fake_js): + assert helpers.wait_for_element("#target", timeout=2.0) is True + + +def test_wait_for_element_returns_false_on_timeout(): + def fake_js(expr, **kwargs): + return False + + with patch("browser_harness.helpers.js", side_effect=fake_js), \ + patch("browser_harness.helpers.time") as mock_time: + # simulate time advancing past the deadline immediately + start = time.time() + mock_time.time.side_effect = [start, start + 5.0] + mock_time.sleep = lambda _: None + assert helpers.wait_for_element("#missing", timeout=1.0) is False + + +def test_wait_for_element_visible_uses_check_visibility(): + js_exprs = [] + + def fake_js(expr, **kwargs): + js_exprs.append(expr) + return True + + with patch("browser_harness.helpers.js", side_effect=fake_js): + helpers.wait_for_element("#btn", visible=True) + + # Prefers checkVisibility (walks ancestor chain) with a computed-style + # fallback for older Chrome. + assert any("checkVisibility" in e for e in js_exprs) + assert any("getComputedStyle" in e for e in js_exprs) + # must NOT use offsetParent (fails for position:fixed elements) + assert not any("offsetParent" in e for e in js_exprs) + + +def test_wait_for_element_non_visible_uses_simple_check(): + js_exprs = [] + + def fake_js(expr, **kwargs): + js_exprs.append(expr) + return True + + with patch("browser_harness.helpers.js", side_effect=fake_js): + helpers.wait_for_element("#btn", visible=False) + + assert any("querySelector" in e and "offsetParent" not in e for e in js_exprs) + + +# --- wait_for_network_idle --- + +def test_wait_for_network_idle_returns_true_when_no_events(): + call_count = 0 + + def fake_send(req): + nonlocal call_count + call_count += 1 + return {"events": []} + + with patch("browser_harness.helpers._send", side_effect=fake_send), \ + patch("browser_harness.helpers.time") as mock_time: + start = 1000.0 + # first call: not idle yet; second call: idle window elapsed + mock_time.time.side_effect = [start, start, start, start + 0.6, start + 0.6] + mock_time.sleep = lambda _: None + result = helpers.wait_for_network_idle(timeout=5.0, idle_ms=500) + + assert result is True + + +def test_wait_for_network_idle_waits_for_inflight_request(): + # Verifies inflight tracking: must not return True until loadingFinished, + # even though >idle_ms elapses between requestWillBeSent and loadingFinished. + # An event-silence-only implementation would return True at iter2 (wrong). + events_seq = [ + [{"method": "Network.requestWillBeSent", "params": {"requestId": "req1"}}], + [], # >500ms elapsed — old impl returns True here; new must NOT + [{"method": "Network.loadingFinished", "params": {"requestId": "req1"}}], + [], # idle_ms after loadingFinished → return True + ] + idx = 0 + + def fake_send(req): + nonlocal idx + evs = events_seq[min(idx, len(events_seq) - 1)] + idx += 1 + return {"events": evs} + + with patch("browser_harness.helpers._send", side_effect=fake_send), \ + patch("browser_harness.helpers.time") as mock_time: + start = 1000.0 + # inflight non-empty → short-circuit skips time.time() in idle check for iter1/iter2 + mock_time.time.side_effect = [ + start, start, # deadline + last_activity init + start + 0.1, # iter1 while-check + start + 0.1, # iter1 rWS last_activity update + # iter1 idle-check: inflight non-empty → short-circuit + start + 0.7, # iter2 while-check (>500ms since rWS but request still in flight) + # iter2 idle-check: inflight non-empty → short-circuit + start + 0.8, # iter3 while-check + start + 0.8, # iter3 lF last_activity update + start + 0.8, # iter3 idle-check: 0ms < 500 → not idle + start + 1.4, # iter4 while-check + start + 1.4, # iter4 idle-check: 600ms >= 500 → True + ] + mock_time.sleep = lambda _: None + result = helpers.wait_for_network_idle(timeout=5.0, idle_ms=500) + + assert result is True + assert idx == 4 # did not short-circuit at iter2 despite silence > idle_ms + + +def test_wait_for_network_idle_returns_false_on_timeout(): + # Continuous rWS keeps inflight non-empty → idle check short-circuits every iteration. + # time.time() is only called for while-check and rWS last_activity (not idle check). + def fake_send(req): + return {"events": [{"method": "Network.requestWillBeSent", "params": {"requestId": "r"}}]} + + with patch("browser_harness.helpers._send", side_effect=fake_send), \ + patch("browser_harness.helpers.time") as mock_time: + start = 1000.0 + mock_time.time.side_effect = [ + start, start, # deadline + last_activity init + start + 0.1, # iter1 while-check (in deadline) + start + 0.1, # iter1 rWS last_activity update + # iter1 idle-check: inflight non-empty → short-circuit + start + 20.0, # iter2 while-check (past deadline → exit) + ] + mock_time.sleep = lambda _: None + result = helpers.wait_for_network_idle(timeout=10.0, idle_ms=500) + + assert result is False + diff --git a/packages/bcode-browser/harness/tests/unit/test_run.py b/packages/bcode-browser/harness/tests/unit/test_run.py index fcf2ed4a7..31cb4e1d8 100644 --- a/packages/bcode-browser/harness/tests/unit/test_run.py +++ b/packages/bcode-browser/harness/tests/unit/test_run.py @@ -16,8 +16,9 @@ def test_c_flag_executes_code(): def test_cloud_bootstrap_on_headless_server(monkeypatch): - """No daemon, no local Chrome, API key set -> auto-provision cloud daemon.""" + """No daemon, no local Chrome, API key + BU_AUTOSPAWN set -> auto-provision cloud daemon.""" monkeypatch.setenv("BROWSER_USE_API_KEY", "test-key") + monkeypatch.setenv("BU_AUTOSPAWN", "1") with patch.object(sys, "argv", ["browser-harness", "-c", "x = 1"]), \ patch("browser_harness.run.daemon_alive", return_value=False), \ patch("browser_harness.run._local_chrome_listening", return_value=False), \