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; +};