From 1708952caa8008de4acf399f7d09b70646c8ef99 Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Wed, 30 Sep 2026 06:16:20 -0500 Subject: [PATCH] =?UTF-8?q?Slice=202:=20recipe=20core=20=E2=80=94=20parse,?= =?UTF-8?q?=20lint,=20render,=20verify.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recipe is a list of agent-browser argv steps ending in an eval that returns a JSON object, with typed params, an origins allowlist, and verify rules. All of it is pure and lives in @tyto/core. - parseRecipe validates structure (kebab name, origins, typed params, last step eval, verify, regression). - lintRecipe enforces the security rules: command allowlist, no {{…}} inside eval code, no @eN snapshot refs, open URLs inside origins, read-only eval heuristics, no password/token/OTP/card fills, no :nth-of-type built from a param. - renderRecipe resolves defaults and types, applies lower/underscore/ path/url filters, refuses values that would become flags or leave the origins, and wraps eval code so it reads params from a JSON literal (proven in a sandbox against quotes, backslashes, and ). - verifyResult checks required fields and regex matches with params escaped, comparing non-strings JSON-style. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/src/index.ts | 5 + packages/core/src/recipe/lint.ts | 71 +++++++++ packages/core/src/recipe/parse.ts | 159 ++++++++++++++++++++ packages/core/src/recipe/render.ts | 101 +++++++++++++ packages/core/src/recipe/types.ts | 46 ++++++ packages/core/src/recipe/verify.ts | 43 ++++++ packages/core/test/recipe.test.ts | 229 +++++++++++++++++++++++++++++ 7 files changed, 654 insertions(+) create mode 100644 packages/core/src/recipe/lint.ts create mode 100644 packages/core/src/recipe/parse.ts create mode 100644 packages/core/src/recipe/render.ts create mode 100644 packages/core/src/recipe/types.ts create mode 100644 packages/core/src/recipe/verify.ts create mode 100644 packages/core/test/recipe.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aa80643..207ad8a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,8 @@ export * from "./ports/index.ts"; export { PageTextGuard, SYSTEM_PREAMBLE } from "./policy/inject.ts"; export { SecretRedactor } from "./identity/redact.ts"; export { SystemClock } from "./clock/system.ts"; +export type { ParamSpec, ParamType, ParamValue, Recipe, RecipeStatus, RegressionCase, Step, VerifyRule } from "./recipe/types.ts"; +export { parseRecipe, type ParseResult } from "./recipe/parse.ts"; +export { lintRecipe } from "./recipe/lint.ts"; +export { evalWithParams, renderRecipe, type RenderResult } from "./recipe/render.ts"; +export { verifyResult, type VerifyOutcome } from "./recipe/verify.ts"; diff --git a/packages/core/src/recipe/lint.ts b/packages/core/src/recipe/lint.ts new file mode 100644 index 0000000..128077d --- /dev/null +++ b/packages/core/src/recipe/lint.ts @@ -0,0 +1,71 @@ +import type { Recipe, Step } from "./types.ts"; + +const ALLOWED = new Set([ + "open", "wait", "find", "click", "dblclick", "fill", "type", "select", "check", "uncheck", "hover", "press", "scroll", "get", "eval", +]); + +/** Read-only backstop for eval code. The approval gate is the real control for logged-in recipes. */ +const EVAL_FORBIDDEN: ReadonlyArray<[RegExp, string]> = [ + [/\bfetch\s*[(]/, "fetch"], + [/\bXMLHttpRequest\b/, "XMLHttpRequest"], + [/\bsendBeacon\b/, "sendBeacon"], + [/\bWebSocket\b/, "WebSocket"], + [/\bimport\s*\(/, "import("], + [/document\s*\.\s*cookie/, "document.cookie"], + [/\blocalStorage\b/, "localStorage"], + [/\bsessionStorage\b/, "sessionStorage"], + [/\blocation(?:\s*\.\s*href)?\s*=(?!=)/, "location assignment"], + [/\blocation\s*\.\s*(?:assign|replace)\s*\(/, "location.assign/replace"], + [/\bwindow\s*\.\s*open\s*\(/, "window.open("], + [/\.submit\s*\(/, ".submit("], +]; + +const SENSITIVE = + /(?:^|[^a-z])(?:pass(?:word|wd|code|phrase)?|token|secret|otp|one[-_ ]?time|cvv|cvc|ssn|pin)(?:[^a-z]|$)|cc-(?:number|csc|exp)|card/i; + +const URL_PARAM_ONLY = /^\{\{\s*\w+\s*\|\s*url\s*\}\}$/; + +function opensAllowedOrigin(url: string, origins: readonly string[]): boolean { + if (URL_PARAM_ONLY.test(url)) return true; // checked against origins at render time + return origins.some((o) => url === o || url.startsWith(`${o}/`) || url.startsWith(`${o}?`) || url.startsWith(`${o}#`)); +} + +function sensitiveLocator(step: Step): string | null { + const cmd = step[0]; + if (cmd === "fill" || cmd === "type") return SENSITIVE.test(step[1] ?? "") ? (step[1] ?? "") : null; + if (cmd === "find") { + const action = step.findIndex((a, i) => i > 0 && (a === "fill" || a === "type")); + if (action < 0) return null; + const locator = [...step.slice(1, action), ...step.slice(action + 2)].join(" "); + return SENSITIVE.test(locator) ? locator : null; + } + return null; +} + +/** Security rules. A recipe with any problem must not be stored or run. */ +export function lintRecipe(recipe: Recipe): string[] { + const problems: string[] = []; + recipe.steps.forEach((step, i) => { + const at = `step ${i + 1}`; + const cmd = step[0] ?? ""; + if (!ALLOWED.has(cmd)) problems.push(`${at}: command "${cmd}" is not allowed in recipes`); + for (const arg of step) { + const ref = /(?:^|[^\w])(@e\d+)\b/.exec(arg); + if (ref) problems.push(`${at}: uses snapshot ref ${ref[1]}, which only exists in one snapshot`); + if (arg.includes(":nth-of-type(") && arg.includes("{{")) + problems.push(`${at}: :nth-of-type built from a param counts mixed siblings; count the items themselves`); + } + if (cmd === "eval") { + const code = step[1] ?? ""; + if (step.length !== 2) problems.push(`${at}: eval takes exactly one code argument`); + if (code.includes("{{")) problems.push(`${at}: eval code must read params, not use {{…}} templates`); + for (const [re, label] of EVAL_FORBIDDEN) if (re.test(code)) problems.push(`${at}: eval code uses ${label}`); + } + if (cmd === "open" && !opensAllowedOrigin(step[1] ?? "", recipe.origins)) { + problems.push(`${at}: opens a URL outside the recipe's origins`); + } + const sensitive = sensitiveLocator(step); + if (sensitive !== null) problems.push(`${at}: fills a password, token, OTP, or card field (${sensitive}); use agent-browser auth`); + }); + return problems; +} diff --git a/packages/core/src/recipe/parse.ts b/packages/core/src/recipe/parse.ts new file mode 100644 index 0000000..bbd3010 --- /dev/null +++ b/packages/core/src/recipe/parse.ts @@ -0,0 +1,159 @@ +import type { ParamSpec, ParamType, Recipe, RecipeStatus, RegressionCase, Step, VerifyRule } from "./types.ts"; + +export type ParseResult = { ok: true; recipe: Recipe } | { ok: false; errors: string[] }; + +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const PARAM_TYPES: readonly ParamType[] = ["string", "int", "enum"]; +const STATUSES: readonly RecipeStatus[] = ["draft", "approved"]; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function isStringArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every((x) => typeof x === "string"); +} + +function isOrigin(v: string): boolean { + try { + const u = new URL(v); + return (u.protocol === "https:" || u.protocol === "http:") && u.origin === v; + } catch { + return false; + } +} + +function parseParam(name: string, v: unknown, errors: string[]): ParamSpec | null { + if (!isRecord(v)) { + errors.push(`param ${name}: must be an object`); + return null; + } + const type = v.type; + if (typeof type !== "string" || !PARAM_TYPES.includes(type as ParamType)) { + errors.push(`param ${name}: type must be string, int, or enum`); + return null; + } + if (typeof v.description !== "string" || typeof v.example !== "string") { + errors.push(`param ${name}: description and example must be strings`); + return null; + } + const spec: ParamSpec = { type: type as ParamType, description: v.description, example: v.example }; + if (v.default !== undefined) { + if (typeof v.default !== "string") errors.push(`param ${name}: default must be a string`); + else spec.default = v.default; + } + if (type === "enum") { + if (!isStringArray(v.values) || v.values.length === 0) errors.push(`param ${name}: enum needs non-empty values`); + else spec.values = v.values; + } + if (type === "int" && spec.default !== undefined && !/^-?\d+$/.test(spec.default)) { + errors.push(`param ${name}: default is not an integer`); + } + if (type === "enum" && spec.default !== undefined && spec.values && !spec.values.includes(spec.default)) { + errors.push(`param ${name}: default is not one of values`); + } + return spec; +} + +function parseVerify(v: unknown, errors: string[]): VerifyRule { + if (!isRecord(v)) { + errors.push("verify must be an object"); + return { required: [], match: {} }; + } + const required = v.required === undefined ? [] : v.required; + if (!isStringArray(required)) errors.push("verify.required must be a string array"); + const match: Record = {}; + if (v.match !== undefined) { + if (!isRecord(v.match)) errors.push("verify.match must be an object"); + else + for (const [k, pat] of Object.entries(v.match)) { + if (typeof pat !== "string") errors.push(`verify.match.${k} must be a string`); + else match[k] = pat; + } + } + return { required: isStringArray(required) ? required : [], match }; +} + +function parseRegression(v: unknown, errors: string[]): RegressionCase[] { + if (v === undefined) return []; + if (!Array.isArray(v)) { + errors.push("regression must be an array"); + return []; + } + const out: RegressionCase[] = []; + for (const c of v) { + if (!isRecord(c) || !isRecord(c.params) || !isRecord(c.expect)) { + errors.push("regression cases need params and expect objects"); + continue; + } + const params: Record = {}; + for (const [k, val] of Object.entries(c.params)) { + if (typeof val !== "string") errors.push(`regression param ${k} must be a string`); + else params[k] = val; + } + out.push({ params, expect: c.expect }); + } + return out; +} + +/** Structural validation. Security rules live in `lintRecipe`. */ +export function parseRecipe(input: unknown): ParseResult { + const errors: string[] = []; + if (!isRecord(input)) return { ok: false, errors: ["recipe must be a JSON object"] }; + + const name = input.name; + if (typeof name !== "string" || !NAME.test(name)) errors.push("name must be kebab-case"); + if (input.version !== 1) errors.push("version must be 1"); + const status = input.status ?? "draft"; + if (typeof status !== "string" || !STATUSES.includes(status as RecipeStatus)) errors.push("status must be draft or approved"); + const auth = input.auth ?? false; + if (typeof auth !== "boolean") errors.push("auth must be a boolean"); + if (typeof input.intent !== "string" || input.intent.trim() === "") errors.push("intent must be a non-empty string"); + const examples = input.examples ?? []; + if (!isStringArray(examples)) errors.push("examples must be a string array"); + const origins = input.origins; + if (!isStringArray(origins) || origins.length === 0) errors.push("origins must be a non-empty string array"); + else for (const o of origins) if (!isOrigin(o)) errors.push(`origin ${o} must look like https://host[:port]`); + + const params: Record = {}; + if (input.params !== undefined) { + if (!isRecord(input.params)) errors.push("params must be an object"); + else + for (const [k, v] of Object.entries(input.params)) { + if (!/^\w+$/.test(k)) errors.push(`param name ${k} must be a word`); + const spec = parseParam(k, v, errors); + if (spec) params[k] = spec; + } + } + + const steps: Step[] = []; + if (!Array.isArray(input.steps) || input.steps.length === 0) errors.push("steps must be a non-empty array"); + else + for (const s of input.steps) { + if (!isStringArray(s) || s.length === 0) errors.push("each step must be a non-empty array of strings"); + else steps.push(s); + } + const last = steps.at(-1); + if (!last || last[0] !== "eval" || last.length !== 2) errors.push('the last step must be ["eval", ""]'); + + const verify = parseVerify(input.verify, errors); + const regression = parseRegression(input.regression, errors); + + if (errors.length) return { ok: false, errors }; + return { + ok: true, + recipe: { + name: name as string, + version: 1, + status: status as RecipeStatus, + auth: auth as boolean, + intent: input.intent as string, + examples: examples as string[], + origins: origins as string[], + params, + steps, + verify, + regression, + }, + }; +} diff --git a/packages/core/src/recipe/render.ts b/packages/core/src/recipe/render.ts new file mode 100644 index 0000000..b9a8b8d --- /dev/null +++ b/packages/core/src/recipe/render.ts @@ -0,0 +1,101 @@ +import { TEMPLATE, type ParamValue, type Recipe } from "./types.ts"; + +export type RenderResult = + | { ok: true; steps: string[][]; params: Record } + | { ok: false; error: string }; + +class RenderError extends Error {} + +function resolveParams(recipe: Recipe, input: Readonly>): Record { + for (const k of Object.keys(input)) if (!(k in recipe.params)) throw new RenderError(`unknown param ${k}`); + const out: Record = {}; + for (const [name, spec] of Object.entries(recipe.params)) { + const raw = input[name] ?? spec.default; + if (raw === undefined) throw new RenderError(`missing param ${name}`); + switch (spec.type) { + case "string": + out[name] = raw; + break; + case "int": + if (!/^-?\d+$/.test(raw)) throw new RenderError(`param ${name} must be an integer`); + out[name] = Number.parseInt(raw, 10); + break; + case "enum": + if (!spec.values?.includes(raw)) throw new RenderError(`param ${name} must be one of ${spec.values?.join(", ") ?? ""}`); + out[name] = raw; + break; + default: { + const never: never = spec.type; + throw new RenderError(`unknown param type ${String(never)}`); + } + } + } + return out; +} + +function inOrigins(url: string, origins: readonly string[]): boolean { + try { + const u = new URL(url); + return (u.protocol === "https:" || u.protocol === "http:") && origins.includes(u.origin); + } catch { + return false; + } +} + +function applyFilter(value: string, filter: string | undefined, origins: readonly string[]): string { + switch (filter) { + case undefined: + return value; + case "lower": + return value.toLowerCase(); + case "underscore": + return value.replace(/ /g, "_"); + case "path": + return value.split("/").map(encodeURIComponent).join("/"); + case "url": + if (!inOrigins(value, origins)) throw new RenderError("url param is outside the recipe's origins"); + return value; + default: + throw new RenderError(`unknown filter ${filter}`); + } +} + +function renderArg(arg: string, params: Record, origins: readonly string[]): string { + if (!arg.includes("{{")) return arg; + const out = arg.replace(TEMPLATE, (_m, name: string, filter: string | undefined) => { + const v = params[name]; + if (v === undefined) throw new RenderError(`template uses unknown param ${name}`); + return applyFilter(String(v), filter, origins); + }); + if (out.startsWith("-")) throw new RenderError("a param value may not start an argument with '-'"); + return out; +} + +function utf8Base64(text: string): string { + let binary = ""; + for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte); + return btoa(binary); +} + +/** Wrap eval code so it reads `params` from a JSON literal; values are never pasted into code. */ +export function evalWithParams(code: string, params: Record): string { + return `(function (params) { return eval(${JSON.stringify(code)}); })(JSON.parse(${JSON.stringify(JSON.stringify(params))}))`; +} + +export function renderRecipe(recipe: Recipe, input: Readonly>): RenderResult { + try { + const params = resolveParams(recipe, input); + const steps = recipe.steps.map((step) => { + if (step[0] === "eval") return ["eval", "-b", utf8Base64(evalWithParams(step[1] ?? "", params))]; + const rendered = step.map((arg) => renderArg(arg, params, recipe.origins)); + if (rendered[0] === "open" && !inOrigins(rendered[1] ?? "", recipe.origins)) { + throw new RenderError("rendered URL is outside the recipe's origins"); + } + return rendered; + }); + return { ok: true, steps, params }; + } catch (e) { + if (e instanceof RenderError) return { ok: false, error: e.message }; + throw e; + } +} diff --git a/packages/core/src/recipe/types.ts b/packages/core/src/recipe/types.ts new file mode 100644 index 0000000..2b79ce0 --- /dev/null +++ b/packages/core/src/recipe/types.ts @@ -0,0 +1,46 @@ +export type ParamType = "string" | "int" | "enum"; + +export type ParamSpec = { + type: ParamType; + description: string; + example: string; + default?: string; + values?: readonly string[]; +}; + +/** One agent-browser command as an argv array, without `--session`. */ +export type Step = readonly string[]; + +export type VerifyRule = { + required: readonly string[]; + /** field → regex; `{{param}}` is replaced by the regex-escaped param value. */ + match: Readonly>; +}; + +export type RegressionCase = { + params: Readonly>; + expect: Readonly>; +}; + +export type RecipeStatus = "draft" | "approved"; + +export type Recipe = { + name: string; + version: 1; + status: RecipeStatus; + /** Needs the user's saved logins; runs only when approved. */ + auth: boolean; + intent: string; + examples: readonly string[]; + /** Origins the recipe may open, e.g. "https://github.com". */ + origins: readonly string[]; + params: Readonly>; + steps: readonly Step[]; + verify: VerifyRule; + regression: readonly RegressionCase[]; +}; + +export type ParamValue = string | number; + +/** `{{name}}` or `{{name|filter}}`. */ +export const TEMPLATE = /\{\{\s*(\w+)(?:\s*\|\s*(\w+))?\s*\}\}/g; diff --git a/packages/core/src/recipe/verify.ts b/packages/core/src/recipe/verify.ts new file mode 100644 index 0000000..97c8615 --- /dev/null +++ b/packages/core/src/recipe/verify.ts @@ -0,0 +1,43 @@ +import { TEMPLATE, type ParamValue, type VerifyRule } from "./types.ts"; + +export type VerifyOutcome = { hit: true; result: Record } | { hit: false; miss: string }; + +const MAX_FIELD = 65_536; + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isEmpty(v: unknown): boolean { + return v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0); +} + +/** Check the final eval result against the recipe's rule. A miss is safe; a wrong hit is not. */ +export function verifyResult(raw: unknown, rule: VerifyRule, params: Readonly>): VerifyOutcome { + let value = raw; + if (typeof value === "string") { + try { + value = JSON.parse(value) as unknown; + } catch { + return { hit: false, miss: "final eval did not return JSON" }; + } + } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { hit: false, miss: "final eval did not return an object" }; + } + const result = value as Record; + for (const k of rule.required) if (isEmpty(result[k])) return { hit: false, miss: `required field empty: ${k}` }; + for (const [k, pattern] of Object.entries(rule.match)) { + const source = pattern.replace(TEMPLATE, (_m, name: string) => escapeRegExp(String(params[name] ?? ""))); + let re: RegExp; + try { + re = new RegExp(source); + } catch { + return { hit: false, miss: `invalid match pattern for ${k}` }; + } + const v = result[k]; + const text = (typeof v === "string" ? v : JSON.stringify(v) ?? "").slice(0, MAX_FIELD); + if (!re.test(text)) return { hit: false, miss: `field ${k} does not match ${source}` }; + } + return { hit: true, result }; +} diff --git a/packages/core/test/recipe.test.ts b/packages/core/test/recipe.test.ts new file mode 100644 index 0000000..485ce64 --- /dev/null +++ b/packages/core/test/recipe.test.ts @@ -0,0 +1,229 @@ +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; +import { lintRecipe, parseRecipe, renderRecipe, verifyResult, type Recipe } from "../src/index.ts"; + +function base(overrides: Record = {}): Record { + return { + name: "github-release-info", + version: 1, + status: "draft", + auth: false, + intent: "Newest release tag and date for a GitHub repo.", + examples: ["latest release of vercel-labs/agent-browser"], + origins: ["https://github.com"], + params: { + repo: { type: "string", description: "owner/name", example: "vercel-labs/agent-browser" }, + n: { type: "int", description: "position, 1 = newest", example: "1", default: "1" }, + }, + steps: [ + ["open", "https://github.com/{{repo|path}}/releases"], + ["wait", "--load", "load"], + ["eval", "JSON.stringify({ tag: 'v' + params.n, repo: params.repo, url: 'https://github.com/' + params.repo })"], + ], + verify: { required: ["tag", "url"], match: { url: "github\\.com/{{repo}}" } }, + regression: [], + ...overrides, + }; +} + +function recipe(overrides: Record = {}): Recipe { + const parsed = parseRecipe(base(overrides)); + if (!parsed.ok) throw new Error(parsed.errors.join("; ")); + return parsed.recipe; +} + +function lintOf(overrides: Record): string[] { + return lintRecipe(recipe(overrides)); +} + +function decodeEval(step: readonly string[]): string { + expect(step.slice(0, 2)).toEqual(["eval", "-b"]); + return new TextDecoder().decode(Uint8Array.from(atob(step[2] ?? ""), (c) => c.charCodeAt(0))); +} + +describe("parseRecipe", () => { + it("accepts a valid recipe and returns typed params", () => { + const r = recipe(); + expect(r.params.n?.type).toBe("int"); + expect(r.params.repo?.default).toBeUndefined(); + expect(r.steps).toHaveLength(3); + }); + + it("rejects a recipe whose last step is not eval", () => { + const parsed = parseRecipe(base({ steps: [["open", "https://github.com/"], ["wait", "--load", "load"]] })); + expect(parsed.ok).toBe(false); + if (!parsed.ok) expect(parsed.errors.join(" ")).toMatch(/last step/i); + }); +}); + +describe("lintRecipe", () => { + it("accepts the base recipe", () => { + expect(lintOf({})).toEqual([]); + }); + + it("rejects {{ inside eval code", () => { + const problems = lintOf({ steps: [["open", "https://github.com/"], ["eval", "JSON.stringify({ n: {{n}} })"]] }); + expect(problems.join(" ")).toMatch(/eval.*\{\{/i); + }); + + it("rejects commands outside the allowlist (cookies, storage, network, download, upload, auth, state, snapshot, screenshot)", () => { + for (const cmd of ["cookies", "storage", "network", "download", "upload", "auth", "state", "snapshot", "screenshot"]) { + const problems = lintOf({ steps: [["open", "https://github.com/"], [cmd, "x"], ["eval", "JSON.stringify({})"]] }); + expect(problems.join(" "), cmd).toMatch(new RegExp(`command.*${cmd}`, "i")); + } + }); + + it("rejects @eN refs in steps", () => { + const problems = lintOf({ steps: [["open", "https://github.com/"], ["click", "@e12"], ["eval", "JSON.stringify({})"]] }); + expect(problems.join(" ")).toMatch(/@e12/); + }); + + it("rejects an open URL whose origin is not in origins", () => { + const problems = lintOf({ steps: [["open", "https://evil.test/x"], ["eval", "JSON.stringify({})"]] }); + expect(problems.join(" ")).toMatch(/origin/i); + }); + + it("rejects eval code using fetch, XMLHttpRequest, sendBeacon, WebSocket, import(, document.cookie, localStorage, sessionStorage, location assignment, or .submit(", () => { + const bad = [ + "fetch('/x')", + "new XMLHttpRequest()", + "navigator.sendBeacon('/x')", + "new WebSocket('wss://x')", + "import('x')", + "document.cookie", + "localStorage.getItem('a')", + "sessionStorage.length", + "location = 'https://evil.test'", + "location.href = 'https://evil.test'", + "document.forms[0].submit()", + ]; + for (const code of bad) { + const problems = lintOf({ steps: [["open", "https://github.com/"], ["eval", `(() => { ${code}; return JSON.stringify({}); })()`]] }); + expect(problems.length, code).toBeGreaterThan(0); + } + }); + + it("rejects fills whose locator looks like a password, token, OTP, or card field", () => { + for (const locator of ["#password", "input[name=api_token]", "#otp-code", "input[autocomplete=cc-number]", "#card-number"]) { + const problems = lintOf({ steps: [["open", "https://github.com/"], ["fill", locator, "{{repo}}"], ["eval", "JSON.stringify({})"]] }); + expect(problems.length, locator).toBeGreaterThan(0); + } + const viaFind = lintOf({ steps: [["open", "https://github.com/"], ["find", "label", "Password", "fill", "x"], ["eval", "JSON.stringify({})"]] }); + expect(viaFind.length).toBeGreaterThan(0); + }); + + it("rejects :nth-of-type selectors built from a param", () => { + const problems = lintOf({ steps: [["open", "https://github.com/"], ["click", "tr.athing:nth-of-type({{n}}) a"], ["eval", "JSON.stringify({})"]] }); + expect(problems.join(" ")).toMatch(/nth-of-type/); + }); +}); + +describe("renderRecipe", () => { + it("applies lower, underscore, path, and url filters", () => { + const r = recipe({ + params: { + q: { type: "string", description: "query", example: "Snowy Owl" }, + target: { type: "string", description: "page", example: "https://github.com/a" }, + }, + steps: [ + ["open", "{{target|url}}"], + ["fill", "#q", "{{q|lower}}"], + ["wait", "--url", "**/wiki/{{q|underscore}}"], + ["open", "https://github.com/search/{{q|path}}"], + ["eval", "JSON.stringify({})"], + ], + verify: { required: [], match: {} }, + }); + const out = renderRecipe(r, { q: "Snowy Owl/Tyto", target: "https://github.com/topics" }); + expect(out.ok).toBe(true); + if (!out.ok) return; + expect(out.steps[0]).toEqual(["open", "https://github.com/topics"]); + expect(out.steps[1]).toEqual(["fill", "#q", "snowy owl/tyto"]); + expect(out.steps[2]).toEqual(["wait", "--url", "**/wiki/Snowy_Owl/Tyto"]); + expect(out.steps[3]).toEqual(["open", "https://github.com/search/Snowy%20Owl/Tyto"]); + }); + + it("fills defaults and rejects missing required params", () => { + const ok = renderRecipe(recipe(), { repo: "vercel-labs/agent-browser" }); + expect(ok.ok && ok.params).toEqual({ repo: "vercel-labs/agent-browser", n: 1 }); + const missing = renderRecipe(recipe(), {}); + expect(missing.ok).toBe(false); + if (!missing.ok) expect(missing.error).toMatch(/repo/); + }); + + it("rejects non-integer int params and values outside an enum", () => { + expect(renderRecipe(recipe(), { repo: "a/b", n: "two" }).ok).toBe(false); + const withEnum = recipe({ + params: { size: { type: "enum", description: "size", example: "medium", values: ["small", "medium", "large"] } }, + steps: [["open", "https://github.com/"], ["eval", "JSON.stringify({ size: params.size })"]], + verify: { required: [], match: {} }, + }); + expect(renderRecipe(withEnum, { size: "medium" }).ok).toBe(true); + expect(renderRecipe(withEnum, { size: "huge" }).ok).toBe(false); + }); + + it("rejects unknown params", () => { + const out = renderRecipe(recipe(), { repo: "a/b", extra: "x" }); + expect(out.ok).toBe(false); + }); + + it('rejects a param value that would start an argv element with "-"', () => { + const r = recipe({ steps: [["open", "https://github.com/"], ["fill", "#q", "{{repo}}"], ["eval", "JSON.stringify({})"]] }); + const out = renderRecipe(r, { repo: "--profile=/tmp/x" }); + expect(out.ok).toBe(false); + }); + + it("keeps rendered open URLs inside origins", () => { + const r = recipe({ + params: { target: { type: "string", description: "page", example: "https://github.com/a" } }, + steps: [["open", "{{target|url}}"], ["eval", "JSON.stringify({})"]], + verify: { required: [], match: {} }, + }); + expect(renderRecipe(r, { target: "https://evil.test/phish" }).ok).toBe(false); + expect(renderRecipe(r, { target: "https://github.com.evil.test/" }).ok).toBe(false); + expect(renderRecipe(recipe(), { repo: "../../evil.test" }).ok).toBe(true); + const out = renderRecipe(recipe(), { repo: "../../evil.test" }); + if (out.ok) expect(new URL(out.steps[0]?.[1] ?? "").origin).toBe("https://github.com"); + }); + + it("eval preamble carries params as JSON and survives quotes, backslashes, and ", () => { + const tricky = `a'b"c\\d\`\${x}`; + const out = renderRecipe(recipe(), { repo: tricky }); + expect(out.ok).toBe(true); + if (!out.ok) return; + const code = decodeEval(out.steps[2] ?? []); + const value = runInNewContext(code, {}) as string; + expect(JSON.parse(value)).toEqual({ tag: "v1", repo: tricky, url: `https://github.com/${tricky}` }); + }); +}); + +describe("verifyResult", () => { + const rule = { required: ["tag", "url"], match: { url: "github\\.com/{{repo}}/releases", latest: "^true$" } }; + + it("hits when required fields are present and matches pass", () => { + const out = verifyResult(JSON.stringify({ tag: "v1", url: "https://github.com/a/b/releases", latest: true }), rule, { repo: "a/b" }); + expect(out).toEqual({ hit: true, result: { tag: "v1", url: "https://github.com/a/b/releases", latest: true } }); + }); + + it("misses naming the first empty required field", () => { + const out = verifyResult({ tag: "", url: "https://github.com/a/b/releases", latest: true }, rule, { repo: "a/b" }); + expect(out.hit).toBe(false); + if (!out.hit) expect(out.miss).toMatch(/tag/); + }); + + it("escapes params inside match patterns", () => { + const out = verifyResult({ tag: "v1", url: "https://github.com/aXb/releases", latest: true }, rule, { repo: "a.b" }); + expect(out.hit).toBe(false); + }); + + it("compares non-string values JSON-style", () => { + expect(verifyResult({ tag: "v1", url: "https://github.com/a/b/releases", latest: true }, rule, { repo: "a/b" }).hit).toBe(true); + expect(verifyResult({ tag: "v1", url: "https://github.com/a/b/releases", latest: "True" }, rule, { repo: "a/b" }).hit).toBe(false); + }); + + it("misses when the final eval result is not a JSON object", () => { + for (const bad of ["not json", "[1,2]", "null", 42]) { + expect(verifyResult(bad, rule, { repo: "a/b" }).hit, String(bad)).toBe(false); + } + }); +});