From bacb9d130c2fe58ab83ac0e7807d79f1eab5ec51 Mon Sep 17 00:00:00 2001
From: RedBotster <258521541+redbotster@users.noreply.github.com>
Date: Wed, 9 Sep 2026 07:01:00 -0700
Subject: [PATCH] feat: step-by-step trace events + debug mode with screenshots
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds TraceEvent, a new op-only channel (separate from AuditEvent, which
crosses to a driver's central log, and separate from onError, which stays
the "why did this fail" channel) emitted via an injectable onStep callback
on FillEngine, RegistrationEngine and CaptureEngine.
Each engine traces its real steps (navigate, type_username, type_secret,
submit, settle, etc.) and screenshots two coarse checkpoints — never the
step where a secret is on the page. CaptureEngine is stricter still: it
screenshots only before the generated value can exist.
1claw-browser-bridge --debug
(or ONECLAW_BRIDGE_DEBUG) is the
reference consumer: one JSONL trace plus numbered PNGs per run, in
bin/debug-trace.mjs. This is also the shape a future 1Claw dashboard would
consume for run playback.
trace.test.ts asserts the property that matters most here: no detail string
or screenshot from any of the three engines ever contains the secret, the
generated capture value, or a registration extraFields value.
276 tests passing (was 267), typecheck and build clean.
---
README.md | 35 +++
.../bin/1claw-browser-bridge.mjs | 9 +
packages/browser-bridge/bin/debug-trace.mjs | 43 +++
packages/browser-bridge/src/bridge.ts | 27 +-
packages/browser-bridge/src/capture-engine.ts | 62 ++++
packages/browser-bridge/src/fill-engine.ts | 87 +++++-
packages/browser-bridge/src/index.ts | 3 +
.../browser-bridge/src/registration-engine.ts | 68 ++++-
packages/browser-bridge/src/trace.test.ts | 274 ++++++++++++++++++
packages/browser-bridge/src/trace.ts | 57 ++++
10 files changed, 658 insertions(+), 7 deletions(-)
create mode 100644 packages/browser-bridge/bin/debug-trace.mjs
create mode 100644 packages/browser-bridge/src/trace.test.ts
create mode 100644 packages/browser-bridge/src/trace.ts
diff --git a/README.md b/README.md
index 399a064..7fdfc13 100644
--- a/README.md
+++ b/README.md
@@ -273,6 +273,40 @@ 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.
+### Debug mode: step-by-step trace and screenshots
+
+A fill, registration or capture that does not work the way you expect is hard
+to debug from the outside — the only thing that crosses the MCP boundary is a
+status. Point the bridge at a directory and it writes a record of what it
+actually did:
+
+```bash
+1claw-browser-bridge --vault ~/.1claw/vault.json --chrome /path/to/chrome \
+ --debug ~/.1claw/debug
+# or: export ONECLAW_BRIDGE_DEBUG=~/.1claw/debug
+```
+
+Each run gets its own timestamped subdirectory: `trace.jsonl`, one line per
+step (`navigate`, `type_username`, `type_secret`, `submit`, `settle`, and so
+on — see `TraceEvent` in `src/trace.ts`), plus a numbered PNG for every step
+the engine screenshots.
+
+The screenshots are the point: `navigate` and `settle` capture what the page
+actually looked like, which is usually the whole answer when a selector never
+matched or a success check never fired. `type_secret` is traced like every
+other step but never screenshotted, on principle — the password is on the
+page at exactly that moment, so this step is recorded, never pictured. A
+capture is stricter still: it screenshots only `navigate`, before the secret
+exists, and nothing from `read_value` onward.
+
+This is also the shape a future 1Claw dashboard would consume for
+run playback: an ordered trace of named steps, each with a timestamp and a
+handful of checkpoint screenshots, is enough to reconstruct a scrubbable
+timeline of what an agent's fill actually did — without ever recording full
+video, and without the recording itself becoming something that could leak a
+credential. `onStep` is the extension point; `--debug` is the file-based
+reference consumer of it.
+
### Creating an account, without the agent knowing the password
The bridge can sign up for a site, generate the password itself, and store it —
@@ -605,6 +639,7 @@ rejecting only cross-site `Origin`s.
`local.test.ts`, 14 tests each), and `saas` is covered end to end against
production rather than by unit tests, since it needs a real vault to answer.
- **v0.2** — governed credential registration **(done, local backend)**, including extra required fields beyond username/password — DOB, address, phone — typed the same way the username is, via repeatable `--field =` **(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. For HITL the client half is already there — `authorizeFill` may answer `awaiting_approval` and the bridge surfaces `get_approval_status` when a backend declares the `hitl` capability — but all three drivers report `hitl: false`, so nothing produces that answer yet. TOTP has no code at all
+- **v0.2** — **debug mode (done, all three engines).** `onStep` on `startBridge` and every engine emits a `TraceEvent` per step — never a substitute for `onError`, which stays the "why did this fail" channel, but a step-by-step record of what actually happened, with a screenshot at the coarse checkpoints (never at the step where a password is on the page). `1claw-browser-bridge --debug ` (or `ONECLAW_BRIDGE_DEBUG`) is the reference consumer: one JSONL trace plus numbered PNGs per run. Doubles as the shape a future dashboard would consume for playback — see `trace.ts` and the README's "Debug mode" section.
- **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
diff --git a/packages/browser-bridge/bin/1claw-browser-bridge.mjs b/packages/browser-bridge/bin/1claw-browser-bridge.mjs
index 064bd3b..4e64cc4 100755
--- a/packages/browser-bridge/bin/1claw-browser-bridge.mjs
+++ b/packages/browser-bridge/bin/1claw-browser-bridge.mjs
@@ -22,6 +22,7 @@
* ONECLAW_AGENT_TOKEN the agent's JWT — asks whether a fill is allowed
* ONECLAW_AGENT_ID the agent fills are requested for
* ONECLAW_BRIDGE_PORT loopback port (default: ephemeral)
+ * ONECLAW_BRIDGE_DEBUG directory to write step-by-step trace + screenshots to
*
* Three credentials, because the vault requires three distinct things: which
* machine (bb_), which person (ONECLAW_TOKEN), which agent (ONECLAW_AGENT_TOKEN).
@@ -32,6 +33,7 @@ import { startBridge, SaasDriver, LocalVaultDriver } from "../dist/index.js";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
+import { makeDebugStep } from "./debug-trace.mjs";
const argv = process.argv.slice(2);
const arg = (name) => {
@@ -39,6 +41,11 @@ const arg = (name) => {
return i > -1 ? argv[i + 1] : undefined;
};
+// Same directory-of-JSONL-plus-PNGs debug sink for either backend below —
+// never a credential's own concern, so it is built once, up front.
+const debugDir = arg("debug") || process.env.ONECLAW_BRIDGE_DEBUG;
+const onStep = debugDir ? makeDebugStep(debugDir) : undefined;
+
const version = JSON.parse(
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"),
).version;
@@ -86,6 +93,7 @@ if (vaultPath) {
backend: local,
host: "127.0.0.1",
...(process.env.ONECLAW_BRIDGE_PORT ? { port: Number(process.env.ONECLAW_BRIDGE_PORT) } : {}),
+ ...(onStep ? { onStep } : {}),
});
console.log(localBridge.url);
console.error(`browser bridge ${version} listening on ${localBridge.host}:${localBridge.port}`);
@@ -138,6 +146,7 @@ const bridge = await startBridge({
backend,
host: "127.0.0.1",
...(process.env.ONECLAW_BRIDGE_PORT ? { port: Number(process.env.ONECLAW_BRIDGE_PORT) } : {}),
+ ...(onStep ? { onStep } : {}),
});
// The URL carries the session token, so it is the one secret this process
diff --git a/packages/browser-bridge/bin/debug-trace.mjs b/packages/browser-bridge/bin/debug-trace.mjs
new file mode 100644
index 0000000..c46c55b
--- /dev/null
+++ b/packages/browser-bridge/bin/debug-trace.mjs
@@ -0,0 +1,43 @@
+// Copyright (C) 2026 1Claw
+// SPDX-License-Identifier: Apache-2.0
+
+import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+
+/**
+ * Build an `onStep` handler for `--debug`/`ONECLAW_BRIDGE_DEBUG` that writes
+ * `TraceEvent`s to disk: one JSON line per step in `trace.jsonl`, and a
+ * numbered PNG file for every step that carries a screenshot (referenced from
+ * the JSON by filename — inlining base64 PNG bytes into a JSON line would
+ * make the log file unreadable and enormous).
+ *
+ * Runs one directory per bridge process, named by start time, so repeated
+ * runs against the same `--debug` root never collide or overwrite each other.
+ *
+ * Never throws. This sits on the hot path of every fill, registration and
+ * capture — a full disk or a permission error while writing debug output must
+ * not fail the operation it is only observing.
+ */
+export function makeDebugStep(baseDir) {
+ const dir = join(baseDir, new Date().toISOString().replace(/[:.]/g, "-"));
+ mkdirSync(dir, { recursive: true });
+ const tracePath = join(dir, "trace.jsonl");
+ let seq = 0;
+
+ console.error(`debug trace: ${dir}`);
+
+ return (event) => {
+ try {
+ seq += 1;
+ const { screenshotPng, ...rest } = event;
+ let screenshot;
+ if (screenshotPng) {
+ screenshot = `${String(seq).padStart(4, "0")}-${event.op}-${event.step}.png`;
+ writeFileSync(join(dir, screenshot), screenshotPng);
+ }
+ appendFileSync(tracePath, `${JSON.stringify({ ...rest, ...(screenshot ? { screenshot } : {}) })}\n`);
+ } catch {
+ // Best-effort. A broken debug sink must not break the fill it is tracing.
+ }
+ };
+}
diff --git a/packages/browser-bridge/src/bridge.ts b/packages/browser-bridge/src/bridge.ts
index 26885c6..e5fda0a 100644
--- a/packages/browser-bridge/src/bridge.ts
+++ b/packages/browser-bridge/src/bridge.ts
@@ -13,6 +13,7 @@ import { buildToolset, dispatchTool, type ToolDefinition, type ToolResult } from
import { PipeCdpTransport } from "./pipe-transport.js";
import { CdpProxyServer } from "./proxy-server.js";
import type { SecretHandle } from "./secret-handle.js";
+import type { TraceEvent } from "./trace.js";
import type { VaultBackend } from "./vault-backend.js";
/**
@@ -68,6 +69,15 @@ export type BridgeOptions = {
readonly args?: readonly string[];
/** Injectable so the whole bridge can be driven by a fake in tests. */
readonly transport?: CdpTransport;
+ /**
+ * A step-by-step record of every fill, registration and capture, for
+ * debugging and future playback — never the agent's, only the operator's.
+ * See `trace.ts` for the full contract on what a `TraceEvent` may and may
+ * not carry. Unset by default: this is opt-in, since a screenshot at every
+ * navigate/settle step is not free, and most operators most of the time
+ * want none of it.
+ */
+ readonly onStep?: (event: TraceEvent) => void;
};
export type BridgeHandle = {
@@ -123,6 +133,7 @@ class RegistrationAdapter {
transport: CdpTransport,
gate: CdpGate,
onError: (e: unknown) => void,
+ onStep: ((e: TraceEvent) => void) | undefined,
) {
this.#backend = backend;
const b = backend as unknown as {
@@ -151,6 +162,7 @@ class RegistrationAdapter {
await b.cancelRegistration?.(id);
},
onError,
+ ...(onStep !== undefined ? { onStep } : {}),
});
}
@@ -195,6 +207,7 @@ class CaptureAdapter {
gate: CdpGate,
browserContextOf: (targetId: string) => string | undefined,
onError: (e: unknown) => void,
+ onStep: ((e: TraceEvent) => void) | undefined,
) {
this.#backend = backend;
const b = backend as unknown as {
@@ -215,6 +228,7 @@ class CaptureAdapter {
await b.cancelCapture?.(id);
},
onError,
+ ...(onStep !== undefined ? { onStep } : {}),
});
}
@@ -339,15 +353,19 @@ export async function startBridge(opts: BridgeOptions): Promise {
// Only constructed when the backend says it can register.
const registrations = backend.capabilities().registration
- ? new RegistrationAdapter(backend, transport, gate, (e) =>
- console.error("[browser-bridge] registration failed:", e),
+ ? new RegistrationAdapter(
+ backend, transport, gate,
+ (e) => console.error("[browser-bridge] registration failed:", e),
+ opts.onStep,
)
: undefined;
// Only constructed when the backend says it can capture.
const captures = backend.capabilities().capture
- ? new CaptureAdapter(backend, transport, gate, browserContextOf, (e) =>
- console.error("[browser-bridge] capture failed:", e),
+ ? new CaptureAdapter(
+ backend, transport, gate, browserContextOf,
+ (e) => console.error("[browser-bridge] capture failed:", e),
+ opts.onStep,
)
: undefined;
@@ -365,6 +383,7 @@ export async function startBridge(opts: BridgeOptions): Promise {
// stderr, not the tool result. The operator needs the reason; the agent
// must not have it.
onError: (e) => console.error("[browser-bridge] fill failed:", e),
+ ...(opts.onStep !== undefined ? { onStep: opts.onStep } : {}),
});
// A token in the URL path, minted per run. The socket is loopback-only and
diff --git a/packages/browser-bridge/src/capture-engine.ts b/packages/browser-bridge/src/capture-engine.ts
index 7d36f38..7f8ae76 100644
--- a/packages/browser-bridge/src/capture-engine.ts
+++ b/packages/browser-bridge/src/capture-engine.ts
@@ -5,6 +5,7 @@ import type { CaptureGrant, CaptureOutcome } from "@1claw/browser-bridge-protoco
import type { CdpGate } from "./cdp-policy.js";
import type { CdpTransport } from "./cdp-transport.js";
import { SecretHandle } from "./secret-handle.js";
+import type { TraceEvent } from "./trace.js";
export type CaptureEngineDeps = {
readonly transport: CdpTransport;
@@ -24,11 +25,30 @@ export type CaptureEngineDeps = {
readonly onError?: (error: unknown) => void;
/** How long to wait for the value to appear before giving up. */
readonly settleMs?: number;
+ /**
+ * Step-by-step record of the capture, for debugging and future playback —
+ * see `fill-engine.ts`'s `onStep` doc for the shared contract. Stricter
+ * here than a fill or registration: the whole point of a capture is a
+ * secret displayed *on the page*, so unlike those two, this never takes a
+ * screenshot once generation or reading has started — only before, at
+ * `navigate`. A `read_value` step's `ok` says whether something was
+ * found, never what.
+ */
+ readonly onStep?: (event: TraceEvent) => void;
};
const DEFAULT_SETTLE_MS = 15_000;
const POLL_MS = 250;
+/** Just the host — see the identical helper and its doc in fill-engine.ts. */
+function safeHost(url: string): string {
+ try {
+ return new URL(url).host;
+ } catch {
+ return "(unparseable url)";
+ }
+}
+
/**
* Read a secret a site generates, and store it — without the agent seeing it.
*
@@ -91,13 +111,19 @@ export class CaptureEngine {
await transport.send({ sessionId, method: "Runtime.enable" });
// The policy's URL, never the agent's.
await transport.send({ sessionId, method: "Page.navigate", params: { url: grant.captureUrl } });
+ // The only screenshot this engine ever takes -- before generation or
+ // reading starts. See the class doc: after this point the page may be
+ // showing the secret itself.
+ this.#trace(grant.captureId, "navigate", true, safeHost(grant.captureUrl), await this.#screenshot(sessionId));
// 3. Generate, if there is a control to click, then read.
if (grant.source.generateSelector) {
await this.#waitFor(sessionId, grant.source.generateSelector, settleMs);
await this.#click(sessionId, grant.source.generateSelector);
+ this.#trace(grant.captureId, "generate", true, grant.source.generateSelector);
}
const value = await this.#readValue(sessionId, grant.source, settleMs);
+ this.#trace(grant.captureId, "read_value", value !== undefined && value !== "", grant.source.valueSelector);
if (value === undefined || value === "") {
await cancel(grant.captureId);
return { status: "rejected", reason: "no_value_found" };
@@ -110,11 +136,13 @@ export class CaptureEngine {
// dispose an inert one.
handle = undefined;
committed = true;
+ this.#trace(grant.captureId, "committed", true, entryId);
return { status: "captured", entryId };
} catch (err) {
// Not `err.message`: this reaches the agent, and the text comes from the
// transport and the backend.
this.#deps.onError?.(err);
+ this.#trace(grant.captureId, "error", false, err instanceof Error ? err.message : "unknown error");
return { status: "error", message: "the capture did not complete" };
} finally {
handle?.dispose();
@@ -127,6 +155,40 @@ export class CaptureEngine {
}
// 5. Always. A stuck window locks the agent out of its own browser.
this.#deps.gate.closeFillWindow(agentTargetId);
+ this.#trace(grant.captureId, "closed", true);
+ }
+ }
+
+ /** Same contract as fill-engine.ts's identical helper. */
+ #trace(captureId: string, step: string, ok: boolean, detail?: string, screenshotPng?: Uint8Array): void {
+ if (!this.#deps.onStep) return;
+ try {
+ this.#deps.onStep({
+ op: "capture",
+ id: captureId,
+ step,
+ at: Date.now(),
+ ok,
+ ...(detail !== undefined ? { detail } : {}),
+ ...(screenshotPng !== undefined ? { screenshotPng } : {}),
+ });
+ } catch {
+ // The operator's own handler threw; not this engine's problem.
+ }
+ }
+
+ /** Same contract as fill-engine.ts's identical helper. */
+ async #screenshot(sessionId: string): Promise {
+ try {
+ const reply = (await this.#deps.transport.send({
+ sessionId,
+ method: "Page.captureScreenshot",
+ params: { format: "png" },
+ })) as { result?: { data?: unknown } };
+ const data = reply.result?.data;
+ return typeof data === "string" ? Buffer.from(data, "base64") : undefined;
+ } catch {
+ return undefined;
}
}
diff --git a/packages/browser-bridge/src/fill-engine.ts b/packages/browser-bridge/src/fill-engine.ts
index 1f2bc3f..6e45827 100644
--- a/packages/browser-bridge/src/fill-engine.ts
+++ b/packages/browser-bridge/src/fill-engine.ts
@@ -5,6 +5,7 @@ import type { Grant } from "@1claw/browser-bridge-protocol";
import type { CdpGate } from "./cdp-policy.js";
import type { CdpMessage, CdpTransport } from "./cdp-transport.js";
import type { SecretHandle } from "./secret-handle.js";
+import type { TraceEvent } from "./trace.js";
import type { VaultBackend } from "./vault-backend.js";
/**
@@ -31,6 +32,20 @@ import type { VaultBackend } from "./vault-backend.js";
* where a half-open window would otherwise persist.
*/
+/**
+ * Just the host, for a trace step's `detail`. The full `loginUrl` is
+ * operator-authored policy, not a secret, but a trace event is meant to be
+ * read at a glance and a bare host says everything a debugging session needs
+ * from this particular step.
+ */
+function safeHost(url: string): string {
+ try {
+ return new URL(url).host;
+ } catch {
+ return "(unparseable url)";
+ }
+}
+
export type FillOutcome =
| { readonly status: "filled" }
| { readonly status: "aborted"; readonly reason: "generation_stale" | "navigated" }
@@ -62,6 +77,15 @@ export type FillEngineDeps = {
* and the agent must not have it, so they cannot be the same string.
*/
readonly onError?: (error: unknown) => void;
+ /**
+ * A step-by-step record of the fill, for debugging and future playback —
+ * never a substitute for `onError`, which stays the "why did this fail"
+ * channel. `onStep` never carries a secret: `detail` is a selector or a
+ * plain reason, and the two screenshots (right after navigating, right
+ * before the page closes) show a password field masked by the browser
+ * itself, never in the clear. See `trace.ts` for the full contract.
+ */
+ readonly onStep?: (event: TraceEvent) => void;
};
export class FillEngine {
@@ -80,6 +104,7 @@ export class FillEngine {
*/
async fill(targetId: string, grant: Grant, selector: string): Promise {
const { backend, transport, gate, currentGeneration } = this.#deps;
+ const bindingId = grant.bindingId;
// 1. Block the agent before the secret exists anywhere in this process.
gate.openFillWindow(targetId);
@@ -115,10 +140,12 @@ export class FillEngine {
// 4. The binding's URL, not the agent's.
await this.#send({ sessionId, method: "Page.navigate", params: { url: grant.loginUrl } });
const loginOrigin = await this.#currentUrl(sessionId);
+ this.#trace(bindingId, "navigate", true, safeHost(grant.loginUrl), await this.#screenshot(sessionId));
// 5. Navigation bumps the generation, so this catches both a page that
// moved on its own and one the agent moved underneath us.
if (currentGeneration(targetId) !== grant.generation) {
+ this.#trace(bindingId, "generation_check", false, "stale before typing began");
return { status: "aborted", reason: "generation_stale" };
}
@@ -127,17 +154,22 @@ export class FillEngine {
// secret, so it is typed plainly — but by the bridge in this windowed
// page, so the agent still never scripts the login.
if (grant.usernameSelector && grant.username) {
- if (!(await this.#typeInto(sessionId, grant.usernameSelector, grant.username))) {
+ const typed = await this.#typeInto(sessionId, grant.usernameSelector, grant.username);
+ this.#trace(bindingId, "type_username", typed, grant.usernameSelector);
+ if (!typed) {
return { status: "error", message: "the username field never appeared" };
}
if (currentGeneration(targetId) !== grant.generation) {
+ this.#trace(bindingId, "generation_check", false, "stale after typing the username");
return { status: "aborted", reason: "navigated" };
}
}
// Wait for the field. Page.navigate resolves before the document exists,
// so without this the focus below runs against an empty page.
- if (!(await this.#waitForSelector(sessionId, selector, this.#deps.readyTimeoutMs ?? 10_000))) {
+ const fieldReady = await this.#waitForSelector(sessionId, selector, this.#deps.readyTimeoutMs ?? 10_000);
+ this.#trace(bindingId, "wait_for_field", fieldReady, selector);
+ if (!fieldReady) {
return { status: "error", message: "the field never appeared" };
}
@@ -157,24 +189,30 @@ export class FillEngine {
`(() => { const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return false; el.focus(); return document.activeElement === el; })()`,
);
+ this.#trace(bindingId, "focus", focused === true, selector);
if (focused !== true) {
return { status: "error", message: "could not focus the field" };
}
// Re-check after every await that could have yielded to a navigation.
if (currentGeneration(targetId) !== grant.generation) {
+ this.#trace(bindingId, "generation_check", false, "stale after focusing the field");
return { status: "aborted", reason: "navigated" };
}
// 6. Borrow, type, and let `use` zero the buffer even if this throws.
const text = handle.use((bytes) => new TextDecoder().decode(bytes));
await this.#send({ sessionId, method: "Input.insertText", params: { text } });
+ // No screenshot here, on principle: this step exists for exactly one
+ // reason, the secret is on the page. Trace it, never picture it.
+ this.#trace(bindingId, "type_secret", true, selector);
// 7. Submit.
//
// Without this the ceremony typed a password into a throwaway page and
// closed it, so nothing ever logged in — and it reported "filled".
await this.#submit(sessionId, selector, grant.submitSelector);
+ this.#trace(bindingId, "submit", true, grant.submitSelector ?? "(enter key)");
// 8. Let the submission land before `finally` closes this page.
//
@@ -185,6 +223,7 @@ export class FillEngine {
// error — so this waits for completion, not for success. Whether the
// credentials were right is the agent's to discover in its own tab.
await this.#waitForNavigation(sessionId, loginOrigin, this.#deps.submitTimeoutMs ?? 10_000);
+ this.#trace(bindingId, "settle", true, await this.#currentUrl(sessionId), await this.#screenshot(sessionId));
return { status: "filled" };
} catch (e) {
@@ -194,6 +233,7 @@ export class FillEngine {
// reading, would put that text in front of the caller this package exists
// to keep it away from. The detail goes to the operator instead.
this.#deps.onError?.(e);
+ this.#trace(bindingId, "error", false, e instanceof Error ? e.message : "unknown error");
return { status: "error", message: "the fill did not complete" };
} finally {
// A handle that was consumed but never typed — because the generation
@@ -210,6 +250,7 @@ export class FillEngine {
}
// 7. Always. A stuck window locks the agent out of its own browser.
gate.closeFillWindow(targetId);
+ this.#trace(bindingId, "closed", true);
}
}
@@ -313,6 +354,48 @@ export class FillEngine {
);
}
+ /**
+ * Emit one trace step. Never throws — a broken `onStep` handler in the
+ * caller's own code must not be able to abort a fill that was otherwise
+ * going fine, which is exactly the kind of failure a debug feature must
+ * never cause.
+ */
+ #trace(bindingId: string, step: string, ok: boolean, detail?: string, screenshotPng?: Uint8Array): void {
+ if (!this.#deps.onStep) return;
+ try {
+ this.#deps.onStep({
+ op: "fill",
+ id: bindingId,
+ step,
+ at: Date.now(),
+ ok,
+ ...(detail !== undefined ? { detail } : {}),
+ ...(screenshotPng !== undefined ? { screenshotPng } : {}),
+ });
+ } catch {
+ // The operator's own handler threw. Not this engine's problem to solve
+ // or to let derail a fill that was otherwise proceeding correctly.
+ }
+ }
+
+ /**
+ * `Page.captureScreenshot` is already on the CDP allowlist (it has to be,
+ * for the agent's own use), so this costs nothing new to the security
+ * model — it is the bridge itself calling an already-permitted method on
+ * its own throwaway page, the same as every other bridge-originated
+ * command in this file. Never throws: a screenshot that fails to capture
+ * is a missing trace frame, not a reason to fail the fill.
+ */
+ async #screenshot(sessionId: string): Promise {
+ try {
+ const reply = await this.#send({ sessionId, method: "Page.captureScreenshot", params: { format: "png" } });
+ const data = (reply.result as { data?: unknown } | undefined)?.data;
+ return typeof data === "string" ? Buffer.from(data, "base64") : undefined;
+ } catch {
+ return undefined;
+ }
+ }
+
async #eval(sessionId: string, expression: string): Promise {
const out = (await this.#send({
sessionId,
diff --git a/packages/browser-bridge/src/index.ts b/packages/browser-bridge/src/index.ts
index 56bfc71..f38b023 100644
--- a/packages/browser-bridge/src/index.ts
+++ b/packages/browser-bridge/src/index.ts
@@ -15,6 +15,9 @@ export { PipeDecoder, encodeMessage } from "./pipe-codec.js";
export { CdpProxyServer, type ProxyServerOptions } from "./proxy-server.js";
export { buildToolset, dispatchTool, type ToolDefinition, type ToolResult } from "./mcp-tools.js";
export { FillEngine, type FillEngineDeps, type FillOutcome } from "./fill-engine.js";
+export { RegistrationEngine, type RegistrationEngineDeps } from "./registration-engine.js";
+export { CaptureEngine, type CaptureEngineDeps } from "./capture-engine.js";
+export type { TraceEvent } from "./trace.js";
export { CAPABILITY_TOOLS, toolsFor, type VaultBackend } from "./vault-backend.js";
export { SaasDriver, type SaasDriverOptions } from "./drivers/saas.js";
// In-memory, no account required — so this package can be run by someone who
diff --git a/packages/browser-bridge/src/registration-engine.ts b/packages/browser-bridge/src/registration-engine.ts
index 03e0b98..290df10 100644
--- a/packages/browser-bridge/src/registration-engine.ts
+++ b/packages/browser-bridge/src/registration-engine.ts
@@ -5,6 +5,7 @@ import type { RegistrationGrant, RegistrationOutcome } from "@1claw/browser-brid
import type { CdpGate } from "./cdp-policy.js";
import type { CdpTransport } from "./cdp-transport.js";
import type { SecretHandle } from "./secret-handle.js";
+import type { TraceEvent } from "./trace.js";
export type RegistrationEngineDeps = {
readonly transport: CdpTransport;
@@ -17,9 +18,30 @@ export type RegistrationEngineDeps = {
readonly onError?: (error: unknown) => void;
/** How long to wait for a success or error signal. */
readonly settleMs?: number;
+ /**
+ * Step-by-step record of the registration, for debugging and future
+ * playback. See `fill-engine.ts`'s `onStep` doc and `trace.ts` for the full
+ * contract — same rule here: `detail` is a selector or a plain reason,
+ * never a username, password, or `extraFields` value. Screenshots (at
+ * navigate and at settle) show whatever the page actually rendered, which
+ * for extra fields (not secrets, typed as plain text) may include the
+ * value if the field displays it — the same as it would to a person
+ * looking at the screen, and no different from what the policy's author
+ * already knows.
+ */
+ readonly onStep?: (event: TraceEvent) => void;
};
const DEFAULT_SETTLE_MS = 15_000;
+
+/** Just the host — see the identical helper and its doc in fill-engine.ts. */
+function safeHost(url: string): string {
+ try {
+ return new URL(url).host;
+ } catch {
+ return "(unparseable url)";
+ }
+}
const POLL_MS = 250;
/**
@@ -79,11 +101,14 @@ export class RegistrationEngine {
method: "Page.navigate",
params: { url: grant.signupUrl },
});
- await this.#waitFor(sessionId, grant.usernameSelector, settleMs);
+ const fieldReady = await this.#waitFor(sessionId, grant.usernameSelector, settleMs).then(() => true, () => false);
+ this.#trace(grant.registrationId, "navigate", fieldReady, safeHost(grant.signupUrl), await this.#screenshot(sessionId));
+ if (!fieldReady) throw new Error("the signup form never appeared");
const before = await this.#url(sessionId);
await this.#type(sessionId, grant.usernameSelector, grant.username);
+ this.#trace(grant.registrationId, "type_username", true, grant.usernameSelector);
// Other required fields the real form has -- DOB, address, phone, and
// the like. Typed the same way the username is: plainly, by the bridge,
// in this same windowed page, so an agent that could observe the
@@ -92,20 +117,26 @@ export class RegistrationEngine {
// form's own top-to-bottom layout.
for (const field of grant.extraFields ?? []) {
await this.#type(sessionId, field.selector, field.value);
+ this.#trace(grant.registrationId, "type_extra_field", true, field.selector);
}
handle = await takeSecret(grant.registrationId);
// `use()` inside typeSecret has already zeroed the buffer; dropping the
// reference stops `finally` from disposing an inert handle again.
await this.#typeSecret(sessionId, grant.passwordSelector, handle);
handle = undefined;
+ // No screenshot at this step, on principle -- see fill-engine.ts's
+ // identical rule for the same step.
+ this.#trace(grant.registrationId, "type_secret", true, grant.passwordSelector);
if (grant.submitSelector) {
await this.#click(sessionId, grant.submitSelector);
} else {
await this.#eval(sessionId, `document.querySelector(${JSON.stringify(grant.passwordSelector)})?.form?.submit()`);
}
+ this.#trace(grant.registrationId, "submit", true, grant.submitSelector ?? "(form.submit())");
const verdict = await this.#settle(sessionId, grant, before, settleMs);
+ this.#trace(grant.registrationId, "settle", verdict === "ok", verdict, await this.#screenshot(sessionId));
if (verdict === "rejected") {
await cancel(grant.registrationId);
return { status: "rejected", reason: "site_rejected_password" };
@@ -125,6 +156,7 @@ export class RegistrationEngine {
// Not `err.message`: this reaches the agent, and the text comes from the
// transport and the backend.
this.#deps.onError?.(err);
+ this.#trace(grant.registrationId, "error", false, err instanceof Error ? err.message : "unknown error");
return { status: "error", message: "the registration did not complete" };
} finally {
handle?.dispose();
@@ -135,6 +167,40 @@ export class RegistrationEngine {
.catch(() => {});
this.#deps.gate.closeFillWindow(target);
}
+ this.#trace(grant.registrationId, "closed", true);
+ }
+ }
+
+ /** Same contract as fill-engine.ts's identical helper. */
+ #trace(registrationId: string, step: string, ok: boolean, detail?: string, screenshotPng?: Uint8Array): void {
+ if (!this.#deps.onStep) return;
+ try {
+ this.#deps.onStep({
+ op: "registration",
+ id: registrationId,
+ step,
+ at: Date.now(),
+ ok,
+ ...(detail !== undefined ? { detail } : {}),
+ ...(screenshotPng !== undefined ? { screenshotPng } : {}),
+ });
+ } catch {
+ // The operator's own handler threw; not this engine's problem.
+ }
+ }
+
+ /** Same contract as fill-engine.ts's identical helper. */
+ async #screenshot(sessionId: string): Promise {
+ try {
+ const reply = (await this.#deps.transport.send({
+ sessionId,
+ method: "Page.captureScreenshot",
+ params: { format: "png" },
+ })) as { result?: { data?: unknown } };
+ const data = reply.result?.data;
+ return typeof data === "string" ? Buffer.from(data, "base64") : undefined;
+ } catch {
+ return undefined;
}
}
diff --git a/packages/browser-bridge/src/trace.test.ts b/packages/browser-bridge/src/trace.test.ts
new file mode 100644
index 0000000..b70be51
--- /dev/null
+++ b/packages/browser-bridge/src/trace.test.ts
@@ -0,0 +1,274 @@
+// Copyright (C) 2026 1Claw
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it, vi } from "vitest";
+import type { CaptureGrant, Grant, RegistrationGrant } from "@1claw/browser-bridge-protocol";
+import { CdpGate } from "./cdp-policy.js";
+import { CaptureEngine } from "./capture-engine.js";
+import { FakeCdpTransport, type CdpMessage } from "./cdp-transport.js";
+import { FillEngine } from "./fill-engine.js";
+import { RegistrationEngine } from "./registration-engine.js";
+import { SecretHandle } from "./secret-handle.js";
+import type { TraceEvent } from "./trace.js";
+import type { VaultBackend } from "./vault-backend.js";
+
+/**
+ * `onStep` is the one channel in this package explicitly designed to leave the
+ * process — to a debug log, a file on disk, eventually a dashboard. Every other
+ * secret-handling test in this suite checks that a value does not come back
+ * through a *return value*; these check the newer, wider door instead. A
+ * screenshot is real pixels from a real page, so the bar here is the same one
+ * `trace.ts`'s own doc comment sets: a password never appears in the clear, an
+ * `extraFields`/generated value never appears in `detail`, and a capture's
+ * secret never appears anywhere at all once generation or reading has begun.
+ */
+
+const PASSWORD = "hunter2-trace-secret-9f3a"; // gitleaks:allow -- test fixture, not a credential
+const DOB = "1990-01-01-do-not-log-me";
+const GENERATED = "generated-api-key-should-never-leak";
+
+/** Not a real PNG, just distinctive bytes a leaked-secret assertion can scan. */
+const FAKE_PNG_B64 = Buffer.from("fake-screenshot-bytes").toString("base64");
+
+/** Answers Page.captureScreenshot with fake bytes; everything else passes through. */
+function withScreenshots(transport: FakeCdpTransport): FakeCdpTransport {
+ const original = transport.send.bind(transport);
+ vi.spyOn(transport, "send").mockImplementation(async (msg: CdpMessage) => {
+ if (msg.method === "Page.captureScreenshot") {
+ return { ...(msg.id !== undefined ? { id: msg.id } : {}), result: { data: FAKE_PNG_B64 } };
+ }
+ return original(msg);
+ });
+ return transport;
+}
+
+function noSecretLeaked(events: readonly TraceEvent[], ...secrets: string[]): void {
+ for (const e of events) {
+ for (const secret of secrets) {
+ expect(e.detail ?? "", `step "${e.step}" leaked a secret into detail`).not.toContain(secret);
+ if (e.screenshotPng) {
+ const asText = Buffer.from(e.screenshotPng).toString("latin1");
+ expect(asText, `step "${e.step}" leaked a secret into a screenshot`).not.toContain(secret);
+ }
+ }
+ }
+}
+
+describe("fill trace", () => {
+ const GRANT: Grant = {
+ kind: "grant",
+ grantId: "g1",
+ bindingId: "b1",
+ loginUrl: "https://app.example.com/login",
+ expiresAt: "",
+ generation: 7,
+ username: "ada@example.com",
+ usernameSelector: "#user",
+ };
+
+ function deps() {
+ const events: TraceEvent[] = [];
+ const gate = new CdpGate();
+ const transport = withScreenshots(new FakeCdpTransport());
+ const backend = { consumeFill: async () => SecretHandle.fromUtf8(PASSWORD) } as unknown as VaultBackend;
+ const engine = new FillEngine({
+ backend,
+ transport,
+ gate,
+ currentGeneration: () => GRANT.generation,
+ onStep: (e) => events.push(e),
+ });
+ return { engine, events };
+ }
+
+ it("emits one step per stage of a successful fill, in order", async () => {
+ const { engine, events } = deps();
+ const out = await engine.fill("agent-target", GRANT, "#password");
+ expect(out).toEqual({ status: "filled" });
+ expect(events.map((e) => e.step)).toEqual([
+ "navigate",
+ "type_username",
+ "wait_for_field",
+ "focus",
+ "type_secret",
+ "submit",
+ "settle",
+ "closed",
+ ]);
+ expect(events.every((e) => e.op === "fill" && e.id === "b1")).toBe(true);
+ });
+
+ it("never puts the password in a detail string or a screenshot", async () => {
+ const { engine, events } = deps();
+ await engine.fill("agent-target", GRANT, "#password");
+ noSecretLeaked(events, PASSWORD);
+ });
+
+ it("screenshots navigate and settle, but never the type_secret step", async () => {
+ const { engine, events } = deps();
+ await engine.fill("agent-target", GRANT, "#password");
+ const byStep = new Map(events.map((e) => [e.step, e]));
+ expect(byStep.get("navigate")?.screenshotPng).toBeDefined();
+ expect(byStep.get("settle")?.screenshotPng).toBeDefined();
+ // The one step this file exists to protect: the password is on the page
+ // at exactly this moment, so this step is traced but never pictured.
+ expect(byStep.get("type_secret")?.screenshotPng).toBeUndefined();
+ });
+
+ it("still leaks nothing when the transport fails after typing the secret", async () => {
+ const events: TraceEvent[] = [];
+ const gate = new CdpGate();
+ const transport = new FakeCdpTransport();
+ vi.spyOn(transport, "send").mockImplementation(async (msg: CdpMessage) => {
+ if (msg.method === "Input.dispatchKeyEvent") throw new Error("browser died mid-submit");
+ return FakeCdpTransport.prototype.send.call(transport, msg);
+ });
+ const backend = { consumeFill: async () => SecretHandle.fromUtf8(PASSWORD) } as unknown as VaultBackend;
+ const engine = new FillEngine({
+ backend,
+ transport,
+ gate,
+ currentGeneration: () => GRANT.generation,
+ onStep: (e) => events.push(e),
+ });
+ const out = await engine.fill("agent-target", GRANT, "#password");
+ expect(out.status).toBe("error");
+ expect(events.map((e) => e.step)).toContain("error");
+ noSecretLeaked(events, PASSWORD);
+ });
+
+ it("a broken onStep handler does not abort an otherwise-successful fill", async () => {
+ const gate = new CdpGate();
+ const transport = new FakeCdpTransport();
+ const backend = { consumeFill: async () => SecretHandle.fromUtf8(PASSWORD) } as unknown as VaultBackend;
+ const engine = new FillEngine({
+ backend,
+ transport,
+ gate,
+ currentGeneration: () => GRANT.generation,
+ onStep: () => {
+ throw new Error("the operator's own handler is broken");
+ },
+ });
+ const out = await engine.fill("agent-target", GRANT, "#password");
+ expect(out).toEqual({ status: "filled" });
+ });
+});
+
+describe("registration trace", () => {
+ const GRANT: RegistrationGrant = {
+ kind: "registration_grant",
+ registrationId: "r1",
+ signupUrl: "https://acme.example.com/signup",
+ username: "ada@example.com",
+ usernameSelector: "#email",
+ passwordSelector: "#password",
+ extraFields: [{ selector: "#dob", value: DOB }],
+ // FakeCdpTransport answers every selector-presence probe with `true` by
+ // default, so a `selector` success signal resolves on the first poll.
+ // `urlChanges` would not: this engine's own submit path calls
+ // `form.submit()` via eval rather than dispatching a key event, and the
+ // fake only advances its simulated URL off the back of a dispatched key —
+ // so `urlChanges` here would poll for the full settle timeout every run.
+ success: { selector: "#registered-ok" },
+ };
+
+ function deps() {
+ const events: TraceEvent[] = [];
+ const gate = new CdpGate();
+ const transport = withScreenshots(new FakeCdpTransport());
+ const engine = new RegistrationEngine({
+ transport,
+ gate,
+ takeSecret: async () => SecretHandle.fromUtf8(PASSWORD),
+ commit: async () => ({ bindingId: "b1" }),
+ cancel: async () => {},
+ onStep: (e) => events.push(e),
+ });
+ return { engine, events };
+ }
+
+ it("traces every field, including extraFields, without the password or its own detail leaking", async () => {
+ const { engine, events } = deps();
+ const out = await engine.register(GRANT);
+ expect(out).toEqual({ status: "registered", bindingId: "b1" });
+ expect(events.map((e) => e.step)).toEqual([
+ "navigate",
+ "type_username",
+ "type_extra_field",
+ "type_secret",
+ "submit",
+ "settle",
+ "closed",
+ ]);
+ noSecretLeaked(events, PASSWORD);
+ // extraFields carries a real value (DOB), typed as plain text; the rule
+ // from trace.ts is that `detail` is a selector or reason only, never the
+ // value itself — so DOB may legally appear in a screenshot (the page
+ // renders it) but never in a `detail` string.
+ for (const e of events) {
+ expect(e.detail ?? "").not.toContain(DOB);
+ }
+ const extraField = events.find((e) => e.step === "type_extra_field");
+ expect(extraField?.detail).toBe("#dob");
+ });
+
+ it("screenshots navigate and settle, never type_secret", async () => {
+ const { engine, events } = deps();
+ await engine.register(GRANT);
+ const byStep = new Map(events.map((e) => [e.step, e]));
+ expect(byStep.get("navigate")?.screenshotPng).toBeDefined();
+ expect(byStep.get("settle")?.screenshotPng).toBeDefined();
+ expect(byStep.get("type_secret")?.screenshotPng).toBeUndefined();
+ });
+});
+
+describe("capture trace", () => {
+ const GRANT: CaptureGrant = {
+ kind: "capture_grant",
+ captureId: "c1",
+ captureUrl: "https://acme.example.com/settings/api-key",
+ source: { valueSelector: "#api-key", valueProp: "value" },
+ entryId: "e1",
+ };
+
+ function deps() {
+ const events: TraceEvent[] = [];
+ const gate = new CdpGate();
+ const transport = withScreenshots(new FakeCdpTransport());
+ // Runtime.evaluate answers every non-location.href expression with this
+ // value, which is exactly what #readValue reads as "the generated secret".
+ transport.evaluateValue = GENERATED;
+ const engine = new CaptureEngine({
+ transport,
+ gate,
+ commit: async () => ({ entryId: "e1" }),
+ cancel: async () => {},
+ onStep: (e) => events.push(e),
+ });
+ return { engine, events };
+ }
+
+ it("traces navigate and read_value, but the generated secret never appears anywhere", async () => {
+ const { engine, events } = deps();
+ const out = await engine.capture("agent-target", GRANT);
+ expect(out).toEqual({ status: "captured", entryId: "e1" });
+ expect(events.map((e) => e.step)).toEqual(["navigate", "read_value", "committed", "closed"]);
+ noSecretLeaked(events, GENERATED);
+ // read_value's detail is the selector it read from, never what it found.
+ const readValue = events.find((e) => e.step === "read_value");
+ expect(readValue?.ok).toBe(true);
+ expect(readValue?.detail).toBe("#api-key");
+ });
+
+ it("the only screenshot in a capture is the one before the value exists", async () => {
+ const { engine, events } = deps();
+ await engine.capture("agent-target", GRANT);
+ const byStep = new Map(events.map((e) => [e.step, e]));
+ expect(byStep.get("navigate")?.screenshotPng).toBeDefined();
+ // Everything from here on could be showing the secret; none of it pictures it.
+ for (const step of ["read_value", "committed", "closed"]) {
+ expect(byStep.get(step)?.screenshotPng, `"${step}" must never carry a screenshot`).toBeUndefined();
+ }
+ });
+});
diff --git a/packages/browser-bridge/src/trace.ts b/packages/browser-bridge/src/trace.ts
new file mode 100644
index 0000000..95239e2
--- /dev/null
+++ b/packages/browser-bridge/src/trace.ts
@@ -0,0 +1,57 @@
+// Copyright (C) 2026 1Claw
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * A step-by-step record of what a fill, registration or capture actually did,
+ * for the operator, never the agent. This is deliberately not in
+ * `@1claw/browser-bridge-protocol`: that package's types cross the MCP
+ * boundary to the agent, and a `TraceEvent` never does. It goes only to
+ * whatever `onStep` callback the operator supplied to `startBridge`, the same
+ * way `onError` and `onAudit` already work.
+ *
+ * Two things a `TraceEvent` is not, on purpose:
+ *
+ * - It is not `AuditEvent` (`@1claw/browser-bridge-protocol`). `AuditEvent` is
+ * a driver's own compliance record — coarse, string/number/boolean metadata
+ * only, meant for a backend's central log (the `centralAudit` capability,
+ * unimplemented by any driver yet). A `TraceEvent` is finer-grained (every
+ * step, not just the outcome), engine-level rather than driver-level, and
+ * can carry a screenshot. Building a debug/playback trace out of
+ * `AuditEvent` would have meant loosening its "no secret, no binary" shape
+ * for everyone; keeping them separate means neither has to compromise.
+ * - It is never a substitute for the invariant. The screenshot, when present,
+ * is a real image of the real page — that is the point, an operator
+ * debugging a selector that never matched needs to see what the page
+ * actually looked like. A password field renders masked by the browser
+ * itself regardless of when the screenshot is taken, so this never shows
+ * one in the clear. An `extraFields` value (a date of birth, typed as plain
+ * text) is a different case: if the field is visible on the settled page,
+ * the screenshot shows it, the same way it would to a person looking at
+ * the screen. That is not a new exposure — the operator who authored the
+ * policy already has that value — but it means trace output deserves the
+ * same handling care as anything else carrying that data, which is the
+ * caller's responsibility once `onStep` hands it over.
+ */
+export type TraceEvent = {
+ readonly op: "fill" | "registration" | "capture";
+ /** bindingId, registrationId or entryId — whichever the operation has. */
+ readonly id: string;
+ /**
+ * A short, stable name for what happened: "navigate", "wait_for_field",
+ * "focus", "type_username", "type_extra_field", "type_secret", "submit",
+ * "settle", "closed". New steps may be added over time; treat this as an
+ * open set, not an enum to switch exhaustively on.
+ */
+ readonly step: string;
+ readonly at: number;
+ readonly ok: boolean;
+ /**
+ * A selector, a hostname, a failure reason — never a secret, and never an
+ * `extraFields` value. Typed as a plain string rather than a closed set
+ * because the detail is for a human reading a log, not for code to branch
+ * on.
+ */
+ readonly detail?: string;
+ /** A PNG of the page at a coarse checkpoint (after navigate, before close) — not every step. */
+ readonly screenshotPng?: Uint8Array;
+};