From b3bb291a0c49351a96cb49b3eed6cd77903432b0 Mon Sep 17 00:00:00 2001 From: RedBotster <258521541+redbotster@users.noreply.github.com> Date: Sat, 12 Sep 2026 05:29:19 -0700 Subject: [PATCH] examples: prove browser-use connects, and correct a stale README claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main README asserted "browser-use is Playwright-based, so it connects to the bridge unchanged" — true when written, false for current browser-use releases (they ship their own CDP client, cdp_use, not Playwright), and never actually tested either way. examples/browser-use/ is the real test: a Node companion (bridge_server.mjs) starts a bridge + login site, and a Python script (test_login.py) connects browser-use directly to the bridge's ws:// URL and drives the same login-fill proof register-login-act.mjs already does for Puppeteer/ Playwright — browser-use's own tab never touches the password field, the fill happens on a throwaway target, and the agent's tab is authenticated afterward with the password appearing nowhere it can read. Confirmed along the way: - browser-use's connect() skips its usual /json/version HTTP discovery whenever cdp_url already starts with "ws", so no special-casing was needed on either side to hand it the bridge's URL directly. - browser-use calls Browser.grantPermissions on connect, which Puppeteer/ Playwright don't; the gate correctly refuses it (not on the allowlist) without breaking the session. - browser-use's own get_tabs()/tab.url cache can read stale right after navigate_to() even though the page really navigated — a browser-use-side quirk (confirmed by reading location.href directly), not a bridge one. Updates both READMEs' framework-compatibility claims to point at this and be accurate about what changed in browser-use itself. --- .gitignore | 5 + README.md | 13 +- packages/browser-bridge/examples/README.md | 12 ++ .../examples/browser-use/README.md | 80 +++++++++ .../examples/browser-use/bridge_server.mjs | 121 +++++++++++++ .../examples/browser-use/test_login.py | 165 ++++++++++++++++++ 6 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 packages/browser-bridge/examples/browser-use/README.md create mode 100644 packages/browser-bridge/examples/browser-use/bridge_server.mjs create mode 100644 packages/browser-bridge/examples/browser-use/test_login.py diff --git a/.gitignore b/.gitignore index 44553f2..4faadb4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,11 @@ dist/ coverage/ .DS_Store +# examples/browser-use/ is the one Python example in an otherwise all-Node repo. +.venv/ +__pycache__/ +*.pyc + # Local bridge configuration and the bb_ credential it holds. config.toml .env diff --git a/README.md b/README.md index 7fdfc13..ed35497 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,16 @@ to you. The agent asks *which* binding; it cannot choose the page, cannot read the field, and cannot collect the value. -browser-use is Playwright-based, so it connects to the bridge unchanged. There -is a test that drives stock `puppeteer-core` and `playwright-core` through the -gate against a real Chromium on every commit. +**browser-use connects too — verified, not assumed.** Current browser-use +releases have their own CDP client rather than sitting on Playwright, so this +took an actual test rather than inheriting Playwright's proof: point +`BrowserProfile(cdp_url=...)` at the URL the bridge prints, and browser-use's +`connect()` skips its usual HTTP `/json/version` discovery whenever the URL +already starts with `ws`, going straight to the WebSocket the same way +`browserWSEndpoint`/`connectOverCDP` do. `examples/browser-use/` drives the +same login-fill proof as `register-login-act.mjs`, but with browser-use as the +agent. There is a test that drives stock `puppeteer-core` and +`playwright-core` through the gate against a real Chromium on every commit. ## How fast diff --git a/packages/browser-bridge/examples/README.md b/packages/browser-bridge/examples/README.md index f273aa7..257482a 100644 --- a/packages/browser-bridge/examples/README.md +++ b/packages/browser-bridge/examples/README.md @@ -17,6 +17,7 @@ node packages/browser-bridge/examples/register-login-act.mjs | `full-flow-capture.mjs` | Adds the two remaining directions: **capture** (while logged in, the bridge generates an API key on the site, reads it, and stores it in the vault — a fill in reverse) and **execute** (the agent runs an intent that uses the captured key in a real request). The key is in none of the agent's output. | | `agent.mjs` | The minimal CDP client the examples use to stand in for a framework. Not a demo on its own. | | `intent-executor.mjs` | A local stand-in for the 1Claw Execution Intents API (`POST /v1/agents/{id}/execute`): a binding says which vaulted secret and how to inject it; the agent passes params, never the secret. Used by `full-flow-capture.mjs`. Not the production path — the hosted Intents API runs inside a TEE with guardrails and audit — but it demonstrates the same property. | +| `browser-use/` | The same login-fill proof as `register-login-act.mjs`, but driven by browser-use (a Python agentic browser framework, not Puppeteer/Playwright-based) instead of a JS client. See `browser-use/README.md` — it's a two-process example (Node bridge + Python agent) with its own setup. | `register-login-act.mjs` prints each step: @@ -56,6 +57,17 @@ The examples still use a small hand-rolled client (`agent.mjs`) because it shows the protocol surface plainly — which methods an agent may call is the whole point of the gate, and a framework hides that behind its own API. +**browser-use connects too, from a different ecosystem entirely.** Its own CDP +client (`cdp_use` — current browser-use releases are not Playwright-based, an +earlier claim in the main README that this section now corrects) skips its +usual `/json/version` HTTP discovery whenever the `cdp_url` you give it already +starts with `ws`, so pointing it straight at the bridge's URL works with no +special-casing on either side — the same shape as `browserWSEndpoint` and +`connectOverCDP`. It also calls `Browser.grantPermissions` on connect, which +Puppeteer and Playwright don't; the gate refuses it (not on the allowlist) and +browser-use handles that refusal without breaking the session. See +`browser-use/` for the same login-fill proof, driven end to end by browser-use. + A framework may also integrate the way `agent.mjs` does, speaking gated CDP directly with the allowlisted methods (`Target.createTarget`, `Target.attachToTarget`, `Page.navigate`, `Page.reload`, `Runtime.evaluate`, diff --git a/packages/browser-bridge/examples/browser-use/README.md b/packages/browser-bridge/examples/browser-use/README.md new file mode 100644 index 0000000..bd3b96a --- /dev/null +++ b/packages/browser-bridge/examples/browser-use/README.md @@ -0,0 +1,80 @@ +# browser-use, end to end + +Proves browser-use (a Python agentic browser framework, not built on Puppeteer +or Playwright) connects to the bridge the same way `puppeteer-core` and +`playwright-core` already do — and that the credential-fill guarantee holds +when browser-use is the one driving the page. + +Two processes, because that is what actually talks to the bridge here: +`bridge_server.mjs` (Node) starts a real bridge and a tiny login site; +`test_login.py` (Python) connects browser-use to it, drives the page, and +requests a fill. + +## Setup + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install browser-use +``` + +## Run + +From the repo root: + +```bash +pnpm install && pnpm build +``` + +Then, from this directory: + +```bash +source .venv/bin/activate +python3 test_login.py +``` + +Expect: + +``` +browser-use connected through the bridge's CDP proxy: OK +agent's target_id (this is what gets windowed during the fill): ... +agent's tab, read live via browser-use's own CDP session: http://127.0.0.1:.../login + +fill result (this, and only this, is what browser-use as the agent would receive): {'status': 'filled', 'bindingId': 'example-login'} +agent's own tab, navigated to /welcome after the fill, reads: 'Welcome, ada@example.com' + +=== RESULT === +stock browser-use connects through the bridge: yes +fill reported status: filled +login actually succeeded (agent's tab sees /welcome): True +password ever visible to browser-use: no +``` + +## What this actually checks + +1. **Connectivity.** `BrowserProfile(cdp_url=bridge.url, is_local=True)` — the + `ws://` URL `startBridge` prints, unchanged. browser-use's own + `connect()` skips its usual `/json/version` HTTP discovery whenever + `cdp_url` already starts with `ws`, so it goes straight to the WebSocket — + the same shape Puppeteer's `browserWSEndpoint` and Playwright's + `connectOverCDP` use, both proven in `framework-connect.test.ts`. No + special-casing was needed on the bridge side for this to work. +2. **The gate still refuses what it should.** browser-use calls + `Browser.grantPermissions` on connect, which Puppeteer and Playwright + don't — the bridge correctly refuses it (`method_not_allowed`, not on the + allowlist) and browser-use handles the refusal without breaking the + session. +3. **The actual product claim.** browser-use's own tab never touches the + password field. The fill happens on a throwaway target the bridge creates + itself. Afterward, browser-use's *same* tab — having typed nothing — loads + a cookie-gated page and gets the authenticated view, proving the session + really landed in the shared browser context. The password never appears in + the fill result or in anything browser-use reads back. + +One browser-use quirk surfaced along the way, unrelated to the bridge: right +after `navigate_to()`, browser-use's own `get_tabs()`/`tab.url` cache can +still say `about:blank` even though the page really navigated (confirmed by +reading `location.href` directly through browser-use's own CDP session +instead of trusting its tab cache). Worth knowing if you build on this +further; not something this example works around, since it doesn't affect the +result. diff --git a/packages/browser-bridge/examples/browser-use/bridge_server.mjs b/packages/browser-bridge/examples/browser-use/bridge_server.mjs new file mode 100644 index 0000000..967e1e7 --- /dev/null +++ b/packages/browser-bridge/examples/browser-use/bridge_server.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// Copyright (C) 2026 1Claw +// SPDX-License-Identifier: Apache-2.0 +// +// Companion process for test_login.py. Starts a real bridge + a tiny login +// site (same shape as examples/demo.mjs and examples/register-login-act.mjs), +// prints the bridge URL and site origin for the Python side to connect to via +// browser-use, then takes line-delimited commands on stdin: +// +// FILL -> request a fill for that agent-owned target, +// retrying an escalating generation guess since this +// harness has no access to the bridge's internal +// per-target counter (a real MCP client wouldn't need +// to guess -- it reads the live generation itself, +// the same way mcp-tools.ts does for a real caller). +// STOP -> close the bridge and site, exit. +// +// Two processes, one Node and one Python, because that is what actually +// connects to the bridge in this example: browser-use is a Python package, +// and there is nothing to gain from reimplementing its CDP client in JS just +// to keep this example single-language. + +import { createServer } from "node:http"; +import { existsSync } from "node:fs"; +import { createInterface } from "node:readline"; +import { startBridge, MockVaultDriver } from "../../dist/index.js"; + +const PASSWORD = "correct-horse-battery-staple-bu"; +const CHROME = + process.env.ONECLAW_BRIDGE_CHROME ?? + { darwin: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + linux: "/usr/bin/google-chrome" }[process.platform]; +if (!CHROME || !existsSync(CHROME)) { + console.error(`No Chromium at ${CHROME ?? "(unknown)"}. Set ONECLAW_BRIDGE_CHROME.`); + process.exit(2); +} + +// A tiny app: the form posts to /session, a correct password sets a cookie +// and redirects to /welcome, and /welcome only renders the authenticated view +// when that cookie is present. That last part is the actual test: the fill +// happens on a throwaway target the bridge creates itself, never the agent's +// own tab, so the only way to prove the session really landed in the agent's +// shared browser context is to have the agent independently load a page that +// checks for it -- a page that renders the same text unconditionally would +// prove nothing. +const site = createServer((req, res) => { + const url = new URL(req.url, "http://x"); + const signedIn = (req.headers.cookie ?? "").includes("session=ada"); + if (url.pathname === "/session" && req.method === "POST") { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + const p = new URLSearchParams(body); + const ok = p.get("username") === "ada@example.com" && p.get("password") === PASSWORD; + res.writeHead(302, { location: ok ? "/welcome" : "/login?bad=1", + ...(ok ? { "set-cookie": "session=ada; Path=/" } : {}) }).end(); + }); + return; + } + if (url.pathname === "/welcome") { + res.writeHead(signedIn ? 200 : 401, { "content-type": "text/html" }); + return void res.end(signedIn + ? `Welcome

