pi-browser-harness 0.2.0 → 0.3.1

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.
@@ -0,0 +1,76 @@
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
+
6
+ type HistoryEntry = { readonly id: number; readonly url: string; readonly title: string };
7
+ type History = { readonly entries: ReadonlyArray<HistoryEntry>; readonly currentIndex: number };
8
+
9
+ const fetchHistory = async (client: BrowserClient): Promise<Result<History, ToolErr>> => {
10
+ const r = await client.session().call("Page.getNavigationHistory");
11
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
12
+ return ok(r.data as History);
13
+ };
14
+
15
+ export const goBackTool = defineBrowserTool({
16
+ name: "browser_go_back",
17
+ label: "Browser Go Back",
18
+ description: "Navigate back one page in history.",
19
+ promptSnippet: "Go back one page",
20
+ promptGuidelines: ["Use to return to the previous page after navigating."],
21
+ parameters: Type.Object({}),
22
+ async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
23
+ const h = await fetchHistory(client);
24
+ if (!h.success) return h;
25
+ if (h.data.currentIndex <= 0) return ok({ text: "Already at the beginning of history." });
26
+ const target = h.data.entries[h.data.currentIndex - 1];
27
+ if (!target) return err({ kind: "internal", message: "history entry missing at index" });
28
+ const nav = await client.session().call("Page.navigateToHistoryEntry", { entryId: target.id });
29
+ if (!nav.success) return err({ kind: "cdp_error", message: nav.error.message });
30
+ return ok({ text: `Navigated back to: ${target.url}`, details: { url: target.url } });
31
+ },
32
+ });
33
+
34
+ export const goForwardTool = defineBrowserTool({
35
+ name: "browser_go_forward",
36
+ label: "Browser Go Forward",
37
+ description: "Navigate forward one page in history.",
38
+ promptSnippet: "Go forward one page",
39
+ promptGuidelines: ["Use to undo a browser_go_back."],
40
+ parameters: Type.Object({}),
41
+ async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
42
+ const h = await fetchHistory(client);
43
+ if (!h.success) return h;
44
+ if (h.data.currentIndex >= h.data.entries.length - 1) return ok({ text: "Already at the end of history." });
45
+ const target = h.data.entries[h.data.currentIndex + 1];
46
+ if (!target) return err({ kind: "internal", message: "history entry missing at index" });
47
+ const nav = await client.session().call("Page.navigateToHistoryEntry", { entryId: target.id });
48
+ if (!nav.success) return err({ kind: "cdp_error", message: nav.error.message });
49
+ return ok({ text: `Navigated forward to: ${target.url}`, details: { url: target.url } });
50
+ },
51
+ });
52
+
53
+ export const reloadTool = defineBrowserTool({
54
+ name: "browser_reload",
55
+ label: "Browser Reload",
56
+ description: "Reload the current page.",
57
+ promptSnippet: "Reload the page",
58
+ promptGuidelines: ["Use to refresh the page, e.g. after server-side changes."],
59
+ parameters: Type.Object({}),
60
+ async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
61
+ const r = await client.session().call("Page.reload");
62
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
63
+ const info = await client.pageInfo();
64
+ if (!info.success) return err({ kind: "cdp_error", message: info.error.message });
65
+ if ("dialog" in info.data) {
66
+ return ok({
67
+ text: `Reloaded. ⚠️ Dialog open: ${info.data.dialog.type}`,
68
+ details: { dialog: info.data.dialog },
69
+ });
70
+ }
71
+ return ok({
72
+ text: `Reloaded: ${info.data.title} (${info.data.url})`,
73
+ details: { page: info.data },
74
+ });
75
+ },
76
+ });
@@ -0,0 +1,197 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve, isAbsolute } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { createRequire } from "node:module";
5
+ import { Type } from "typebox";
6
+ import { type Result, err, ok } from "../util/result";
7
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
8
+ import { applyTruncation } from "../util/truncate";
9
+
10
+ // AsyncFunction constructor — the documented Node.js mechanism for compiling
11
+ // arbitrary user-supplied source. Equivalent to `new Function` but produces an
12
+ // async function we can `await`. The full RCE risk is contained at the source
13
+ // boundary: see the path allowlist + timeout below. A plain `unknown` cast
14
+ // would lose the constructor signature; we keep this single typed cast.
15
+ const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as
16
+ new (...args: ReadonlyArray<string>) => (...args: ReadonlyArray<unknown>) => Promise<unknown>;
17
+
18
+ // ESNext module — bare `require` is not in scope. createRequire provides the
19
+ // CJS resolver anchored to this file's URL so scripts can require() builtins
20
+ // and installed CJS packages.
21
+ const requireFromHere = createRequire(import.meta.url);
22
+
23
+ const ExecuteJsArgs = Type.Object({
24
+ expression: Type.String({ description: "JavaScript expression. `return X` is auto-wrapped in an IIFE." }),
25
+ targetId: Type.Optional(Type.String({ description: "Optional iframe targetId; default = current page." })),
26
+ });
27
+
28
+ export const executeJsTool = defineBrowserTool({
29
+ name: "browser_execute_js",
30
+ label: "Browser Execute JS",
31
+ description:
32
+ "Run JavaScript in the page (or a specific iframe target). `return X` gets auto-wrapped in an IIFE for convenience. Always use safeJs / JSON.stringify when interpolating untrusted strings into source.",
33
+ promptSnippet: "Execute JS in the page (or iframe)",
34
+ promptGuidelines: [
35
+ "`return foo` is auto-wrapped in an IIFE — both `foo` and `(() => foo)()` work.",
36
+ "For iframes, get a targetId via browser_execute_js with `Object.values(document.querySelectorAll('iframe'))` then call browser_execute_js with targetId.",
37
+ "Result must be JSON-serializable (Runtime.evaluate returnByValue=true).",
38
+ ],
39
+ parameters: ExecuteJsArgs,
40
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
41
+ const r = await client.evaluateJs(args.expression, args.targetId);
42
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
43
+ const valueStr = r.data === undefined ? "undefined" : JSON.stringify(r.data);
44
+ const truncated = await applyTruncation(valueStr, "js");
45
+ return ok({
46
+ text: truncated.text,
47
+ details: truncated.fullOutputPath !== undefined
48
+ ? { valueLength: valueStr.length, fullOutputPath: truncated.fullOutputPath }
49
+ : { valueLength: valueStr.length },
50
+ });
51
+ },
52
+ });
53
+
54
+ const RunScriptArgs = Type.Object({
55
+ path: Type.String({ description: "Absolute path to the script file (.js or .mjs)" }),
56
+ params: Type.Optional(
57
+ Type.Object({}, { additionalProperties: true, description: "Args passed to the script as `params`" }),
58
+ ),
59
+ timeoutMs: Type.Optional(
60
+ Type.Integer({
61
+ default: 60_000,
62
+ minimum: 100,
63
+ maximum: 600_000,
64
+ description: "Hard timeout in ms. Default 60s, max 600s.",
65
+ }),
66
+ ),
67
+ });
68
+
69
+ const allowedRoots = (): ReadonlyArray<string> => {
70
+ const env = process.env["BH_SCRIPT_DIR"];
71
+ return [tmpdir(), process.cwd(), ...(env !== undefined ? [env] : [])].map((d) => resolve(d));
72
+ };
73
+
74
+ const isPathAllowed = (p: string): boolean => {
75
+ const abs = resolve(p);
76
+ return allowedRoots().some((root) => abs === root || abs.startsWith(`${root}/`));
77
+ };
78
+
79
+ const MAX_SOURCE_BYTES = 1_000_000;
80
+
81
+ type ContentItem = { readonly type: "text"; readonly text: string };
82
+
83
+ const isContentItem = (v: unknown): v is ContentItem =>
84
+ typeof v === "object" && v !== null
85
+ && (v as Readonly<Record<string, unknown>>)["type"] === "text"
86
+ && typeof (v as Readonly<Record<string, unknown>>)["text"] === "string";
87
+
88
+ export const runScriptTool = defineBrowserTool({
89
+ name: "browser_run_script",
90
+ label: "Browser Run Script",
91
+ description:
92
+ "Execute a temporary JavaScript script with full Node.js + browser-daemon access. Path must be inside tmpdir, cwd, or BH_SCRIPT_DIR. Mandatory timeout. The script is full RCE on the harness's process — only invoke scripts you wrote and reviewed.",
93
+ promptSnippet: "Run a temporary script with daemon + Node access (security-bounded)",
94
+ promptGuidelines: [
95
+ "Write the script with the write tool first; pass its absolute path.",
96
+ "Path must be inside tmpdir, cwd, or BH_SCRIPT_DIR — otherwise rejected.",
97
+ "Default timeout is 60s; pass timeoutMs to extend (max 600s).",
98
+ "Script bindings: params, daemon, require, signal, onUpdate, ctx, console, fetch, JSON, Buffer, setTimeout, clearTimeout.",
99
+ "Script MUST return { content: [{ type: 'text', text: '...' }], details?: {...} }. Throw on errors.",
100
+ ],
101
+ parameters: RunScriptArgs,
102
+ async handler(args, { client, signal, onUpdate, extensionCtx }): Promise<Result<ToolOk, ToolErr>> {
103
+ if (!isAbsolute(args.path)) {
104
+ return err({ kind: "invalid_state", message: "Script path must be absolute" });
105
+ }
106
+ if (!isPathAllowed(args.path)) {
107
+ return err({
108
+ kind: "invalid_state",
109
+ message: `Script path outside allowed directories (allowed: ${allowedRoots().join(", ")})`,
110
+ });
111
+ }
112
+ let source: string;
113
+ try {
114
+ source = await readFile(args.path, "utf8");
115
+ } catch (e) {
116
+ return err({
117
+ kind: "io_error",
118
+ message: `Failed to read script: ${e instanceof Error ? e.message : String(e)}`,
119
+ });
120
+ }
121
+ if (source.length === 0) return err({ kind: "invalid_state", message: "Script is empty" });
122
+ if (source.length > MAX_SOURCE_BYTES) {
123
+ return err({ kind: "invalid_state", message: `Script exceeds ${MAX_SOURCE_BYTES}B size cap` });
124
+ }
125
+
126
+ let executeFn: (...a: ReadonlyArray<unknown>) => Promise<unknown>;
127
+ try {
128
+ executeFn = new AsyncFunction(
129
+ "params", "daemon", "require", "signal", "onUpdate", "ctx",
130
+ "console", "fetch", "JSON", "Buffer", "setTimeout", "clearTimeout",
131
+ `"use strict";\n${source}`,
132
+ );
133
+ } catch (e) {
134
+ return err({
135
+ kind: "invalid_state",
136
+ message: `Syntax error: ${e instanceof Error ? e.message : String(e)}`,
137
+ });
138
+ }
139
+
140
+ const timeoutMs = args.timeoutMs ?? 60_000;
141
+ const ac = new AbortController();
142
+ const onAbort = (): void => ac.abort();
143
+ if (signal !== undefined) signal.addEventListener("abort", onAbort, { once: true });
144
+ const timeoutTimer = setTimeout(() => ac.abort(), timeoutMs);
145
+
146
+ try {
147
+ const scriptPromise = executeFn(
148
+ args.params ?? {},
149
+ client, // bound as `daemon` — name preserved for back-compat
150
+ requireFromHere,
151
+ ac.signal,
152
+ (u: unknown) => {
153
+ if (typeof u !== "object" || u === null) return;
154
+ const content = (u as Readonly<Record<string, unknown>>)["content"];
155
+ if (!Array.isArray(content) || content.length === 0) return;
156
+ const first = content[0];
157
+ if (typeof first !== "object" || first === null) return;
158
+ const txt = (first as Readonly<Record<string, unknown>>)["text"];
159
+ if (typeof txt !== "string") return;
160
+ try { onUpdate({ text: txt }); } catch { /* swallow */ }
161
+ },
162
+ extensionCtx ?? { cwd: process.cwd() },
163
+ console, fetch, JSON, Buffer, setTimeout, clearTimeout,
164
+ );
165
+ const abortPromise = new Promise<never>((_, reject) => {
166
+ ac.signal.addEventListener("abort", () => reject(new Error("script aborted (timeout or cancellation)")), { once: true });
167
+ });
168
+ const result = await Promise.race([scriptPromise, abortPromise]);
169
+ clearTimeout(timeoutTimer);
170
+ if (signal !== undefined) signal.removeEventListener("abort", onAbort);
171
+
172
+ if (typeof result !== "object" || result === null) {
173
+ return err({ kind: "invalid_state", message: `Script must return an object; got ${JSON.stringify(result)}` });
174
+ }
175
+ const content = (result as Readonly<Record<string, unknown>>)["content"];
176
+ if (!Array.isArray(content)) {
177
+ return err({ kind: "invalid_state", message: "Script return value must have a content array" });
178
+ }
179
+ if (!content.every(isContentItem)) {
180
+ return err({ kind: "invalid_state", message: "Script content array must contain { type: 'text', text: string } items" });
181
+ }
182
+ const textOut = content.map((c) => c.text).join("\n");
183
+ const details = (result as Readonly<Record<string, unknown>>)["details"];
184
+ const isPlainObject = typeof details === "object" && details !== null && !Array.isArray(details);
185
+ return isPlainObject
186
+ ? ok({ text: textOut, details: details as Readonly<Record<string, unknown>> })
187
+ : ok({ text: textOut });
188
+ } catch (e) {
189
+ clearTimeout(timeoutTimer);
190
+ if (signal !== undefined) signal.removeEventListener("abort", onAbort);
191
+ return err({
192
+ kind: "internal",
193
+ message: `Script execution failed: ${e instanceof Error ? e.message : String(e)}`,
194
+ });
195
+ }
196
+ },
197
+ });
@@ -0,0 +1,127 @@
1
+ import { Type } from "typebox";
2
+ import { safeJs } from "../util/js-template";
3
+ import { virtualKeyCode } from "../util/keycodes";
4
+ import { type Result, err, ok } from "../util/result";
5
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
6
+
7
+ const TypeArgs = Type.Object({
8
+ text: Type.String({ description: "Text to type" }),
9
+ });
10
+
11
+ export const typeTool = defineBrowserTool({
12
+ name: "browser_type",
13
+ label: "Browser Type",
14
+ description: "Type text into the currently focused element. Use browser_click first to focus an input field.",
15
+ promptSnippet: "Type text into the focused element",
16
+ promptGuidelines: [
17
+ "Use browser_type to enter text. Click on an input field with browser_click first to focus it.",
18
+ "For special keys (Enter, Tab, Escape, arrows), use browser_press_key instead.",
19
+ ],
20
+ parameters: TypeArgs,
21
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
22
+ const r = await client.session().call("Input.insertText", { text: args.text });
23
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
24
+ return ok({ text: `Typed: "${args.text}"` });
25
+ },
26
+ });
27
+
28
+ const PressKeyArgs = Type.Object({
29
+ key: Type.String({
30
+ description:
31
+ 'Key to press. Special keys: Enter, Tab, Backspace, Escape, Delete, ArrowLeft/Up/Right/Down, Home, End, PageUp, PageDown. Space as " "',
32
+ }),
33
+ modifiers: Type.Optional(
34
+ Type.Integer({
35
+ minimum: 0,
36
+ maximum: 15,
37
+ description: "Modifier bitfield: 1=Alt, 2=Ctrl, 4=Meta(Cmd), 8=Shift. Combine with OR.",
38
+ }),
39
+ ),
40
+ });
41
+
42
+ export const pressKeyTool = defineBrowserTool({
43
+ name: "browser_press_key",
44
+ label: "Browser Press Key",
45
+ description:
46
+ "Press a keyboard key. Supports special keys (Enter, Tab, Backspace, Escape, Delete, arrows, Home, End, PageUp, PageDown, Space as ' ') and regular characters. Optional modifier bitfield: 1=Alt, 2=Ctrl, 4=Meta/Cmd, 8=Shift.",
47
+ promptSnippet: "Press a key (Enter, Tab, Escape, arrows, or any character)",
48
+ promptGuidelines: [
49
+ "Use browser_press_key for keyboard shortcuts and navigation keys.",
50
+ "Special key names: Enter, Tab, Backspace, Escape, Delete, ArrowLeft, ArrowUp, ArrowRight, ArrowDown, Home, End, PageUp, PageDown.",
51
+ "Use Space as ' ' (a single space character).",
52
+ "Modifiers: 1=Alt, 2=Ctrl, 4=Meta(Cmd), 8=Shift. Combine with bitwise OR: Ctrl+Shift = 2|8 = 10.",
53
+ ],
54
+ parameters: PressKeyArgs,
55
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
56
+ const k = args.key;
57
+ const code = virtualKeyCode(k);
58
+ const modifiers = args.modifiers ?? 0;
59
+ const isChar = k.length === 1;
60
+ const downParams: Record<string, unknown> = {
61
+ type: "keyDown",
62
+ key: k,
63
+ code: k,
64
+ modifiers,
65
+ ...(code ? { windowsVirtualKeyCode: code, nativeVirtualKeyCode: code } : {}),
66
+ ...(isChar ? { text: k, unmodifiedText: k } : {}),
67
+ };
68
+ const down = await client.session().call("Input.dispatchKeyEvent", downParams);
69
+ if (!down.success) return err({ kind: "cdp_error", message: down.error.message });
70
+ const up = await client.session().call("Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, modifiers });
71
+ if (!up.success) return err({ kind: "cdp_error", message: up.error.message });
72
+ return ok({ text: `Pressed: ${k}${modifiers ? ` (modifiers=${modifiers})` : ""}` });
73
+ },
74
+ });
75
+
76
+ const DispatchKeyArgs = Type.Object({
77
+ selector: Type.String({ description: "CSS selector of the target element" }),
78
+ key: Type.String({ description: "Key value (e.g., 'Enter', 'a')" }),
79
+ eventType: Type.Optional(
80
+ Type.Union(
81
+ [Type.Literal("keydown"), Type.Literal("keyup"), Type.Literal("keypress")],
82
+ { default: "keydown", description: "Event type to dispatch. Default: keydown" },
83
+ ),
84
+ ),
85
+ });
86
+
87
+ export const dispatchKeyTool = defineBrowserTool({
88
+ name: "browser_dispatch_key",
89
+ label: "Browser Dispatch Key",
90
+ description:
91
+ "Dispatch a DOM KeyboardEvent on a specific element via JS injection. Use for React/Vue components that listen to synthetic events more reliably than CDP input.",
92
+ promptSnippet: "Dispatch a DOM KeyboardEvent on a specific element",
93
+ promptGuidelines: [
94
+ "Dispatches a synthetic DOM KeyboardEvent — for React/Vue synthetic event listeners. Does NOT insert text into inputs (use browser_type or browser_press_key for actual typing).",
95
+ "Try browser_press_key first; only use browser_dispatch_key when the page ignores raw CDP key events.",
96
+ "The selector must match exactly one or more elements; zero matches is reported as an error.",
97
+ "eventType defaults to 'keydown'.",
98
+ ],
99
+ parameters: DispatchKeyArgs,
100
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
101
+ const eventType = args.eventType ?? "keydown";
102
+ const expr = safeJs`
103
+ (() => {
104
+ const els = document.querySelectorAll(${args.selector});
105
+ if (els.length === 0) return 0;
106
+ for (const el of els) {
107
+ el.dispatchEvent(new KeyboardEvent(${eventType}, { key: ${args.key}, bubbles: true, cancelable: true }));
108
+ }
109
+ return els.length;
110
+ })()
111
+ `;
112
+ const r = await client.evaluateJs(expr);
113
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
114
+ const matched = Number(r.data ?? 0);
115
+ if (matched === 0) {
116
+ return err({
117
+ kind: "invalid_state",
118
+ message: `Selector matched 0 elements: ${args.selector}`,
119
+ details: { matched: 0, selector: args.selector },
120
+ });
121
+ }
122
+ return ok({
123
+ text: `Dispatched ${eventType}('${args.key}') on ${matched} element(s)`,
124
+ details: { matched, selector: args.selector },
125
+ });
126
+ },
127
+ });
@@ -0,0 +1,146 @@
1
+ import { Type } from "typebox";
2
+ import { Text } from "@mariozechner/pi-tui";
3
+ import { type Result, err, ok } from "../util/result";
4
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
5
+ import { applyTruncation } from "../util/truncate";
6
+
7
+ const NavigateArgs = Type.Object({
8
+ url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }),
9
+ });
10
+
11
+ export type NavOutcome =
12
+ | { readonly kind: "in_place"; readonly targetId: string }
13
+ | { readonly kind: "new_tab_created"; readonly targetId: string; readonly reason: "no_tabs" };
14
+
15
+ export const navigateTool = defineBrowserTool({
16
+ name: "browser_navigate",
17
+ label: "Browser Navigate",
18
+ description:
19
+ "Navigate to a URL. Creates a new tab on first call (when no real tabs exist). Otherwise navigates the current tab in place.",
20
+ promptSnippet: "Navigate to a URL",
21
+ promptGuidelines: [
22
+ "Use browser_navigate to go to URLs.",
23
+ "Use browser_wait_for_load after browser_navigate to wait for the page to finish loading.",
24
+ "For extracting data from a page you already navigated to, use browser_execute_js or browser_http_get (faster for APIs).",
25
+ "Note: Google and some sites with strict anti-bot detection may reject CDP navigation. Use browser_http_get for search results.",
26
+ ],
27
+ parameters: NavigateArgs,
28
+ renderCall: (a) => new Text(`🌐 Navigate to ${a.url}`, 0, 0),
29
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
30
+ const tabs = await client.listTabs(false);
31
+ if (!tabs.success) return err({ kind: "cdp_error", message: tabs.error.message });
32
+ let outcome: NavOutcome;
33
+ if (tabs.data.length === 0) {
34
+ const created = await client.newTab(args.url);
35
+ if (!created.success) return err({ kind: "cdp_error", message: created.error.message });
36
+ outcome = { kind: "new_tab_created", targetId: created.data, reason: "no_tabs" };
37
+ } else {
38
+ const nav = await client.session().call("Page.navigate", { url: args.url });
39
+ if (!nav.success) return err({ kind: "cdp_error", message: nav.error.message });
40
+ const cur = client.current();
41
+ if (!cur) return err({ kind: "internal", message: "navigate succeeded but no current target tracked" });
42
+ outcome = { kind: "in_place", targetId: cur.targetId };
43
+ }
44
+ const info = await client.pageInfo();
45
+ if (!info.success) return err({ kind: "cdp_error", message: info.error.message });
46
+ if ("dialog" in info.data) {
47
+ return ok({
48
+ text: `Navigated to ${args.url}\nTarget: ${outcome.targetId}\n⚠️ Dialog open: ${info.data.dialog.type} — "${info.data.dialog.message}"\nUse browser_handle_dialog.`,
49
+ details: { outcome, dialog: info.data.dialog },
50
+ });
51
+ }
52
+ const prefix = outcome.kind === "new_tab_created" ? "New tab: " : "";
53
+ return ok({
54
+ text: `${prefix}Navigated to: ${info.data.url}\nTitle: ${info.data.title}\nViewport: ${info.data.width}x${info.data.height}\nTarget: ${outcome.targetId}`,
55
+ details: { outcome, page: info.data },
56
+ });
57
+ },
58
+ });
59
+
60
+ const OpenUrlsArgs = Type.Object({
61
+ urls: Type.Array(Type.String(), { description: "Array of URLs to open in new tabs" }),
62
+ });
63
+
64
+ type TabResult = {
65
+ readonly url: string;
66
+ readonly targetId: string;
67
+ readonly ok: boolean;
68
+ readonly error?: string;
69
+ };
70
+
71
+ export const openUrlsTool = defineBrowserTool({
72
+ name: "browser_open_urls",
73
+ label: "Browser Open URLs",
74
+ description: "Open multiple URLs in parallel new tabs. Returns per-URL outcomes.",
75
+ promptSnippet: "Open multiple URLs in new tabs (parallel)",
76
+ promptGuidelines: [
77
+ "Use after web_search to open citations in parallel.",
78
+ "After opening, use browser_list_tabs / browser_switch_tab / browser_screenshot to interact.",
79
+ "Use browser_wait_for_load on a tab before extracting data from SPAs.",
80
+ ],
81
+ parameters: OpenUrlsArgs,
82
+ renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0),
83
+ async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> {
84
+ const total = args.urls.length;
85
+ // Phase 1: create tabs in parallel.
86
+ const created = await Promise.all(args.urls.map(async (url): Promise<TabResult> => {
87
+ const r = await client.session().callBrowser("Target.createTarget", { url: "about:blank" });
88
+ if (!r.success) return { url, targetId: "", ok: false, error: r.error.message };
89
+ const c = r.data as { targetId: string }; // CDP boundary cast: Target.createTarget returns { targetId: string }
90
+ return { url, targetId: c.targetId, ok: true };
91
+ }));
92
+ // Phase 2: attach + navigate each created tab in parallel.
93
+ let completed = 0;
94
+ const settled = await Promise.all(
95
+ created.filter((t) => t.ok).map(async (tab): Promise<TabResult> => {
96
+ try {
97
+ const attached = await client.session().callBrowser("Target.attachToTarget", { targetId: tab.targetId, flatten: true });
98
+ if (!attached.success) return { ...tab, ok: false, error: attached.error.message };
99
+ const sid = (attached.data as { sessionId: string }).sessionId; // CDP boundary cast: Target.attachToTarget returns { sessionId: string }
100
+ const enabled = await client.session().callOnTarget("Page.enable", {}, sid);
101
+ if (!enabled.success) return { ...tab, ok: false, error: enabled.error.message };
102
+ const nav = await client.session().callOnTarget("Page.navigate", { url: tab.url }, sid);
103
+ if (!nav.success) return { ...tab, ok: false, error: nav.error.message };
104
+ return tab;
105
+ } finally {
106
+ completed++;
107
+ // onUpdate must never escape: a renderer crash would otherwise
108
+ // tear down the tool call.
109
+ try {
110
+ onUpdate({ text: `Opening URLs… ${completed}/${created.filter((t) => t.ok).length} navigated` });
111
+ } catch { /* swallow */ }
112
+ }
113
+ }),
114
+ );
115
+ const failures = created.filter((t) => !t.ok);
116
+ const all: ReadonlyArray<TabResult> = [...settled, ...failures];
117
+ const okTabs = all.filter((r) => r.ok);
118
+ const failTabs = all.filter((r) => !r.ok);
119
+ // Phase 3: activate the last successfully-opened tab so the user sees something.
120
+ if (okTabs.length > 0) {
121
+ const last = okTabs[okTabs.length - 1];
122
+ if (last) {
123
+ await client.session().callBrowser("Target.activateTarget", { targetId: last.targetId });
124
+ }
125
+ }
126
+ const lines: string[] = [];
127
+ if (okTabs.length) {
128
+ lines.push(`✅ ${okTabs.length}/${total} tabs opened:`, ...okTabs.map((t, i) => ` [${i}] ${t.url} → ${t.targetId}`));
129
+ }
130
+ if (failTabs.length) {
131
+ lines.push(`❌ ${failTabs.length}/${total} failed:`, ...failTabs.map((t) => ` ${t.url}: ${t.error ?? "unknown"}`));
132
+ }
133
+ const truncated = await applyTruncation(lines.join("\n"), "urls");
134
+ if (okTabs.length === 0) {
135
+ return err({
136
+ kind: "cdp_error",
137
+ message: `All ${total} URLs failed`,
138
+ ...(truncated.fullOutputPath ? { details: { tabs: all, fullOutputPath: truncated.fullOutputPath } } : { details: { tabs: all } }),
139
+ });
140
+ }
141
+ return ok({
142
+ text: truncated.text,
143
+ ...(truncated.fullOutputPath ? { details: { tabs: all, fullOutputPath: truncated.fullOutputPath } } : { details: { tabs: all } }),
144
+ });
145
+ },
146
+ });
@@ -0,0 +1,80 @@
1
+ import { Type } from "typebox";
2
+ import { type Result, err, ok } from "../util/result";
3
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
+ import { applyTruncation } from "../util/truncate";
5
+
6
+ const HttpGetArgs = Type.Object({
7
+ url: Type.String({ description: "URL to GET" }),
8
+ headers: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Optional HTTP headers" })),
9
+ timeout: Type.Optional(
10
+ Type.Number({ default: 20, minimum: 1, maximum: 120, description: "Total seconds (covers headers AND body)" }),
11
+ ),
12
+ });
13
+
14
+ export const httpGetTool = defineBrowserTool({
15
+ name: "browser_http_get",
16
+ label: "Browser HTTP GET",
17
+ description:
18
+ "Fetch a URL outside the browser (faster than browser_navigate for APIs and static pages). Timeout covers headers AND body read.",
19
+ promptSnippet: "HTTP GET (outside browser; for APIs/static pages)",
20
+ promptGuidelines: [
21
+ "Faster than navigate+execute_js for JSON/HTML APIs.",
22
+ "No JS rendering — for SPAs use browser_navigate.",
23
+ ],
24
+ parameters: HttpGetArgs,
25
+ ensureAlive: false,
26
+ async handler(args): Promise<Result<ToolOk, ToolErr>> {
27
+ const ac = new AbortController();
28
+ const timer = setTimeout(() => ac.abort(), (args.timeout ?? 20) * 1000);
29
+ try {
30
+ const fetchInit: RequestInit = { signal: ac.signal };
31
+ if (args.headers !== undefined) fetchInit.headers = args.headers;
32
+ const res = await fetch(args.url, fetchInit);
33
+ const body = await res.text();
34
+ clearTimeout(timer);
35
+ const ct = res.headers.get("content-type") ?? "";
36
+ const truncated = await applyTruncation(body, "http");
37
+ return ok({
38
+ text: `HTTP ${res.status} ${ct}\n${truncated.text}`,
39
+ details: truncated.fullOutputPath !== undefined
40
+ ? { status: res.status, contentType: ct, length: body.length, fullOutputPath: truncated.fullOutputPath, wasTruncated: truncated.wasTruncated }
41
+ : { status: res.status, contentType: ct, length: body.length, wasTruncated: truncated.wasTruncated },
42
+ });
43
+ } catch (e) {
44
+ clearTimeout(timer);
45
+ const msg = e instanceof Error ? e.message : String(e);
46
+ const kind: ToolErr["kind"] = msg.toLowerCase().includes("abort") ? "timeout" : "io_error";
47
+ return err({ kind, message: msg });
48
+ }
49
+ },
50
+ });
51
+
52
+ const NetLogArgs = Type.Object({
53
+ eventTypes: Type.Optional(
54
+ Type.Array(Type.String(), { description: "CDP event names to filter (e.g., ['Network.requestWillBeSent'])" }),
55
+ ),
56
+ limit: Type.Optional(Type.Integer({ default: 50, minimum: 1, maximum: 500 })),
57
+ });
58
+
59
+ export const getNetworkLogTool = defineBrowserTool({
60
+ name: "browser_get_network_log",
61
+ label: "Browser Get Network Log",
62
+ description:
63
+ "Read buffered Network.* CDP events. NOTE: in v0.3 the event stream is consumed internally by the session manager; this tool is a placeholder. Use browser_execute_js with PerformanceObserver as a workaround.",
64
+ promptSnippet: "Get buffered network events (DEPRECATED in v0.3 — use browser_execute_js with PerformanceObserver instead)",
65
+ promptGuidelines: [
66
+ "v0.3 routes CDP events through an internal AsyncIterable; a synchronous drain API isn't exposed yet.",
67
+ "For now, use browser_execute_js with PerformanceObserver, performance.getEntries(), or fetch interceptors.",
68
+ ],
69
+ parameters: NetLogArgs,
70
+ async handler(args): Promise<Result<ToolOk, ToolErr>> {
71
+ return ok({
72
+ text: "browser_get_network_log: in v0.3 the CDP event stream is consumed by the session; use browser_execute_js with PerformanceObserver or performance.getEntries() to inspect network activity.",
73
+ details: {
74
+ deprecated: true,
75
+ eventTypes: args.eventTypes ?? null,
76
+ limit: args.limit ?? null,
77
+ },
78
+ });
79
+ },
80
+ });