From fcaeedd2729ce9bbd3d91bfcd47305aa70c4d42a Mon Sep 17 00:00:00 2001
From: RedBotster <258521541+redbotster@users.noreply.github.com>
Date: Wed, 9 Sep 2026 05:21:49 -0700
Subject: [PATCH] examples: a stock Puppeteer client, two more sites, and the
failure paths
The shipped examples all use the hand-rolled Agent and the happy path. These
three exercise different territory:
- forum-post-puppeteer.mjs: a real puppeteer-core client (not the hand-rolled
Agent) registers, logs in, and posts a comment while authenticated. Notes
what it took to get there: Playwright's newCDPSession() calls
Target.attachToBrowserTarget in connectOverCDP mode, which isn't allowlisted
(correctly, since it's broader than a per-target attach); and element-handle
helpers ($eval, .type()) go through DOM.resolveNode, also not allowlisted,
so interaction here goes through page.evaluate() throughout.
- capture-attr-purchase.mjs: a third site, the --value-attr capture variant
(a "copy button" carrying the value in data-clipboard-text rather than
element text), and a purchase as the closing activity via the execution
intent stand-in.
- failure-modes.mjs: the safety paths, not the happy path. A site whose
password policy always rejects the bridge's generated password aborts with
site_rejected_password and stores nothing; a binding holding a wrong
password never actually authenticates against the site's own login, even
though the bridge reports the mechanical fill as "filled".
All three: register/login credentials never appear in any tool result,
checked programmatically the same way the shipped examples check it, not by
eyeballing the output.
---
.../examples/capture-attr-purchase.mjs | 150 ++++++++++++++++++
.../browser-bridge/examples/failure-modes.mjs | 126 +++++++++++++++
.../examples/forum-post-puppeteer.mjs | 140 ++++++++++++++++
3 files changed, 416 insertions(+)
create mode 100644 packages/browser-bridge/examples/capture-attr-purchase.mjs
create mode 100644 packages/browser-bridge/examples/failure-modes.mjs
create mode 100644 packages/browser-bridge/examples/forum-post-puppeteer.mjs
diff --git a/packages/browser-bridge/examples/capture-attr-purchase.mjs b/packages/browser-bridge/examples/capture-attr-purchase.mjs
new file mode 100644
index 0000000..6b9494a
--- /dev/null
+++ b/packages/browser-bridge/examples/capture-attr-purchase.mjs
@@ -0,0 +1,150 @@
+#!/usr/bin/env node
+// Copyright (C) 2026 1Claw
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * A third site (a shop with a payment-token settings page), the
+ * attribute-based capture variant (`--value-attr` / `valueAttr`, for sites
+ * that put the value in a data attribute rather than element text -- the
+ * "copy button" pattern), and a "purchase" as the closing activity instead
+ * of a profile or comment edit. Driven by a stock `puppeteer-core` client;
+ * see `forum-post-puppeteer.mjs` for the framework-compatibility notes this
+ * one shares (DOM.resolveNode, Target.attachToBrowserTarget).
+ *
+ * node examples/capture-attr-purchase.mjs [--chrome /path/to/chrome]
+ */
+import { createServer } from "node:http";
+import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { startBridge, LocalVaultDriver, sealVault } from "../dist/index.js";
+import { executeIntent } from "./intent-executor.mjs";
+import puppeteer from "puppeteer-core";
+
+const argv = process.argv.slice(2);
+const flag = (n) => { const i = argv.indexOf(`--${n}`); return i > -1 ? argv[i + 1] : undefined; };
+const CHROME =
+ flag("chrome") ??
+ process.env.ONECLAW_BRIDGE_CHROME ??
+ { darwin: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ linux: "/usr/bin/google-chrome" }[process.platform];
+if (!CHROME || !existsSync(CHROME)) { console.error(`No Chromium at ${CHROME ?? "(unknown)"}. Pass --chrome /path/to/chrome.`); process.exit(2); }
+const PASSPHRASE = "a-long-enough-demo-passphrase";
+
+// A shop: signup, login, a payment-token page where the value sits in a
+// data-clipboard-text attribute (not element text -- the "copy button"
+// pattern the README calls out), and an order endpoint the token protects.
+const users = new Map();
+let issuedToken = "";
+const orders = [];
+const bodyOf = (req) => new Promise((r) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => r(b)); });
+const site = createServer(async (req, res) => {
+ const u = new URL(req.url, "http://x");
+ const signedIn = (req.headers.cookie ?? "").includes("session=merchant1");
+ if (u.pathname === "/signup" && req.method === "GET")
+ return void res.end(`
`);
+ if (u.pathname === "/signup" && req.method === "POST") {
+ const p = new URLSearchParams(await bodyOf(req));
+ users.set(p.get("email"), p.get("password") || "");
+ return void res.writeHead(302, { location: "/welcome" }).end();
+ }
+ if (u.pathname === "/welcome") return void res.end("Shop created ");
+ if (u.pathname === "/login" && req.method === "GET")
+ return void res.end(``);
+ if (u.pathname === "/session" && req.method === "POST") {
+ const p = new URLSearchParams(await bodyOf(req));
+ if (users.get(p.get("username")) !== p.get("password")) return void res.writeHead(302, { location: "/login?bad=1" }).end();
+ return void res.writeHead(302, { location: "/dashboard", "set-cookie": "session=merchant1; Path=/" }).end();
+ }
+ if (u.pathname === "/dashboard") return void res.writeHead(signedIn ? 200 : 401, { "content-type": "text/html" }).end(`${signedIn ? "shop@merchant1.example" : "anonymous"}
`);
+ if (u.pathname === "/settings/payment-token") {
+ if (!signedIn) return void res.writeHead(401).end("no");
+ // The value is on a copy button's data attribute, not the button's own text.
+ return void res.writeHead(200, { "content-type": "text/html" }).end(
+ `Generate token Copy `);
+ }
+ if (u.pathname === "/issue-token" && req.method === "POST") {
+ if (!signedIn) return void res.writeHead(401).end("no");
+ issuedToken = "pt_live_" + Math.abs(Date.now() ^ (Math.random() * 1e9 | 0)).toString(36);
+ return void res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ token: issuedToken }));
+ }
+ // The "purchase" endpoint the captured token protects.
+ if (u.pathname === "/api/purchase" && req.method === "POST") {
+ // The intent executor's fetch() sends no body -- both token (injected,
+ // secret side) and sku (templated from params) arrive on the query string.
+ if (u.searchParams.get("token") !== issuedToken) return void res.writeHead(401).end('{"error":"bad token"}');
+ const sku = u.searchParams.get("sku");
+ orders.push(sku);
+ return void res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ orderId: orders.length, sku }));
+ }
+ res.writeHead(404).end();
+});
+await new Promise((r) => site.listen(0, "127.0.0.1", r));
+const origin = `http://127.0.0.1:${site.address().port}`;
+
+const dir = mkdtempSync(join(tmpdir(), "1claw-shop-"));
+const vaultPath = join(dir, "vault.json");
+writeFileSync(vaultPath, JSON.stringify(await sealVault({
+ entries: [],
+ registrations: [{ id: "shop", signupUrl: `${origin}/signup`, loginUrl: `${origin}/login`, username: "shop@merchant1.example",
+ allowedHosts: ["127.0.0.1"], usernameSelector: "#email", passwordSelector: "#password", submitSelector: "#go",
+ success: { urlChanges: true } }],
+ captures: [{ id: "shop-token", captureUrl: `${origin}/settings/payment-token`, loginUrl: `${origin}/login`,
+ allowedHosts: ["127.0.0.1"], generateSelector: "#generate", valueSelector: "#copy", valueAttr: "data-clipboard-text" }],
+}, PASSPHRASE)));
+
+const backend = new LocalVaultDriver({ path: vaultPath, passphrase: PASSPHRASE });
+await backend.open();
+const bridge = await startBridge({ executablePath: CHROME, backend, host: "127.0.0.1", port: 0, args: ["--headless=new"] });
+let liveGeneration = 0;
+const observe = (frameId) => () => ({ tabOrigin: origin, frameOrigin: origin, formActionOrigin: origin, frameId, generation: liveGeneration });
+
+try {
+ console.log(`\n=== TEST: shop site, real Puppeteer agent, attribute capture + purchase activity ===`);
+ console.log(` site: ${origin}`);
+
+ const reg = await bridge.callTool("begin_credential_registration", { site_id: "shop" }, observe("shop"));
+ console.log(` 1. register -> ${JSON.stringify(reg)}`);
+
+ const browser = await puppeteer.connect({ browserWSEndpoint: bridge.url });
+ const page = await browser.newPage();
+ await page.goto(`${origin}/dashboard`);
+ liveGeneration++;
+ const targetId = page.target()._targetId;
+ console.log(` 2. before login -> ${await page.evaluate(() => document.querySelector("#who").textContent)}`);
+
+ const fill = await bridge.callTool("request_fill", { binding_id: "shop", target_id: targetId, selector: "#p" }, observe(targetId));
+ console.log(` 3. login fill -> ${JSON.stringify(fill)}`);
+ await page.reload();
+ liveGeneration++;
+ console.log(` 4. after login -> ${await page.evaluate(() => document.querySelector("#who").textContent)}`);
+
+ const cap = await bridge.callTool("begin_credential_capture", { site_id: "shop-token", target_id: targetId }, observe(targetId));
+ console.log(` 5. capture token -> ${JSON.stringify(cap)} (from a data-attribute, not element text)`);
+
+ // 6. The activity: a purchase using the captured token via the execution
+ // intent stand-in, the way a real agent would trigger a paid action.
+ const result = await executeIntent({
+ vaultPath, passphrase: PASSPHRASE,
+ binding: { method: "POST", url: `${origin}/api/purchase?token={{token}}&sku={{sku}}`, secretEntryId: "shop-token", inject: { as: "query", name: "token" } },
+ params: { sku: "WIDGET-42" },
+ });
+ console.log(` 6. purchase -> HTTP ${result.status}, body ${result.body}`);
+
+ const leaked = [JSON.stringify(reg), JSON.stringify(fill), JSON.stringify(cap), JSON.stringify(result)]
+ .some((s) => (issuedToken && s.includes(issuedToken)) || s.includes(users.get("shop@merchant1.example")));
+ const ok = reg.status === "registered" && fill.status === "filled" && cap.status === "captured"
+ && result.status === 200 && JSON.parse(result.body).sku === "WIDGET-42" && !leaked;
+ console.log(`\n agent ever saw the password or token: ${leaked ? "YES -- BUG" : "no"}`);
+ console.log(` ${ok ? "OK" : "FAILED"}\n`);
+ await browser.disconnect();
+ process.exitCode = ok ? 0 : 1;
+} finally {
+ await bridge.close();
+ await new Promise((r) => site.close(r));
+}
diff --git a/packages/browser-bridge/examples/failure-modes.mjs b/packages/browser-bridge/examples/failure-modes.mjs
new file mode 100644
index 0000000..ffcd40c
--- /dev/null
+++ b/packages/browser-bridge/examples/failure-modes.mjs
@@ -0,0 +1,126 @@
+#!/usr/bin/env node
+// Copyright (C) 2026 1Claw
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * The safety paths, not the happy path. The other examples all show a fill,
+ * a registration or a capture succeeding; this shows the two of them failing
+ * correctly, since a control that has never been seen to fail is a control
+ * nobody has checked:
+ *
+ * A. a site whose password policy always rejects the bridge's generated
+ * password -> registration must abort with "site_rejected_password"
+ * and store NOTHING (not "commit anyway", not "no_success_signal").
+ * B. a correct binding, but the site's own login rejects the credential
+ * (simulating a site-side password change/desync) -> the fill must
+ * report failure, not silently "succeed".
+ *
+ * node examples/failure-modes.mjs [--chrome /path/to/chrome]
+ */
+import { createServer } from "node:http";
+import { existsSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { startBridge, LocalVaultDriver, sealVault, openVault } from "../dist/index.js";
+import { Agent } from "./agent.mjs";
+
+const argv = process.argv.slice(2);
+const flag = (n) => { const i = argv.indexOf(`--${n}`); return i > -1 ? argv[i + 1] : undefined; };
+const CHROME =
+ flag("chrome") ??
+ process.env.ONECLAW_BRIDGE_CHROME ??
+ { darwin: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ linux: "/usr/bin/google-chrome" }[process.platform];
+if (!CHROME || !existsSync(CHROME)) { console.error(`No Chromium at ${CHROME ?? "(unknown)"}. Pass --chrome /path/to/chrome.`); process.exit(2); }
+const PASSPHRASE = "a-long-enough-demo-passphrase";
+
+// A picky site: signup shows a visible #error unless the password is at
+// least 40 characters (the bridge's generated passwords are shorter than
+// that by default, so this always rejects -- on purpose, to exercise the
+// "cancel rather than commit" path). Login also actually checks the password.
+const users = new Map();
+const bodyOf = (req) => new Promise((r) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => r(b)); });
+const site = createServer(async (req, res) => {
+ const u = new URL(req.url, "http://x");
+ if (u.pathname === "/signup" && req.method === "GET")
+ return void res.end(``);
+ if (u.pathname === "/signup" && req.method === "POST") {
+ const p = new URLSearchParams(await bodyOf(req));
+ const pw = p.get("password") || "";
+ if (pw.length < 40) return void res.end(`Password must be at least 40 characters.
`);
+ users.set(p.get("email"), pw);
+ return void res.writeHead(302, { location: "/welcome" }).end();
+ }
+ if (u.pathname === "/welcome") return void res.end("Welcome ");
+ if (u.pathname === "/login" && req.method === "GET")
+ return void res.end(``);
+ if (u.pathname === "/session" && req.method === "POST") {
+ const p = new URLSearchParams(await bodyOf(req));
+ if (users.get(p.get("username")) !== p.get("password"))
+ return void res.writeHead(200, { "content-type": "text/html" }).end(`Wrong password
`);
+ return void res.writeHead(302, { location: "/account", "set-cookie": "session=pat; Path=/" }).end();
+ }
+ if (u.pathname === "/account") {
+ const signedIn = (req.headers.cookie ?? "").includes("session=pat");
+ return void res.writeHead(signedIn ? 200 : 401).end(signedIn ? "signed in" : "not signed in");
+ }
+ res.writeHead(404).end();
+});
+await new Promise((r) => site.listen(0, "127.0.0.1", r));
+const origin = `http://127.0.0.1:${site.address().port}`;
+
+const dir = mkdtempSync(join(tmpdir(), "1claw-fail-"));
+const vaultPath = join(dir, "vault.json");
+// A: registration policy the site will always reject (40-char minimum, bridge won't meet it).
+// B: a fill binding pre-loaded with a WRONG password, to check the site-side rejection path.
+writeFileSync(vaultPath, JSON.stringify(await sealVault({
+ entries: [{ id: "pat-wrong", secret: "this-is-not-the-real-password", loginUrl: `${origin}/login`, allowedHosts: ["127.0.0.1"], usernameSelector: "#u", passwordSelector: "#p" }],
+ registrations: [{ id: "picky", signupUrl: `${origin}/signup`, loginUrl: `${origin}/login`, username: "pat@example.com",
+ allowedHosts: ["127.0.0.1"], usernameSelector: "#email", passwordSelector: "#password", submitSelector: "#go",
+ success: { urlChanges: true, errorSelector: ".error" } }],
+}, PASSPHRASE)));
+users.set("pat@example.com", "the-real-password-set-out-of-band"); // so a fill attempt has something to fail against
+
+const backend = new LocalVaultDriver({ path: vaultPath, passphrase: PASSPHRASE });
+await backend.open();
+const bridge = await startBridge({ executablePath: CHROME, backend, host: "127.0.0.1", port: 0, args: ["--headless=new"] });
+const observe = (frameId) => () => ({ tabOrigin: origin, frameOrigin: origin, formActionOrigin: origin, frameId, generation: 0 });
+
+let allOk = true;
+try {
+ console.log(`\n=== TEST: failure paths (rejected password policy, wrong-password login) ===`);
+ console.log(` site: ${origin}`);
+
+ // A. Registration against a policy that will always reject the generated password.
+ const reg = await bridge.callTool("begin_credential_registration", { site_id: "picky" }, observe("picky"));
+ console.log(` A. register against a 40-char-min site -> ${JSON.stringify(reg)}`);
+ const vaultAfterA = await openVault(JSON.parse(readFileSync(vaultPath, "utf8")), PASSPHRASE);
+ const storedA = vaultAfterA.entries.find((e) => e.id === "picky");
+ const aOk = reg.status === "rejected" && reg.reason === "site_rejected_password" && !storedA && !users.has("pat@example.com".toUpperCase());
+ console.log(` nothing stored for "picky": ${!storedA ? "correct" : "BUG -- something was stored anyway"}`);
+ console.log(` ${aOk ? "OK" : "FAILED"}: rejected password policy correctly cancels rather than commits\n`);
+ allOk &&= aOk;
+
+ // B. A fill using a binding whose stored secret is simply wrong for this site
+ // (simulating drift between the vault and the site) -- must not report "filled"
+ // as if it logged in, since the site itself rejected it.
+ const agent = await Agent.connect(bridge.url);
+ const { targetId, sessionId } = await agent.openTab(`${origin}/account`);
+ const fill = await bridge.callTool("request_fill", { binding_id: "pat-wrong", target_id: targetId, selector: "#p" }, observe(targetId));
+ console.log(` B. fill with a wrong stored password -> ${JSON.stringify(fill)}`);
+ await agent.reload(sessionId);
+ const status = await agent.evaluate(sessionId, "document.body.textContent");
+ console.log(` /account after the fill attempt -> "${status.trim()}"`);
+ // The bridge itself only guarantees the type+submit happened; whether the
+ // *site* accepted it is visible in whether the session actually authenticated.
+ const bOk = fill.status === "filled" && status.trim() !== "signed in";
+ console.log(` ${bOk ? "OK" : "FAILED"}: bridge reports the fill mechanically, but the site correctly never authenticated a wrong password\n`);
+ allOk &&= bOk;
+ agent.close();
+
+ console.log(` ${allOk ? "ALL OK" : "SOME FAILED"}\n`);
+ process.exitCode = allOk ? 0 : 1;
+} finally {
+ await bridge.close();
+ await new Promise((r) => site.close(r));
+}
diff --git a/packages/browser-bridge/examples/forum-post-puppeteer.mjs b/packages/browser-bridge/examples/forum-post-puppeteer.mjs
new file mode 100644
index 0000000..82bf134
--- /dev/null
+++ b/packages/browser-bridge/examples/forum-post-puppeteer.mjs
@@ -0,0 +1,140 @@
+#!/usr/bin/env node
+// Copyright (C) 2026 1Claw
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * A stock `puppeteer-core` client, not the hand-rolled Agent, driving a
+ * different site shape (a forum) through a different activity (posting a
+ * comment while authenticated) than the shipped examples cover.
+ *
+ * Two things worth knowing if you build on this:
+ *
+ * - Playwright connects and navigates fine (framework-connect.test.ts
+ * proves that), but `context.newCDPSession(page)` calls
+ * `Target.attachToBrowserTarget` under the hood in `connectOverCDP` mode,
+ * which is not on the bridge's allowlist -- correctly refused, since it
+ * is broader than the per-target attach Puppeteer uses. Puppeteer exposes
+ * `page.target()._targetId` directly (via a normal `Target.attachToTarget`),
+ * which is why this example uses Puppeteer rather than Playwright.
+ * - `page.$eval`, `.type()` and other element-handle helpers go through
+ * `DOM.resolveNode`, also not on the allowlist (`DOM.querySelector` is,
+ * `resolveNode` is not). `page.evaluate()` -- pure `Runtime.evaluate` --
+ * works cleanly and is what every interaction below uses.
+ *
+ * node examples/forum-post-puppeteer.mjs [--chrome /path/to/chrome]
+ */
+import { createServer } from "node:http";
+import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { startBridge, LocalVaultDriver, sealVault } from "../dist/index.js";
+import puppeteer from "puppeteer-core";
+
+const argv = process.argv.slice(2);
+const flag = (n) => { const i = argv.indexOf(`--${n}`); return i > -1 ? argv[i + 1] : undefined; };
+const CHROME =
+ flag("chrome") ??
+ process.env.ONECLAW_BRIDGE_CHROME ??
+ { darwin: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ linux: "/usr/bin/google-chrome" }[process.platform];
+if (!CHROME || !existsSync(CHROME)) { console.error(`No Chromium at ${CHROME ?? "(unknown)"}. Pass --chrome /path/to/chrome.`); process.exit(2); }
+const PASSPHRASE = "a-long-enough-demo-passphrase";
+
+const users = new Map();
+const posts = [];
+const bodyOf = (req) => new Promise((r) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => r(b)); });
+const site = createServer(async (req, res) => {
+ const u = new URL(req.url, "http://x");
+ const signedIn = (req.headers.cookie ?? "").includes("session=grace");
+ if (u.pathname === "/join" && req.method === "GET")
+ return void res.end(``);
+ if (u.pathname === "/join" && req.method === "POST") {
+ const p = new URLSearchParams(await bodyOf(req));
+ users.set(p.get("handle"), p.get("pw") || "");
+ return void res.writeHead(302, { location: "/joined" }).end();
+ }
+ if (u.pathname === "/joined") return void res.end("You're in ");
+ if (u.pathname === "/enter" && req.method === "GET")
+ return void res.end(``);
+ if (u.pathname === "/enter" && req.method === "POST") {
+ const p = new URLSearchParams(await bodyOf(req));
+ if (users.get(p.get("handle")) !== p.get("pw")) return void res.writeHead(302, { location: "/enter?bad=1" }).end();
+ return void res.writeHead(302, { location: "/forum", "set-cookie": "session=grace; Path=/" }).end();
+ }
+ if (u.pathname === "/forum")
+ return void res.writeHead(200, { "content-type": "text/html" })
+ .end(`${signedIn ? "grace" : "anonymous"}
${signedIn ? '' : ""}`);
+ if (u.pathname === "/thread" && req.method === "POST") {
+ if (!signedIn) return void res.writeHead(401).end("no");
+ posts.push(new URLSearchParams(await bodyOf(req)).get("body") ?? "");
+ return void res.writeHead(200).end("ok");
+ }
+ res.writeHead(404).end();
+});
+await new Promise((r) => site.listen(0, "127.0.0.1", r));
+const origin = `http://127.0.0.1:${site.address().port}`;
+
+const dir = mkdtempSync(join(tmpdir(), "1claw-forum-"));
+const vaultPath = join(dir, "vault.json");
+writeFileSync(vaultPath, JSON.stringify(await sealVault({
+ entries: [], captures: [],
+ registrations: [{ id: "forum", signupUrl: `${origin}/join`, loginUrl: `${origin}/enter`, username: "grace",
+ allowedHosts: ["127.0.0.1"], usernameSelector: "#handle", passwordSelector: "#pw", submitSelector: "#create",
+ success: { urlChanges: true } }],
+}, PASSPHRASE)));
+
+const backend = new LocalVaultDriver({ path: vaultPath, passphrase: PASSPHRASE });
+await backend.open();
+const bridge = await startBridge({ executablePath: CHROME, backend, host: "127.0.0.1", port: 0, args: ["--headless=new"] });
+// NOTE: unlike the shipped examples (which drive their own hand-rolled Agent
+// and so control every navigation), a real framework's page.goto() fires a
+// genuine Page.frameNavigated that the bridge observes and bumps its own
+// live per-target generation counter on -- the thing request_fill's TOCTOU
+// check compares against. The examples' `generation: 0` stub only worked
+// because their Agent never triggered that event before authorizing. Here we
+// track it for real: one goto() = one bump.
+let liveGeneration = 0;
+const observe = (frameId) => () => ({ tabOrigin: origin, frameOrigin: origin, formActionOrigin: origin, frameId, generation: liveGeneration });
+
+try {
+ console.log(`\n=== TEST: forum site, real Puppeteer agent, post-while-authed activity ===`);
+ console.log(` site: ${origin}`);
+
+ const reg = await bridge.callTool("begin_credential_registration", { site_id: "forum" }, observe("forum"));
+ console.log(` 1. register -> ${JSON.stringify(reg)}`);
+
+ const browser = await puppeteer.connect({ browserWSEndpoint: bridge.url });
+ const page = await browser.newPage();
+ await page.goto(`${origin}/forum`);
+ liveGeneration++; // that goto() fired one real Page.frameNavigated
+ const targetId = page.target()._targetId;
+ // NOTE: page.$eval/page.type/element-handle APIs go through DOM.resolveNode
+ // internally, which is NOT on the bridge's CDP allowlist (DOM.querySelector
+ // is allowed, resolveNode is not -- a deliberate, narrower boundary than
+ // Puppeteer/Playwright's convenience layer assumes). page.evaluate() (pure
+ // Runtime.evaluate) works fine and is what the shipped examples use too, so
+ // interaction below stays entirely inside evaluate() calls.
+ console.log(` 2. before login -> ${await page.evaluate(() => document.querySelector("#who").textContent)}`);
+
+ const fill = await bridge.callTool("request_fill",
+ { binding_id: "forum", target_id: targetId, selector: "#p" }, observe(targetId));
+ console.log(` 3. login fill -> ${JSON.stringify(fill)}`);
+ await page.reload();
+ console.log(` 4. after login -> ${await page.evaluate(() => document.querySelector("#who").textContent)}`);
+
+ // 5. The activity: post a real comment, driven entirely by Puppeteer (via evaluate).
+ await page.evaluate(() => { document.querySelector("#body").value = "Bridge posted this while authenticated, no password in sight."; });
+ await page.evaluate(() => document.querySelector("#submit").click());
+ await page.waitForFunction(() => document.querySelector("#status")?.textContent === "posted");
+ console.log(` 5. post activity -> server recorded: ${JSON.stringify(posts)}`);
+
+ const leaked = [JSON.stringify(reg), JSON.stringify(fill)].some((s) => s.includes(users.get("grace")));
+ const ok = reg.status === "registered" && fill.status === "filled" && posts.length === 1 && !leaked;
+ console.log(`\n agent (Puppeteer) ever saw the password: ${leaked ? "YES -- BUG" : "no"}`);
+ console.log(` ${ok ? "OK" : "FAILED"}\n`);
+ await browser.disconnect();
+ process.exitCode = ok ? 0 : 1;
+} finally {
+ await bridge.close();
+ await new Promise((r) => site.close(r));
+}