Welcome, ada@example.com

` + : `Welcome

not signed in

`); + } + res.writeHead(200, { "content-type": "text/html" }); + res.end(`Example login +
+ + + +
`); +}); +await new Promise((r) => site.listen(0, "127.0.0.1", r)); +const origin = `http://127.0.0.1:${site.address().port}`; + +const backend = new MockVaultDriver({ + bindings: [{ id: "example-login", secret: PASSWORD, loginUrl: `${origin}/login`, allowedHosts: ["127.0.0.1"] }], +}); + +const bridge = await startBridge({ + executablePath: CHROME, + backend, + host: "127.0.0.1", + args: ["--headless=new", ...(process.env.CI && process.platform === "linux" + ? ["--no-sandbox", "--disable-dev-shm-usage"] : [])], +}); + +// Machine-readable, one line each, so the Python side doesn't have to guess +// which console line is which. +console.log(`BRIDGE_URL=${bridge.url}`); +console.log(`SITE_URL=${origin}/login`); +console.log(`READY`); + +const rl = createInterface({ input: process.stdin }); +rl.on("line", async (line) => { + const [cmd, ...rest] = line.trim().split(/\s+/); + if (cmd === "FILL") { + const targetId = rest[0]; + let result; + // See the file header: no access to the bridge's internal generation + // counter from outside, so this tries the value a single real navigation + // should have produced, then escalates on a stale-generation abort. + for (let gen = 0; gen <= 3; gen++) { + result = await bridge.callTool( + "request_fill", + { binding_id: "example-login", target_id: targetId, selector: "#password" }, + () => ({ + tabOrigin: origin, frameOrigin: origin, formActionOrigin: origin, + frameId: targetId, generation: gen, + }), + ); + if (!(result && result.status === "aborted" && result.reason === "generation_stale")) break; + } + console.log(`FILL_RESULT=${JSON.stringify(result)}`); + } else if (cmd === "STOP") { + await bridge.close(); + await new Promise((r) => site.close(r)); + process.exit(0); + } +}); diff --git a/packages/browser-bridge/examples/browser-use/test_login.py b/packages/browser-bridge/examples/browser-use/test_login.py new file mode 100644 index 0000000..bc0cf23 --- /dev/null +++ b/packages/browser-bridge/examples/browser-use/test_login.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +Does browser-use (github.com/browser-use/browser-use), a real third-party +agentic browser framework, work against browser-bridge's CDP proxy the same +way Puppeteer and Playwright already do -- and does the credential-fill +guarantee still hold when browser-use is the one driving the page? + +Starts a companion Node process (bridge_server.mjs) that runs a real bridge + +a tiny login site, connects browser-use directly to the bridge's ws:// URL, +drives the page normally, then asks the bridge (via the Node side) to fill +the password. + +The ws:// URL matters: browser-use's BrowserSession.connect() normally treats +`cdp_url` as an http(s) endpoint and fetches /json/version from it to find the +real WebSocket URL -- a discovery step browser-bridge does not serve (plain +HTTP gets a flat 404; the only surface is the WebSocket upgrade, by design). +But browser-use's own connect() checks `if not self.cdp_url.startswith('ws')` +before doing that fetch, so handing it the ws:// URL the bridge already prints +skips discovery entirely -- no special-casing needed on either side. This is +the same shape as Puppeteer's `browserWSEndpoint` and Playwright's +`connectOverCDP`, both proven in framework-connect.test.ts; this file is the +same proof for browser-use. + +Success is: the fill reports "filled", browser-use's own tab (never having +typed anything) is authenticated afterward, and PASSWORD never appears in +anything browser-use itself received -- the fill result or the page it reads. + +Setup: + python3 -m venv .venv && source .venv/bin/activate + pip install browser-use + +Run (from this directory, after `pnpm build` at the repo root): + python3 test_login.py +""" +import asyncio +import json +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +PASSWORD = "correct-horse-battery-staple-bu" + + +async def main(): + node = subprocess.Popen( + ["node", os.path.join(HERE, "bridge_server.mjs")], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, + ) + + bridge_url = site_url = None + for _ in range(50): + line = node.stdout.readline() + if not line: + raise RuntimeError("bridge server exited before READY") + line = line.strip() + print(f"[node] {line}") + if line.startswith("BRIDGE_URL="): + bridge_url = line.split("=", 1)[1] + elif line.startswith("SITE_URL="): + site_url = line.split("=", 1)[1] + elif line == "READY": + break + if not (bridge_url and site_url): + raise RuntimeError("did not get BRIDGE_URL/SITE_URL from bridge_server.mjs") + + print(f"\nbridge: {bridge_url}") + print(f"site: {site_url}\n") + + from browser_use.browser.profile import BrowserProfile + from browser_use.browser.session import BrowserSession + + session = BrowserSession(browser_profile=BrowserProfile(cdp_url=bridge_url, is_local=True)) + + leaked = False + try: + # 1) Stock-framework connectivity: same claim framework-connect.test.ts + # proves for puppeteer-core/playwright-core, now for browser-use's + # own CDP client (cdp_use, not Playwright, despite older browser-use + # releases having been Playwright-based). + await session.start() + print("browser-use connected through the bridge's CDP proxy: OK") + + await session.navigate_to(site_url) + tabs = await session.get_tabs() + assert tabs, "browser-use opened no tab" + target_id = tabs[-1].target_id + print(f"agent's target_id (this is what gets windowed during the fill): {target_id}") + + # browser-use's own get_tabs()/tab.url cache can stay stale at + # "about:blank" right after navigate_to() even though the page really + # did navigate -- a browser-use-internal quirk, not a bridge one. + # Reading location.href directly through browser-use's own CDP + # session (rather than trusting its tab cache) confirms the real state. + cdp_session = await session.get_or_create_cdp_session(target_id=target_id, focus=False) + href = await cdp_session.cdp_client.send.Runtime.evaluate( + params={"expression": "location.href", "returnByValue": True}, session_id=cdp_session.session_id, + ) + print(f"agent's tab, read live via browser-use's own CDP session: {(href.get('result') or {}).get('value')}\n") + + # 2) The actual product claim: request a fill on browser-use's own + # tab, without browser-use itself ever touching the password field. + node.stdin.write(f"FILL {target_id}\n") + node.stdin.flush() + result_line = "" + for _ in range(50): + l = node.stdout.readline().strip() + print(f"[node] {l}") + if l.startswith("FILL_RESULT="): + result_line = l.split("=", 1)[1] + break + result = json.loads(result_line) if result_line else {} + print(f"\nfill result (this, and only this, is what browser-use as the agent would receive): {result}") + if PASSWORD in json.dumps(result): + leaked = True + print("PASSWORD FOUND IN FILL RESULT -- BUG") + + # 3) Prove the login actually took. The fill happens on a throwaway + # target the bridge creates itself, never the agent's own tab, so + # seeing this requires the agent to independently load a page that + # reflects the resulting session -- exactly what a real agent would + # do next, and exactly what register-login-act.mjs's /account + # endpoint proves for the Puppeteer examples. bridge_server.mjs's + # /welcome only renders the authenticated view when the session + # cookie is present, so this is a genuine check that the cookie + # landed in the shared browser context, not a page that renders + # the same text regardless. + welcome_url = site_url.replace("/login", "/welcome") + await cdp_session.cdp_client.send.Page.navigate( + params={"url": welcome_url}, session_id=cdp_session.session_id, + ) + await asyncio.sleep(0.5) + page_text = await cdp_session.cdp_client.send.Runtime.evaluate( + params={"expression": "document.body.innerText", "returnByValue": True}, + session_id=cdp_session.session_id, + ) + body_text = (page_text.get("result") or {}).get("value", "") + print(f"agent's own tab, navigated to /welcome after the fill, reads: {body_text!r}") + if PASSWORD in body_text: + leaked = True + print("PASSWORD FOUND IN THE PAGE BROWSER-USE CAN READ -- BUG") + logged_in = "Welcome" in body_text and "ada@example.com" in body_text + + print("\n=== RESULT ===") + print("stock browser-use connects through the bridge: yes") + print(f"fill reported status: {result.get('status')}") + print(f"login actually succeeded (agent's tab sees /welcome): {logged_in}") + print(f"password ever visible to browser-use: {'YES -- BUG' if leaked else 'no'}") + sys.exit(1 if (leaked or result.get("status") != "filled" or not logged_in) else 0) + finally: + try: + await session.stop() + except Exception as e: + print(f"(session.stop() error, non-fatal for this example: {e})") + node.stdin.write("STOP\n") + node.stdin.flush() + try: + node.wait(timeout=10) + except Exception: + node.kill() + + +if __name__ == "__main__": + asyncio.run(main())