From 1ef04ca834d23d1be51ab9269da51a5c511283f8 Mon Sep 17 00:00:00 2001 From: Victor <70475442+vsolano9@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:14:34 +0200 Subject: [PATCH] fix: make browser confirmation conservative Require confirmation for typing, all clicks, and navigation away from the canonical entry URL. Keep only waits and redundant entry navigation read-only, add exhaustive gate tests, and align public/runtime documentation. --- README.md | 14 +++++--- package.json | 1 + src/browser.ts | 5 +-- src/index.ts | 6 ++-- src/recipe.ts | 31 +++++++++++++---- test/recipe.test.mjs | 80 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 122 insertions(+), 15 deletions(-) create mode 100644 test/recipe.test.mjs diff --git a/README.md b/README.md index 37df145..af97f3f 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,11 @@ extraction spec. Webhands runs it in a headless browser and returns: - `screenshotBase64`, proof of what it saw - `steps`, the actions it took -Any step marked `write: true` (e.g. clicking "Issue refund") is **refused unless -the request includes `confirm: true`**, so reads are safe by default and writes -are deliberate. +Webhands uses a conservative confirmation gate. Only `waitFor` and a redundant +`goto` to the recipe's canonical entry URL are considered provably read-only. +Typing, every click, and navigation to any other path or query are **refused +unless the request includes `confirm: true`**. The legacy `write` field remains +accepted for recipe compatibility, but `write: false` does not bypass the gate. ## Modes @@ -49,6 +51,7 @@ npm run deploy # Cloudflare Workers (workers.dev URL) curl -s "$URL/run" -H "x-webhands-token: $WEBHANDS_TOKEN" \ -H "content-type: application/json" \ -d '{ + "confirm": true, "recipe": { "url": "https://seller.example.com/login", "steps": [ @@ -63,5 +66,6 @@ curl -s "$URL/run" -H "x-webhands-token: $WEBHANDS_TOKEN" \ }' ``` -A write recipe (e.g. clicking a "confirm shipment" button) returns an error -until you resend it with `"confirm": true`. +An interactive recipe returns an error until you resend it with +`"confirm": true`. Recipes containing only waits and redundant entry-URL +navigation can run without confirmation. diff --git a/package.json b/package.json index 7b57877..70c7192 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", + "test": "node --test test/*.test.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/src/browser.ts b/src/browser.ts index 6063f44..ba91212 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -22,14 +22,15 @@ export async function runRecipe( ): Promise { const { recipe, confirm } = req; - // Refuse write recipes unless explicitly confirmed. + // Refuse recipes that are not provably read-only unless explicitly confirmed. if (hasWriteStep(recipe) && !confirm) { return { ok: false, mode: env.BROWSER ? "live" : "dry", steps: [], error: - "recipe contains a write step; resend with confirm:true to execute", + "recipe contains an interaction that requires confirmation; " + + "resend with confirm:true to execute", }; } diff --git a/src/index.ts b/src/index.ts index 313275b..f42594c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,8 @@ import type { RunRequest } from "./recipe"; import { runRecipe } from "./browser"; // Webhands: POST a recipe, get structured data back. The agent operates the real -// dashboard UI for tools that have no usable API. Writes require confirm:true. +// dashboard UI for tools that have no usable API. Interactive recipes require +// confirm:true unless every step is provably read-only. export default { async fetch(req: Request, env: Env): Promise { const url = new URL(req.url); @@ -125,7 +126,8 @@ const AI_SYSTEM = "You are the assistant for Webhands, a computer-use agent for tools that have " + "no usable API. It drives a real headless browser through a recipe (login, " + "navigate, extract), returns structured data plus a screenshot, and refuses " + - "any write step unless confirm:true is set. Answer questions about Webhands " + + "typing, clicking, or navigation away from the entry URL unless confirm:true " + + "is set. Answer questions about Webhands " + "and browser automation clearly in at most two complete short sentences. Never " + "prefix your answer with assistant or a role label."; diff --git a/src/recipe.ts b/src/recipe.ts index a8dba72..66af505 100644 --- a/src/recipe.ts +++ b/src/recipe.ts @@ -1,10 +1,11 @@ // A recipe describes how to operate a dashboard that has no usable API. -// Read recipes pull structured data; any step that mutates state (a "write") -// must be explicitly marked and is refused unless the request carries confirm:true. +// Read recipes pull structured data. Only waits and redundant navigation to the +// entry URL are provably read-only; every other interaction needs confirmation. export type Step = | { action: "goto"; url: string } | { action: "type"; selector: string; text: string; secret?: boolean } + // `write` is retained for recipe compatibility; all clicks are gated. | { action: "click"; selector: string; write?: boolean } | { action: "waitFor"; selector: string; timeoutMs?: number }; @@ -24,12 +25,30 @@ export interface Recipe { export interface RunRequest { recipe: Recipe; - // Must be true to allow any step marked write:true to execute. + // Must be true to allow typing, clicking, or navigation away from recipe.url. confirm?: boolean; } export function hasWriteStep(recipe: Recipe): boolean { - return (recipe.steps ?? []).some( - (s) => s.action === "click" && s.write === true, - ); + return (recipe.steps ?? []).some((step) => { + switch (step.action) { + case "waitFor": + return false; + case "goto": + return !isSameUrl(step.url, recipe.url); + case "type": + case "click": + return true; + } + }); +} + +function isSameUrl(candidate: string, entry: string): boolean { + try { + return new URL(candidate).href === new URL(entry).href; + } catch { + // Invalid URLs cannot reach the browser successfully. Treat only an exact + // duplicate as redundant; every other value remains confirmation-required. + return candidate === entry; + } } diff --git a/test/recipe.test.mjs b/test/recipe.test.mjs new file mode 100644 index 0000000..0edc0c0 --- /dev/null +++ b/test/recipe.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import ts from "typescript"; + +const source = await readFile( + new URL("../src/recipe.ts", import.meta.url), + "utf8", +); +const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + }, + fileName: "src/recipe.ts", + reportDiagnostics: true, +}); +assert.equal(compiled.diagnostics?.length ?? 0, 0); + +const moduleUrl = `data:text/javascript;base64,${Buffer.from( + compiled.outputText, +).toString("base64")}`; +const { hasWriteStep } = await import(moduleUrl); + +const entryUrl = "https://dashboard.example.com"; + +test("requires confirmation unless every recipe step is provably read-only", async (t) => { + const cases = [ + { name: "no steps", steps: [], expected: false }, + { + name: "wait for content", + steps: [{ action: "waitFor", selector: ".orders" }], + expected: false, + }, + { + name: "canonical entry URL", + steps: [{ action: "goto", url: `${entryUrl}/` }], + expected: false, + }, + { + name: "different path", + steps: [{ action: "goto", url: `${entryUrl}/orders/123/cancel` }], + expected: true, + }, + { + name: "different query", + steps: [{ action: "goto", url: `${entryUrl}/?confirm=1` }], + expected: true, + }, + { + name: "typing", + steps: [{ action: "type", selector: "#search", text: "order 123" }], + expected: true, + }, + { + name: "unlabelled click", + steps: [{ action: "click", selector: "#cancel" }], + expected: true, + }, + { + name: "click labelled read-only", + steps: [{ action: "click", selector: "#details", write: false }], + expected: true, + }, + { + name: "click labelled write", + steps: [{ action: "click", selector: "#confirm", write: true }], + expected: true, + }, + ]; + + for (const scenario of cases) { + await t.test(scenario.name, () => { + assert.equal( + hasWriteStep({ url: entryUrl, steps: scenario.steps }), + scenario.expected, + ); + }); + } +});