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( + ``); + } + 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)); +}