Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions packages/browser-bridge/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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`,
Expand Down
80 changes: 80 additions & 0 deletions packages/browser-bridge/examples/browser-use/README.md
Original file line number Diff line number Diff line change
@@ -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.
121 changes: 121 additions & 0 deletions packages/browser-bridge/examples/browser-use/bridge_server.mjs
Original file line number Diff line number Diff line change
@@ -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 <target_id> -> 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
? `<!doctype html><title>Welcome</title><body><h1 id="ok">Welcome, ada@example.com</h1></body>`
: `<!doctype html><title>Welcome</title><body><h1 id="anon">not signed in</h1></body>`);
}
res.writeHead(200, { "content-type": "text/html" });
res.end(`<!doctype html><title>Example login</title><body>
<form action="/session" method="post">
<input id="username" name="username" value="ada@example.com">
<input id="password" name="password" type="password">
<button id="go" type="submit">Log in</button>
</form></body>`);
});
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);
}
});
Loading
Loading