Skip to content
Open
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
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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": [
Expand All @@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "node --test test/*.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand Down
5 changes: 3 additions & 2 deletions src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ export async function runRecipe(
): Promise<RunResult> {
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",
};
}

Expand Down
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
const url = new URL(req.url);
Expand Down Expand Up @@ -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.";

Expand Down
31 changes: 25 additions & 6 deletions src/recipe.ts
Original file line number Diff line number Diff line change
@@ -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 };

Expand All @@ -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;
}
}
80 changes: 80 additions & 0 deletions test/recipe.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
});
}
});