pi-browser-harness 0.11.0 → 0.13.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +102 -1
  3. package/package.json +13 -7
  4. package/skills/pi-browser-harness/SKILL.md +84 -0
  5. package/src/cdp/commands.ts +204 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +72 -37
  8. package/src/cdp/events.ts +8 -3
  9. package/src/cdp/network-buffer.ts +38 -32
  10. package/src/cdp/ownership.ts +22 -4
  11. package/src/cdp/record-store.ts +2 -2
  12. package/src/cdp/session.ts +320 -217
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +76 -43
  16. package/src/daemon/bridge.ts +115 -191
  17. package/src/daemon/connection.ts +323 -0
  18. package/src/daemon/index.ts +77 -48
  19. package/src/daemon/lines.ts +40 -0
  20. package/src/daemon/main.ts +12 -0
  21. package/src/daemon/protocol.ts +55 -25
  22. package/src/daemon/server.ts +100 -63
  23. package/src/daemon/socket.ts +10 -0
  24. package/src/daemon/spawn.ts +84 -27
  25. package/src/daemon/transport.ts +88 -52
  26. package/src/domains/ax-tree.ts +8 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -7
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -66
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +64 -11
  34. package/src/domains/form-batch.ts +13 -11
  35. package/src/domains/form.ts +79 -116
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +7 -7
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +68 -53
  41. package/src/domains/keyboard.ts +16 -18
  42. package/src/domains/navigate.ts +102 -69
  43. package/src/domains/network.ts +56 -98
  44. package/src/domains/page/fill.page.ts +139 -0
  45. package/src/domains/page/form.page.ts +61 -0
  46. package/src/domains/page/inject.ts +37 -0
  47. package/src/domains/page/keyboard.page.ts +16 -0
  48. package/src/domains/page/live-value.page.ts +10 -0
  49. package/src/domains/page/read-page.page.ts +51 -0
  50. package/src/domains/page/serp.page.ts +39 -0
  51. package/src/domains/page.ts +19 -58
  52. package/src/domains/poll.ts +42 -0
  53. package/src/domains/readpage/read-page.ts +48 -54
  54. package/src/domains/ref-resolve.ts +43 -29
  55. package/src/domains/ref-signature.ts +11 -0
  56. package/src/domains/render.ts +12 -23
  57. package/src/domains/screenshot-capture.ts +4 -2
  58. package/src/domains/screenshot.ts +13 -14
  59. package/src/domains/scroll.ts +4 -5
  60. package/src/domains/search/google-serp.ts +1 -1
  61. package/src/domains/search/web-search.ts +38 -34
  62. package/src/domains/setup.ts +0 -2
  63. package/src/domains/snapshot.ts +72 -77
  64. package/src/domains/tab-resolve.ts +36 -0
  65. package/src/domains/tabs.ts +19 -78
  66. package/src/domains/target.ts +1 -1
  67. package/src/domains/viewport.ts +0 -1
  68. package/src/index.ts +100 -39
  69. package/src/profile/bind.ts +45 -14
  70. package/src/profile/browser-process.ts +50 -12
  71. package/src/profile/command.ts +20 -12
  72. package/src/profile/launch.ts +13 -9
  73. package/src/profile/list.ts +9 -13
  74. package/src/profile/store.ts +2 -1
  75. package/src/prompt.ts +42 -117
  76. package/src/registry.ts +68 -4
  77. package/src/schemas/parse.ts +2 -2
  78. package/src/setup.ts +28 -19
  79. package/src/state.ts +33 -2
  80. package/src/util/debug.ts +7 -1
  81. package/src/util/guards.ts +3 -0
  82. package/src/util/mutex.ts +1 -1
  83. package/src/util/paths.ts +12 -1
  84. package/src/util/result.ts +8 -0
  85. package/src/util/sharp-shim.ts +18 -11
  86. package/src/util/sleep.ts +18 -0
  87. package/src/util/tool.ts +163 -114
  88. package/src/util/truncate.ts +1 -1
  89. package/tsconfig.json +2 -1
  90. package/src/domains/fill-engine.ts +0 -80
@@ -1,4 +1,4 @@
1
- import { mkdir, access, constants, stat, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, access, constants, stat } from "node:fs/promises";
2
2
  import { basename } from "node:path";
3
3
  import { Type } from "typebox";
4
4
  import type { BrowserClient } from "../client";
@@ -7,6 +7,7 @@ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
7
7
  import { safeJs } from "../util/js-template";
8
8
  import { pdfPath } from "../util/paths";
9
9
  import { cdpCall, cdpCallBrowser, evalJs } from "./cdp-call";
10
+ import { readFileResult, statResult, writeFileResult } from "./io";
10
11
  import { resolveRefToBackendId } from "./ref-resolve";
11
12
 
