pi-browser-harness 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +46 -0
- package/package.json +5 -3
- package/skills/pi-browser-harness/SKILL.md +32 -0
- package/src/cdp/commands.ts +5 -0
- package/src/cdp/events.ts +1 -0
- package/src/cdp/session.ts +40 -1
- package/src/client.ts +51 -11
- package/src/domains/ax-tree.ts +19 -0
- package/src/domains/js.ts +2 -2
- package/src/domains/page/recipe.page.ts +82 -0
- package/src/domains/page/serp.page.ts +4 -1
- package/src/domains/ref-resolve.ts +1 -1
- package/src/domains/search/google-serp.ts +77 -7
- package/src/domains/search/redirect-resolver.ts +41 -0
- package/src/domains/search/web-search.ts +65 -7
- package/src/domains/snapshot.ts +11 -6
- package/src/index.ts +12 -1
- package/src/prompt.ts +2 -1
- package/src/recipes/command.ts +96 -0
- package/src/recipes/compile.ts +198 -0
- package/src/recipes/find.ts +49 -0
- package/src/recipes/journal.ts +125 -0
- package/src/recipes/recipe.ts +215 -0
- package/src/recipes/recordable.ts +359 -0
- package/src/recipes/replay.ts +354 -0
- package/src/recipes/run-tool.ts +132 -0
- package/src/recipes/save-tool.ts +247 -0
- package/src/recipes/store.ts +103 -0
- package/src/recipes/trust.ts +172 -0
- package/src/registry.ts +4 -0
- package/src/util/js-source.ts +28 -0
- package/src/util/tool.ts +25 -3
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import type { BrowserClient } from "../client";
|
|
4
|
+
import { type Result, err, ok } from "../util/result";
|
|
5
|
+
import { defineBrowserTool, type ToolErr } from "../util/tool";
|
|
6
|
+
import { applyParams, compileSteps, isNoEffectScript, latestTask, siteOf } from "./compile";
|
|
7
|
+
import { type JournalEntry, activeJournal } from "./journal";
|
|
8
|
+
import { RECIPE_NAME, type Recipe, type RecipeStep, stringifyRecipe } from "./recipe";
|
|
9
|
+
import { type StoredRecipe, findRecipe, recipeRoots, saveRecipe } from "./store";
|
|
10
|
+
import { printable, trustWritten } from "./trust";
|
|
11
|
+
import { describeStep, runRecipe } from "./replay";
|
|
12
|
+
|
|
13
|
+
const SaveArgs = Type.Object({
|
|
14
|
+
name: Type.String({ pattern: RECIPE_NAME.source, description: "Recipe name, lowercase words joined by '-' (e.g. 'vendor-invoices-export')" }),
|
|
15
|
+
params: Type.Optional(
|
|
16
|
+
Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), Type.String(), {
|
|
17
|
+
description: "Values from this run that change between runs, by param name: { month: '2026-09' }. Each value becomes {{name}} in the steps.",
|
|
18
|
+
}),
|
|
19
|
+
),
|
|
20
|
+
project: Type.Optional(Type.Boolean({ description: "Save under the project's .pi/browser-recipes instead of the user's recipe folder" })),
|
|
21
|
+
repair_step: Type.Optional(
|
|
22
|
+
Type.Integer({
|
|
23
|
+
minimum: 1,
|
|
24
|
+
description: "Repair an existing recipe after a failed replay: replace this step through the end with what you did since the failure. Use the step number the failure reported. The repaired recipe is replayed before it is written, so finish the task from the page where it failed, without detours.",
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// The user sees exactly what will be written, then any notes on it; without a UI the agent's call is the
|
|
30
|
+
// confirmation.
|
|
31
|
+
export const confirmAndSave = async (
|
|
32
|
+
ctx: ExtensionContext,
|
|
33
|
+
root: string,
|
|
34
|
+
recipe: Recipe,
|
|
35
|
+
title: string,
|
|
36
|
+
notes = "",
|
|
37
|
+
): Promise<Result<StoredRecipe, ToolErr>> => {
|
|
38
|
+
if (ctx.hasUI) {
|
|
39
|
+
const yes = await ctx.ui.confirm(title, `${stringifyRecipe(recipe)}${notes === "" ? "" : `\n${notes}`}`);
|
|
40
|
+
if (!yes) return err({ kind: "invalid_state", message: "The user declined to save the recipe; nothing was written." });
|
|
41
|
+
}
|
|
42
|
+
const saved = await saveRecipe(root, recipe);
|
|
43
|
+
if (!saved.success) return err({ kind: "io_error", message: saved.error });
|
|
44
|
+
await trustWritten(saved.data, ctx.cwd);
|
|
45
|
+
return ok(saved.data);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const fragileNote = (recipe: Recipe): string => {
|
|
49
|
+
const steps = recipe.steps.flatMap((s, i) => (s.fragile === true ? [i + 1] : []));
|
|
50
|
+
return steps.length === 0 ? "" : `\nFragile steps (located by coordinates, may break on layout changes): ${steps.join(", ")}`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const leftOutNote = (entries: ReadonlyArray<JournalEntry>): string => {
|
|
54
|
+
const n = entries.filter(isNoEffectScript).length;
|
|
55
|
+
const reads = entries.filter((e) => e.ok && e.read === true && !e.replayable).length;
|
|
56
|
+
const scripts =
|
|
57
|
+
n === 0
|
|
58
|
+
? ""
|
|
59
|
+
: `\nLeft out ${n} browser_execute_js call${n === 1 ? "" : "s"} that changed nothing on the page; a replay does not return what they read. If the task needs it, read it with browser_http_get or browser_read_page instead and save again.`;
|
|
60
|
+
const unkept =
|
|
61
|
+
reads === 0
|
|
62
|
+
? ""
|
|
63
|
+
: `\nLeft out ${reads} read${reads === 1 ? "" : "s"} that used request headers or a tab id, which a recipe does not keep.`;
|
|
64
|
+
return `${scripts}${unkept}`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const MAX_QUOTED = 120;
|
|
68
|
+
|
|
69
|
+
// Page text in the dialog is quoted on one line and cut short: a newline in it could forge lines of the dialog,
|
|
70
|
+
// and a long one could push the recipe out of view.
|
|
71
|
+
const quoted = (text: string): string => `"${printable(text.length > MAX_QUOTED ? `${text.slice(0, MAX_QUOTED)}…` : text)}"`;
|
|
72
|
+
|
|
73
|
+
const PLACEHOLDER = /\{\{[a-zA-Z_][a-zA-Z0-9_]*(?:\|[a-z]+)?\}\}/g;
|
|
74
|
+
|
|
75
|
+
// Text a step matches that the recorded run's page showed, which another run's page may not: a wait for page text
|
|
76
|
+
// with no param in it, and an element name holding a param next to other words. Saving cannot prove a recipe fits
|
|
77
|
+
// other values, so these are shown for the user and the agent to judge, not refused.
|
|
78
|
+
export const runSpecificText = (steps: ReadonlyArray<RecipeStep>): ReadonlyArray<string> =>
|
|
79
|
+
steps.flatMap((s, i) => {
|
|
80
|
+
const at = `step ${i + 1}`;
|
|
81
|
+
const text = s.args["text"];
|
|
82
|
+
const waits = s.action === "wait_for" && typeof text === "string" && text.match(PLACEHOLDER) === null ? [`${at}: waits for the text ${quoted(text)}`] : [];
|
|
83
|
+
const names = [...(s.locator !== undefined && "name" in s.locator ? [s.locator.name] : []), ...(s.expect?.appeared ?? []).map((a) => a.name)];
|
|
84
|
+
const mixed = names.filter((n) => n.match(PLACEHOLDER) !== null && n.replace(PLACEHOLDER, "").trim() !== "");
|
|
85
|
+
return [...waits, ...mixed.map((n) => `${at}: ${printable(describeStep(s))} matches ${quoted(n)}`)];
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const specificNote = (steps: ReadonlyArray<RecipeStep>): string => {
|
|
89
|
+
const found = runSpecificText(steps);
|
|
90
|
+
return found.length === 0
|
|
91
|
+
? ""
|
|
92
|
+
: `These steps match text from this run's page, which another run's page may not show; replace them with steps that do not depend on it if so:\n${found.join("\n")}\n`;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const isFailureMarker = (e: JournalEntry, name: string): boolean =>
|
|
96
|
+
e.tool === "browser_run_recipe" && !e.ok && e.recipe?.name === name;
|
|
97
|
+
|
|
98
|
+
const stringParams = (args: JournalEntry["args"]): Record<string, string> =>
|
|
99
|
+
Object.fromEntries(Object.entries(args).flatMap(([k, v]) => (typeof v === "string" ? [[k, v]] : [])));
|
|
100
|
+
|
|
101
|
+
// Steps 1..N-1 stay; N..end become what the agent did after the failed replay, templated with that run's params.
|
|
102
|
+
// A param the agent did not retype is not an error: the earlier steps still use it.
|
|
103
|
+
export const repairSteps = (
|
|
104
|
+
recipe: Recipe,
|
|
105
|
+
entries: ReadonlyArray<JournalEntry>,
|
|
106
|
+
step: number,
|
|
107
|
+
): Result<{ readonly steps: ReadonlyArray<RecipeStep>; readonly note: string; readonly params: Readonly<Record<string, string>> }, string> => {
|
|
108
|
+
let at = -1;
|
|
109
|
+
for (let i = entries.length - 1; i >= 0 && at < 0; i--) {
|
|
110
|
+
const e = entries[i];
|
|
111
|
+
if (e !== undefined && isFailureMarker(e, recipe.name)) at = i;
|
|
112
|
+
}
|
|
113
|
+
const marker = entries[at];
|
|
114
|
+
if (marker === undefined) return err(`no failed replay of ${recipe.name} in this session's journal`);
|
|
115
|
+
if (marker.recipe?.step !== step) return err(`the last failed replay of ${recipe.name} stopped at step ${marker.recipe?.step ?? "?"}, not ${step}`);
|
|
116
|
+
const after = entries.slice(at + 1).filter((e) => e.source === "agent");
|
|
117
|
+
const kept = recipe.steps.slice(0, step - 1);
|
|
118
|
+
// Each kept new_tab opens a tab and each kept close_tab closes one; only a tab still open may be closed again.
|
|
119
|
+
const openTabs = kept.reduce((n, s) => (s.action === "new_tab" ? n + 1 : s.action === "close_tab" ? Math.max(0, n - 1) : n), 0);
|
|
120
|
+
const compiled = compileSteps(after, openTabs);
|
|
121
|
+
if (!compiled.success) return err(`cannot repair from what was done after the failure: ${compiled.error}`);
|
|
122
|
+
const declared = Object.fromEntries(Object.entries(stringParams(marker.args)).filter(([k]) => k in recipe.params));
|
|
123
|
+
const templated = applyParams(compiled.data, declared);
|
|
124
|
+
const steps = templated.success ? templated.data : compiled.data;
|
|
125
|
+
return ok({ steps: [...kept, ...steps], note: leftOutNote(after), params: stringParams(marker.args) });
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Actions that only load or read a page, or wait, and so cannot send data however they were recorded.
|
|
129
|
+
const READS_ONLY: ReadonlySet<string> = new Set([
|
|
130
|
+
"navigate",
|
|
131
|
+
"new_tab",
|
|
132
|
+
"go_back",
|
|
133
|
+
"go_forward",
|
|
134
|
+
"wait_for",
|
|
135
|
+
"wait_for_load",
|
|
136
|
+
"scroll",
|
|
137
|
+
"focus",
|
|
138
|
+
"close_tab",
|
|
139
|
+
"http_get",
|
|
140
|
+
"read_page",
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
// What running a step again would repeat beyond the page itself. In a recipe saved before steps were marked, any
|
|
144
|
+
// other step may have sent data, so it is listed too: the gate fails closed.
|
|
145
|
+
const sideEffectsOf = (recipe: Recipe): ReadonlyArray<string> =>
|
|
146
|
+
recipe.steps.flatMap((s, i) => {
|
|
147
|
+
const unmarked = recipe.writesTracked !== true && !READS_ONLY.has(s.action) && s.writes !== true;
|
|
148
|
+
const effects = [
|
|
149
|
+
...(s.action === "upload_file" ? ["uploads a file"] : []),
|
|
150
|
+
...(s.action === "download" ? ["changes where downloads go"] : []),
|
|
151
|
+
...(s.expect?.download === true ? ["starts a download"] : []),
|
|
152
|
+
...(s.writes === true ? ["sends data to the site"] : []),
|
|
153
|
+
...(unmarked ? ["may send data to the site: saved before this was tracked"] : []),
|
|
154
|
+
];
|
|
155
|
+
return effects.length > 0 ? [`step ${i + 1}: ${describeStep(s)} (${effects.join(", ")})`] : [];
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// A repair is built from whatever the agent did after the failure, detours included, so it is replayed from step 1
|
|
159
|
+
// before it is written. A replay repeats side effects, so when the steps have any the user decides; with no UI to
|
|
160
|
+
// ask, the repair is written unchecked and says so.
|
|
161
|
+
const checkRepair = async (
|
|
162
|
+
client: BrowserClient,
|
|
163
|
+
ctx: ExtensionContext,
|
|
164
|
+
recipe: Recipe,
|
|
165
|
+
params: Readonly<Record<string, string>>,
|
|
166
|
+
signal: AbortSignal | undefined,
|
|
167
|
+
): Promise<Result<string, string>> => {
|
|
168
|
+
const effects = sideEffectsOf(recipe);
|
|
169
|
+
if (effects.length > 0) {
|
|
170
|
+
const why = `a check runs every step again, including:\n${effects.join("\n")}`;
|
|
171
|
+
if (!ctx.hasUI) return ok(`Not checked by a replay: ${why}\nRun the recipe to check it.`);
|
|
172
|
+
const go = await ctx.ui.confirm(`Check the repaired recipe "${recipe.name}" by running it?`, `${why}\n\nChoose No to save it without the check.`);
|
|
173
|
+
if (!go) return ok("Not checked by a replay: the user chose not to run its steps again.");
|
|
174
|
+
}
|
|
175
|
+
const outcome = await runRecipe({ client, extensionCtx: ctx, recipe, params, record: false, ...(signal !== undefined ? { signal } : {}) });
|
|
176
|
+
if (!outcome.ok) {
|
|
177
|
+
const f = outcome.failure;
|
|
178
|
+
return err(
|
|
179
|
+
`repair not saved: a replay of the repaired recipe stopped at step ${f.step} (${f.action}): ${f.reason}. The steps after the failure did not redo the task from where it stopped. Run the recipe again with browser_run_recipe for a fresh failure, finish the task from that page with browser tools, then repair from the step it reports.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return ok(`Checked: a replay of all ${outcome.steps} steps passed.`);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const saveRecipeTool = defineBrowserTool({
|
|
186
|
+
name: "browser_save_recipe",
|
|
187
|
+
label: "Save Browser Recipe",
|
|
188
|
+
description:
|
|
189
|
+
"Save the browser task you just finished as a named, replayable recipe. Compiles the page-changing calls and the reads (browser_http_get, browser_read_page of a url) of your most recent browser task from the session journal, turns the given param values into {{params}}, shows the recipe to the user to confirm, and writes a YAML file. A replay returns what the reads fetched; a browser_execute_js that only read the page is left out. Saving over an existing name keeps the previous version.",
|
|
190
|
+
promptSnippet: "Save the last browser task as a replayable recipe",
|
|
191
|
+
promptGuidelines: [
|
|
192
|
+
"After finishing a multi-step browser task the user is likely to repeat (an export, a report, a routine check), offer once to save it as a recipe: propose a name and which typed or selected values are params. Do not offer again for the same task.",
|
|
193
|
+
"Params are values that change between runs and that you typed, selected or put in a URL — a month, a search term, an id. Never make a button label or page text a param.",
|
|
194
|
+
],
|
|
195
|
+
parameters: SaveArgs,
|
|
196
|
+
concurrency: "parallel",
|
|
197
|
+
ensureAlive: false,
|
|
198
|
+
async handler(args, { client, extensionCtx, signal }) {
|
|
199
|
+
const journal = activeJournal();
|
|
200
|
+
if (journal === undefined) return err({ kind: "invalid_state", message: "No browser journal is active in this session." });
|
|
201
|
+
if (args.repair_step !== undefined) {
|
|
202
|
+
const stored = await findRecipe(extensionCtx.cwd, args.name);
|
|
203
|
+
if (!stored.success) return err({ kind: "invalid_args", message: stored.error });
|
|
204
|
+
const repaired = repairSteps(stored.data.recipe, await journal.entries(), args.repair_step);
|
|
205
|
+
if (!repaired.success) return err({ kind: "invalid_state", message: repaired.error });
|
|
206
|
+
const recipe: Recipe = {
|
|
207
|
+
...stored.data.recipe,
|
|
208
|
+
steps: repaired.data.steps,
|
|
209
|
+
lastRun: { at: new Date().toISOString(), result: "ok", repairedStep: args.repair_step },
|
|
210
|
+
};
|
|
211
|
+
const checked = await checkRepair(client, extensionCtx, recipe, repaired.data.params, signal);
|
|
212
|
+
if (!checked.success) return err({ kind: "invalid_state", message: checked.error });
|
|
213
|
+
const notes = specificNote(recipe.steps);
|
|
214
|
+
const saved = await confirmAndSave(extensionCtx, stored.data.root, recipe, `Repair recipe "${args.name}" from step ${args.repair_step}?`, notes);
|
|
215
|
+
if (!saved.success) return saved;
|
|
216
|
+
const r = saved.data.recipe;
|
|
217
|
+
return ok({
|
|
218
|
+
text: `Repaired recipe ${r.name}: steps ${args.repair_step}–${r.steps.length} replaced, now v${r.version}; previous version kept in .history/.\n${checked.data}${fragileNote(r)}${repaired.data.note}${notes === "" ? "" : `\n${notes.trimEnd()}`}`,
|
|
219
|
+
details: { name: r.name, version: r.version, path: saved.data.path, steps: r.steps.length },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const task = latestTask(await journal.entries());
|
|
223
|
+
const compiled = compileSteps(task);
|
|
224
|
+
if (!compiled.success) return err({ kind: "invalid_state", message: compiled.error });
|
|
225
|
+
const params = args.params ?? {};
|
|
226
|
+
const steps = applyParams(compiled.data, params);
|
|
227
|
+
if (!steps.success) return err({ kind: "invalid_args", message: `${steps.error}${leftOutNote(task)}` });
|
|
228
|
+
const recipe: Recipe = {
|
|
229
|
+
name: args.name,
|
|
230
|
+
site: siteOf(task),
|
|
231
|
+
version: 1,
|
|
232
|
+
params: Object.fromEntries(Object.entries(params).map(([k, v]) => [k, { type: "string", example: v }])),
|
|
233
|
+
steps: steps.data,
|
|
234
|
+
recorded: { at: new Date().toISOString(), session: extensionCtx.sessionManager.getSessionId() },
|
|
235
|
+
writesTracked: true,
|
|
236
|
+
};
|
|
237
|
+
const roots = recipeRoots(extensionCtx.cwd);
|
|
238
|
+
const notes = specificNote(recipe.steps);
|
|
239
|
+
const saved = await confirmAndSave(extensionCtx, args.project === true ? roots.project : roots.user, recipe, `Save recipe "${args.name}"?`, notes);
|
|
240
|
+
if (!saved.success) return saved;
|
|
241
|
+
const r = saved.data.recipe;
|
|
242
|
+
return ok({
|
|
243
|
+
text: `Saved recipe ${r.name} v${r.version} (${r.steps.length} steps) to ${saved.data.path}${fragileNote(r)}${leftOutNote(task)}${notes === "" ? "" : `\n${notes.trimEnd()}`}`,
|
|
244
|
+
details: { name: r.name, version: r.version, path: saved.data.path, steps: r.steps.length },
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { copyFile, mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join, resolve, sep } from "node:path";
|
|
4
|
+
import { agentDir } from "../profile/paths";
|
|
5
|
+
import { type Result, err, ok } from "../util/result";
|
|
6
|
+
import { type LastRun, RECIPE_SITE, type Recipe, parseRecipe, stringifyRecipe } from "./recipe";
|
|
7
|
+
|
|
8
|
+
export type StoredRecipe = { readonly recipe: Recipe; readonly path: string; readonly root: string };
|
|
9
|
+
|
|
10
|
+
export const recipeRoots = (cwd: string): { readonly project: string; readonly user: string } => ({
|
|
11
|
+
project: join(cwd, ".pi", "browser-recipes"),
|
|
12
|
+
user: join(agentDir(), "browser-recipes"),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const readStored = async (root: string, path: string): Promise<Result<StoredRecipe, string>> => {
|
|
16
|
+
let text: string;
|
|
17
|
+
try {
|
|
18
|
+
text = await readFile(path, "utf8");
|
|
19
|
+
} catch {
|
|
20
|
+
return err(`no recipe file at ${path}`);
|
|
21
|
+
}
|
|
22
|
+
const parsed = parseRecipe(text);
|
|
23
|
+
if (!parsed.success) return err(`${path}: ${parsed.error}`);
|
|
24
|
+
return ok({ recipe: parsed.data, path, root });
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const filesUnder = async (root: string): Promise<ReadonlyArray<string>> => {
|
|
28
|
+
const sites = await readdir(root).catch(() => []);
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
for (const site of sites) {
|
|
31
|
+
const names = await readdir(join(root, site)).catch(() => []);
|
|
32
|
+
for (const n of names) if (n.endsWith(".yaml")) out.push(join(root, site, n));
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Project recipes shadow user recipes of the same name.
|
|
38
|
+
export const listRecipes = async (cwd: string): Promise<ReadonlyArray<StoredRecipe>> => {
|
|
39
|
+
const { project, user } = recipeRoots(cwd);
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
const out: StoredRecipe[] = [];
|
|
42
|
+
for (const root of [project, user]) {
|
|
43
|
+
for (const path of await filesUnder(root)) {
|
|
44
|
+
const stored = await readStored(root, path);
|
|
45
|
+
if (!stored.success || seen.has(stored.data.recipe.name)) continue;
|
|
46
|
+
seen.add(stored.data.recipe.name);
|
|
47
|
+
out.push(stored.data);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const findRecipe = async (cwd: string, name: string): Promise<Result<StoredRecipe, string>> => {
|
|
54
|
+
const { project, user } = recipeRoots(cwd);
|
|
55
|
+
for (const root of [project, user]) {
|
|
56
|
+
for (const path of await filesUnder(root)) {
|
|
57
|
+
if (!path.endsWith(`/${name}.yaml`)) continue;
|
|
58
|
+
return readStored(root, path);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return err(`no recipe named "${name}"; call browser_run_recipe with no name to list saved recipes`);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const writeAtomic = async (path: string, text: string): Promise<void> => {
|
|
65
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
66
|
+
try {
|
|
67
|
+
await writeFile(tmp, text, "utf8");
|
|
68
|
+
await rename(tmp, path);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
await unlink(tmp).catch(() => undefined);
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// A save over an existing recipe keeps the old file under .history and bumps the version (R5).
|
|
76
|
+
export const saveRecipe = async (root: string, recipe: Recipe): Promise<Result<StoredRecipe, string>> => {
|
|
77
|
+
const dir = join(root, recipe.site);
|
|
78
|
+
if (!RECIPE_SITE.test(recipe.site) || !resolve(dir).startsWith(resolve(root) + sep)) {
|
|
79
|
+
return err(`refusing to save recipe ${recipe.name}: site "${recipe.site}" is not a host name`);
|
|
80
|
+
}
|
|
81
|
+
const path = join(dir, `${recipe.name}.yaml`);
|
|
82
|
+
try {
|
|
83
|
+
await mkdir(dir, { recursive: true });
|
|
84
|
+
const existing = await readStored(root, path);
|
|
85
|
+
let version = recipe.version;
|
|
86
|
+
if (existing.success) {
|
|
87
|
+
const history = join(dir, ".history");
|
|
88
|
+
await mkdir(history, { recursive: true });
|
|
89
|
+
await copyFile(path, join(history, `${recipe.name}.v${existing.data.recipe.version}.yaml`));
|
|
90
|
+
version = existing.data.recipe.version + 1;
|
|
91
|
+
}
|
|
92
|
+
const saved: Recipe = { ...recipe, version };
|
|
93
|
+
await writeAtomic(path, stringifyRecipe(saved));
|
|
94
|
+
return ok({ recipe: saved, path, root });
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return err(`could not save recipe to ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Records the outcome of a replay without bumping the version; a run is not a new recipe.
|
|
101
|
+
export const recordRun = async (stored: StoredRecipe, lastRun: LastRun): Promise<void> => {
|
|
102
|
+
await writeAtomic(stored.path, stringifyRecipe({ ...stored.recipe, lastRun })).catch(() => undefined);
|
|
103
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { agentDir } from "../profile/paths";
|
|
6
|
+
import { isRecord } from "../util/guards";
|
|
7
|
+
import { type Result, err, ok } from "../util/result";
|
|
8
|
+
import { describeStep, fill, fillArgs, hostOf } from "./replay";
|
|
9
|
+
import type { Recipe } from "./recipe";
|
|
10
|
+
import { type StoredRecipe, recipeRoots } from "./store";
|
|
11
|
+
|
|
12
|
+
// A project recipe's absolute path -> sha256 of the file bytes the user approved, and `${path}#params` -> sha256 of the
|
|
13
|
+
// param-decided values (see paramScope) the user last approved for it.
|
|
14
|
+
type TrustStore = Readonly<Record<string, string>>;
|
|
15
|
+
|
|
16
|
+
const trustFile = (): string => join(agentDir(), "browser-harness", "trusted-recipes.json");
|
|
17
|
+
|
|
18
|
+
const sha256 = (data: Uint8Array | string): string => createHash("sha256").update(data).digest("hex");
|
|
19
|
+
|
|
20
|
+
const paramsKey = (path: string): string => `${resolve(path)}#params`;
|
|
21
|
+
|
|
22
|
+
const readStore = async (): Promise<TrustStore> => {
|
|
23
|
+
try {
|
|
24
|
+
const raw: unknown = JSON.parse(await readFile(trustFile(), "utf8"));
|
|
25
|
+
if (!isRecord(raw)) return {};
|
|
26
|
+
return Object.fromEntries(Object.entries(raw).flatMap(([k, v]) => (typeof v === "string" ? [[k, v]] : [])));
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const isTrusted = async (path: string, bytes: Uint8Array, scope?: string): Promise<boolean> => {
|
|
33
|
+
const store = await readStore();
|
|
34
|
+
return store[resolve(path)] === sha256(bytes) && (scope === undefined || store[paramsKey(path)] === sha256(scope));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const trust = async (path: string, bytes: Uint8Array, scope?: string): Promise<void> => {
|
|
38
|
+
const file = trustFile();
|
|
39
|
+
const next: TrustStore = {
|
|
40
|
+
...(await readStore()),
|
|
41
|
+
[resolve(path)]: sha256(bytes),
|
|
42
|
+
...(scope !== undefined ? { [paramsKey(path)]: sha256(scope) } : {}),
|
|
43
|
+
};
|
|
44
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
45
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
46
|
+
try {
|
|
47
|
+
await writeFile(tmp, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
48
|
+
await rename(tmp, file);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
await unlink(tmp).catch(() => undefined);
|
|
51
|
+
throw e;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const isProject = (stored: StoredRecipe, cwd: string): boolean => stored.root === recipeRoots(cwd).project;
|
|
56
|
+
|
|
57
|
+
// The harness's own writes to a project recipe (a save, a repair, last_run) never make the user approve it again.
|
|
58
|
+
export const trustWritten = async (stored: StoredRecipe, cwd: string): Promise<void> => {
|
|
59
|
+
if (!isProject(stored, cwd)) return;
|
|
60
|
+
const bytes = await readFile(stored.path).catch(() => undefined);
|
|
61
|
+
if (bytes !== undefined) await trust(stored.path, bytes).catch(() => undefined);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// A URL template whose text before its first {{param}} already ends the origin cannot be sent elsewhere by a param.
|
|
65
|
+
const FIXED_ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*[/?#]/i;
|
|
66
|
+
const steersOrigin = (template: string): boolean => {
|
|
67
|
+
const at = template.indexOf("{{");
|
|
68
|
+
return at !== -1 && !FIXED_ORIGIN.test(template.slice(0, at));
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// An opaque origin ("null", as for file: URLs) names nothing, so the whole URL stands in for it.
|
|
72
|
+
const originOf = (url: string): string => {
|
|
73
|
+
try {
|
|
74
|
+
const origin = new URL(url).origin;
|
|
75
|
+
return origin === "null" ? url : origin;
|
|
76
|
+
} catch {
|
|
77
|
+
return url;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// What this run's params decide outside the page: each origin a param can change and each local path a param fills.
|
|
82
|
+
// Undefined when params decide none of it, so such a recipe is approved once per file, whatever the values.
|
|
83
|
+
const paramScope = (recipe: Recipe, params: Readonly<Record<string, string>>): string | undefined => {
|
|
84
|
+
const parts = recipe.steps.flatMap((s) => [
|
|
85
|
+
...[s.args["url"], s.expect?.url].flatMap((u) => (typeof u === "string" && steersOrigin(u) ? [originOf(fill(u, params))] : [])),
|
|
86
|
+
...[s.args["filePath"], s.args["downloadPath"]].flatMap((p) => (typeof p === "string" && p.includes("{{") ? [fill(p, params)] : [])),
|
|
87
|
+
]);
|
|
88
|
+
return parts.length > 0 ? JSON.stringify(parts) : undefined;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const ESCAPES: Readonly<Record<string, string>> = { "\n": "\\n", "\r": "\\r", "\t": "\\t" };
|
|
92
|
+
// A param value is the caller's text: a raw newline or terminal escape in it could forge lines of the dialog.
|
|
93
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
94
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
|
|
95
|
+
export const printable = (s: string): string => s.replace(CONTROL, (c) => ESCAPES[c] ?? `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
96
|
+
|
|
97
|
+
const shownUrl = (url: string): string => {
|
|
98
|
+
const origin = originOf(url);
|
|
99
|
+
return origin === url ? url : `${url} (origin ${origin})`;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// What a project recipe can do outside the page: where it goes, which local files it sends, what it downloads. Shown
|
|
103
|
+
// with this run's params filled in, since a {{placeholder}} hides exactly what the user is approving.
|
|
104
|
+
// Hosts on this machine or its private network: a read sent there can reach services the internet cannot.
|
|
105
|
+
const PRIVATE_V4 = /^(?:127\.|10\.|192\.168\.|169\.254\.|0\.|172\.(?:1[6-9]|2\d|3[01])\.)/;
|
|
106
|
+
export const isPrivateHost = (host: string): boolean => {
|
|
107
|
+
const h = host.toLowerCase().replace(/^\[|\]$/g, "");
|
|
108
|
+
return (
|
|
109
|
+
h === "localhost" ||
|
|
110
|
+
h.endsWith(".localhost") ||
|
|
111
|
+
h.endsWith(".local") ||
|
|
112
|
+
h.endsWith(".internal") ||
|
|
113
|
+
PRIVATE_V4.test(h) ||
|
|
114
|
+
h === "::1" ||
|
|
115
|
+
h === "::" ||
|
|
116
|
+
/^f[cd][0-9a-f]{2}:/.test(h) ||
|
|
117
|
+
h.startsWith("fe80:") ||
|
|
118
|
+
h.startsWith("::ffff:")
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const READ_VERBS: ReadonlyMap<string, string> = new Map([
|
|
123
|
+
["http_get", "fetch outside the browser"],
|
|
124
|
+
["read_page", "read in a tab of its own"],
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
const approvalBody = (stored: StoredRecipe, params: Readonly<Record<string, string>>, scoped: boolean): string => {
|
|
128
|
+
const { recipe } = stored;
|
|
129
|
+
const lines = recipe.steps.flatMap((s, i) => {
|
|
130
|
+
const at = ` step ${i + 1}:`;
|
|
131
|
+
const args = fillArgs(s.args, params);
|
|
132
|
+
const url = args["url"];
|
|
133
|
+
const file = args["filePath"];
|
|
134
|
+
const lands = s.expect?.url !== undefined ? fill(s.expect.url, params) : undefined;
|
|
135
|
+
if (s.action === "navigate" && typeof url === "string") return [`${at} navigate to ${shownUrl(url)}`];
|
|
136
|
+
if (s.action === "new_tab" && typeof url === "string") return [`${at} open a new tab at ${shownUrl(url)}`];
|
|
137
|
+
const verb = READ_VERBS.get(s.action);
|
|
138
|
+
if (verb !== undefined && typeof url === "string") {
|
|
139
|
+
const host = hostOf(url);
|
|
140
|
+
const local = host !== undefined && isPrivateHost(host) ? " (a local or private-network address)" : "";
|
|
141
|
+
return [`${at} ${verb} ${shownUrl(url)}${local}`];
|
|
142
|
+
}
|
|
143
|
+
if (s.action === "upload_file" && typeof file === "string") return [`${at} upload ${file}`];
|
|
144
|
+
if (s.action === "download") return [`${at} ${describeStep(s)}${typeof args["downloadPath"] === "string" ? ` to ${args["downloadPath"]}` : ""}`];
|
|
145
|
+
if (lands !== undefined && hostOf(lands) !== recipe.site) return [`${at} ${describeStep(s)}, landing on ${shownUrl(lands)}`];
|
|
146
|
+
return [];
|
|
147
|
+
});
|
|
148
|
+
const note = scoped ? ["", "Approval covers these values; a run with other values asks again."] : [];
|
|
149
|
+
return [`File: ${stored.path}`, `Site: ${recipe.site}`, ...(lines.length > 0 ? ["", ...lines] : []), ...note, "", `${recipe.steps.length} steps`]
|
|
150
|
+
.map(printable)
|
|
151
|
+
.join("\n");
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// A recipe from the project's .pi/browser-recipes was written by whoever wrote the repo and replays with no model in
|
|
155
|
+
// the loop, so its exact bytes, and the values its params give to URLs' origins and file paths, must be approved
|
|
156
|
+
// before it runs. Recipes under the agent dir are the user's own.
|
|
157
|
+
export const approveProjectRecipe = async (
|
|
158
|
+
ctx: ExtensionContext,
|
|
159
|
+
stored: StoredRecipe,
|
|
160
|
+
params: Readonly<Record<string, string>>,
|
|
161
|
+
): Promise<Result<void, string>> => {
|
|
162
|
+
if (!isProject(stored, ctx.cwd)) return ok(undefined);
|
|
163
|
+
const name = stored.recipe.name;
|
|
164
|
+
const bytes = await readFile(stored.path).catch(() => undefined);
|
|
165
|
+
if (bytes === undefined) return err(`recipe ${name} could not be read at ${stored.path}`);
|
|
166
|
+
const scope = paramScope(stored.recipe, params);
|
|
167
|
+
if (await isTrusted(stored.path, bytes, scope)) return ok(undefined);
|
|
168
|
+
if (!ctx.hasUI) return err(`recipe ${name} is a project recipe that has not been approved; run it once in an interactive session to approve it`);
|
|
169
|
+
if (!(await ctx.ui.confirm(`Run project recipe ${name}?`, approvalBody(stored, params, scope !== undefined)))) return err(`recipe ${name} not run: not approved`);
|
|
170
|
+
await trust(stored.path, bytes, scope);
|
|
171
|
+
return ok(undefined);
|
|
172
|
+
};
|
package/src/registry.ts
CHANGED
|
@@ -24,6 +24,8 @@ import { executeJsTool, runScriptTool } from "./domains/js";
|
|
|
24
24
|
import { setupTool } from "./domains/setup";
|
|
25
25
|
import { webSearchTool } from "./domains/search/web-search";
|
|
26
26
|
import { readPageTool } from "./domains/readpage/read-page";
|
|
27
|
+
import { saveRecipeTool } from "./recipes/save-tool";
|
|
28
|
+
import { runRecipeTool } from "./recipes/run-tool";
|
|
27
29
|
|
|
28
30
|
export const ALL_TOOLS: ReadonlyArray<BrowserTool> = [
|
|
29
31
|
setupTool,
|
|
@@ -68,6 +70,8 @@ export const ALL_TOOLS: ReadonlyArray<BrowserTool> = [
|
|
|
68
70
|
runScriptTool,
|
|
69
71
|
webSearchTool,
|
|
70
72
|
readPageTool,
|
|
73
|
+
saveRecipeTool,
|
|
74
|
+
runRecipeTool,
|
|
71
75
|
];
|
|
72
76
|
|
|
73
77
|
export type RegistryError = {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parse } from "acorn";
|
|
2
|
+
import { isRecord } from "./guards";
|
|
3
|
+
|
|
4
|
+
const FUNCTION_NODES: ReadonlySet<string> = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
|
5
|
+
|
|
6
|
+
// True when a return statement sits in the program body itself, not inside a function it defines.
|
|
7
|
+
const hasTopLevelReturn = (node: unknown): boolean => {
|
|
8
|
+
if (Array.isArray(node)) return node.some(hasTopLevelReturn);
|
|
9
|
+
if (!isRecord(node) || typeof node["type"] !== "string") return false;
|
|
10
|
+
if (node["type"] === "ReturnStatement") return true;
|
|
11
|
+
if (FUNCTION_NODES.has(node["type"])) return false;
|
|
12
|
+
return Object.values(node).some((v) => typeof v === "object" && v !== null && hasTopLevelReturn(v));
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// The source Runtime.evaluate should run for what the model wrote. A top-level `return`, anywhere in the body,
|
|
16
|
+
// makes it a function body, so it runs inside an async function. The call is awaited in the source itself: in REPL
|
|
17
|
+
// mode awaitPromise waits only for the REPL's own completion, so a returned promise would come back unresolved
|
|
18
|
+
// (serialized as {}). Anything else, including source that does not parse, is sent as written, so the page reports
|
|
19
|
+
// its own error.
|
|
20
|
+
export const pageExpression = (src: string): string => {
|
|
21
|
+
let program: unknown;
|
|
22
|
+
try {
|
|
23
|
+
program = parse(src, { ecmaVersion: "latest", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true });
|
|
24
|
+
} catch {
|
|
25
|
+
return src;
|
|
26
|
+
}
|
|
27
|
+
return isRecord(program) && hasTopLevelReturn(program["body"]) ? `await (async () => {\n${src}\n})()` : src;
|
|
28
|
+
};
|
package/src/util/tool.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
import { type Component, Text } from "@mariozechner/pi-tui";
|
|
13
13
|
import { daemonSocketMayExist } from "../daemon/spawn";
|
|
14
14
|
import type { BrowserClient } from "../client";
|
|
15
|
-
import { type Result, err } from "./result";
|
|
15
|
+
import { type Result, err, ok } from "./result";
|
|
16
16
|
|
|
17
17
|
// Details are stored by pi as JSON and handed back on replay, so a renderer only ever reads them.
|
|
18
18
|
export type DeepReadonly<T> = T extends ReadonlyArray<infer U>
|
|
@@ -141,6 +141,23 @@ const detailsGuard = <D extends TSchema>(schema: D | undefined): ((v: unknown) =
|
|
|
141
141
|
const notConnected = (message: string): Result<never, ToolErr> =>
|
|
142
142
|
err({ kind: "not_connected", message });
|
|
143
143
|
|
|
144
|
+
export type CallOutcome = { readonly ok: boolean; readonly error?: string };
|
|
145
|
+
|
|
146
|
+
// Watches a tool call from outside the tool (the recipe journal). Returns the function that receives the call's
|
|
147
|
+
// outcome and may return a note for the end of a successful result, or undefined to skip the call. Errors it throws
|
|
148
|
+
// are swallowed by runHandler.
|
|
149
|
+
export type CallObserver = (call: {
|
|
150
|
+
readonly name: string;
|
|
151
|
+
readonly concurrency: ToolConcurrency;
|
|
152
|
+
readonly args: unknown;
|
|
153
|
+
readonly client: BrowserClient;
|
|
154
|
+
}) => Promise<((outcome: CallOutcome) => Promise<string | undefined>) | undefined>;
|
|
155
|
+
|
|
156
|
+
let callObserver: CallObserver | undefined;
|
|
157
|
+
export const setCallObserver = (o: CallObserver | undefined): void => {
|
|
158
|
+
callObserver = o;
|
|
159
|
+
};
|
|
160
|
+
|
|
144
161
|
const runHandler = async <S extends TSchema, D extends TSchema>(
|
|
145
162
|
def: BrowserToolDefinition<S, D>,
|
|
146
163
|
client: BrowserClient,
|
|
@@ -164,16 +181,21 @@ const runHandler = async <S extends TSchema, D extends TSchema>(
|
|
|
164
181
|
return err({ kind: "invalid_state", message: "Tool execution aborted before entering serialized lane" });
|
|
165
182
|
}
|
|
166
183
|
}
|
|
184
|
+
const finish = await callObserver?.({ name: def.name, concurrency: def.concurrency, args, client }).catch(() => undefined);
|
|
167
185
|
try {
|
|
168
|
-
|
|
186
|
+
const result = await def.handler(args, {
|
|
169
187
|
client,
|
|
170
188
|
signal,
|
|
171
189
|
onUpdate: (u) => onUpdate?.(okResult(u)),
|
|
172
190
|
extensionCtx,
|
|
173
191
|
});
|
|
192
|
+
const note = await finish?.(result.success ? { ok: true } : { ok: false, error: result.error.message }).catch(() => undefined);
|
|
193
|
+
return result.success && note !== undefined ? ok({ ...result.data, text: `${result.data.text}\n\n${note}` }) : result;
|
|
174
194
|
} catch (e) {
|
|
175
195
|
// A throw is a bug in the handler, not a tool outcome; it still has to reach the model as an error.
|
|
176
|
-
|
|
196
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
197
|
+
await finish?.({ ok: false, error: message }).catch(() => undefined);
|
|
198
|
+
return err({ kind: "internal", message });
|
|
177
199
|
} finally {
|
|
178
200
|
release?.();
|
|
179
201
|
}
|