pi-roundtable-browser 0.0.0-stage → 0.22.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,448 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { PluginError } from "pi-roundtable";
6
+ import { Type } from "typebox";
7
+ import { refuseAgentBrowserCommand } from "./agent-browser-commands.ts";
8
+ import { docker, firstLine, requireDocker } from "./docker.ts";
9
+ import {
10
+ type BrowserDriver,
11
+ type BrowserSkill,
12
+ type BrowserTaskContext,
13
+ type BrowserTaskKit,
14
+ BrowserToolError,
15
+ browserTool,
16
+ } from "./driver.ts";
17
+ import { EgressProxy } from "./egress-proxy.ts";
18
+
19
+ const PACKAGE_DIR = resolve(dirname(new URL(import.meta.url).pathname), "..");
20
+ const VERSION = (
21
+ JSON.parse(readFileSync(join(PACKAGE_DIR, "package.json"), "utf8")) as {
22
+ version: string;
23
+ }
24
+ ).version;
25
+
26
+ /** The Dockerfile's folder, for the host's build command. */
27
+ export const BROWSER_IMAGE_CONTEXT = join(PACKAGE_DIR, "docker");
28
+ /** The image the default driver runs unless told otherwise; the version is this package's. */
29
+ export const DEFAULT_BROWSER_IMAGE = `pi-roundtable-browser:${VERSION}`;
30
+
31
+ /** The command that builds the image the default driver needs. */
32
+ export function browserImageBuildCommand(
33
+ image = DEFAULT_BROWSER_IMAGE,
34
+ ): string {
35
+ return `docker build -t ${image} -f ${join(BROWSER_IMAGE_CONTEXT, "Dockerfile")} ${BROWSER_IMAGE_CONTEXT}`;
36
+ }
37
+
38
+ export interface AgentBrowserDriverOptions {
39
+ /** The image to run; default `DEFAULT_BROWSER_IMAGE`, built with `browserImageBuildCommand()`. */
40
+ image?: string;
41
+ /** The Docker CLI; default `docker`. */
42
+ binary?: string;
43
+ /** The user and group the container runs as; default the host process's. Never root. */
44
+ user?: { uid: number; gid: number };
45
+ /** Container memory in MiB; default 1024. */
46
+ memoryMb?: number;
47
+ /** Container CPUs; default 1.5. */
48
+ cpus?: number;
49
+ /** Processes the container may run; default 512. */
50
+ pids?: number;
51
+ /** Destination ports the browser may reach; default 80 and 443. */
52
+ ports?: readonly number[];
53
+ /** Time one agent-browser command may take, in milliseconds; default 60,000. */
54
+ commandTimeoutMs?: number;
55
+ /** Told about each request the egress proxy refused: the destination and why. */
56
+ onRefused?: (destination: string, reason: string) => void;
57
+ }
58
+
59
+ const LABEL = "pi-roundtable.browser";
60
+ const PROXY_PORT = 3128;
61
+ const SCREENSHOT_DIR = "/tmp/shots";
62
+
63
+ export interface BrowserContainerSpec {
64
+ name: string;
65
+ image: string;
66
+ /** The host folder holding the egress proxy's socket, mounted read-only at /egress. */
67
+ egressDir: string;
68
+ uid: number;
69
+ gid: number;
70
+ memoryMb: number;
71
+ cpus: number;
72
+ pids: number;
73
+ /** Seconds the container may live; it exits by itself after that, even if the host is gone. */
74
+ lifetimeSeconds: number;
75
+ maxOutputChars: number;
76
+ hostPid: number;
77
+ }
78
+
79
+ /**
80
+ * `docker run` arguments of one task's browser. The container has no network interface but
81
+ * loopback; the only way out is the proxy socket mounted at /egress, relayed to 127.0.0.1:3128.
82
+ * It runs unprivileged on a read-only root with a small tmpfs, and the browser's policy (in the
83
+ * image) refuses downloads and local files.
84
+ */
85
+ export function browserContainerArgs(spec: BrowserContainerSpec): string[] {
86
+ if (
87
+ !/^[a-z0-9][a-z0-9_.-]{0,90}$/.test(spec.name) ||
88
+ !spec.image ||
89
+ spec.image.startsWith("-") ||
90
+ /\s/.test(spec.image)
91
+ )
92
+ throw new PluginError("browser container name or image is not valid.");
93
+ if (
94
+ !Number.isInteger(spec.uid) ||
95
+ spec.uid <= 0 ||
96
+ !Number.isInteger(spec.gid) ||
97
+ spec.gid <= 0
98
+ )
99
+ throw new PluginError(
100
+ "the browser container must run as a non-root user and group: run the host as one, or pass docker: { user: { uid, gid } }.",
101
+ );
102
+ if (!isAbsolute(spec.egressDir) || /[,\n\r]/.test(spec.egressDir))
103
+ throw new PluginError("the egress folder must be an absolute path.");
104
+ const proxy = `http://127.0.0.1:${PROXY_PORT}`;
105
+ const env: Record<string, string> = {
106
+ HOME: "/tmp/home",
107
+ HTTP_PROXY: proxy,
108
+ HTTPS_PROXY: proxy,
109
+ http_proxy: proxy,
110
+ https_proxy: proxy,
111
+ AGENT_BROWSER_PROXY: proxy,
112
+ AGENT_BROWSER_SESSION: "task",
113
+ AGENT_BROWSER_EXECUTABLE_PATH: "/usr/bin/chromium",
114
+ AGENT_BROWSER_ARGS: [
115
+ "--no-sandbox",
116
+ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
117
+ ].join("\n"),
118
+ AGENT_BROWSER_CONTENT_BOUNDARIES: "1",
119
+ AGENT_BROWSER_MAX_OUTPUT: String(spec.maxOutputChars),
120
+ BROWSER_LIFETIME_SECONDS: String(spec.lifetimeSeconds),
121
+ };
122
+ return [
123
+ "run",
124
+ "--detach",
125
+ "--rm",
126
+ "--init",
127
+ "--pull",
128
+ "never",
129
+ "--name",
130
+ spec.name,
131
+ "--label",
132
+ `${LABEL}=1`,
133
+ "--label",
134
+ `${LABEL}.pid=${spec.hostPid}`,
135
+ "--network",
136
+ "none",
137
+ "--read-only",
138
+ "--cap-drop",
139
+ "ALL",
140
+ "--security-opt",
141
+ "no-new-privileges:true",
142
+ "--user",
143
+ `${spec.uid}:${spec.gid}`,
144
+ "--memory",
145
+ `${spec.memoryMb}m`,
146
+ "--memory-swap",
147
+ `${spec.memoryMb}m`,
148
+ "--cpus",
149
+ String(spec.cpus),
150
+ "--pids-limit",
151
+ String(spec.pids),
152
+ "--ulimit",
153
+ "nofile=1024:1024",
154
+ "--shm-size",
155
+ "256m",
156
+ "--tmpfs",
157
+ "/tmp:rw,nosuid,nodev,size=512m,mode=1777",
158
+ "--mount",
159
+ `type=bind,src=${spec.egressDir},dst=/egress,readonly,bind-propagation=rprivate`,
160
+ ...Object.entries(env).flatMap(([key, value]) => [
161
+ "--env",
162
+ `${key}=${value}`,
163
+ ]),
164
+ spec.image,
165
+ ];
166
+ }
167
+
168
+ function ownerOf(options: AgentBrowserDriverOptions): {
169
+ uid: number;
170
+ gid: number;
171
+ } {
172
+ return (
173
+ options.user ?? {
174
+ uid: process.getuid?.() ?? 0,
175
+ gid: process.getgid?.() ?? 0,
176
+ }
177
+ );
178
+ }
179
+
180
+ function isAlive(pid: number): boolean {
181
+ try {
182
+ process.kill(pid, 0);
183
+ return true;
184
+ } catch (error) {
185
+ return (error as NodeJS.ErrnoException).code === "EPERM";
186
+ }
187
+ }
188
+
189
+ /** Removes containers a host that is gone left behind. */
190
+ async function sweepStale(binary: string): Promise<void> {
191
+ try {
192
+ const listed = await docker(
193
+ binary,
194
+ [
195
+ "ps",
196
+ "--all",
197
+ "--filter",
198
+ `label=${LABEL}`,
199
+ "--format",
200
+ `{{.Names}} {{.Label "${LABEL}.pid"}}`,
201
+ ],
202
+ { timeoutMs: 15_000 },
203
+ );
204
+ if (listed.code !== 0) return;
205
+ for (const line of new TextDecoder().decode(listed.stdout).split("\n")) {
206
+ const [name, pid] = line.trim().split(" ");
207
+ if (name && Number(pid) > 0 && !isAlive(Number(pid)))
208
+ await docker(binary, ["rm", "--force", name], { timeoutMs: 15_000 });
209
+ }
210
+ } catch {
211
+ // Best effort: a container left behind exits by itself when its lifetime ends.
212
+ }
213
+ }
214
+
215
+ const SKILL_FRONT_MATTER = /^---\n[\s\S]*?\n---\n/;
216
+
217
+ const INSTRUCTIONS = [
218
+ 'You drive the task\'s browser with one tool, `agent_browser`, which runs one agent-browser command per call. `args` is the list of arguments after `agent-browser`, for example ["open", "https://example.com"], ["snapshot", "-i"], ["click", "@e3"] or ["fill", "@e2", "some text"]. It is not a shell: write no quotes around arguments, and use no pipes, redirects, environment variables or `export`. For `eval --stdin`, put the script in `stdin`. The browser session is already set up and closes itself when the task ends.',
219
+ "The guide below is agent-browser's own; its examples are shell commands, so turn each into an `args` list, and skip what this tool refuses: downloads, uploads, installs, cookies and saved state, the auth vault, plugins, and changes to the browser's network, proxy or launch settings. Its references are served by `skills get core --full`.",
220
+ "To look at a page, call `snapshot` (add `-i` for interactive elements only) or `screenshot`. A screenshot is shown to you; set `keep: true` on the `screenshot` call to also attach it to the reply of the agent that handed you this task, which is how the agent shows what the page looked like. Keep only what the task needs.",
221
+ ].join("\n\n");
222
+
223
+ const AGENT_BROWSER_TOOL = {
224
+ name: "agent_browser",
225
+ description:
226
+ 'Run one agent-browser command in this task\'s browser and get its output. `args` are the arguments after `agent-browser`, as a list (not a shell line), such as ["open", "https://example.com"] or ["snapshot", "-i"]. `screenshot` takes no path; set `keep` to attach the image to the reply. Downloads, uploads, installs, cookie or state import and export, logins, plugins and network or proxy settings are refused.',
227
+ parameters: Type.Object({
228
+ args: Type.Array(Type.String({ maxLength: 4_000 }), {
229
+ minItems: 1,
230
+ maxItems: 64,
231
+ description:
232
+ "The arguments after `agent-browser`, one list item per argument.",
233
+ }),
234
+ stdin: Type.Optional(
235
+ Type.String({
236
+ maxLength: 65_536,
237
+ description:
238
+ "Text for the command's standard input, such as the script of `eval --stdin`.",
239
+ }),
240
+ ),
241
+ keep: Type.Optional(
242
+ Type.Boolean({
243
+ description:
244
+ "With `screenshot`: also attach the image to the reply. Ignored by other commands.",
245
+ }),
246
+ ),
247
+ }),
248
+ } as const;
249
+
250
+ /**
251
+ * The default driver: agent-browser and Chromium in a Docker container started for each task and
252
+ * removed after it, with no network but a unix socket to a proxy on the host that refuses every
253
+ * destination that is not a public address. See the package README for the isolation it gives.
254
+ */
255
+ export function agentBrowserDriver(
256
+ options: AgentBrowserDriverOptions = {},
257
+ ): BrowserDriver {
258
+ const binary = options.binary ?? "docker";
259
+ const image = options.image ?? DEFAULT_BROWSER_IMAGE;
260
+ const commandTimeoutMs = options.commandTimeoutMs ?? 60_000;
261
+ return {
262
+ async check() {
263
+ await requireDocker(binary);
264
+ const { uid } = ownerOf(options);
265
+ if (uid <= 0)
266
+ throw new PluginError(
267
+ "the browser container must run as a non-root user: run the host as one, or pass docker: { user: { uid, gid } }.",
268
+ );
269
+ const found = await docker(
270
+ binary,
271
+ ["image", "inspect", image, "--format", "{{.Id}}"],
272
+ {
273
+ timeoutMs: 15_000,
274
+ },
275
+ );
276
+ if (found.code !== 0)
277
+ throw new PluginError(
278
+ `the browser image ${image} is not built on this machine. Build it once with: ${browserImageBuildCommand(image)}`,
279
+ );
280
+ await sweepStale(binary);
281
+ },
282
+
283
+ async start(context: BrowserTaskContext): Promise<BrowserTaskKit> {
284
+ const { signal, limits } = context;
285
+ const name = `pi-roundtable-browser-${randomBytes(6).toString("hex")}`;
286
+ const dir = mkdtempSync(join(tmpdir(), "prb-"));
287
+ let proxy: EgressProxy | undefined;
288
+ const remove = async () => {
289
+ const failures: string[] = [];
290
+ const removed = await docker(binary, ["rm", "--force", name], {
291
+ timeoutMs: 20_000,
292
+ }).catch((error: unknown) => ({
293
+ code: 1,
294
+ stderr: String(error),
295
+ }));
296
+ if (removed.code !== 0 && !/No such container/i.test(removed.stderr))
297
+ failures.push(
298
+ `the container ${name} was not removed (${firstLine(removed.stderr)}); remove it with \`${binary} rm --force ${name}\``,
299
+ );
300
+ await proxy?.close().catch(() => undefined);
301
+ rmSync(dir, { recursive: true, force: true });
302
+ if (failures.length > 0) throw new Error(failures.join("; "));
303
+ };
304
+ try {
305
+ proxy = await EgressProxy.listen({
306
+ socketPath: join(dir, "proxy.sock"),
307
+ ...(options.ports ? { ports: options.ports } : {}),
308
+ ...(options.onRefused ? { onRefused: options.onRefused } : {}),
309
+ });
310
+ const { uid, gid } = ownerOf(options);
311
+ const started = await docker(
312
+ binary,
313
+ browserContainerArgs({
314
+ name,
315
+ image,
316
+ egressDir: dir,
317
+ uid,
318
+ gid,
319
+ memoryMb: options.memoryMb ?? 1024,
320
+ cpus: options.cpus ?? 1.5,
321
+ pids: options.pids ?? 512,
322
+ lifetimeSeconds: Math.ceil(limits.timeoutMs / 1000) + 120,
323
+ maxOutputChars: limits.maxOutputChars,
324
+ hostPid: process.pid,
325
+ }),
326
+ { signal, timeoutMs: 60_000 },
327
+ );
328
+ if (started.code !== 0)
329
+ throw new Error(
330
+ `the browser container did not start (${firstLine(started.stderr)}). If the image is missing, build it with: ${browserImageBuildCommand(image)}`,
331
+ );
332
+ await exec(["mkdir", "-p", SCREENSHOT_DIR]);
333
+ const guide = await exec(["agent-browser", "skills", "get", "core"], {
334
+ maxBytes: 512 * 1024,
335
+ });
336
+ if (guide.code !== 0)
337
+ throw new Error(
338
+ `the browser image does not serve agent-browser's guide (${firstLine(guide.stderr)}); rebuild it with: ${browserImageBuildCommand(image)}`,
339
+ );
340
+ const skill: BrowserSkill = {
341
+ name: "agent-browser core guide",
342
+ content: new TextDecoder()
343
+ .decode(guide.stdout)
344
+ .replace(SKILL_FRONT_MATTER, ""),
345
+ };
346
+ return makeKit(skill);
347
+ } catch (error) {
348
+ await remove().catch(() => undefined);
349
+ throw error;
350
+ }
351
+
352
+ function exec(
353
+ args: readonly string[],
354
+ extra: { stdin?: string; maxBytes?: number } = {},
355
+ ) {
356
+ return docker(
357
+ binary,
358
+ [
359
+ "exec",
360
+ ...(extra.stdin === undefined ? [] : ["--interactive"]),
361
+ "--workdir",
362
+ "/tmp",
363
+ name,
364
+ ...args,
365
+ ],
366
+ {
367
+ signal,
368
+ timeoutMs: commandTimeoutMs,
369
+ maxBytes: extra.maxBytes ?? limits.maxOutputChars * 4,
370
+ ...(extra.stdin === undefined ? {} : { stdin: extra.stdin }),
371
+ },
372
+ );
373
+ }
374
+
375
+ function makeKit(skill: BrowserSkill): BrowserTaskKit {
376
+ // One command at a time: the worker may emit several calls in one message.
377
+ let queue: Promise<unknown> = Promise.resolve();
378
+ let shots = 0;
379
+ const serial = <T>(work: () => Promise<T>): Promise<T> => {
380
+ const next = queue.then(work, work);
381
+ queue = next.catch(() => undefined);
382
+ return next;
383
+ };
384
+ /** Runs one agent-browser command; a failure is an error the worker reads. */
385
+ const agentBrowser = async (
386
+ args: readonly string[],
387
+ stdin?: string,
388
+ ): Promise<string> => {
389
+ const result = await exec(
390
+ ["agent-browser", ...args],
391
+ stdin === undefined ? {} : { stdin },
392
+ );
393
+ const text = new TextDecoder().decode(result.stdout).trim();
394
+ if (result.code !== 0)
395
+ throw new BrowserToolError(
396
+ `agent-browser exited with ${result.code}: ${(result.stderr.trim() || text).slice(0, 4_000)}`,
397
+ );
398
+ return text;
399
+ };
400
+ /** Takes a screenshot into the container, reads it back, and optionally keeps it for the reply. */
401
+ const screenshot = async (flags: readonly string[], keep: boolean) => {
402
+ if (flags.some((flag) => flag !== "--full" && flag !== "--annotate"))
403
+ throw new BrowserToolError(
404
+ "screenshot takes no path and only the flags --full and --annotate; the image is shown to you, and keep: true attaches it to the reply.",
405
+ );
406
+ const path = `${SCREENSHOT_DIR}/${++shots}.png`;
407
+ await agentBrowser(["screenshot", ...flags, path]);
408
+ const image = await exec(["cat", path], {
409
+ maxBytes: limits.maxScreenshotBytes + 1,
410
+ });
411
+ void exec(["rm", "-f", path]).catch(() => undefined);
412
+ if (image.code !== 0 || image.truncated)
413
+ throw new BrowserToolError(
414
+ image.truncated
415
+ ? `The screenshot is over ${limits.maxScreenshotBytes} bytes. Take it without --full, or scroll to what you need first.`
416
+ : "The screenshot could not be read back.",
417
+ );
418
+ const picture = { mimeType: "image/png", data: image.stdout };
419
+ if (!keep) return { text: "Screenshot taken.", images: [picture] };
420
+ const kept = context.keepScreenshot(picture);
421
+ return {
422
+ text: `Screenshot taken and kept for the reply (${kept} of at most ${limits.maxScreenshots}).`,
423
+ images: [picture],
424
+ };
425
+ };
426
+ return {
427
+ prompt: INSTRUCTIONS,
428
+ skills: [skill],
429
+ tools: [
430
+ browserTool({
431
+ ...AGENT_BROWSER_TOOL,
432
+ execute: ({ args, stdin, keep }) =>
433
+ serial(async () => {
434
+ const refusal = refuseAgentBrowserCommand(args);
435
+ if (refusal) throw new BrowserToolError(refusal);
436
+ if (args[0] === "screenshot")
437
+ return screenshot(args.slice(1), keep === true);
438
+ const result = await agentBrowser(args, stdin);
439
+ return { text: result || "(no output)" };
440
+ }),
441
+ }),
442
+ ],
443
+ dispose: remove,
444
+ };
445
+ }
446
+ },
447
+ };
448
+ }
package/src/docker.ts ADDED
@@ -0,0 +1,131 @@
1
+ import { PluginError } from "pi-roundtable";
2
+
3
+ export interface ExecResult {
4
+ code: number;
5
+ stdout: Uint8Array;
6
+ stderr: string;
7
+ /** Output was cut at `maxBytes`. */
8
+ truncated: boolean;
9
+ }
10
+
11
+ async function readBounded(
12
+ stream: ReadableStream<Uint8Array>,
13
+ maxBytes: number,
14
+ onFull: () => void,
15
+ ): Promise<{ data: Uint8Array; truncated: boolean }> {
16
+ const chunks: Uint8Array[] = [];
17
+ let size = 0;
18
+ let truncated = false;
19
+ for await (const chunk of stream) {
20
+ if (truncated) continue;
21
+ const room = maxBytes - size;
22
+ if (chunk.byteLength > room) {
23
+ chunks.push(chunk.subarray(0, room));
24
+ size = maxBytes;
25
+ truncated = true;
26
+ onFull();
27
+ } else {
28
+ chunks.push(chunk);
29
+ size += chunk.byteLength;
30
+ }
31
+ }
32
+ return { data: Buffer.concat(chunks), truncated };
33
+ }
34
+
35
+ /**
36
+ * Runs `docker <args>` and returns its bounded output. Aborting, or running past `timeoutMs`,
37
+ * kills the Docker client at once (SIGKILL: SIGTERM can be proxied into a container's PID 1 and
38
+ * ignored) and rejects with the signal's reason, or a timeout error, without waiting for output
39
+ * that something else still holds open.
40
+ */
41
+ export async function docker(
42
+ binary: string,
43
+ args: readonly string[],
44
+ options: {
45
+ signal?: AbortSignal;
46
+ stdin?: string;
47
+ maxBytes?: number;
48
+ timeoutMs?: number;
49
+ } = {},
50
+ ): Promise<ExecResult> {
51
+ options.signal?.throwIfAborted();
52
+ const child = Bun.spawn([binary, ...args], {
53
+ stdin: options.stdin === undefined ? "ignore" : "pipe",
54
+ stdout: "pipe",
55
+ stderr: "pipe",
56
+ });
57
+ const kill = () => child.kill("SIGKILL");
58
+ const stop = new AbortController();
59
+ stop.signal.addEventListener("abort", kill, { once: true });
60
+ const forward = () => stop.abort(options.signal?.reason);
61
+ options.signal?.addEventListener("abort", forward, { once: true });
62
+ const timer = options.timeoutMs
63
+ ? setTimeout(
64
+ () =>
65
+ stop.abort(
66
+ new Error(
67
+ `\`${binary} ${args[0] ?? ""}\` did not finish within ${options.timeoutMs} ms`,
68
+ ),
69
+ ),
70
+ options.timeoutMs,
71
+ )
72
+ : undefined;
73
+ const gone = new Promise<never>((_resolve, reject) =>
74
+ stop.signal.addEventListener("abort", () => reject(stop.signal.reason), {
75
+ once: true,
76
+ }),
77
+ );
78
+ try {
79
+ if (options.stdin !== undefined && child.stdin) {
80
+ child.stdin.write(options.stdin);
81
+ await child.stdin.end();
82
+ }
83
+ const maxBytes = options.maxBytes ?? 1024 * 1024;
84
+ const work = Promise.all([
85
+ readBounded(child.stdout, maxBytes, kill),
86
+ readBounded(child.stderr, 64 * 1024, () => undefined),
87
+ child.exited,
88
+ ]);
89
+ work.catch(() => undefined);
90
+ const [out, err, code] = await Promise.race([work, gone]);
91
+ return {
92
+ code,
93
+ stdout: out.data,
94
+ stderr: new TextDecoder().decode(err.data),
95
+ truncated: out.truncated,
96
+ };
97
+ } finally {
98
+ clearTimeout(timer);
99
+ options.signal?.removeEventListener("abort", forward);
100
+ kill();
101
+ await child.exited;
102
+ }
103
+ }
104
+
105
+ /** The first line of Docker's complaint, short enough for a message. */
106
+ export function firstLine(text: string): string {
107
+ return (text.trim().split("\n")[0] ?? "").slice(0, 300);
108
+ }
109
+
110
+ /** Throws a PluginError naming the fix unless a Docker daemon answers. */
111
+ export async function requireDocker(binary: string): Promise<void> {
112
+ let result: ExecResult | undefined;
113
+ let failure = "";
114
+ try {
115
+ result = await docker(
116
+ binary,
117
+ ["version", "--format", "{{.Server.Version}}"],
118
+ { timeoutMs: 15_000 },
119
+ );
120
+ } catch (error) {
121
+ failure = error instanceof Error ? error.message : String(error);
122
+ }
123
+ if (result?.code !== 0)
124
+ throw new PluginError(
125
+ `the browser worker needs Docker, and ${
126
+ result
127
+ ? `the daemon did not answer (${firstLine(result.stderr)})`
128
+ : `\`${binary}\` could not run (${firstLine(failure)})`
129
+ }. Install Docker and start its daemon (\`${binary} info\` must work), or pass docker: { binary } with the path of its CLI.`,
130
+ );
131
+ }
package/src/driver.ts ADDED
@@ -0,0 +1,109 @@
1
+ import type { Static, TSchema } from "typebox";
2
+
3
+ /** What a task may use, each with a safe default. */
4
+ export interface BrowserLimits {
5
+ /** The whole task, from the first tool call to the report, in milliseconds; default 5 minutes. */
6
+ timeoutMs: number;
7
+ /** Tool calls the worker may make; the next one is refused. Default 40. */
8
+ maxSteps: number;
9
+ /** Screenshots the worker may keep for the agent's reply; default 4. */
10
+ maxScreenshots: number;
11
+ /** The largest screenshot, kept or only looked at, in bytes; default 4 MiB. */
12
+ maxScreenshotBytes: number;
13
+ /** The longest text one tool call may hand the worker, in characters; default 24,000. */
14
+ maxOutputChars: number;
15
+ }
16
+
17
+ export const DEFAULT_BROWSER_LIMITS: Readonly<BrowserLimits> = Object.freeze({
18
+ timeoutMs: 5 * 60_000,
19
+ maxSteps: 40,
20
+ maxScreenshots: 4,
21
+ maxScreenshotBytes: 4 * 1024 * 1024,
22
+ maxOutputChars: 24_000,
23
+ });
24
+
25
+ /** A screenshot, or any image, as bytes. */
26
+ export interface BrowserImage {
27
+ /** `image/png`, `image/jpeg`, `image/gif` or `image/webp`. */
28
+ mimeType: string;
29
+ data: Uint8Array;
30
+ }
31
+
32
+ /** What one tool call returns to the worker: text, and images it can look at. */
33
+ export interface BrowserToolResult {
34
+ text: string;
35
+ images?: readonly BrowserImage[];
36
+ }
37
+
38
+ /**
39
+ * A call the worker should correct, such as a command the driver refuses: its message becomes the
40
+ * tool's error result. Any other error also fails the call, with its text scrubbed of secrets.
41
+ */
42
+ export class BrowserToolError extends Error {
43
+ override name = "BrowserToolError";
44
+ }
45
+
46
+ /** One tool of the worker. The worker's model reads `description` and fills `parameters`. */
47
+ export interface BrowserTool<Schema extends TSchema = TSchema> {
48
+ /** Lowercase words joined by underscores; unique within the task. */
49
+ name: string;
50
+ description: string;
51
+ /** The arguments as a Typebox schema; the call is checked against it before `execute` runs. */
52
+ parameters: Schema;
53
+ /** Aborted when the task is cancelled, times out, or the host stops. */
54
+ execute(
55
+ input: Static<Schema>,
56
+ signal: AbortSignal,
57
+ ): Promise<BrowserToolResult>;
58
+ }
59
+
60
+ /** Types a tool's `input` from its schema, and fits it in the list of a kit's tools. */
61
+ export function browserTool<Schema extends TSchema>(
62
+ tool: BrowserTool<Schema>,
63
+ ): BrowserTool {
64
+ // SAFETY: the host checks every call against `parameters` before `execute`, so the input has the schema's type.
65
+ return tool as unknown as BrowserTool;
66
+ }
67
+
68
+ /** Instructions the worker reads as part of its system prompt, such as a tool's usage guide. */
69
+ export interface BrowserSkill {
70
+ name: string;
71
+ /** The full text, without front matter. */
72
+ content: string;
73
+ }
74
+
75
+ /** What a driver is told about a task. */
76
+ export interface BrowserTaskContext {
77
+ /** Aborted when the turn is cancelled, the time limit passes, or the host stops. */
78
+ signal: AbortSignal;
79
+ limits: Readonly<BrowserLimits>;
80
+ /**
81
+ * Keeps an image for the agent's reply, once the task ends. Throws a `BrowserToolError` when the
82
+ * screenshot count or size limit refuses it; the message tells the worker why. Returns how many
83
+ * images are kept so far.
84
+ */
85
+ keepScreenshot(image: BrowserImage): number;
86
+ }
87
+
88
+ /** The worker's tools and instructions for one task, and the means to clean up after it. */
89
+ export interface BrowserTaskKit {
90
+ tools: readonly BrowserTool[];
91
+ skills?: readonly BrowserSkill[];
92
+ /** Added to the worker's system prompt after the standing rules, for example how to keep a screenshot. */
93
+ prompt?: string;
94
+ /** Closes the task's browser and removes everything it made. Called once, whether the task succeeded, failed or was cancelled. */
95
+ dispose(): Promise<void>;
96
+ }
97
+
98
+ /**
99
+ * The extension point: how a task gets a browser. A driver builds, for one task, the tools and
100
+ * skills the worker may use, and cleans up after it. The package's default drives agent-browser in
101
+ * a Docker container; another driver can drive any browser, as long as it keeps the task's
102
+ * browser from reaching what the host should not expose.
103
+ */
104
+ export interface BrowserDriver {
105
+ /** Runs at startup; throws a `PluginError` naming the fix when the driver cannot work. */
106
+ check?(): Promise<void>;
107
+ /** Prepares one task's browser. A rejection leaves nothing behind that `dispose` would remove. */
108
+ start(context: BrowserTaskContext): Promise<BrowserTaskKit>;
109
+ }