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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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 <selector>=<value>` **(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 <dir>` (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
Expand Down
9 changes: 9 additions & 0 deletions packages/browser-bridge/bin/1claw-browser-bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -32,13 +33,19 @@ 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) => {
const i = argv.indexOf(`--${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;
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions packages/browser-bridge/bin/debug-trace.mjs
Original file line number Diff line number Diff line change
@@ -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.
}
};
}
27 changes: 23 additions & 4 deletions packages/browser-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -151,6 +162,7 @@ class RegistrationAdapter {
await b.cancelRegistration?.(id);
},
onError,
...(onStep !== undefined ? { onStep } : {}),
});
}

Expand Down Expand Up @@ -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 {
Expand All @@ -215,6 +228,7 @@ class CaptureAdapter {
await b.cancelCapture?.(id);
},
onError,
...(onStep !== undefined ? { onStep } : {}),
});
}

Expand Down Expand Up @@ -339,15 +353,19 @@ export async function startBridge(opts: BridgeOptions): Promise<BridgeHandle> {

// 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;

Expand All @@ -365,6 +383,7 @@ export async function startBridge(opts: BridgeOptions): Promise<BridgeHandle> {
// 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
Expand Down
62 changes: 62 additions & 0 deletions packages/browser-bridge/src/capture-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
Expand Down Expand Up @@ -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" };
Expand All @@ -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();
Expand All @@ -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<Uint8Array | undefined> {
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;
}
}

Expand Down
Loading
Loading