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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.22.1] - 2026-10-21
6
+
7
+ - Release in lockstep with pi-roundtable 0.22.1; no package-specific behavior changes. The core peer range stays `>=0.22.0 <0.23.0`.
8
+
9
+ ## [0.22.0] - 2026-10-21
10
+
11
+ - First release, in lockstep with pi-roundtable 0.22: `browserAgent(options)` and its `browser_task` tool, the `BrowserDriver` port (`start(context)` returns the worker's tools, skills and a `dispose`), and the default `agentBrowserDriver(options)`: agent-browser and Chromium in a Docker container with no network, a host-side egress proxy that refuses every non-public destination, one `agent_browser` tool with a command policy, and the agent-browser guide of the image's own version loaded into the worker. Requires pi-roundtable `>=0.22.0 <0.23.0`, whose `WORKER_SESSIONS` service it uses.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-roundtable-browser contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,145 @@
1
- # Temporary Holding Version
1
+ # pi-roundtable-browser
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A browser worker for [pi-roundtable][roundtable] agents.
4
+ Source lives in [`packages/browser`][source] in the pi-roundtable repository and releases in lockstep with the core.
5
+ The plugin adds one tool, `browser_task({ task, url? })`.
6
+ It starts a fresh worker session whose only tools drive a real browser, lets it work for a few minutes, and returns its written report; the screenshots the worker chose to keep are attached to the agent's reply.
7
+ The browser runs in a Docker container that is started for the task and removed after it, with no network of its own and no route to the host's loopback, link-local or private addresses.
8
+
9
+ [roundtable]: https://github.com/wayne930242/pi-roundtable
10
+ [source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/browser
11
+
12
+ ## Requirements
13
+
14
+ - Bun 1.4.2 or later and pi-roundtable `>=0.22.0 <0.23.0`, which provides the `WORKER_SESSIONS` service the plugin runs its worker on.
15
+ - A native Linux host with a local Docker daemon, and a non-root service account permitted to run Docker. The container runs as that account; root is refused.
16
+ - The Docker image, built once on that host (below).
17
+
18
+ macOS supports the offline tests and building the image.
19
+ Browsing needs Linux: the container reaches the host's proxy through a bind-mounted Unix socket, and Docker Desktop does not share host Unix sockets into its Linux VM.
20
+ Remote Docker daemons are unsupported for the same reason: the socket must exist on the host that runs the container.
21
+
22
+ ## Setup
23
+
24
+ ```sh
25
+ bun add pi-roundtable-browser
26
+ ```
27
+
28
+ Build the image from the installed package (the host builds it; the package publishes no image):
29
+
30
+ ```sh
31
+ docker build -t pi-roundtable-browser:<version> \
32
+ -f node_modules/pi-roundtable-browser/docker/Dockerfile \
33
+ node_modules/pi-roundtable-browser/docker
34
+ ```
35
+
36
+ `<version>` is the installed package's version; the default driver looks for exactly that tag.
37
+ `browserImageBuildCommand()` prints the command with your paths filled in, and the error you get when the image is missing quotes it.
38
+ To use another tag, build under it and pass `docker: { image }`.
39
+ The image installs Debian's Chromium (Chrome for Testing has no Linux ARM64 build), CJK and emoji fonts, `socat`, and `agent-browser` at the version in the Dockerfile's `AGENT_BROWSER_VERSION` build argument.
40
+ Rebuild it after upgrading the package.
41
+
42
+ Add the plugin to `roundtable.config.ts`:
43
+
44
+ ```ts
45
+ import { browserAgent } from "pi-roundtable-browser";
46
+
47
+ export default {
48
+ // ...the settings created by pi-roundtable init...
49
+ plugins: [
50
+ browserAgent({
51
+ model: "anthropic/claude-sonnet-5-5", // default: the host's delegation.model
52
+ thinking: "medium", // default: the host's delegation.thinking
53
+ }),
54
+ ],
55
+ };
56
+ ```
57
+
58
+ Setup stops with a `PluginError` that names the fix when Docker is missing, its daemon does not answer, the image is not built, or the host runs as root.
59
+
60
+ The tool needs the owner tier.
61
+ Lower it with the host's `toolTiers`, for example `toolTiers: { browser_task: "admin" }`; remember that whoever may call it can make the host fetch any public page.
62
+ It is not a held action: nothing waits for the owner's approval.
63
+ Every task starts from a clean browser profile, so there are no stored logins, and the worker is told it has no credentials.
64
+
65
+ ## Options
66
+
67
+ | Option | Default | What it does |
68
+ |---|---|---|
69
+ | `driver` | the agent-browser driver | How a task gets a browser; see [Another driver](#another-driver) |
70
+ | `docker` | | Options of the default driver: `image`, `binary`, `user: { uid, gid }`, `memoryMb` (1024), `cpus` (1.5), `pids` (512), `ports` (80 and 443), `commandTimeoutMs` (60,000), `onRefused(destination, reason)` |
71
+ | `model`, `thinking` | the host's delegation settings | The worker's model (`provider/model-id`) and thinking level |
72
+ | `limits.timeoutMs` | 5 minutes | The whole task. Past it the task ends with a message and the screenshots kept so far are still attached |
73
+ | `limits.maxSteps` | 40 | Tool calls. The call after the last gets an error telling the worker to report |
74
+ | `limits.maxScreenshots` | 4 | Screenshots the worker may keep for the reply |
75
+ | `limits.maxScreenshotBytes` | 4 MiB | The largest screenshot, kept or only looked at (at most 10 MiB, the core's limit for one reply file) |
76
+ | `limits.maxOutputChars` | 24,000 | The longest text one tool call hands the worker |
77
+ | `maxConcurrentTasks` | 2 | Tasks at once; the next call is refused until one ends |
78
+
79
+ A bad value stops startup with a `PluginError` naming the option.
80
+ Cancelling the turn, or stopping the host, aborts the worker and removes the task's container before the call settles.
81
+
82
+ ## What the worker gets
83
+
84
+ The default driver gives the worker one tool, `agent_browser`, and the guide of the agent-browser the image holds.
85
+ `args` is the argument list after `agent-browser` (never a shell), `stdin` feeds `eval --stdin`, and `keep: true` on a `screenshot` attaches that image to the reply.
86
+ The guide is what `agent-browser skills get core` prints in the container, so the worker's instructions always match the binary it drives; the package adds a short note on translating the guide's shell examples into argument lists.
87
+
88
+ The command policy (`refuseAgentBrowserCommand`) keeps the worker to browsing.
89
+ It runs only commands on its list and refuses, with a reason the worker reads: downloads, uploads, installs and upgrades, cookies, storage and saved state, the auth vault, plugins, `connect`, `batch`, recording and tracing, and any flag that changes how the browser launches or what it trusts (`--proxy`, `--args`, `--cdp`, `--profile`, `--state`, `--ignore-https-errors`, `--headers` and the like).
90
+ `open` and `tab new` take only http and https addresses.
91
+ This is not the security boundary, which is the container's network; it keeps a worker from wasting steps on things that cannot work.
92
+
93
+ ## How the container is isolated
94
+
95
+ - `--network none`: the container has a loopback interface and nothing else. There is no route to the host's loopback or private networks, to link-local addresses such as cloud metadata, or to the internet.
96
+ - The browser's only way out is a Unix socket, bind-mounted read-only from a private folder (mode 0700) at `/egress`, which `socat` relays to `127.0.0.1:3128` inside the container. Chromium uses it as its HTTP proxy for every request: pages, redirects, subresources, `fetch`, and WebSockets (`CONNECT`).
97
+ - On the host, the proxy resolves each destination itself, refuses it unless every address in the answer is a public unicast address (loopback, private, link-local, carrier-grade NAT, multicast, documentation and benchmarking ranges, IPv4-mapped and transition IPv6 forms are not), and connects to one of the vetted addresses. The browser's own DNS answer is never used, so a name that later resolves elsewhere (DNS rebinding) reaches nothing, and every redirect is a new request that is vetted again. Numeric forms of IPv4 addresses (`2130706433`, `0x7f.1`) are refused outright, and only ports 80 and 443 are open unless `docker.ports` adds more. A plain HTTP request is sent on its own connection, and every connection has idle and size limits.
98
+ - WebRTC is disabled for non-proxied UDP (`--force-webrtc-ip-handling-policy=disable_non_proxied_udp`), and with no interface there is no other path for it.
99
+ - Chromium runs under a managed policy that refuses downloads, local files, `chrome://` pages, file dialogs, sign-in, password saving and autofill. The agent-browser upload and download commands are refused as well.
100
+ - The container is read-only with a small tmpfs, runs as the host's non-root user with all capabilities dropped and `no-new-privileges`, and has memory, CPU and process limits. It is created with `--rm` and labelled; it exits by itself shortly after the task's time limit even if the host dies, and a restarted host removes containers a dead host left.
101
+ - A task that fails to clean up (for example the Docker daemon is down) logs a warning that names the container; the cleanup runs again when the host stops or restarts.
102
+
103
+ Nothing in the container is a secret: no credentials, no cookies, no mounted host files except the proxy socket.
104
+ What the worker reads on the web is untrusted, and its standing instructions say so; keep the `model` you give it in mind when the pages it reads may try to steer it.
105
+ A public page the worker opens is fetched from your host's IP address.
106
+
107
+ ## Another driver
108
+
109
+ `BrowserDriver` is the extension point:
110
+
111
+ ```ts
112
+ import { Type } from "typebox";
113
+ import { browserAgent, browserTool, type BrowserDriver } from "pi-roundtable-browser";
114
+
115
+ const driver: BrowserDriver = {
116
+ async check() { /* throw a PluginError naming the fix if it cannot work */ },
117
+ async start({ signal, limits, keepScreenshot }) {
118
+ const session = await openMyBrowser(signal);
119
+ return {
120
+ tools: [browserTool({ name: "browser_open", description: "...", parameters: Type.Object({ url: Type.String() }), execute: async ({ url }) => ({ text: await session.open(url) }) })],
121
+ skills: [{ name: "my browser", content: "How to use these tools..." }],
122
+ prompt: "Optional instructions added after the standing rules.",
123
+ dispose: () => session.close(),
124
+ };
125
+ },
126
+ };
127
+
128
+ browserAgent({ driver });
129
+ ```
130
+
131
+ `start` builds one task's tools, skills and instructions; `dispose` is called once when the task ends, however it ended.
132
+ The package counts every tool call against `limits.maxSteps`, cuts text at `limits.maxOutputChars`, scrubs unexpected errors before the worker reads them, aborts `signal` on cancellation, time-out and shutdown, and enforces the screenshot count and size when a tool calls `keepScreenshot(image)`.
133
+ A driver is responsible for keeping its browser away from what the host should not expose; the package cannot check that for it.
134
+
135
+ ## Testing
136
+
137
+ `bun test` runs offline: the tool flow with a fake driver and scripted worker sessions, the limits, cancellation and cleanup, the egress proxy with a stand-in network, the command policy, and the driver against a fake `docker` binary.
138
+ The Docker integration test builds the image, browses a public page, and proves that requests to the host's loopback, a private address and the metadata address fail.
139
+ It is opt-in and runs on native Linux as a non-root user:
140
+
141
+ ```sh
142
+ (cd packages/browser && BROWSER_DOCKER_TEST=1 bun test src/docker.integration.test.ts)
143
+ ```
144
+
145
+ CI runs it on its Ubuntu runner and requires it to pass.
@@ -0,0 +1,25 @@
1
+ # The browser worker's image: agent-browser and the distribution's Chromium, nothing else of use.
2
+ # The host runs it with no network at all; see packages/browser/README.md.
3
+ FROM node:24-bookworm-slim
4
+
5
+ ARG AGENT_BROWSER_VERSION=0.39.1
6
+
7
+ # Chromium comes from the distribution because Chrome for Testing has no Linux ARM64 build, and its
8
+ # managed policy directory is /etc/chromium/policies/managed. socat relays the container's loopback
9
+ # proxy port to the host's egress proxy socket.
10
+ RUN apt-get update \
11
+ && apt-get install -y --no-install-recommends \
12
+ chromium socat ca-certificates fonts-liberation fonts-noto-cjk fonts-noto-color-emoji \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ RUN npm install --global --allow-scripts=agent-browser "agent-browser@${AGENT_BROWSER_VERSION}" \
16
+ && rm -rf /root/.npm
17
+
18
+ # Chromium refuses downloads, local files, and the pages that expose the browser's own state.
19
+ COPY policies.json /etc/chromium/policies/managed/pi-roundtable.json
20
+
21
+ COPY entrypoint.sh /usr/local/bin/pi-roundtable-browser-entrypoint
22
+ RUN chmod 0755 /usr/local/bin/pi-roundtable-browser-entrypoint
23
+
24
+ ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
25
+ ENTRYPOINT ["/usr/local/bin/pi-roundtable-browser-entrypoint"]
@@ -0,0 +1,7 @@
1
+ #!/bin/sh
2
+ # Relays 127.0.0.1:3128 to the host's egress proxy on the mounted unix socket, then idles.
3
+ # The host drives the browser with `docker exec agent-browser ...`.
4
+ set -eu
5
+ socat TCP-LISTEN:3128,bind=127.0.0.1,fork,reuseaddr UNIX-CONNECT:/egress/proxy.sock &
6
+ # Exits by itself once the task could not still be running, even if the host that started it is gone.
7
+ exec sleep "${BROWSER_LIFETIME_SECONDS:-1200}"
@@ -0,0 +1,20 @@
1
+ {
2
+ "DownloadRestrictions": 3,
3
+ "AllowFileSelectionDialogs": false,
4
+ "URLBlocklist": [
5
+ "file://*",
6
+ "chrome://*",
7
+ "chrome-extension://*",
8
+ "view-source:*",
9
+ "devtools://*",
10
+ "ftp://*"
11
+ ],
12
+ "WebRtcIPHandling": "disable_non_proxied_udp",
13
+ "PasswordManagerEnabled": false,
14
+ "AutofillAddressEnabled": false,
15
+ "AutofillCreditCardEnabled": false,
16
+ "BrowserSignin": 0,
17
+ "SyncDisabled": true,
18
+ "DefaultBrowserSettingEnabled": false,
19
+ "MetricsReportingEnabled": false
20
+ }
@@ -0,0 +1,11 @@
1
+ import { browserAgent } from "../src/index.ts";
2
+
3
+ // Merge with the rest of the configuration generated by pi-roundtable init.
4
+ export const browserPlugins = [
5
+ browserAgent({
6
+ // Default: the host's delegation.model and delegation.thinking.
7
+ model: "anthropic/claude-sonnet-5-5",
8
+ thinking: "medium",
9
+ limits: { timeoutMs: 5 * 60_000, maxSteps: 40, maxScreenshots: 4 },
10
+ }),
11
+ ];
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
- "name": "pi-roundtable-browser",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "pi-roundtable-browser",
3
+ "version": "0.22.1",
4
+ "description": "A browser worker for pi-roundtable agents: agent-browser in a Docker container with no route to the host's networks",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/wayne930242/pi-roundtable.git",
10
+ "directory": "packages/browser"
11
+ },
12
+ "engines": {
13
+ "bun": ">=1.4.2"
14
+ },
15
+ "exports": {
16
+ ".": "./src/index.ts"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "!src/**/*.test.ts",
21
+ "!src/testing",
22
+ "docker",
23
+ "examples",
24
+ "LICENSE",
25
+ "README.md",
26
+ "CHANGELOG.md"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": true
31
+ },
32
+ "scripts": {
33
+ "test": "bun test",
34
+ "typecheck": "tsc --noEmit",
35
+ "lint": "biome check ."
36
+ },
37
+ "dependencies": {
38
+ "@earendil-works/pi-coding-agent": ">=1.0.0 <2",
39
+ "typebox": "1.3.37"
40
+ },
41
+ "peerDependencies": {
42
+ "pi-roundtable": ">=0.22.0 <0.23.0"
43
+ },
44
+ "devDependencies": {
45
+ "@biomejs/biome": "2.5.15",
46
+ "@types/bun": "1.4.3",
47
+ "pi-roundtable": "0.22.1",
48
+ "typescript": "7.0.2"
49
+ }
50
+ }
package/src/address.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { isIP } from "node:net";
2
+
3
+ /**
4
+ * Whether an address is a globally routable unicast one. Everything else is refused: loopback,
5
+ * private, link-local (cloud metadata lives there), carrier-grade NAT, documentation, benchmarking,
6
+ * multicast, reserved, and the IPv6 transition ranges that can wrap an IPv4 one (Teredo, 6to4,
7
+ * IPv4-mapped). A scoped IPv6 address (`fe80::1%eth0`) and anything that is not an IP literal is refused.
8
+ */
9
+ export function isPublicAddress(raw: string): boolean {
10
+ let address = raw.toLowerCase().replace(/^\[|\]$/g, "");
11
+ if (address.includes("%")) return false;
12
+ if (isIP(address) === 4) {
13
+ const [a = 0, b = 0, c = 0] = address.split(".").map(Number);
14
+ return !(
15
+ a === 0 ||
16
+ a === 10 ||
17
+ a === 127 ||
18
+ a >= 224 ||
19
+ (a === 100 && b >= 64 && b <= 127) ||
20
+ (a === 169 && b === 254) ||
21
+ (a === 172 && b >= 16 && b <= 31) ||
22
+ (a === 192 &&
23
+ (b === 168 ||
24
+ (b === 0 && (c === 0 || c === 2)) ||
25
+ (b === 88 && c === 99))) ||
26
+ (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
27
+ (a === 203 && b === 0 && c === 113)
28
+ );
29
+ }
30
+ if (isIP(address) !== 6) return false;
31
+ // URL canonicalization normalizes expanded IPv6 and dotted IPv4-mapped representations.
32
+ address = new URL(`http://[${address}]/`).hostname.slice(1, -1);
33
+ if (address.startsWith("::ffff:")) {
34
+ const parts = address.slice(7).split(":");
35
+ if (parts.length !== 2) return false;
36
+ const high = Number.parseInt(parts[0] ?? "", 16);
37
+ const low = Number.parseInt(parts[1] ?? "", 16);
38
+ return isPublicAddress(
39
+ `${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`,
40
+ );
41
+ }
42
+ // Only ordinary global IPv6, excluding documentation, benchmarking, Teredo and 6to4.
43
+ // Hextets are read numerically after expansion, so compressed forms such as 2001::1 are caught too.
44
+ const [head = "", tail = ""] = address.split("::");
45
+ const left = head ? head.split(":") : [];
46
+ const right = tail ? tail.split(":") : [];
47
+ const hextets = address.includes("::")
48
+ ? [
49
+ ...left,
50
+ ...Array<string>(Math.max(0, 8 - left.length - right.length)).fill("0"),
51
+ ...right,
52
+ ]
53
+ : left;
54
+ const first = Number.parseInt(hextets[0] ?? "", 16);
55
+ const second = Number.parseInt(hextets[1] ?? "", 16);
56
+ return (
57
+ first >= 0x2000 &&
58
+ first <= 0x3fff &&
59
+ first !== 0x2002 &&
60
+ !(
61
+ first === 0x2001 &&
62
+ (second === 0 ||
63
+ second === 2 ||
64
+ second === 0xdb8 ||
65
+ (second >= 0x10 && second <= 0x2f))
66
+ ) &&
67
+ first !== 0x3fff
68
+ );
69
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * What the worker may run. The browser container has no network of its own, so this list is not
3
+ * the security boundary; it keeps the worker to browsing: no downloads, uploads, installs, saved
4
+ * logins, cookie or state import and export, plugins, or changes to the browser's launch or
5
+ * network settings. Anything not listed is refused, so a command a later agent-browser adds starts
6
+ * out refused.
7
+ */
8
+
9
+ /** Commands the worker may run, with the subcommands allowed where only some are. */
10
+ const COMMANDS: Readonly<Record<string, readonly string[] | true>> = {
11
+ open: true,
12
+ read: true,
13
+ click: true,
14
+ dblclick: true,
15
+ type: true,
16
+ fill: true,
17
+ press: true,
18
+ keyboard: true,
19
+ hover: true,
20
+ focus: true,
21
+ check: true,
22
+ uncheck: true,
23
+ select: true,
24
+ drag: true,
25
+ scroll: true,
26
+ scrollintoview: true,
27
+ wait: true,
28
+ snapshot: true,
29
+ eval: true,
30
+ back: true,
31
+ forward: true,
32
+ reload: true,
33
+ find: true,
34
+ is: true,
35
+ mouse: true,
36
+ frame: true,
37
+ highlight: true,
38
+ pushstate: true,
39
+ console: true,
40
+ errors: true,
41
+ get: [
42
+ "text",
43
+ "html",
44
+ "value",
45
+ "attr",
46
+ "title",
47
+ "url",
48
+ "count",
49
+ "box",
50
+ "styles",
51
+ ],
52
+ tab: true,
53
+ dialog: ["status", "accept", "dismiss"],
54
+ diff: ["snapshot"],
55
+ network: ["requests"],
56
+ set: ["viewport", "device", "geo", "media"],
57
+ skills: ["get", "list"],
58
+ webmcp: ["list", "invoke", "result", "cancel"],
59
+ // Handled by the driver itself, which picks where the image goes.
60
+ screenshot: true,
61
+ };
62
+
63
+ /** Why a command is not offered, for the ones a worker is likely to try. */
64
+ const REASONS: Readonly<Record<string, string>> = {
65
+ download: "downloads are disabled",
66
+ upload: "file uploads are disabled",
67
+ pdf: "saving files is disabled",
68
+ install: "the browser is already installed",
69
+ upgrade: "the browser is already installed",
70
+ cookies: "cookies cannot be imported or exported",
71
+ storage: "web storage cannot be imported or exported",
72
+ state: "saved state cannot be imported or exported",
73
+ auth: "the browser has no stored logins and the worker has no credentials",
74
+ plugin: "plugins are disabled",
75
+ profiles: "browser profiles are disabled",
76
+ connect: "the task's browser cannot be replaced",
77
+ close: "the browser is closed for you when the task ends",
78
+ batch: "run each command with its own call, so each is checked",
79
+ record: "recording is disabled",
80
+ trace: "tracing is disabled",
81
+ profiler: "profiling is disabled",
82
+ stream: "streaming is disabled",
83
+ mcp: "the MCP server is disabled",
84
+ chat: "chat is disabled",
85
+ dashboard: "the dashboard is disabled",
86
+ session: "the task's session is fixed",
87
+ doctor: "diagnostics are disabled",
88
+ clipboard: "the clipboard is disabled",
89
+ inspect: "the inspector is disabled",
90
+ };
91
+
92
+ /** Flags that change how the browser launches, what it trusts, where it connects or what it saves. */
93
+ const REFUSED_FLAGS = new Set([
94
+ "--profile",
95
+ "--restore",
96
+ "--restore-save",
97
+ "--restore-check-url",
98
+ "--restore-check-text",
99
+ "--restore-check-fn",
100
+ "--session-name",
101
+ "--state",
102
+ "--auto-connect",
103
+ "--cdp",
104
+ "--headers",
105
+ "--session",
106
+ "--namespace",
107
+ "--executable-path",
108
+ "--extension",
109
+ "--init-script",
110
+ "--enable",
111
+ "--args",
112
+ "--user-agent",
113
+ "--proxy",
114
+ "--proxy-bypass",
115
+ "--ignore-https-errors",
116
+ "--ca-cert",
117
+ "--no-ca-cert",
118
+ "--allow-file-access",
119
+ "--provider",
120
+ "-p",
121
+ "--device",
122
+ "--headed",
123
+ "--webgpu",
124
+ "--download-path",
125
+ "--screenshot-dir",
126
+ "--allowed-domains",
127
+ "--action-policy",
128
+ "--confirm-actions",
129
+ "--confirm-interactive",
130
+ "--engine",
131
+ "--config",
132
+ "--idle-timeout",
133
+ "--model",
134
+ "--pin-tab",
135
+ "--no-pin-tab",
136
+ ]);
137
+
138
+ /** `--flag=value` and `--flag` name the same flag. */
139
+ const flagName = (arg: string) => arg.split("=", 1)[0] ?? arg;
140
+
141
+ /** Addresses of other kinds than http(s): `file:`, `javascript:`, `chrome://`, `data:` and the like. */
142
+ const OTHER_SCHEME =
143
+ /^(?:[a-z][a-z0-9+.-]*:\/\/|(?:file|javascript|data|blob|about|chrome|view-source|devtools|ftp):)/i;
144
+
145
+ /**
146
+ * The reason a command is refused, written for the worker, or undefined when it may run.
147
+ * `args` is the argument list after `agent-browser`.
148
+ */
149
+ export function refuseAgentBrowserCommand(
150
+ args: readonly string[],
151
+ ): string | undefined {
152
+ const [command, sub] = args;
153
+ if (!command) return "Give a command, such as open, snapshot or click.";
154
+ if (command.startsWith("-")) return "Put the command first, then its flags.";
155
+ for (const arg of args.slice(1)) {
156
+ const flag = flagName(arg);
157
+ if (REFUSED_FLAGS.has(flag))
158
+ return `${flag} is refused: it changes how the task's browser launches, connects or saves state.`;
159
+ }
160
+ const allowed = COMMANDS[command];
161
+ if (allowed === undefined)
162
+ return REASONS[command]
163
+ ? `${command} is refused: ${REASONS[command]}.`
164
+ : `${command} is not one of the commands this browser worker offers.`;
165
+ if (allowed !== true && (!sub || !allowed.includes(sub)))
166
+ return `${command} may only be followed by ${allowed.join(", ")}.`;
167
+ if (command === "open" || (command === "tab" && sub === "new"))
168
+ for (const arg of args.slice(command === "open" ? 1 : 2))
169
+ if (
170
+ !arg.startsWith("-") &&
171
+ OTHER_SCHEME.test(arg) &&
172
+ !/^https?:\/\//i.test(arg)
173
+ )
174
+ return "Only http and https pages open; other addresses (file:, javascript:, chrome: and the like) are refused.";
175
+ return undefined;
176
+ }