12
13
  const UploadArgs = Type.Object({
@@ -73,18 +74,20 @@ const jsFallbackUpload = async (
73
74
  selector: string,
74
75
  filePath: string,
75
76
  ): Promise<Result<void, ToolErr>> => {
76
- const buf = await readFile(filePath);
77
- const st = await stat(filePath);
77
+ const buf = await readFileResult(filePath);
78
+ if (!buf.success) return buf;
79
+ const st = await statResult(filePath);
80
+ if (!st.success) return st;
78
81
  const name = basename(filePath);
79
82
  const mime = inferMime(name);
80
83
  const expr = safeJs`
81
84
  (() => {
82
85
  const input = document.querySelector(${selector});
83
86
  if (!input || input.type !== 'file') throw new Error('File input not found');
84
- const bin = atob(${buf.toString("base64")});
87
+ const bin = atob(${buf.data.toString("base64")});
85
88
  const bytes = new Uint8Array(bin.length);
86
89
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
87
- const file = new File([bytes], ${name}, { type: ${mime}, lastModified: ${st.mtimeMs} });
90
+ const file = new File([bytes], ${name}, { type: ${mime}, lastModified: ${st.data.mtimeMs} });
88
91
  const dt = new DataTransfer();
89
92
  dt.items.add(file);
90
93
  input.files = dt.files;
@@ -104,7 +107,7 @@ export const uploadFileTool = defineBrowserTool({
104
107
  description: "Set files on a file <input> via CDP, with a JS-DataTransfer fallback for stubborn pages. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`.",
105
108
  promptSnippet: "Upload a file to a file input by ref (preferred) or selector",
106
109
  promptGuidelines: [
107
- "PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders. (File inputs are often hidden; snapshot still surfaces them.)",
110
+ "File inputs are often hidden; browser_snapshot still surfaces them.",
108
111
  "File path must be absolute and readable.",
109
112
  ],
110
113
  parameters: UploadArgs,
@@ -122,7 +125,7 @@ export const uploadFileTool = defineBrowserTool({
122
125
  }
123
126
 
124
127
  if (args.selector === undefined) {
125
- return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
128
+ return err({ kind: "invalid_args", message: "Provide either `ref` or `selector`." });
126
129
  }
127
130
  const cdp = await tryCdpUpload(client, args.selector, args.filePath);
128
131
  if (cdp.success) {
@@ -145,22 +148,70 @@ export const uploadFileTool = defineBrowserTool({
145
148
  },
146
149
  });
147
150
 
151
+ // Chrome stores the override in its default browser context, so it outlives this client and
152
+ // silently redirects the user's own downloads. Track it, so session teardown can undo it.
153
+ // The flag only records what THIS process set: the daemon is shared by every pi process on the
154
+ // machine and the CDP primitive is last-writer-wins, so another session may have reset or
155
+ // re-pointed it since. A crashed or killed session never reaches teardown, so the override can
156
+ // also outlive the process that set it.
157
+ let activeDownloadOverride: string | undefined;
158
+
159
+ // Shutdown must not block on a browser that is already gone: a pending restore against a
160
+ // disconnected daemon would otherwise trigger the one-shot reconnect (and Chrome's consent
161
+ // prompt) that 0.11.1 confined to explicit tool calls, and wait up to the connect timeout.
162
+ const SHUTDOWN_RESTORE_TIMEOUT_MS = 2_000;
163
+
164
+ export const restoreDownloadBehavior = async (client: BrowserClient): Promise<Result<void, ToolErr>> => {
165
+ const pending = activeDownloadOverride;
166
+ if (pending === undefined) return ok(undefined);
167
+ if (!client.status().alive) {
168
+ return err({ kind: "not_connected", message: `browser not connected; downloads may keep going to ${pending} until Chrome restarts` });
169
+ }
170
+ const r = await cdpCallBrowser(client, "Browser.setDownloadBehavior", { behavior: "default" }, { timeoutMs: SHUTDOWN_RESTORE_TIMEOUT_MS });
171
+ if (!r.success) {
172
+ return err({ ...r.error, message: `${r.error.message} (downloads may keep going to ${pending})` });
173
+ }
174
+ activeDownloadOverride = undefined;
175
+ return ok(undefined);
176
+ };
177
+
148
178
  const DownloadArgs = Type.Object({
149
- downloadPath: Type.String({ description: "Absolute path to a writable directory where downloads should be saved" }),
179
+ downloadPath: Type.Optional(
180
+ Type.String({ description: "Absolute path to a writable directory where downloads should be saved" }),
181
+ ),
182
+ restore: Type.Optional(
183
+ Type.Boolean({
184
+ description:
185
+ "Undo a previous browser_download: reset Chrome's download behavior back to the user's own default.",
186
+ }),
187
+ ),
150
188
  });
151
189
 
152
190
  export const downloadTool = defineBrowserTool({
153
191
  name: "browser_download",
154
192
  label: "Browser Download",
155
- description: "Configure Chrome's download behavior: set the save directory and disable the save-as prompt.",
193
+ description:
194
+ "Configure Chrome's download behavior: set the save directory and disable the save-as prompt, or restore the user's default. Restored automatically when this session shuts down cleanly.",
156
195
  promptSnippet: "Configure download directory",
157
196
  promptGuidelines: [
158
197
  "Pass an absolute path to an existing writable directory.",
159
- "Affects all subsequent downloads on this browser.",
198
+ "The override applies to the user's own downloads too, not just yours, and only a clean session shutdown undoes it. Call browser_download({restore:true}) as soon as you are done.",
160
199
  ],
161
200
  parameters: DownloadArgs,
162
201
  concurrency: "serialized",
163
202
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
203
+ if (args.restore === true) {
204
+ if (args.downloadPath !== undefined) {
205
+ return err({ kind: "invalid_args", message: "Pass either downloadPath or restore, not both" });
206
+ }
207
+ const r = await cdpCallBrowser(client, "Browser.setDownloadBehavior", { behavior: "default" });
208
+ if (!r.success) return r;
209
+ activeDownloadOverride = undefined;
210
+ return ok({ text: "Downloads restored to Chrome's default location", details: { restored: true } });
211
+ }
212
+ if (args.downloadPath === undefined) {
213
+ return err({ kind: "invalid_args", message: "Provide downloadPath to set a directory, or restore:true to undo" });
214
+ }
164
215
  try {
165
216
  await mkdir(args.downloadPath, { recursive: true });
166
217
  } catch (e) {
@@ -187,6 +238,7 @@ export const downloadTool = defineBrowserTool({
187
238
  eventsEnabled: true,
188
239
  });
189
240
  if (!r.success) return r;
241
+ activeDownloadOverride = args.downloadPath;
190
242
  return ok({
191
243
  text: `Downloads will save to: ${args.downloadPath}`,
192
244
  details: { downloadPath: args.downloadPath },
@@ -213,7 +265,8 @@ export const printToPdfTool = defineBrowserTool({
213
265
  });
214
266
  if (!r.success) return r;
215
267
  const path = args.outputPath ?? pdfPath(client.namespace);
216
- await writeFile(path, Buffer.from(r.data.data, "base64"));
268
+ const written = await writeFileResult(path, Buffer.from(r.data.data, "base64"));
269
+ if (!written.success) return written;
217
270
  return ok({ text: `PDF saved: ${path}`, details: { path } });
218
271
  },
219
272
  });
@@ -1,10 +1,12 @@
1
1
  import { Type } from "typebox";
2
2
  import { type Result, err, ok } from "../util/result";
3
- import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
- import { fillDeclaration } from "./fill-engine";
3
+ import { defineBrowserTool, type JsonObject, type ToolErr, type ToolOk } from "../util/tool";
5
4
  import { detailsOf, resolveAndCall } from "./element-call";
5
+ import { fillField, type FillOptions } from "./page/fill.page";
6
+ import { elementFunctionSource } from "./page/inject";
6
7
 
7
- const FILL_FN = fillDeclaration({ rejectSelect: false, focusFirst: true });
8
+ const FILL_FN = elementFunctionSource(fillField);
9
+ const FILL_OPTIONS: FillOptions = { rejectSelect: false, focusFirst: true };
8
10
 
9
11
  const FieldValue = Type.Union([Type.String(), Type.Boolean()], {
10
12
  description: "Value to set. String for text inputs / textareas / selects / contenteditable; boolean for checkboxes & radios.",
@@ -28,30 +30,30 @@ export const fillFormTool = defineBrowserTool({
28
30
  promptSnippet: "Fill many form fields at once by ref — React-safe, self-confirming",
29
31
  promptGuidelines: [
30
32
  "Preferred for forms: snapshot once, then fill all fields in a single browser_fill_form call.",
31
- "Each field: { ref, value }. value is a string for text/select/contenteditable, boolean for checkbox/radio.",
32
33
  "The result summarizes how many fields filled cleanly and flags any value mismatches or per-field errors.",
33
34
  "Re-run browser_snapshot afterwards to verify the form state if needed.",
34
35
  ],
35
36
  parameters: FillFormArgs,
36
37
  concurrency: "serialized",
37
38
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
38
- const results: Array<Readonly<Record<string, unknown>>> = [];
39
+ const results: JsonObject[] = [];
39
40
  let okCount = 0;
40
41
  const issues: string[] = [];
41
42
  for (const f of args.fields) {
42
- const r = await resolveAndCall(client, f.ref, FILL_FN, [f.value]);
43
+ const r = await resolveAndCall(client, f.ref, FILL_FN, [f.value, FILL_OPTIONS]);
43
44
  if (!r.success) {
44
45
  results.push({ ref: f.ref, ok: false, error: r.error.message });
45
46
  issues.push(`ref ${f.ref} (${r.error.message})`);
46
47
  continue;
47
48
  }
48
49
  const res = r.data;
49
- const wanted = typeof f.value === "boolean" ? undefined : String(f.value);
50
- const got = res.value !== undefined ? String(res.value) : undefined;
51
- const mismatch = wanted !== undefined && got !== undefined && got !== wanted && res.text !== wanted;
52
- const fieldOk = res.ok === true && !mismatch;
50
+ const wanted = typeof f.value === "boolean" ? undefined : f.value;
51
+ const got = res.ok && "value" in res ? res.value : undefined;
52
+ const label = res.ok && res.kind === "select" ? res.text : undefined;
53
+ const mismatch = wanted !== undefined && got !== undefined && got !== wanted && label !== wanted;
54
+ const fieldOk = res.ok && !mismatch;
53
55
  if (fieldOk) okCount++;
54
- else issues.push(`ref ${f.ref} (${res.ok === false ? (res.reason ?? "failed") : "value mismatch"})`);
56
+ else issues.push(`ref ${f.ref} (${res.ok ? "value mismatch" : res.reason})`);
55
57
  results.push({ ref: f.ref, ok: fieldOk, mismatch, ...detailsOf(f.ref, res) });
56
58
  }
57
59
  const text =
@@ -1,10 +1,47 @@
1
1
  import { Type } from "typebox";
2
2
  import { type Result, err, ok } from "../util/result";
3
- import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
- import { detailsOf, isPageResult, type PageResult, resolveAndCall, runOnElement } from "./element-call";
5
- import { fillBody } from "./fill-engine";
3
+ import { defineBrowserTool, type JsonObject, type ToolErr, type ToolOk } from "../util/tool";
4
+ import { detailsOf, type ElementOutcome, isPageResult, resolveAndCall, runOnElement } from "./element-call";
5
+ import { fillField, type FillOptions } from "./page/fill.page";
6
+ import { type FocusResult, focusElement, type SelectOption, type SelectResult, type SelectWanted, selectOption, setChecked } from "./page/form.page";
7
+ import { elementFunctionSource } from "./page/inject";
6
8
  import { interactiveDiff } from "./ref-resolve";
7
9
 
10
+ type Target = { readonly ref?: string | undefined; readonly selector?: string | undefined };
11
+
12
+ const targetLabel = (t: Target): string => t.ref ?? t.selector ?? "";
13
+
14
+ // Only the locator the caller actually passed goes into details; an undefined key would not survive pi's JSON store.
15
+ const targetDetails = (t: Target): JsonObject => ({
16
+ ...(t.ref !== undefined ? { ref: t.ref } : {}),
17
+ ...(t.selector !== undefined ? { selector: t.selector } : {}),
18
+ });
19
+
20
+ const noMatch = (t: Target): ToolErr => ({
21
+ kind: "invalid_state",
22
+ message: `No element matched: ${targetLabel(t)}`,
23
+ details: targetDetails(t),
24
+ });
25
+
26
+ // A page function that returned something the guard does not recognise is a harness bug, not a missing element.
27
+ const badShape = (t: Target, what: string): ToolErr => ({
28
+ kind: "internal",
29
+ message: `unexpected page result shape from ${what}`,
30
+ details: targetDetails(t),
31
+ });
32
+
33
+ // Narrows an element outcome to the page's typed result, or returns the error to surface.
34
+ const narrow = <T>(
35
+ outcome: ElementOutcome,
36
+ t: Target,
37
+ what: string,
38
+ guard: (v: unknown) => v is T,
39
+ ): Result<T, ToolErr> => {
40
+ if (!outcome.found) return err(noMatch(t));
41
+ if (!guard(outcome.value)) return err(badShape(t, what));
42
+ return ok(outcome.value);
43
+ };
44
+
8
45
  const FillArgs = Type.Object({
9
46
  ref: Type.Optional(
10
47
  Type.String({
@@ -16,9 +53,8 @@ const FillArgs = Type.Object({
16
53
  value: Type.String({ description: "Value to set in the field" }),
17
54
  });
18
55
 
19
- const FILL_BODY = `const value = arg;\n${fillBody({ rejectSelect: true, focusFirst: false })}`;
20
-
21
- const NOT_FOUND: PageResult = { ok: false, reason: "not_found" };
56
+ const FILL_FN = elementFunctionSource(fillField);
57
+ const FILL_OPTIONS: FillOptions = { rejectSelect: true, focusFirst: false };
22
58
 
23
59
  export const fillTool = defineBrowserTool({
24
60
  name: "browser_fill",
@@ -27,63 +63,39 @@ export const fillTool = defineBrowserTool({
27
63
  "Fill a form field (input/textarea/contenteditable), or tick a checkbox/radio by passing 'true'/'false'. PREFERRED: pass `ref` (e.g. 'e7') from browser_snapshot — survives re-renders. Fallback: a CSS `selector`. Writes through the native value setter and fires bubbling 'input'/'change' events, so React/Vue/Angular controlled components and rich-text editors update correctly — unlike browser_type. Refuses a disabled field rather than writing a value the page will ignore. Returns the field's value after writing, plus a compact diff of page changes.",
28
64
  promptSnippet: "Fill a form field by ref (preferred) or selector (works with React/Vue controlled inputs)",
29
65
  promptGuidelines: [
30
- "PREFER `ref` from browser_snapshot (the '[eN]' handle) over a guessed CSS selector — refs survive re-renders and don't require you to invent a selector.",
31
66
  "Pass the desired value; no browser_click is needed first.",
32
67
  "For a checkbox or radio pass 'true' or 'false' — it toggles `checked`, not the submit value. browser_set_checked does the same thing with a boolean.",
33
- "A disabled field is refused: the error says so, rather than reporting a write the page never accepted.",
34
- "A 'ref is stale' error means the page changed — re-run browser_snapshot to get fresh refs.",
35
68
  "Use browser_type instead only for keystroke-sensitive widgets (autocomplete, masked/segmented inputs).",
36
69
  "Fill sets the value but does not focus or submit. To submit a tag/autocomplete input, follow with browser_dispatch_key({ ref, key: 'Enter' }) — not browser_press_key, which targets the focused element and may miss this field.",
37
70
  "For a custom (div-based) dropdown, browser_fill won't validate against its option list — open it, run browser_snapshot, then browser_click the option's ref.",
38
- "The result reports the field's value after writing and an appended page-changes diff — confirm both match what you intended before moving on.",
71
+ "Confirm the field value browser_fill returns matches what you intended.",
39
72
  ],
40
73
  parameters: FillArgs,
41
74
  concurrency: "serialized",
42
75
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
43
- const r = await runOnElement(client, {
44
- ref: args.ref,
45
- selector: args.selector,
46
- fnBody: FILL_BODY,
47
- arg: args.value,
48
- notFound: NOT_FOUND,
49
- });
76
+ const r = await runOnElement(client, { ref: args.ref, selector: args.selector, functionDeclaration: FILL_FN, args: [args.value, FILL_OPTIONS] });
50
77
  if (!r.success) return r;
51
- const res = isPageResult(r.data) ? r.data : undefined;
52
- const target = args.ref ?? args.selector ?? "";
53
- if (res === undefined || res.reason === "not_found") {
54
- return err({
55
- kind: "invalid_state",
56
- message: `No element matched: ${target}`,
57
- details: { ref: args.ref, selector: args.selector },
58
- });
59
- }
78
+ const narrowed = narrow(r.data, args, "browser_fill", isPageResult);
79
+ if (!narrowed.success) return narrowed;
80
+ const res = narrowed.data;
81
+ const target = targetLabel(args);
60
82
  if (!res.ok) {
61
83
  const details = { ref: args.ref, selector: args.selector, ...(res.tag !== undefined ? { tag: res.tag } : {}) };
62
- if (res.kind === "select") {
84
+ if (res.code === "select_rejected") {
63
85
  return err({
64
86
  kind: "invalid_state",
65
87
  message: `Element <select> is not fillable (not an input, textarea, or contenteditable): ${target}. For <select> use browser_select_option.`,
66
88
  details,
67
89
  });
68
90
  }
69
- return err({
70
- kind: "invalid_state",
71
- message: `Could not fill ${target}: ${res.reason ?? "unknown reason"}`,
72
- details,
73
- });
91
+ return err({ kind: "invalid_state", message: `Could not fill ${target}: ${res.reason}`, details });
74
92
  }
75
- const shown = res.value !== undefined ? JSON.stringify(res.value) : `checked=${res.checked === true}`;
93
+ const verified = "checked" in res ? res.checked : res.value;
94
+ const shown = "checked" in res ? `checked=${res.checked}` : JSON.stringify(res.value);
76
95
  const diff = await interactiveDiff(client);
77
96
  return ok({
78
97
  text: `Filled ${target} = ${shown}${diff}`,
79
- details: {
80
- ref: args.ref,
81
- selector: args.selector,
82
- value: args.value,
83
- verified: res.value ?? res.checked,
84
- ...(res.tag !== undefined ? { tag: res.tag } : {}),
85
- ...(res.kind !== undefined ? { kind: res.kind } : {}),
86
- },
98
+ details: { ...targetDetails(args), value: args.value, verified, tag: res.tag, kind: res.kind },
87
99
  });
88
100
  },
89
101
  });
@@ -95,21 +107,13 @@ const FocusArgs = Type.Object({
95
107
  selector: Type.Optional(Type.String({ description: "CSS selector of the element to focus (fallback when no ref)" })),
96
108
  });
97
109
 
98
- type FocusResult = { status: "ok"; tag: string } | { status: "not_found" } | { status: "not_focusable"; tag: string };
99
-
100
110
  const isFocusResult = (v: unknown): v is FocusResult => {
101
111
  if (typeof v !== "object" || v === null || !("status" in v)) return false;
102
- if (v.status === "not_found") return true;
103
112
  if (v.status === "ok" || v.status === "not_focusable") return "tag" in v && typeof v.tag === "string";
104
113
  return false;
105
114
  };
106
115
 
107
- const FOCUS_FN = `
108
- const el = this;
109
- if (typeof el.focus !== "function") return { status: "not_focusable", tag: el.tagName };
110
- el.focus();
111
- return document.activeElement === el ? { status: "ok", tag: el.tagName } : { status: "not_focusable", tag: el.tagName };
112
- `;
116
+ const FOCUS_FN = elementFunctionSource(focusElement);
113
117
 
114
118
  export const focusTool = defineBrowserTool({
115
119
  name: "browser_focus",
@@ -118,24 +122,21 @@ export const focusTool = defineBrowserTool({
118
122
  "Focus an element via the DOM .focus() method — deterministic, no coordinate accuracy needed. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`. Use before browser_type when a click might miss the field.",
119
123
  promptSnippet: "Focus an element by ref (preferred) or selector",
120
124
  promptGuidelines: [
121
- "PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders.",
122
- "Use before browser_type to guarantee the right field is focused without relying on click coordinates.",
123
125
  "For simply setting a value, prefer browser_fill (it doesn't need a separate focus step).",
124
126
  ],
125
127
  parameters: FocusArgs,
126
128
  concurrency: "serialized",
127
129
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
128
- const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: FOCUS_FN, arg: "" });
130
+ const r = await runOnElement(client, { ref: args.ref, selector: args.selector, functionDeclaration: FOCUS_FN, args: [] });
129
131
  if (!r.success) return r;
130
- const res = isFocusResult(r.data) ? r.data : undefined;
131
- const target = args.ref ?? args.selector ?? "";
132
- if (res === undefined || res.status === "not_found") {
133
- return err({ kind: "invalid_state", message: `No element matched: ${target}`, details: { ref: args.ref, selector: args.selector } });
134
- }
132
+ const narrowed = narrow(r.data, args, "browser_focus", isFocusResult);
133
+ if (!narrowed.success) return narrowed;
134
+ const res = narrowed.data;
135
+ const target = targetLabel(args);
135
136
  if (res.status === "not_focusable") {
136
- return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> could not be focused: ${target}`, details: { ref: args.ref, selector: args.selector, tag: res.tag } });
137
+ return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> could not be focused: ${target}`, details: { ...targetDetails(args), tag: res.tag } });
137
138
  }
138
- return ok({ text: `Focused ${target}`, details: { ref: args.ref, selector: args.selector, tag: res.tag } });
139
+ return ok({ text: `Focused ${target}`, details: { ...targetDetails(args), tag: res.tag } });
139
140
  },
140
141
  });
141
142
 
@@ -149,20 +150,13 @@ const SelectOptionArgs = Type.Object({
149
150
  index: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based option index to select" })),
150
151
  });
151
152
 
152
- type SelectResult =
153
- | { status: "ok"; value: string; label: string }
154
- | { status: "not_found" }
155
- | { status: "not_select"; tag: string }
156
- | { status: "no_match"; options: ReadonlyArray<{ value: string; label: string }> };
157
-
158
- const isSelectOption = (v: unknown): v is { value: string; label: string } =>
153
+ const isSelectOption = (v: unknown): v is SelectOption =>
159
154
  typeof v === "object" && v !== null
160
155
  && "value" in v && typeof v.value === "string"
161
156
  && "label" in v && typeof v.label === "string";
162
157
 
163
158
  const isSelectResult = (v: unknown): v is SelectResult => {
164
159
  if (typeof v !== "object" || v === null || !("status" in v)) return false;
165
- if (v.status === "not_found") return true;
166
160
  if (v.status === "not_select") return "tag" in v && typeof v.tag === "string";
167
161
  if (v.status === "ok") {
168
162
  return "value" in v && typeof v.value === "string" && "label" in v && typeof v.label === "string";
@@ -173,21 +167,7 @@ const isSelectResult = (v: unknown): v is SelectResult => {
173
167
  return false;
174
168
  };
175
169
 
176
- const SELECT_FN = `
177
- const el = this;
178
- if (el.tagName !== "SELECT") return { status: "not_select", tag: el.tagName };
179
- const opts = Array.from(el.options);
180
- const wv = arg.wv, wl = arg.wl, wi = arg.wi;
181
- let opt = null;
182
- if (wi !== null) opt = opts[wi] || null;
183
- else if (wv !== null) opt = opts.find(o => o.value === wv) || null;
184
- else if (wl !== null) opt = opts.find(o => o.text === wl) || null;
185
- if (!opt) return { status: "no_match", options: opts.map(o => ({ value: o.value, label: o.text })) };
186
- el.value = opt.value;
187
- el.dispatchEvent(new Event("input", { bubbles: true }));
188
- el.dispatchEvent(new Event("change", { bubbles: true }));
189
- return { status: "ok", value: opt.value, label: opt.text };
190
- `;
170
+ const SELECT_FN = elementFunctionSource(selectOption);
191
171
 
192
172
  export const selectOptionTool = defineBrowserTool({
193
173
  name: "browser_select_option",
@@ -196,60 +176,41 @@ export const selectOptionTool = defineBrowserTool({
196
176
  "Select an option in a native <select> element by value, visible label, or index. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`. Sets the selection and fires bubbling 'input'/'change' events so framework listeners update. On no match, returns the available options.",
197
177
  promptSnippet: "Select an option in a native <select> by ref (preferred) or selector",
198
178
  promptGuidelines: [
199
- "PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders.",
200
179
  "Provide exactly one of value, label, or index to choose the option.",
201
180
  "For custom (non-native) dropdowns built from divs, click to open then browser_click the option instead.",
202
- "If no option matches, the error details list the available options so you can retry.",
203
181
  ],
204
182
  parameters: SelectOptionArgs,
205
183
  concurrency: "serialized",
206
184
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
207
185
  if (args.value === undefined && args.label === undefined && args.index === undefined) {
208
- return err({ kind: "invalid_state", message: "Provide one of value, label, or index." });
186
+ return err({ kind: "invalid_args", message: "Provide one of value, label, or index." });
209
187
  }
210
- // null sentinels distinguish "not provided" from a real empty-string match.
211
- const arg = { wv: args.value ?? null, wl: args.label ?? null, wi: args.index ?? null };
212
- const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: SELECT_FN, arg });
188
+ const wanted: SelectWanted = { wv: args.value ?? null, wl: args.label ?? null, wi: args.index ?? null };
189
+ const r = await runOnElement(client, { ref: args.ref, selector: args.selector, functionDeclaration: SELECT_FN, args: [wanted] });
213
190
  if (!r.success) return r;
214
- const res = isSelectResult(r.data) ? r.data : undefined;
215
- const target = args.ref ?? args.selector ?? "";
216
- if (res === undefined || res.status === "not_found") {
217
- return err({ kind: "invalid_state", message: `No element matched: ${target}`, details: { ref: args.ref, selector: args.selector } });
218
- }
191
+ const narrowed = narrow(r.data, args, "browser_select_option", isSelectResult);
192
+ if (!narrowed.success) return narrowed;
193
+ const res = narrowed.data;
194
+ const target = targetLabel(args);
219
195
  if (res.status === "not_select") {
220
- return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> is not a <select>: ${target}`, details: { ref: args.ref, selector: args.selector, tag: res.tag } });
196
+ return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> is not a <select>: ${target}`, details: { ...targetDetails(args), tag: res.tag } });
221
197
  }
222
198
  if (res.status === "no_match") {
223
199
  return err({
224
200
  kind: "invalid_state",
225
201
  message: `No matching option in ${target}. Available: ${res.options.map((o) => o.label).join(", ")}`,
226
- details: { ref: args.ref, selector: args.selector, options: res.options },
202
+ details: { ...targetDetails(args), options: res.options },
227
203
  });
228
204
  }
229
205
  const diff = await interactiveDiff(client);
230
206
  return ok({
231
207
  text: `Selected "${res.label}" (value=${JSON.stringify(res.value)}) in ${target}${diff}`,
232
- details: { ref: args.ref, selector: args.selector, value: res.value, label: res.label },
208
+ details: { ...targetDetails(args), value: res.value, label: res.label },
233
209
  });
234
210
  },
235
211
  });
236
212
 
237
- const CHECK_FN = `
238
- function(checked) {
239
- const el = this;
240
- const tag = (el.tagName || "").toLowerCase();
241
- const type = (el.type || "").toLowerCase();
242
- if (tag !== "input" || (type !== "checkbox" && type !== "radio")) return { ok: false, reason: "ref is not a checkbox or radio" };
243
- if (el.disabled) return { ok: false, reason: "element is disabled" };
244
- const want = checked === true;
245
- let changed = false;
246
- if (el.checked !== want) {
247
- try { el.click(); } catch (e) {}
248
- if (el.checked !== want) { el.checked = want; el.dispatchEvent(new Event("change", { bubbles: true })); }
249
- changed = true;
250
- }
251
- return { ok: true, checked: el.checked, changed: changed };
252
- }`;
213
+ const CHECK_FN = elementFunctionSource(setChecked);
253
214
 
254
215
  const SetCheckedArgs = Type.Object({
255
216
  ref: Type.String({ description: "Stable element ref of a checkbox or radio from browser_snapshot (e.g. 'e7')." }),
@@ -263,7 +224,6 @@ export const setCheckedTool = defineBrowserTool({
263
224
  "Set a checkbox or radio to a desired checked state by ref. Only acts if the current state differs, fires change, and reports the final state. Get the ref from browser_snapshot's [eN].",
264
225
  promptSnippet: "Set a checkbox/radio checked state by ref",
265
226
  promptGuidelines: [
266
- "checked:true ticks the box, checked:false unticks it. Idempotent — no-op if already in the desired state.",
267
227
  "For radios, set checked:true on the option you want; the group deselects the others.",
268
228
  ],
269
229
  parameters: SetCheckedArgs,
@@ -273,10 +233,13 @@ export const setCheckedTool = defineBrowserTool({
273
233
  if (!r.success) return r;
274
234
  const res = r.data;
275
235
  if (!res.ok) {
276
- return err({ kind: "invalid_state", message: `Could not set checked on ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) });
236
+ return err({ kind: "invalid_state", message: `Could not set checked on ref ${args.ref}: ${res.reason}`, details: detailsOf(args.ref, res) });
237
+ }
238
+ if (!("checked" in res)) {
239
+ return err({ kind: "internal", message: `unexpected page result shape from browser_set_checked (kind ${res.kind})`, details: { ref: args.ref } });
277
240
  }
278
241
  return ok({
279
- text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed === false ? " (already)" : ""),
242
+ text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed ? "" : " (already)"),
280
243
  details: detailsOf(args.ref, res),
281
244
  });
282
245
  },
@@ -0,0 +1,89 @@
1
+ import { Type } from "typebox";
2
+ import type { BrowserClient } from "../client";
3
+ import { type Result, err, ok } from "../util/result";
4
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
5
+ import { cdpCall } from "./cdp-call";
6
+
7
+ const HeaderValue = Type.String({
8
+ maxLength: 8192,
9
+ pattern: "^[^\\r\\n]*$",
10
+ });
11
+
12
+ const SetHeadersArgs = Type.Object({
13
+ headers: Type.Record(Type.String(), HeaderValue, {
14
+ minProperties: 1,
15
+ maxProperties: 64,
16
+ description: "Extra HTTP request headers for the current owned tab. Names must be valid HTTP tokens.",
17
+ }),
18
+ });
19
+
20
+ const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
21
+
22
+ const currentOwnedTarget = (
23
+ client: BrowserClient,
24
+ ): Result<{ readonly targetId: string }, ToolErr> => {
25
+ const current = client.current();
26
+ if (current === null) {
27
+ return err({ kind: "invalid_state", message: "No tab attached." });
28
+ }
29
+ if (!client.owns(current.targetId)) {
30
+ return err({
31
+ kind: "invalid_state",
32
+ message: `Current tab ${current.targetId} is not owned by this harness session.`,
33
+ });
34
+ }
35
+ return ok({ targetId: current.targetId });
36
+ };
37
+
38
+ export const setHeadersTool = defineBrowserTool({
39
+ name: "browser_set_headers",
40
+ label: "Browser Set Headers",
41
+ description:
42
+ "Set extra HTTP request headers on the current owned tab. Headers persist for subsequent document, subresource, fetch, and XHR requests until cleared or the tab closes.",
43
+ promptSnippet: "Set extra request headers on the current owned tab",
44
+ promptGuidelines: [
45
+ "If no tab is attached yet, call browser_new_tab without a URL first.",
46
+ "Call before navigation when a site requires routing or environment headers.",
47
+ "Call browser_clear_headers before switching tabs or navigating this tab to another origin.",
48
+ "Header values are never echoed in tool output.",
49
+ ],
50
+ parameters: SetHeadersArgs,
51
+ concurrency: "serialized",
52
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
53
+ const target = currentOwnedTarget(client);
54
+ if (!target.success) return target;
55
+ const invalidName = Object.keys(args.headers).find((name) => !HEADER_NAME.test(name));
56
+ if (invalidName !== undefined) {
57
+ return err({ kind: "invalid_args", message: `Invalid HTTP header name: ${JSON.stringify(invalidName)}` });
58
+ }
59
+ const result = await cdpCall(client, "Network.setExtraHTTPHeaders", { headers: args.headers });
60
+ if (!result.success) return result;
61
+ const headerNames = Object.keys(args.headers).sort();
62
+ return ok({
63
+ text: `Set ${headerNames.length} extra HTTP header(s) on tab ${target.data.targetId}: ${headerNames.join(", ")}. Values are hidden. Clear them before switching tabs or navigating to another origin.`,
64
+ details: { targetId: target.data.targetId, headerNames, count: headerNames.length },
65
+ });
66
+ },
67
+ });
68
+
69
+ export const clearHeadersTool = defineBrowserTool({
70
+ name: "browser_clear_headers",
71
+ label: "Browser Clear Headers",
72
+ description: "Clear all extra HTTP request headers previously set on the current owned tab.",
73
+ promptSnippet: "Clear extra request headers from the current owned tab",
74
+ promptGuidelines: [
75
+ "Call after finishing an environment-specific workflow, before switching tabs, or before navigating to another origin.",
76
+ ],
77
+ parameters: Type.Object({}),
78
+ concurrency: "serialized",
79
+ async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
80
+ const target = currentOwnedTarget(client);
81
+ if (!target.success) return target;
82
+ const result = await cdpCall(client, "Network.setExtraHTTPHeaders", { headers: {} });
83
+ if (!result.success) return result;
84
+ return ok({
85
+ text: `Cleared extra HTTP headers from tab ${target.data.targetId}.`,
86
+ details: { targetId: target.data.targetId },
87
+ });
88
+ },
89
+ });