diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0770e0062..e37880ccd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,8 @@ Status: unreleased.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
+- Browser: route browser control via gateway/node; remove standalone browser control command and control URL config.
+- Browser: route `browser.request` via node proxies when available; honor proxy timeouts; derive browser ports from `gateway.port`.
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
@@ -705,7 +707,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
### Highlights
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
-- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`.
+- Browser control: Chrome extension relay takeover mode + remote browser control support.
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
@@ -723,7 +725,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
-- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
+- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control (standalone server + token auth).
### Fixes
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
diff --git a/README.md b/README.md
index db80c6cd0..a3f0b11d1 100644
--- a/README.md
+++ b/README.md
@@ -384,7 +384,6 @@ Browser control (optional):
{
browser: {
enabled: true,
- controlUrl: "http://127.0.0.1:18791",
color: "#FF4500"
}
}
diff --git a/assets/chrome-extension/options.html b/assets/chrome-extension/options.html
index 4e701826d..f66608f43 100644
--- a/assets/chrome-extension/options.html
+++ b/assets/chrome-extension/options.html
@@ -168,8 +168,7 @@
Getting started
If you see a red ! badge on the extension icon, the relay server is not reachable.
- Start Clawdbot’s browser relay on this machine (Gateway or clawdbot browser serve),
- then click the toolbar button again.
+ Start Clawdbot’s browser relay on this machine (Gateway or node host), then click the toolbar button again.
Full guide (install, remote Gateway, security): docs.clawd.bot/tools/chrome-extension
diff --git a/docs/cli/browser.md b/docs/cli/browser.md
index 8ba2bc237..6d54b8a10 100644
--- a/docs/cli/browser.md
+++ b/docs/cli/browser.md
@@ -1,8 +1,8 @@
---
-summary: "CLI reference for `clawdbot browser` (profiles, tabs, actions, extension relay, remote serve)"
+summary: "CLI reference for `clawdbot browser` (profiles, tabs, actions, extension relay)"
read_when:
- You use `clawdbot browser` and want examples for common tasks
- - You want to control a remote browser via `browser.controlUrl`
+ - You want to control a browser running on another machine via a node host
- You want to use the Chrome extension relay (attach/detach via toolbar button)
---
@@ -16,8 +16,10 @@ Related:
## Common flags
-- `--url `: override `browser.controlUrl` for this command invocation.
-- `--browser-profile `: choose a browser profile (default comes from config).
+- `--url `: Gateway WebSocket URL (defaults to config).
+- `--token `: Gateway token (if required).
+- `--timeout `: request timeout (ms).
+- `--browser-profile `: choose a browser profile (default from config).
- `--json`: machine-readable output (where supported).
## Quick start (local)
@@ -93,14 +95,10 @@ Then Chrome → `chrome://extensions` → enable “Developer mode” → “Loa
Full guide: [Chrome extension](/tools/chrome-extension)
-## Remote browser control (`clawdbot browser serve`)
+## Remote browser control (node host proxy)
-If the Gateway runs on a different machine than the browser, run a standalone browser control server on the machine that runs Chrome:
+If the Gateway runs on a different machine than the browser, run a **node host** on the machine that has Chrome/Brave/Edge/Chromium. The Gateway will proxy browser actions to that node (no separate browser control server required).
-```bash
-clawdbot browser serve --bind 127.0.0.1 --port 18791 --token
-```
+Use `gateway.nodes.browser.mode` to control auto-routing and `gateway.nodes.browser.node` to pin a specific node if multiple are connected.
-Then point the Gateway at it using `browser.controlUrl` + `browser.controlToken` (or `CLAWDBOT_BROWSER_CONTROL_TOKEN`).
-
-Security + TLS best-practices: [Browser tool](/tools/browser), [Tailscale](/gateway/tailscale), [Security](/gateway/security)
+Security + remote setup: [Browser tool](/tools/browser), [Remote access](/gateway/remote), [Tailscale](/gateway/tailscale), [Security](/gateway/security)
diff --git a/docs/cli/index.md b/docs/cli/index.md
index c49677cbf..9d809bde5 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -859,9 +859,8 @@ Location:
Browser control CLI (dedicated Chrome/Brave/Edge/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
Common options:
-- `--url `
+- `--url`, `--token`, `--timeout`, `--json`
- `--browser-profile `
-- `--json`
Manage:
- `browser status`
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index 9c850e070..3473c1ade 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -2759,7 +2759,7 @@ Example:
### `browser` (clawd-managed browser)
-Clawdbot can start a **dedicated, isolated** Chrome/Brave/Edge/Chromium instance for clawd and expose a small loopback control server.
+Clawdbot can start a **dedicated, isolated** Chrome/Brave/Edge/Chromium instance for clawd and expose a small loopback control service.
Profiles can point at a **remote** Chromium-based browser via `profiles..cdpUrl`. Remote
profiles are attach-only (start/stop/reset are disabled).
@@ -2768,8 +2768,8 @@ scheme/host for profiles that only set `cdpPort`.
Defaults:
- enabled: `true`
-- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
-- CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
+- control service: loopback only (port derived from `gateway.port`, default `18791`)
+- CDP URL: `http://127.0.0.1:18792` (control service + 1, legacy single-profile)
- profile color: `#FF4500` (lobster-orange)
- Note: the control server is started by the running gateway (Clawdbot.app menubar, or `clawdbot gateway`).
- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
@@ -2778,7 +2778,6 @@ Defaults:
{
browser: {
enabled: true,
- controlUrl: "http://127.0.0.1:18791",
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
defaultProfile: "chrome",
profiles: {
diff --git a/docs/gateway/index.md b/docs/gateway/index.md
index 824984bde..b357d80f2 100644
--- a/docs/gateway/index.md
+++ b/docs/gateway/index.md
@@ -83,13 +83,13 @@ Defaults (can be overridden via env/flags/config):
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
- `CLAWDBOT_GATEWAY_PORT=19001` (Gateway WS + HTTP)
-- `browser.controlUrl=http://127.0.0.1:19003` (derived: `gateway.port+2`)
+- browser control service port = `19003` (derived: `gateway.port+2`, loopback only)
- `canvasHost.port=19005` (derived: `gateway.port+4`)
- `agents.defaults.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`.
Derived ports (rules of thumb):
- Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`)
-- `browser.controlUrl port = base + 2` (or `CLAWDBOT_BROWSER_CONTROL_URL` / config override)
+- browser control service port = base + 2 (loopback only)
- `canvasHost.port = base + 4` (or `CLAWDBOT_CANVAS_HOST_PORT` / config override)
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108` (persisted per profile).
diff --git a/docs/gateway/multiple-gateways.md b/docs/gateway/multiple-gateways.md
index b444290f6..9b5193621 100644
--- a/docs/gateway/multiple-gateways.md
+++ b/docs/gateway/multiple-gateways.md
@@ -73,7 +73,7 @@ clawdbot --profile rescue gateway install
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).
-- `browser.controlUrl port = base + 2`
+- browser control service port = base + 2 (loopback only)
- `canvasHost.port = base + 4`
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108`
@@ -81,8 +81,8 @@ If you override any of these in config or env, you must keep them unique per ins
## Browser/CDP notes (common footgun)
-- Do **not** pin `browser.controlUrl` or `browser.cdpUrl` to the same values on multiple instances.
-- Each instance needs its own browser control port and CDP range.
+- Do **not** pin `browser.cdpUrl` to the same values on multiple instances.
+- Each instance needs its own browser control port and CDP range (derived from its gateway port).
- If you need explicit CDP ports, set `browser.profiles..cdpPort` per instance.
- Remote Chrome: use `browser.profiles..cdpUrl` (per profile, per instance).
diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md
index aa3fff7f7..13c514845 100644
--- a/docs/gateway/remote.md
+++ b/docs/gateway/remote.md
@@ -117,6 +117,6 @@ Short version: **keep the Gateway loopback-only** unless you’re sure you need
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
Set it to `false` if you want tokens/passwords instead.
-- Treat `browser.controlUrl` like an admin API: tailnet-only + token auth.
+- Treat browser control like operator access: tailnet-only + deliberate node pairing.
Deep dive: [Security](/gateway/security).
diff --git a/docs/gateway/security.md b/docs/gateway/security.md
index 52671d864..ca532aae0 100644
--- a/docs/gateway/security.md
+++ b/docs/gateway/security.md
@@ -36,7 +36,7 @@ Start with the smallest access that still works, then widen it as you gain confi
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
- **Tool blast radius** (elevated tools + open rooms): could prompt injection turn into shell/file/network actions?
- **Network exposure** (Gateway bind/auth, Tailscale Serve/Funnel).
-- **Browser control exposure** (remote controlUrl without token, HTTP, token reuse).
+- **Browser control exposure** (remote nodes, relay ports, remote CDP endpoints).
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
- **Plugins** (extensions exist without an explicit allowlist).
- **Model hygiene** (warn when configured models look legacy; not a hard block).
@@ -61,7 +61,7 @@ When the audit prints findings, treat this as a priority order:
1. **Anything “open” + tools enabled**: lock down DMs/groups first (pairing/allowlists), then tighten tool policy/sandboxing.
2. **Public network exposure** (LAN bind, Funnel, missing auth): fix immediately.
-3. **Browser control remote exposure**: treat it like a remote admin API (token required; HTTPS/tailnet-only).
+3. **Browser control remote exposure**: treat it like operator access (tailnet-only, pair nodes deliberately, avoid public exposure).
4. **Permissions**: make sure state/config/credentials/auth are not group/world-readable.
5. **Plugins/extensions**: only load what you explicitly trust.
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
@@ -277,7 +277,7 @@ Assume “compromised” means: someone got into a room that can trigger the bot
- Lock down inbound surfaces (DM policy, group allowlists, mention gating).
2. **Rotate secrets**
- Rotate `gateway.auth` token/password.
- - Rotate `browser.controlToken` and `hooks.token` (if used).
+ - Rotate `hooks.token` (if used) and revoke any suspicious node pairings.
- Revoke/rotate model provider credentials (API keys / OAuth).
3. **Review artifacts**
- Check Gateway logs and recent sessions/transcripts for unexpected tool calls.
@@ -430,26 +430,19 @@ Trusted proxies:
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
-### 0.6.1) Browser control server over Tailscale (recommended)
+### 0.6.1) Browser control via node host (recommended)
-If your Gateway is remote but the browser runs on another machine, you’ll often run a **separate browser control server**
-on the browser machine (see [Browser tool](/tools/browser)). Treat this like an admin API.
+If your Gateway is remote but the browser runs on another machine, run a **node host**
+on the browser machine and let the Gateway proxy browser actions (see [Browser tool](/tools/browser)).
+Treat node pairing like admin access.
Recommended pattern:
-
-```bash
-# on the machine that runs Chrome
-clawdbot browser serve --bind 127.0.0.1 --port 18791 --token
-tailscale serve https / http://127.0.0.1:18791
-```
-
-Then on the Gateway, set:
-- `browser.controlUrl` to the `https://…` Serve URL (MagicDNS/ts.net)
-- and authenticate with the same token (`CLAWDBOT_BROWSER_CONTROL_TOKEN` env preferred)
+- Keep the Gateway and node host on the same tailnet (Tailscale).
+- Pair the node intentionally; disable browser proxy routing if you don’t need it.
Avoid:
-- `--bind 0.0.0.0` (LAN-visible surface)
-- Tailscale Funnel for browser control endpoints (public exposure)
+- Exposing relay/control ports over LAN or public Internet.
+- Tailscale Funnel for browser control endpoints (public exposure).
### 0.7) Secrets on disk (what’s sensitive)
@@ -581,9 +574,8 @@ access those accounts and data. Treat browser profiles as **sensitive state**:
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach.
-- Treat `browser.controlUrl` endpoints as an admin API: tailnet-only + token auth. Prefer Tailscale Serve over LAN binds.
-- Keep `browser.controlToken` separate from `gateway.auth.token` (you can reuse it, but that increases blast radius).
-- Prefer env vars for the token (`CLAWDBOT_BROWSER_CONTROL_TOKEN`) instead of storing it in config on disk.
+- Keep the Gateway and node hosts tailnet-only; avoid exposing relay/control ports to LAN or public Internet.
+- Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`).
- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach.
## Per-agent access profiles (multi-agent)
diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md
index e6477fbfc..6b68c0c61 100644
--- a/docs/gateway/tailscale.md
+++ b/docs/gateway/tailscale.md
@@ -100,35 +100,13 @@ clawdbot gateway --tailscale funnel --auth password
- Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over
the same Gateway WS endpoint, so Serve can work for node access.
-## Browser control server (remote Gateway + local browser)
+## Browser control (remote Gateway + local browser)
-If you run the Gateway on one machine but want to drive a browser on another machine, use a **separate browser control server**
-and publish it through Tailscale **Serve** (tailnet-only):
+If you run the Gateway on one machine but want to drive a browser on another machine,
+run a **node host** on the browser machine and keep both on the same tailnet.
+The Gateway will proxy browser actions to the node; no separate control server or Serve URL needed.
-```bash
-# on the machine that runs Chrome
-clawdbot browser serve --bind 127.0.0.1 --port 18791 --token
-tailscale serve https / http://127.0.0.1:18791
-```
-
-Then point the Gateway config at the HTTPS URL:
-
-```json5
-{
- browser: {
- enabled: true,
- controlUrl: "https:///"
- }
-}
-```
-
-And authenticate from the Gateway with the same token (prefer env):
-
-```bash
-export CLAWDBOT_BROWSER_CONTROL_TOKEN=""
-```
-
-Avoid Funnel for browser control endpoints unless you explicitly want public exposure.
+Avoid Funnel for browser control; treat node pairing like operator access.
## Tailscale prerequisites + limits
diff --git a/docs/help/faq.md b/docs/help/faq.md
index 336b324c9..554597165 100644
--- a/docs/help/faq.md
+++ b/docs/help/faq.md
@@ -1093,9 +1093,10 @@ clawdbot browser extension path
Then Chrome → `chrome://extensions` → enable “Developer mode” → “Load unpacked” → pick that folder.
-Full guide (including remote Gateway via Tailscale + security notes): [Chrome extension](/tools/chrome-extension)
+Full guide (including remote Gateway + security notes): [Chrome extension](/tools/chrome-extension)
-If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
+If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need anything extra.
+If the Gateway runs elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions.
You still need to click the extension button on the tab you want to control (it doesn’t auto-attach).
## Sandboxing and memory
@@ -1479,7 +1480,7 @@ setup is an always‑on host plus your laptop as a node.
- **Safer execution controls.** `system.run` is gated by node allowlists/approvals on that laptop.
- **More device tools.** Nodes expose `canvas`, `camera`, and `screen` in addition to `system.run`.
- **Local browser automation.** Keep the Gateway on a VPS, but run Chrome locally and relay control
- with the Chrome extension + `clawdbot browser serve`.
+ with the Chrome extension + a node host on the laptop.
SSH is fine for ad‑hoc shell access, but nodes are simpler for ongoing agent workflows and
device automation.
diff --git a/docs/tools/browser.md b/docs/tools/browser.md
index 3bf597e33..4b8b7eb00 100644
--- a/docs/tools/browser.md
+++ b/docs/tools/browser.md
@@ -1,5 +1,5 @@
---
-summary: "Integrated browser control server + action commands"
+summary: "Integrated browser control service + action commands"
read_when:
- Adding agent-controlled browser automation
- Debugging why clawd is interfering with your own Chrome
@@ -10,7 +10,7 @@ read_when:
Clawdbot can run a **dedicated Chrome/Brave/Edge/Chromium profile** that the agent controls.
It is isolated from your personal browser and is managed through a small local
-control server.
+control service inside the Gateway (loopback only).
Beginner view:
- Think of it as a **separate, agent-only browser**.
@@ -57,8 +57,7 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
{
browser: {
enabled: true, // default: true
- controlUrl: "http://127.0.0.1:18791",
- cdpUrl: "http://127.0.0.1:18792", // defaults to controlUrl + 1
+ // cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms)
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
defaultProfile: "chrome",
@@ -77,10 +76,11 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
```
Notes:
-- `controlUrl` defaults to `http://127.0.0.1:18791`.
+- The browser control service binds to loopback on a port derived from `gateway.port`
+ (default: `18791`, which is gateway + 2). The relay uses the next port (`18792`).
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
- the default browser ports shift to stay in the same “family” (control = gateway + 2).
-- `cdpUrl` defaults to `controlUrl + 1` when unset.
+ the derived browser ports shift to stay in the same “family”.
+- `cdpUrl` defaults to the relay port when unset.
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks.
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
@@ -126,38 +126,11 @@ clawdbot config set browser.executablePath "/usr/bin/google-chrome"
## Local vs remote control
-- **Local control (default):** `controlUrl` is loopback (`127.0.0.1`/`localhost`).
- The Gateway starts the control server and can launch a local browser.
-- **Remote control:** `controlUrl` is non-loopback. The Gateway **does not** start
- a local server; it assumes you are pointing at an existing server elsewhere.
+- **Local control (default):** the Gateway starts the loopback control service and can launch a local browser.
+- **Remote control (node host):** run a node host on the machine that has the browser; the Gateway proxies browser actions to it.
- **Remote CDP:** set `browser.profiles..cdpUrl` (or `browser.cdpUrl`) to
attach to a remote Chromium-based browser. In this case, Clawdbot will not launch a local browser.
-## Remote browser (control server)
-
-You can run the **browser control server** on another machine and point your
-Gateway at it with a remote `controlUrl`. This lets the agent drive a browser
-outside the host (lab box, VM, remote desktop, etc.).
-
-Key points:
-- The **control server** speaks to Chromium-based browsers (Chrome/Brave/Edge/Chromium) via **CDP**.
-- The **Gateway** only needs the HTTP control URL.
-- Profiles are resolved on the **control server** side.
-
-Example:
-```json5
-{
- browser: {
- enabled: true,
- controlUrl: "http://10.0.0.42:18791",
- defaultProfile: "work"
- }
-}
-```
-
-Use `profiles..cdpUrl` for **remote CDP** if you want the Gateway to talk
-directly to a Chromium-based browser instance without a remote control server.
-
Remote CDP URLs can include auth:
- Query tokens (e.g., `https://provider.example?token=`)
- HTTP Basic auth (e.g., `https://user:pass@provider.example`)
@@ -166,11 +139,11 @@ Clawdbot preserves the auth when calling `/json/*` endpoints and when connecting
to the CDP WebSocket. Prefer environment variables or secrets managers for
tokens instead of committing them to config files.
-### Node browser proxy (zero-config default)
+## Node browser proxy (zero-config default)
If you run a **node host** on the machine that has your browser, Clawdbot can
-auto-route browser tool calls to that node without any custom `controlUrl`
-setup. This is the default path for remote gateways.
+auto-route browser tool calls to that node without any extra browser config.
+This is the default path for remote gateways.
Notes:
- The node host exposes its local browser control server via a **proxy command**.
@@ -179,7 +152,7 @@ Notes:
- On the node: `nodeHost.browserProxy.enabled=false`
- On the gateway: `gateway.nodes.browser.mode="off"`
-### Browserless (hosted remote CDP)
+## Browserless (hosted remote CDP)
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
CDP endpoints over HTTPS. You can point a Clawdbot browser profile at a
@@ -207,94 +180,16 @@ Notes:
- Replace `` with your real Browserless token.
- Choose the region endpoint that matches your Browserless account (see their docs).
-### Running the control server on the browser machine
-
-Run a standalone browser control server (recommended when your Gateway is remote):
-
-```bash
-# on the machine that runs Chrome/Brave/Edge
-clawdbot browser serve --bind --port 18791 --token
-```
-
-Then point your Gateway at it:
-
-```json5
-{
- browser: {
- enabled: true,
- controlUrl: "http://:18791",
-
- // Option A (recommended): keep token in env on the Gateway
- // (avoid writing secrets into config files)
- // controlToken: ""
- }
-}
-```
-
-And set the auth token in the Gateway environment:
-
-```bash
-export CLAWDBOT_BROWSER_CONTROL_TOKEN=""
-```
-
-Option B: store the token in the Gateway config instead (same shared token):
-
-```json5
-{
- browser: {
- enabled: true,
- controlUrl: "http://:18791",
- controlToken: ""
- }
-}
-```
-
## Security
-This section covers the **browser control server** (`browser.controlUrl`) used for agent browser automation.
-
Key ideas:
-- Treat the browser control server like an admin API: **private network only**.
-- Use **token auth** always when the server is reachable off-machine.
-- Prefer **Tailnet-only** connectivity over LAN exposure.
+- Browser control is loopback-only; access flows through the Gateway’s auth or node pairing.
+- Keep the Gateway and any node hosts on a private network (Tailscale); avoid public exposure.
+- Treat remote CDP URLs/tokens as secrets; prefer env vars or a secrets manager.
-### Tokens (what is shared with what?)
-
-- `browser.controlToken` / `CLAWDBOT_BROWSER_CONTROL_TOKEN` is **only** for authenticating browser control HTTP requests to `browser.controlUrl`.
-- It is **not** the Gateway token (`gateway.auth.token`) and **not** a node pairing token.
-- You *can* reuse the same string value, but it’s better to keep them separate to reduce blast radius.
-
-### Binding (don’t expose to your LAN by accident)
-
-Recommended:
-- Keep `clawdbot browser serve` bound to loopback (`127.0.0.1`) and publish it via Tailscale.
-- Or bind to a Tailnet IP only (never `0.0.0.0`) and require a token.
-
-Avoid:
-- `--bind 0.0.0.0` (LAN-visible). Even with token auth, traffic is plain HTTP unless you also add TLS.
-
-### TLS / HTTPS (recommended approach: terminate in front)
-
-Best practice here: keep `clawdbot browser serve` on HTTP and terminate TLS in front.
-
-If you’re already using Tailscale, you have two good options:
-
-1) **Tailnet-only, still HTTP** (transport is encrypted by Tailscale):
-- Keep `controlUrl` as `http://…` but ensure it’s only reachable over your tailnet.
-
-2) **Serve HTTPS via Tailscale** (nice UX: `https://…` URL):
-
-```bash
-# on the browser machine
-clawdbot browser serve --bind 127.0.0.1 --port 18791 --token
-tailscale serve https / http://127.0.0.1:18791
-```
-
-Then set your Gateway config `browser.controlUrl` to the HTTPS URL (MagicDNS/ts.net) and keep using the same token.
-
-Notes:
-- Do **not** use Tailscale Funnel for this unless you explicitly want to make the endpoint public.
-- For Tailnet setup/background, see [Gateway web surfaces](/web/index) and the [Gateway CLI](/cli/gateway).
+Remote CDP tips:
+- Prefer HTTPS endpoints and short-lived tokens where possible.
+- Avoid embedding long-lived tokens directly in config files.
## Profiles (multi-browser)
@@ -318,13 +213,12 @@ Clawdbot can also drive **your existing Chrome tabs** (no separate “clawd” C
Full guide: [Chrome extension](/tools/chrome-extension)
Flow:
-- You run a **browser control server** (Gateway on the same machine, or `clawdbot browser serve`).
+- The Gateway runs locally (same machine) or a node host runs on the browser machine.
- A local **relay server** listens at a loopback `cdpUrl` (default: `http://127.0.0.1:18792`).
- You click the **Clawdbot Browser Relay** extension icon on a tab to attach (it does not auto-attach).
- The agent controls that tab via the normal `browser` tool, by selecting the right profile.
-If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
-Use `browser serve` only when the Gateway runs elsewhere (remote mode).
+If the Gateway runs elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions.
### Sandboxed sessions
@@ -387,8 +281,7 @@ Platforms:
## Control API (optional)
-If you want to integrate directly, the browser control server exposes a small
-HTTP API:
+For local integrations only, the Gateway exposes a small loopback HTTP API:
- Status/start/stop: `GET /`, `POST /start`, `POST /stop`
- Tabs: `GET /tabs`, `POST /tabs/open`, `POST /tabs/focus`, `DELETE /tabs/:targetId`
@@ -613,7 +506,7 @@ These are useful for “make the site behave like X” workflows:
- The clawd browser profile may contain logged-in sessions; treat it as sensitive.
- For logins and anti-bot notes (X/Twitter, etc.), see [Browser login + X/Twitter posting](/tools/browser-login).
-- Keep control URLs loopback-only unless you intentionally expose the server.
+- Keep the Gateway/node host private (loopback or tailnet-only).
- Remote CDP endpoints are powerful; tunnel and protect them.
## Troubleshooting
@@ -631,12 +524,10 @@ How it maps:
- `browser act` uses the snapshot `ref` IDs to click/type/drag/select.
- `browser screenshot` captures pixels (full page or element).
- `browser` accepts:
- - `profile` to choose a named browser profile (host or remote control server).
- - `target` (`sandbox` | `host` | `custom`) to select where the browser lives.
- - `controlUrl` sets `target: "custom"` implicitly (remote control server).
+ - `profile` to choose a named browser profile (clawd, chrome, or remote CDP).
+ - `target` (`sandbox` | `host` | `node`) to select where the browser lives.
- In sandboxed sessions, `target: "host"` requires `agents.defaults.sandbox.browser.allowHostControl=true`.
- If `target` is omitted: sandboxed sessions default to `sandbox`, non-sandbox sessions default to `host`.
- - Sandbox allowlists can restrict `target: "custom"` to specific URLs/hosts/ports.
- - Defaults: allowlists unset (no restriction), and sandbox host control is disabled.
+ - If a browser-capable node is connected, the tool may auto-route to it unless you pin `target="host"` or `target="node"`.
This keeps the agent deterministic and avoids brittle selectors.
diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md
index 1b1d0e9e0..afb70856b 100644
--- a/docs/tools/chrome-extension.md
+++ b/docs/tools/chrome-extension.md
@@ -15,7 +15,7 @@ Attach/detach happens via a **single Chrome toolbar button**.
## What it is (concept)
There are three parts:
-- **Browser control server** (HTTP): the API the agent/tool calls (`browser.controlUrl`)
+- **Browser control service** (Gateway or node): the API the agent/tool calls (via the Gateway)
- **Local relay server** (loopback CDP): bridges between the control server and the extension (`http://127.0.0.1:18792` by default)
- **Chrome MV3 extension**: attaches to the active tab using `chrome.debugger` and pipes CDP messages to the relay
@@ -87,23 +87,22 @@ clawdbot browser create-profile \
- `!`: relay not reachable (most common: browser relay server isn’t running on this machine).
If you see `!`:
-- Make sure the Gateway is running locally (default setup), or run `clawdbot browser serve` on this machine (remote gateway setup).
+- Make sure the Gateway is running locally (default setup), or run a node host on this machine if the Gateway runs elsewhere.
- Open the extension Options page; it shows whether the relay is reachable.
-## Do I need `clawdbot browser serve`?
+## Remote Gateway (use a node host)
-### Local Gateway (same machine as Chrome) — usually **no**
+### Local Gateway (same machine as Chrome) — usually **no extra steps**
-If the Gateway is running on the same machine as Chrome and your `browser.controlUrl` is loopback (default),
-you typically **do not** need `clawdbot browser serve`.
+If the Gateway runs on the same machine as Chrome, it starts the browser control service on loopback
+and auto-starts the relay server. The extension talks to the local relay; the CLI/tool calls go to the Gateway.
-The Gateway’s built-in browser control server will start on `http://127.0.0.1:18791/` and Clawdbot will
-auto-start the local relay server on `http://127.0.0.1:18792/`.
+### Remote Gateway (Gateway runs elsewhere) — **run a node host**
-### Remote Gateway (Gateway runs elsewhere) — **yes**
+If your Gateway runs on another machine, start a node host on the machine that runs Chrome.
+The Gateway will proxy browser actions to that node; the extension + relay stay local to the browser machine.
-If your Gateway runs on another machine, run `clawdbot browser serve` on the machine that runs Chrome
-(and publish it via Tailscale Serve / TLS). See the section below.
+If multiple nodes are connected, pin one with `gateway.nodes.browser.node` or set `gateway.nodes.browser.mode`.
## Sandboxing (tool containers)
@@ -134,26 +133,10 @@ Then ensure the tool isn’t denied by tool policy, and (if needed) call `browse
Debugging: `clawdbot sandbox explain`
-## Remote Gateway (recommended: Tailscale Serve)
+## Remote access tips
-Goal: Gateway runs on one machine, but Chrome runs somewhere else.
-
-On the **browser machine**:
-
-```bash
-clawdbot browser serve --bind 127.0.0.1 --port 18791 --token
-tailscale serve https / http://127.0.0.1:18791
-```
-
-On the **Gateway machine**:
-- Set `browser.controlUrl` to the HTTPS Serve URL (MagicDNS/ts.net).
-- Provide the token (prefer env):
-
-```bash
-export CLAWDBOT_BROWSER_CONTROL_TOKEN=""
-```
-
-Then the agent can drive the browser by calling the remote `browser.controlUrl` API, while the extension + relay stay local on the browser machine.
+- Keep the Gateway and node host on the same tailnet; avoid exposing relay ports to LAN or public Internet.
+- Pair nodes intentionally; disable browser proxy routing if you don’t want remote control (`gateway.nodes.browser.mode="off"`).
## How “extension path” works
@@ -176,8 +159,8 @@ This is powerful and risky. Treat it like giving the model “hands on your brow
Recommendations:
- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage.
-- Keep the browser control server tailnet-only (Tailscale) and require a token.
-- Avoid exposing browser control over LAN (`0.0.0.0`) and avoid Funnel (public).
+- Keep the Gateway and any node hosts tailnet-only; rely on Gateway auth + node pairing.
+- Avoid exposing relay ports over LAN (`0.0.0.0`) and avoid Funnel (public).
Related:
- Browser tool overview: [Browser](/tools/browser)
diff --git a/docs/tools/index.md b/docs/tools/index.md
index 7d9b3f581..3ada44edd 100644
--- a/docs/tools/index.md
+++ b/docs/tools/index.md
@@ -249,16 +249,17 @@ Profile management:
- `reset-profile` — kill orphan process on profile's port (local only)
Common parameters:
-- `controlUrl` (defaults from config)
- `profile` (optional; defaults to `browser.defaultProfile`)
+- `target` (`sandbox` | `host` | `node`)
+- `node` (optional; picks a specific node id/name)
Notes:
- Requires `browser.enabled=true` (default is `true`; set `false` to disable).
-- Uses `browser.controlUrl` unless `controlUrl` is passed explicitly.
- All actions accept optional `profile` parameter for multi-instance support.
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "chrome").
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
- Port range: 18800-18899 (~100 profiles max).
- Remote profiles are attach-only (no start/stop/reset).
+- If a browser-capable node is connected, the tool may auto-route to it (unless you pin `target`).
- `snapshot` defaults to `ai` when Playwright is installed; use `aria` for the accessibility tree.
- `snapshot` also supports role-snapshot options (`interactive`, `compact`, `depth`, `selector`) which return refs like `e12`.
- `act` requires `ref` from `snapshot` (numeric `12` from AI snapshots, or `e12` from role snapshots); use `evaluate` for rare CSS selector needs.
@@ -410,7 +411,9 @@ Gateway-backed tools (`canvas`, `nodes`, `cron`):
- `timeoutMs`
Browser tool:
-- `controlUrl` (defaults from config)
+- `profile` (optional; defaults to `browser.defaultProfile`)
+- `target` (`sandbox` | `host` | `node`)
+- `node` (optional; pin a specific node id/name)
## Recommended agent flows
diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts
index b420cad6f..995925cfe 100644
--- a/src/agents/clawdbot-tools.ts
+++ b/src/agents/clawdbot-tools.ts
@@ -20,11 +20,8 @@ import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
import { createTtsTool } from "./tools/tts-tool.js";
export function createClawdbotTools(options?: {
- browserControlUrl?: string;
+ sandboxBrowserBridgeUrl?: string;
allowHostBrowserControl?: boolean;
- allowedControlUrls?: string[];
- allowedControlHosts?: string[];
- allowedControlPorts?: number[];
agentSessionKey?: string;
agentChannel?: GatewayMessageChannel;
agentAccountId?: string;
@@ -75,11 +72,8 @@ export function createClawdbotTools(options?: {
});
const tools: AnyAgentTool[] = [
createBrowserTool({
- defaultControlUrl: options?.browserControlUrl,
+ sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
allowHostControl: options?.allowHostBrowserControl,
- allowedControlUrls: options?.allowedControlUrls,
- allowedControlHosts: options?.allowedControlHosts,
- allowedControlPorts: options?.allowedControlPorts,
}),
createCanvasTool(),
createNodesTool({
diff --git a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts
index 6602a8c20..325fef691 100644
--- a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts
+++ b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts
@@ -127,7 +127,7 @@ describe("buildEmbeddedSandboxInfo", () => {
},
browserAllowHostControl: true,
browser: {
- controlUrl: "http://localhost:9222",
+ bridgeUrl: "http://localhost:9222",
noVncUrl: "http://localhost:6080",
containerName: "clawdbot-sbx-browser-test",
},
@@ -138,7 +138,7 @@ describe("buildEmbeddedSandboxInfo", () => {
workspaceDir: "/tmp/clawdbot-sandbox",
workspaceAccess: "none",
agentWorkspaceMount: undefined,
- browserControlUrl: "http://localhost:9222",
+ browserBridgeUrl: "http://localhost:9222",
browserNoVncUrl: "http://localhost:6080",
hostBrowserAllowed: true,
});
diff --git a/src/agents/pi-embedded-runner/sandbox-info.ts b/src/agents/pi-embedded-runner/sandbox-info.ts
index 1ffc76f0c..a72797c9c 100644
--- a/src/agents/pi-embedded-runner/sandbox-info.ts
+++ b/src/agents/pi-embedded-runner/sandbox-info.ts
@@ -13,12 +13,9 @@ export function buildEmbeddedSandboxInfo(
workspaceDir: sandbox.workspaceDir,
workspaceAccess: sandbox.workspaceAccess,
agentWorkspaceMount: sandbox.workspaceAccess === "ro" ? "/agent" : undefined,
- browserControlUrl: sandbox.browser?.controlUrl,
+ browserBridgeUrl: sandbox.browser?.bridgeUrl,
browserNoVncUrl: sandbox.browser?.noVncUrl,
hostBrowserAllowed: sandbox.browserAllowHostControl,
- allowedControlUrls: sandbox.browserAllowedControlUrls,
- allowedControlHosts: sandbox.browserAllowedControlHosts,
- allowedControlPorts: sandbox.browserAllowedControlPorts,
...(elevatedAllowed
? {
elevated: {
diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts
index 6a1ee1128..4be395bce 100644
--- a/src/agents/pi-embedded-runner/types.ts
+++ b/src/agents/pi-embedded-runner/types.ts
@@ -69,12 +69,9 @@ export type EmbeddedSandboxInfo = {
workspaceDir?: string;
workspaceAccess?: "none" | "ro" | "rw";
agentWorkspaceMount?: string;
- browserControlUrl?: string;
+ browserBridgeUrl?: string;
browserNoVncUrl?: string;
hostBrowserAllowed?: boolean;
- allowedControlUrls?: string[];
- allowedControlHosts?: string[];
- allowedControlPorts?: number[];
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off" | "ask" | "full";
diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts
index 221222338..329f58c72 100644
--- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts
+++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts
@@ -96,7 +96,6 @@ describe("createClawdbotCodingTools", () => {
};
expect(parameters.properties?.action).toBeDefined();
expect(parameters.properties?.target).toBeDefined();
- expect(parameters.properties?.controlUrl).toBeDefined();
expect(parameters.properties?.targetUrl).toBeDefined();
expect(parameters.properties?.request).toBeDefined();
expect(parameters.required ?? []).toContain("action");
diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts
index 4a0bebed0..87dd0919d 100644
--- a/src/agents/pi-tools.ts
+++ b/src/agents/pi-tools.ts
@@ -294,11 +294,8 @@ export function createClawdbotCodingTools(options?: {
// Channel docking: include channel-defined agent tools (login, etc.).
...listChannelAgentTools({ cfg: options?.config }),
...createClawdbotTools({
- browserControlUrl: sandbox?.browser?.controlUrl,
+ sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl,
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
- allowedControlUrls: sandbox?.browserAllowedControlUrls,
- allowedControlHosts: sandbox?.browserAllowedControlHosts,
- allowedControlPorts: sandbox?.browserAllowedControlPorts,
agentSessionKey: options?.sessionKey,
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
agentAccountId: options?.agentAccountId,
diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts
index dcfde8975..cf552e157 100644
--- a/src/agents/sandbox/browser.ts
+++ b/src/agents/sandbox/browser.ts
@@ -40,13 +40,9 @@ function buildSandboxBrowserResolvedConfig(params: {
cdpPort: number;
headless: boolean;
}): ResolvedBrowserConfig {
- const controlHost = "127.0.0.1";
- const controlUrl = `http://${controlHost}:${params.controlPort}`;
const cdpHost = "127.0.0.1";
return {
enabled: true,
- controlUrl,
- controlHost,
controlPort: params.controlPort,
cdpProtocol: "http",
cdpHost,
@@ -204,7 +200,7 @@ export async function ensureSandboxBrowser(params: {
: undefined;
return {
- controlUrl: resolvedBridge.baseUrl,
+ bridgeUrl: resolvedBridge.baseUrl,
noVncUrl,
containerName,
};
diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts
index a633a0fd9..cabf907bc 100644
--- a/src/agents/sandbox/config.ts
+++ b/src/agents/sandbox/config.ts
@@ -86,11 +86,6 @@ export function resolveSandboxBrowserConfig(params: {
}): SandboxBrowserConfig {
const agentBrowser = params.scope === "shared" ? undefined : params.agentBrowser;
const globalBrowser = params.globalBrowser;
- const allowedControlUrls = agentBrowser?.allowedControlUrls ?? globalBrowser?.allowedControlUrls;
- const allowedControlHosts =
- agentBrowser?.allowedControlHosts ?? globalBrowser?.allowedControlHosts;
- const allowedControlPorts =
- agentBrowser?.allowedControlPorts ?? globalBrowser?.allowedControlPorts;
return {
enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false,
image: agentBrowser?.image ?? globalBrowser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
@@ -105,18 +100,6 @@ export function resolveSandboxBrowserConfig(params: {
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
enableNoVnc: agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
allowHostControl: agentBrowser?.allowHostControl ?? globalBrowser?.allowHostControl ?? false,
- allowedControlUrls:
- Array.isArray(allowedControlUrls) && allowedControlUrls.length > 0
- ? allowedControlUrls
- : undefined,
- allowedControlHosts:
- Array.isArray(allowedControlHosts) && allowedControlHosts.length > 0
- ? allowedControlHosts
- : undefined,
- allowedControlPorts:
- Array.isArray(allowedControlPorts) && allowedControlPorts.length > 0
- ? allowedControlPorts
- : undefined,
autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true,
autoStartTimeoutMs:
agentBrowser?.autoStartTimeoutMs ??
diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts
index ff6372e69..8e0c004b5 100644
--- a/src/agents/sandbox/context.ts
+++ b/src/agents/sandbox/context.ts
@@ -87,9 +87,6 @@ export async function resolveSandboxContext(params: {
docker: cfg.docker,
tools: cfg.tools,
browserAllowHostControl: cfg.browser.allowHostControl,
- browserAllowedControlUrls: cfg.browser.allowedControlUrls,
- browserAllowedControlHosts: cfg.browser.allowedControlHosts,
- browserAllowedControlPorts: cfg.browser.allowedControlPorts,
browser: browser ?? undefined,
};
}
diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts
index 03b7713ce..f27dfd715 100644
--- a/src/agents/sandbox/types.ts
+++ b/src/agents/sandbox/types.ts
@@ -37,9 +37,6 @@ export type SandboxBrowserConfig = {
headless: boolean;
enableNoVnc: boolean;
allowHostControl: boolean;
- allowedControlUrls?: string[];
- allowedControlHosts?: string[];
- allowedControlPorts?: number[];
autoStart: boolean;
autoStartTimeoutMs: number;
};
@@ -63,7 +60,7 @@ export type SandboxConfig = {
};
export type SandboxBrowserContext = {
- controlUrl: string;
+ bridgeUrl: string;
noVncUrl?: string;
containerName: string;
};
@@ -79,9 +76,6 @@ export type SandboxContext = {
docker: SandboxDockerConfig;
tools: SandboxToolPolicy;
browserAllowHostControl: boolean;
- browserAllowedControlUrls?: string[];
- browserAllowedControlHosts?: string[];
- browserAllowedControlPorts?: number[];
browser?: SandboxBrowserContext;
};
diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts
index 41ec9a7d5..1d1a6a5eb 100644
--- a/src/agents/system-prompt.ts
+++ b/src/agents/system-prompt.ts
@@ -165,12 +165,9 @@ export function buildAgentSystemPrompt(params: {
workspaceDir?: string;
workspaceAccess?: "none" | "ro" | "rw";
agentWorkspaceMount?: string;
- browserControlUrl?: string;
+ browserBridgeUrl?: string;
browserNoVncUrl?: string;
hostBrowserAllowed?: boolean;
- allowedControlUrls?: string[];
- allowedControlHosts?: string[];
- allowedControlPorts?: number[];
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off" | "ask" | "full";
@@ -419,9 +416,7 @@ export function buildAgentSystemPrompt(params: {
: ""
}`
: "",
- params.sandboxInfo.browserControlUrl
- ? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}`
- : "",
+ params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "",
params.sandboxInfo.browserNoVncUrl
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
: "",
@@ -430,15 +425,6 @@ export function buildAgentSystemPrompt(params: {
: params.sandboxInfo.hostBrowserAllowed === false
? "Host browser control: blocked."
: "",
- params.sandboxInfo.allowedControlUrls?.length
- ? `Browser control URL allowlist: ${params.sandboxInfo.allowedControlUrls.join(", ")}`
- : "",
- params.sandboxInfo.allowedControlHosts?.length
- ? `Browser control host allowlist: ${params.sandboxInfo.allowedControlHosts.join(", ")}`
- : "",
- params.sandboxInfo.allowedControlPorts?.length
- ? `Browser control port allowlist: ${params.sandboxInfo.allowedControlPorts.join(", ")}`
- : "",
params.sandboxInfo.elevated?.allowed
? "Elevated exec is available for this session."
: "",
diff --git a/src/agents/tools/browser-tool.schema.ts b/src/agents/tools/browser-tool.schema.ts
index 5861f7de4..30cf3cc0f 100644
--- a/src/agents/tools/browser-tool.schema.ts
+++ b/src/agents/tools/browser-tool.schema.ts
@@ -35,7 +35,7 @@ const BROWSER_TOOL_ACTIONS = [
"act",
] as const;
-const BROWSER_TARGETS = ["sandbox", "host", "custom", "node"] as const;
+const BROWSER_TARGETS = ["sandbox", "host", "node"] as const;
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
@@ -86,7 +86,6 @@ export const BrowserToolSchema = Type.Object({
target: optionalStringEnum(BROWSER_TARGETS),
node: Type.Optional(Type.String()),
profile: Type.Optional(Type.String()),
- controlUrl: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
diff --git a/src/agents/tools/browser-tool.test.ts b/src/agents/tools/browser-tool.test.ts
index e50082d66..7248a7a2f 100644
--- a/src/agents/tools/browser-tool.test.ts
+++ b/src/agents/tools/browser-tool.test.ts
@@ -28,23 +28,7 @@ vi.mock("../../browser/client.js", () => browserClientMocks);
const browserConfigMocks = vi.hoisted(() => ({
resolveBrowserConfig: vi.fn(() => ({
enabled: true,
- controlUrl: "http://127.0.0.1:18791",
- controlHost: "127.0.0.1",
controlPort: 18791,
- cdpProtocol: "http",
- cdpHost: "127.0.0.1",
- cdpIsLoopback: true,
- color: "#FF0000",
- headless: true,
- noSandbox: false,
- attachOnly: false,
- defaultProfile: "clawd",
- profiles: {
- clawd: {
- cdpPort: 18792,
- color: "#FF0000",
- },
- },
})),
}));
vi.mock("../../browser/config.js", () => browserConfigMocks);
@@ -99,7 +83,7 @@ describe("browser tool snapshot maxChars", () => {
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
- "http://127.0.0.1:18791",
+ undefined,
expect.objectContaining({
format: "ai",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
@@ -117,7 +101,7 @@ describe("browser tool snapshot maxChars", () => {
});
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
- "http://127.0.0.1:18791",
+ undefined,
expect.objectContaining({
maxChars: override,
}),
@@ -141,7 +125,7 @@ describe("browser tool snapshot maxChars", () => {
const tool = createBrowserTool();
await tool.execute?.(null, { action: "profiles" });
- expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith("http://127.0.0.1:18791");
+ expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith(undefined);
});
it("passes refs mode through to browser snapshot", async () => {
@@ -149,7 +133,7 @@ describe("browser tool snapshot maxChars", () => {
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", refs: "aria" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
- "http://127.0.0.1:18791",
+ undefined,
expect.objectContaining({
format: "ai",
refs: "aria",
@@ -165,7 +149,7 @@ describe("browser tool snapshot maxChars", () => {
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
- "http://127.0.0.1:18791",
+ undefined,
expect.objectContaining({
mode: "efficient",
}),
@@ -185,11 +169,11 @@ describe("browser tool snapshot maxChars", () => {
});
it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => {
- const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
+ const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
await tool.execute?.(null, { action: "snapshot", profile: "chrome", snapshotFormat: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
- "http://127.0.0.1:18791",
+ undefined,
expect.objectContaining({
profile: "chrome",
}),
@@ -220,7 +204,7 @@ describe("browser tool snapshot maxChars", () => {
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
});
- it("keeps sandbox control url when node proxy is available", async () => {
+ it("keeps sandbox bridge url when node proxy is available", async () => {
nodesUtilsMocks.listNodes.mockResolvedValue([
{
nodeId: "node-1",
@@ -230,7 +214,7 @@ describe("browser tool snapshot maxChars", () => {
commands: ["browser.proxy"],
},
]);
- const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
+ const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
await tool.execute?.(null, { action: "status" });
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
@@ -254,7 +238,7 @@ describe("browser tool snapshot maxChars", () => {
await tool.execute?.(null, { action: "status", profile: "chrome" });
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
- "http://127.0.0.1:18791",
+ undefined,
expect.objectContaining({ profile: "chrome" }),
);
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts
index 998059b54..1f063bdea 100644
--- a/src/agents/tools/browser-tool.ts
+++ b/src/agents/tools/browser-tool.ts
@@ -55,9 +55,8 @@ function isBrowserNode(node: NodeListNode) {
async function resolveBrowserNodeTarget(params: {
requestedNode?: string;
- target?: "sandbox" | "host" | "custom" | "node";
- controlUrl?: string;
- defaultControlUrl?: string;
+ target?: "sandbox" | "host" | "node";
+ sandboxBridgeUrl?: string;
}): Promise {
const cfg = loadConfig();
const policy = cfg.gateway?.nodes?.browser;
@@ -68,10 +67,9 @@ async function resolveBrowserNodeTarget(params: {
}
return null;
}
- if (params.defaultControlUrl?.trim() && params.target !== "node" && !params.requestedNode) {
+ if (params.sandboxBridgeUrl?.trim() && params.target !== "node" && !params.requestedNode) {
return null;
}
- if (params.controlUrl?.trim()) return null;
if (params.target && params.target !== "node") return null;
if (mode === "manual" && params.target !== "node" && !params.requestedNode) {
return null;
@@ -187,70 +185,22 @@ function applyProxyPaths(result: unknown, mapping: Map) {
}
function resolveBrowserBaseUrl(params: {
- target?: "sandbox" | "host" | "custom";
- controlUrl?: string;
- defaultControlUrl?: string;
+ target?: "sandbox" | "host";
+ sandboxBridgeUrl?: string;
allowHostControl?: boolean;
- allowedControlUrls?: string[];
- allowedControlHosts?: string[];
- allowedControlPorts?: number[];
-}) {
+}): string | undefined {
const cfg = loadConfig();
- const resolved = resolveBrowserConfig(cfg.browser);
- const normalizedControlUrl = params.controlUrl?.trim() ?? "";
- const normalizedDefault = params.defaultControlUrl?.trim() ?? "";
- const target =
- params.target ?? (normalizedControlUrl ? "custom" : normalizedDefault ? "sandbox" : "host");
-
- const assertAllowedControlUrl = (url: string) => {
- const allowedUrls = params.allowedControlUrls?.map((entry) => entry.trim().replace(/\/$/, ""));
- const allowedHosts = params.allowedControlHosts?.map((entry) => entry.trim().toLowerCase());
- const allowedPorts = params.allowedControlPorts;
- if (!allowedUrls?.length && !allowedHosts?.length && !allowedPorts?.length) {
- return;
- }
- let parsed: URL;
- try {
- parsed = new URL(url);
- } catch {
- throw new Error(`Invalid browser controlUrl: ${url}`);
- }
- const normalizedUrl = parsed.toString().replace(/\/$/, "");
- if (allowedUrls?.length && !allowedUrls.includes(normalizedUrl)) {
- throw new Error("Browser controlUrl is not in the allowed URL list.");
- }
- if (allowedHosts?.length && !allowedHosts.includes(parsed.hostname)) {
- throw new Error("Browser controlUrl hostname is not in the allowed host list.");
- }
- if (allowedPorts?.length) {
- const port =
- parsed.port?.trim() !== "" ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
- if (!Number.isFinite(port) || !allowedPorts.includes(port)) {
- throw new Error("Browser controlUrl port is not in the allowed port list.");
- }
- }
- };
-
- if (target !== "custom" && params.target && normalizedControlUrl) {
- throw new Error('controlUrl is only supported with target="custom".');
- }
-
- if (target === "custom") {
- if (!normalizedControlUrl) {
- throw new Error("Custom browser target requires controlUrl.");
- }
- const normalized = normalizedControlUrl.replace(/\/$/, "");
- assertAllowedControlUrl(normalized);
- return normalized;
- }
+ const resolved = resolveBrowserConfig(cfg.browser, cfg);
+ const normalizedSandbox = params.sandboxBridgeUrl?.trim() ?? "";
+ const target = params.target ?? (normalizedSandbox ? "sandbox" : "host");
if (target === "sandbox") {
- if (!normalizedDefault) {
+ if (!normalizedSandbox) {
throw new Error(
'Sandbox browser is unavailable. Enable agents.defaults.sandbox.browser.enabled or use target="host" if allowed.',
);
}
- return normalizedDefault.replace(/\/$/, "");
+ return normalizedSandbox.replace(/\/$/, "");
}
if (params.allowHostControl === false) {
@@ -261,27 +211,16 @@ function resolveBrowserBaseUrl(params: {
"Browser control is disabled. Set browser.enabled=true in ~/.clawdbot/clawdbot.json.",
);
}
- const normalized = resolved.controlUrl.replace(/\/$/, "");
- assertAllowedControlUrl(normalized);
- return normalized;
+ return undefined;
}
export function createBrowserTool(opts?: {
- defaultControlUrl?: string;
+ sandboxBridgeUrl?: string;
allowHostControl?: boolean;
- allowedControlUrls?: string[];
- allowedControlHosts?: string[];
- allowedControlPorts?: number[];
}): AnyAgentTool {
- const targetDefault = opts?.defaultControlUrl ? "sandbox" : "host";
+ const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
const hostHint =
opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed.";
- const allowlistHint =
- opts?.allowedControlUrls?.length ||
- opts?.allowedControlHosts?.length ||
- opts?.allowedControlPorts?.length
- ? "Custom targets are restricted by sandbox allowlists."
- : "Custom targets are unrestricted.";
return {
label: "Browser",
name: "browser",
@@ -294,33 +233,22 @@ export function createBrowserTool(opts?: {
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
- `target selects browser location (sandbox|host|custom|node). Default: ${targetDefault}.`,
- "controlUrl implies target=custom (remote control server).",
+ `target selects browser location (sandbox|host|node). Default: ${targetDefault}.`,
hostHint,
- allowlistHint,
].join(" "),
parameters: BrowserToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record;
const action = readStringParam(params, "action", { required: true });
- const controlUrl = readStringParam(params, "controlUrl");
const profile = readStringParam(params, "profile");
const requestedNode = readStringParam(params, "node");
- let target = readStringParam(params, "target") as
- | "sandbox"
- | "host"
- | "custom"
- | "node"
- | undefined;
+ let target = readStringParam(params, "target") as "sandbox" | "host" | "node" | undefined;
- if (controlUrl?.trim() && (target === "node" || requestedNode)) {
- throw new Error('controlUrl is not supported with target="node".');
- }
- if (target === "custom" && requestedNode) {
- throw new Error('node is not supported with target="custom".');
+ if (requestedNode && target && target !== "node") {
+ throw new Error('node is only supported with target="node".');
}
- if (!target && !controlUrl?.trim() && !requestedNode && profile === "chrome") {
+ if (!target && !requestedNode && profile === "chrome") {
// Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node.
target = "host";
}
@@ -328,21 +256,16 @@ export function createBrowserTool(opts?: {
const nodeTarget = await resolveBrowserNodeTarget({
requestedNode: requestedNode ?? undefined,
target,
- controlUrl,
- defaultControlUrl: opts?.defaultControlUrl,
+ sandboxBridgeUrl: opts?.sandboxBridgeUrl,
});
const resolvedTarget = target === "node" ? undefined : target;
const baseUrl = nodeTarget
- ? ""
+ ? undefined
: resolveBrowserBaseUrl({
target: resolvedTarget,
- controlUrl,
- defaultControlUrl: opts?.defaultControlUrl,
+ sandboxBridgeUrl: opts?.sandboxBridgeUrl,
allowHostControl: opts?.allowHostControl,
- allowedControlUrls: opts?.allowedControlUrls,
- allowedControlHosts: opts?.allowedControlHosts,
- allowedControlPorts: opts?.allowedControlPorts,
});
const proxyRequest = nodeTarget
diff --git a/src/browser/bridge-server.ts b/src/browser/bridge-server.ts
index 6bfe190a5..eaab062cc 100644
--- a/src/browser/bridge-server.ts
+++ b/src/browser/bridge-server.ts
@@ -4,6 +4,7 @@ import express from "express";
import type { ResolvedBrowserConfig } from "./config.js";
import { registerBrowserRoutes } from "./routes/index.js";
+import type { BrowserRouteRegistrar } from "./routes/types.js";
import {
type BrowserServerState,
createBrowserRouteContext,
@@ -50,7 +51,7 @@ export async function startBrowserBridgeServer(params: {
getState: () => state,
onEnsureAttachTarget: params.onEnsureAttachTarget,
});
- registerBrowserRoutes(app, ctx);
+ registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
const server = await new Promise((resolve, reject) => {
const s = app.listen(port, host, () => resolve(s));
@@ -61,11 +62,9 @@ export async function startBrowserBridgeServer(params: {
const resolvedPort = address?.port ?? port;
state.server = server;
state.port = resolvedPort;
- state.resolved.controlHost = host;
state.resolved.controlPort = resolvedPort;
- state.resolved.controlUrl = `http://${host}:${resolvedPort}`;
- const baseUrl = state.resolved.controlUrl;
+ const baseUrl = `http://${host}:${resolvedPort}`;
return { server, port: resolvedPort, baseUrl, state };
}
diff --git a/src/browser/client-actions-core.ts b/src/browser/client-actions-core.ts
index 667600ef3..96688f701 100644
--- a/src/browser/client-actions-core.ts
+++ b/src/browser/client-actions-core.ts
@@ -9,6 +9,12 @@ function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
+function withBaseUrl(baseUrl: string | undefined, path: string): string {
+ const trimmed = baseUrl?.trim();
+ if (!trimmed) return path;
+ return `${trimmed.replace(/\/$/, "")}${path}`;
+}
+
export type BrowserFormField = {
ref: string;
type: string;
@@ -92,11 +98,15 @@ export type BrowserDownloadPayload = {
};
export async function browserNavigate(
- baseUrl: string,
- opts: { url: string; targetId?: string; profile?: string },
+ baseUrl: string | undefined,
+ opts: {
+ url: string;
+ targetId?: string;
+ profile?: string;
+ },
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/navigate${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/navigate${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
@@ -105,7 +115,7 @@ export async function browserNavigate(
}
export async function browserArmDialog(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
accept: boolean;
promptText?: string;
@@ -115,7 +125,7 @@ export async function browserArmDialog(
},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/hooks/dialog${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/hooks/dialog${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -129,7 +139,7 @@ export async function browserArmDialog(
}
export async function browserArmFileChooser(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
paths: string[];
ref?: string;
@@ -141,7 +151,7 @@ export async function browserArmFileChooser(
},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/hooks/file-chooser${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/hooks/file-chooser${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -157,7 +167,7 @@ export async function browserArmFileChooser(
}
export async function browserWaitForDownload(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
path?: string;
targetId?: string;
@@ -170,7 +180,7 @@ export async function browserWaitForDownload(
ok: true;
targetId: string;
download: BrowserDownloadPayload;
- }>(`${baseUrl}/wait/download${q}`, {
+ }>(withBaseUrl(baseUrl, `/wait/download${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -183,7 +193,7 @@ export async function browserWaitForDownload(
}
export async function browserDownload(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
ref: string;
path: string;
@@ -197,7 +207,7 @@ export async function browserDownload(
ok: true;
targetId: string;
download: BrowserDownloadPayload;
- }>(`${baseUrl}/download${q}`, {
+ }>(withBaseUrl(baseUrl, `/download${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -211,12 +221,12 @@ export async function browserDownload(
}
export async function browserAct(
- baseUrl: string,
+ baseUrl: string | undefined,
req: BrowserActRequest,
opts?: { profile?: string },
): Promise {
const q = buildProfileQuery(opts?.profile);
- return await fetchBrowserJson(`${baseUrl}/act${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/act${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
@@ -225,7 +235,7 @@ export async function browserAct(
}
export async function browserScreenshotAction(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
targetId?: string;
fullPage?: boolean;
@@ -236,7 +246,7 @@ export async function browserScreenshotAction(
},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/screenshot${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/screenshot${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
diff --git a/src/browser/client-actions-observe.ts b/src/browser/client-actions-observe.ts
index 4002d48c5..4b042d9b0 100644
--- a/src/browser/client-actions-observe.ts
+++ b/src/browser/client-actions-observe.ts
@@ -10,8 +10,14 @@ function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
+function withBaseUrl(baseUrl: string | undefined, path: string): string {
+ const trimmed = baseUrl?.trim();
+ if (!trimmed) return path;
+ return `${trimmed.replace(/\/$/, "")}${path}`;
+}
+
export async function browserConsoleMessages(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: { level?: string; targetId?: string; profile?: string } = {},
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
const q = new URLSearchParams();
@@ -23,15 +29,15 @@ export async function browserConsoleMessages(
ok: true;
messages: BrowserConsoleMessage[];
targetId: string;
- }>(`${baseUrl}/console${suffix}`, { timeoutMs: 20000 });
+ }>(withBaseUrl(baseUrl, `/console${suffix}`), { timeoutMs: 20000 });
}
export async function browserPdfSave(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/pdf${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/pdf${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
@@ -40,7 +46,7 @@ export async function browserPdfSave(
}
export async function browserPageErrors(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: { targetId?: string; clear?: boolean; profile?: string } = {},
): Promise<{ ok: true; targetId: string; errors: BrowserPageError[] }> {
const q = new URLSearchParams();
@@ -52,11 +58,11 @@ export async function browserPageErrors(
ok: true;
targetId: string;
errors: BrowserPageError[];
- }>(`${baseUrl}/errors${suffix}`, { timeoutMs: 20000 });
+ }>(withBaseUrl(baseUrl, `/errors${suffix}`), { timeoutMs: 20000 });
}
export async function browserRequests(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
targetId?: string;
filter?: string;
@@ -74,11 +80,11 @@ export async function browserRequests(
ok: true;
targetId: string;
requests: BrowserNetworkRequest[];
- }>(`${baseUrl}/requests${suffix}`, { timeoutMs: 20000 });
+ }>(withBaseUrl(baseUrl, `/requests${suffix}`), { timeoutMs: 20000 });
}
export async function browserTraceStart(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
targetId?: string;
screenshots?: boolean;
@@ -88,7 +94,7 @@ export async function browserTraceStart(
} = {},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/trace/start${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/trace/start${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -102,11 +108,11 @@ export async function browserTraceStart(
}
export async function browserTraceStop(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: { targetId?: string; path?: string; profile?: string } = {},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/trace/stop${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/trace/stop${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
@@ -115,11 +121,11 @@ export async function browserTraceStop(
}
export async function browserHighlight(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: { ref: string; targetId?: string; profile?: string },
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/highlight${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/highlight${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
@@ -128,7 +134,7 @@ export async function browserHighlight(
}
export async function browserResponseBody(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
url: string;
targetId?: string;
@@ -158,7 +164,7 @@ export async function browserResponseBody(
body: string;
truncated?: boolean;
};
- }>(`${baseUrl}/response/body${q}`, {
+ }>(withBaseUrl(baseUrl, `/response/body${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
diff --git a/src/browser/client-actions-state.ts b/src/browser/client-actions-state.ts
index 192cad997..751634a9e 100644
--- a/src/browser/client-actions-state.ts
+++ b/src/browser/client-actions-state.ts
@@ -5,8 +5,14 @@ function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
+function withBaseUrl(baseUrl: string | undefined, path: string): string {
+ const trimmed = baseUrl?.trim();
+ if (!trimmed) return path;
+ return `${trimmed.replace(/\/$/, "")}${path}`;
+}
+
export async function browserCookies(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
const q = new URLSearchParams();
@@ -17,11 +23,11 @@ export async function browserCookies(
ok: true;
targetId: string;
cookies: unknown[];
- }>(`${baseUrl}/cookies${suffix}`, { timeoutMs: 20000 });
+ }>(withBaseUrl(baseUrl, `/cookies${suffix}`), { timeoutMs: 20000 });
}
export async function browserCookiesSet(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
cookie: Record;
targetId?: string;
@@ -29,7 +35,7 @@ export async function browserCookiesSet(
},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/cookies/set${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/cookies/set${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
@@ -38,11 +44,11 @@ export async function browserCookiesSet(
}
export async function browserCookiesClear(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/cookies/clear${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/cookies/clear${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
@@ -51,7 +57,7 @@ export async function browserCookiesClear(
}
export async function browserStorageGet(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
kind: "local" | "session";
key?: string;
@@ -68,11 +74,11 @@ export async function browserStorageGet(
ok: true;
targetId: string;
values: Record;
- }>(`${baseUrl}/storage/${opts.kind}${suffix}`, { timeoutMs: 20000 });
+ }>(withBaseUrl(baseUrl, `/storage/${opts.kind}${suffix}`), { timeoutMs: 20000 });
}
export async function browserStorageSet(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
kind: "local" | "session";
key: string;
@@ -82,25 +88,28 @@ export async function browserStorageSet(
},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/storage/${opts.kind}/set${q}`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- targetId: opts.targetId,
- key: opts.key,
- value: opts.value,
- }),
- timeoutMs: 20000,
- });
+ return await fetchBrowserJson(
+ withBaseUrl(baseUrl, `/storage/${opts.kind}/set${q}`),
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ targetId: opts.targetId,
+ key: opts.key,
+ value: opts.value,
+ }),
+ timeoutMs: 20000,
+ },
+ );
}
export async function browserStorageClear(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
): Promise {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson(
- `${baseUrl}/storage/${opts.kind}/clear${q}`,
+ withBaseUrl(baseUrl, `/storage/${opts.kind}/clear${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -111,11 +120,11 @@ export async function browserStorageClear(
}
export async function browserSetOffline(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: { offline: boolean; targetId?: string; profile?: string },
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/set/offline${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/set/offline${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
@@ -124,7 +133,7 @@ export async function browserSetOffline(
}
export async function browserSetHeaders(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
headers: Record;
targetId?: string;
@@ -132,7 +141,7 @@ export async function browserSetHeaders(
},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/set/headers${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/set/headers${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
@@ -141,7 +150,7 @@ export async function browserSetHeaders(
}
export async function browserSetHttpCredentials(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
username?: string;
password?: string;
@@ -151,21 +160,24 @@ export async function browserSetHttpCredentials(
} = {},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/set/credentials${q}`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- targetId: opts.targetId,
- username: opts.username,
- password: opts.password,
- clear: opts.clear,
- }),
- timeoutMs: 20000,
- });
+ return await fetchBrowserJson(
+ withBaseUrl(baseUrl, `/set/credentials${q}`),
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ targetId: opts.targetId,
+ username: opts.username,
+ password: opts.password,
+ clear: opts.clear,
+ }),
+ timeoutMs: 20000,
+ },
+ );
}
export async function browserSetGeolocation(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
latitude?: number;
longitude?: number;
@@ -177,23 +189,26 @@ export async function browserSetGeolocation(
} = {},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/set/geolocation${q}`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- targetId: opts.targetId,
- latitude: opts.latitude,
- longitude: opts.longitude,
- accuracy: opts.accuracy,
- origin: opts.origin,
- clear: opts.clear,
- }),
- timeoutMs: 20000,
- });
+ return await fetchBrowserJson(
+ withBaseUrl(baseUrl, `/set/geolocation${q}`),
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ targetId: opts.targetId,
+ latitude: opts.latitude,
+ longitude: opts.longitude,
+ accuracy: opts.accuracy,
+ origin: opts.origin,
+ clear: opts.clear,
+ }),
+ timeoutMs: 20000,
+ },
+ );
}
export async function browserSetMedia(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
colorScheme: "dark" | "light" | "no-preference" | "none";
targetId?: string;
@@ -201,7 +216,7 @@ export async function browserSetMedia(
},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/set/media${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/set/media${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -213,11 +228,11 @@ export async function browserSetMedia(
}
export async function browserSetTimezone(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: { timezoneId: string; targetId?: string; profile?: string },
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/set/timezone${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/set/timezone${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -229,11 +244,11 @@ export async function browserSetTimezone(
}
export async function browserSetLocale(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: { locale: string; targetId?: string; profile?: string },
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/set/locale${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/set/locale${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
@@ -242,11 +257,11 @@ export async function browserSetLocale(
}
export async function browserSetDevice(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: { name: string; targetId?: string; profile?: string },
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/set/device${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/set/device${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
@@ -255,11 +270,11 @@ export async function browserSetDevice(
}
export async function browserClearPermissions(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/set/geolocation${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/set/geolocation${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts
index e815f45c4..43bc7c07b 100644
--- a/src/browser/client-fetch.ts
+++ b/src/browser/client-fetch.ts
@@ -1,57 +1,44 @@
-import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
-import { loadConfig } from "../config/config.js";
import { formatCliCommand } from "../cli/command-format.js";
-import { resolveBrowserConfig } from "./config.js";
+import {
+ createBrowserControlContext,
+ startBrowserControlServiceFromConfig,
+} from "./control-service.js";
+import { createBrowserRouteDispatcher } from "./routes/dispatcher.js";
-let cachedConfigToken: string | null | undefined = undefined;
-
-function getBrowserControlToken(): string | null {
- const env = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
- if (env) return env;
-
- if (cachedConfigToken !== undefined) return cachedConfigToken;
- try {
- const cfg = loadConfig();
- const resolved = resolveBrowserConfig(cfg.browser);
- const token = resolved.controlToken?.trim() || "";
- cachedConfigToken = token ? token : null;
- } catch {
- cachedConfigToken = null;
- }
- return cachedConfigToken;
-}
-
-function unwrapCause(err: unknown): unknown {
- if (!err || typeof err !== "object") return null;
- const cause = (err as { cause?: unknown }).cause;
- return cause ?? null;
+function isAbsoluteHttp(url: string): boolean {
+ return /^https?:\/\//i.test(url.trim());
}
function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
- const cause = unwrapCause(err);
- const code = extractErrorCode(cause) ?? extractErrorCode(err) ?? "";
-
- const hint = `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
-
- if (code === "ECONNREFUSED") {
+ const hint = isAbsoluteHttp(url)
+ ? "If this is a sandboxed session, ensure the sandbox browser is running and try again."
+ : `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
+ const msg = String(err);
+ if (msg.toLowerCase().includes("timed out") || msg.toLowerCase().includes("timeout")) {
return new Error(
- `Can't reach the clawd browser control server at ${url} (connection refused). ${hint}`,
- );
- }
- if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT") {
- return new Error(
- `Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,
+ `Can't reach the clawd browser control service (timed out after ${timeoutMs}ms). ${hint}`,
);
}
+ return new Error(`Can't reach the clawd browser control service. ${hint} (${msg})`);
+}
- const msg = formatErrorMessage(err);
- if (msg.toLowerCase().includes("abort")) {
- return new Error(
- `Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,
- );
+async function fetchHttpJson(
+ url: string,
+ init: RequestInit & { timeoutMs?: number },
+): Promise {
+ const timeoutMs = init.timeoutMs ?? 5000;
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
+ try {
+ const res = await fetch(url, { ...init, signal: ctrl.signal });
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(text || `HTTP ${res.status}`);
+ }
+ return (await res.json()) as T;
+ } finally {
+ clearTimeout(t);
}
-
- return new Error(`Can't reach the clawd browser control server at ${url}. ${hint} (${msg})`);
}
export async function fetchBrowserJson(
@@ -59,32 +46,58 @@ export async function fetchBrowserJson(
init?: RequestInit & { timeoutMs?: number },
): Promise {
const timeoutMs = init?.timeoutMs ?? 5000;
- const ctrl = new AbortController();
- const t = setTimeout(() => ctrl.abort(), timeoutMs);
- let res: Response;
try {
- const token = getBrowserControlToken();
- const mergedHeaders = (() => {
- if (!token) return init?.headers;
- const h = new Headers(init?.headers ?? {});
- if (!h.has("Authorization")) {
- h.set("Authorization", `Bearer ${token}`);
+ if (isAbsoluteHttp(url)) {
+ return await fetchHttpJson(url, { ...(init ?? {}), timeoutMs });
+ }
+ const started = await startBrowserControlServiceFromConfig();
+ if (!started) {
+ throw new Error("browser control disabled");
+ }
+ const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
+ const parsed = new URL(url, "http://localhost");
+ const query: Record = {};
+ for (const [key, value] of parsed.searchParams.entries()) {
+ query[key] = value;
+ }
+ let body = init?.body;
+ if (typeof body === "string") {
+ try {
+ body = JSON.parse(body);
+ } catch {
+ // keep as string
}
- return h;
- })();
- res = await fetch(url, {
- ...init,
- ...(mergedHeaders ? { headers: mergedHeaders } : {}),
- signal: ctrl.signal,
- } as RequestInit);
+ }
+ const dispatchPromise = dispatcher.dispatch({
+ method:
+ init?.method?.toUpperCase() === "DELETE"
+ ? "DELETE"
+ : init?.method?.toUpperCase() === "POST"
+ ? "POST"
+ : "GET",
+ path: parsed.pathname,
+ query,
+ body,
+ });
+
+ const result = await (timeoutMs
+ ? Promise.race([
+ dispatchPromise,
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("timed out")), timeoutMs),
+ ),
+ ])
+ : dispatchPromise);
+
+ if (result.status >= 400) {
+ const message =
+ result.body && typeof result.body === "object" && "error" in result.body
+ ? String((result.body as { error?: unknown }).error)
+ : `HTTP ${result.status}`;
+ throw new Error(message);
+ }
+ return result.body as T;
} catch (err) {
throw enhanceBrowserFetchError(url, err, timeoutMs);
- } finally {
- clearTimeout(t);
}
- if (!res.ok) {
- const text = await res.text().catch(() => "");
- throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`);
- }
- return (await res.json()) as T;
}
diff --git a/src/browser/client.test.ts b/src/browser/client.test.ts
index 7721828f8..848c53d18 100644
--- a/src/browser/client.test.ts
+++ b/src/browser/client.test.ts
@@ -16,7 +16,7 @@ describe("browser client", () => {
vi.unstubAllGlobals();
});
- it("wraps connection failures with a gateway hint", async () => {
+ it("wraps connection failures with a sandbox hint", async () => {
const refused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1"), {
code: "ECONNREFUSED",
});
@@ -26,7 +26,7 @@ describe("browser client", () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(fetchFailed));
- await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/Start .*gateway/i);
+ await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/sandboxed session/i);
});
it("adds useful timeout messaging for abort-like failures", async () => {
@@ -34,41 +34,6 @@ describe("browser client", () => {
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/timed out/i);
});
- it("adds Authorization when CLAWDBOT_BROWSER_CONTROL_TOKEN is set", async () => {
- const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
- process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = "t1";
-
- const calls: Array<{ url: string; init?: RequestInit }> = [];
- vi.stubGlobal(
- "fetch",
- vi.fn(async (url: string, init?: RequestInit) => {
- calls.push({ url, init });
- return {
- ok: true,
- json: async () => ({
- enabled: true,
- controlUrl: "http://127.0.0.1:18791",
- running: false,
- pid: null,
- cdpPort: 18792,
- chosenBrowser: null,
- userDataDir: null,
- color: "#FF0000",
- headless: true,
- attachOnly: false,
- }),
- } as unknown as Response;
- }),
- );
-
- await browserStatus("http://127.0.0.1:18791");
- const init = calls[0]?.init;
- const auth = new Headers(init?.headers ?? {}).get("Authorization");
- expect(auth).toBe("Bearer t1");
-
- process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev;
- });
-
it("surfaces non-2xx responses with body text", async () => {
vi.stubGlobal(
"fetch",
@@ -81,7 +46,7 @@ describe("browser client", () => {
await expect(
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
- ).rejects.toThrow(/409: conflict/i);
+ ).rejects.toThrow(/conflict/i);
});
it("adds labels + efficient mode query params to snapshots", async () => {
@@ -255,7 +220,6 @@ describe("browser client", () => {
ok: true,
json: async () => ({
enabled: true,
- controlUrl: "http://127.0.0.1:18791",
running: true,
pid: 1,
cdpPort: 18792,
diff --git a/src/browser/client.ts b/src/browser/client.ts
index eb98e8138..fa8cf2d69 100644
--- a/src/browser/client.ts
+++ b/src/browser/client.ts
@@ -1,10 +1,7 @@
-import { loadConfig } from "../config/config.js";
import { fetchBrowserJson } from "./client-fetch.js";
-import { resolveBrowserConfig } from "./config.js";
export type BrowserStatus = {
enabled: boolean;
- controlUrl: string;
profile?: string;
running: boolean;
cdpReady?: boolean;
@@ -89,59 +86,64 @@ export type SnapshotResult =
imageType?: "png" | "jpeg";
};
-export function resolveBrowserControlUrl(overrideUrl?: string) {
- const cfg = loadConfig();
- const resolved = resolveBrowserConfig(cfg.browser);
- const url = overrideUrl?.trim() ? overrideUrl.trim() : resolved.controlUrl;
- return url.replace(/\/$/, "");
-}
-
function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
+function withBaseUrl(baseUrl: string | undefined, path: string): string {
+ const trimmed = baseUrl?.trim();
+ if (!trimmed) return path;
+ return `${trimmed.replace(/\/$/, "")}${path}`;
+}
+
export async function browserStatus(
- baseUrl: string,
+ baseUrl?: string,
opts?: { profile?: string },
): Promise {
const q = buildProfileQuery(opts?.profile);
- return await fetchBrowserJson(`${baseUrl}/${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/${q}`), {
timeoutMs: 1500,
});
}
-export async function browserProfiles(baseUrl: string): Promise {
- const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(`${baseUrl}/profiles`, {
- timeoutMs: 3000,
- });
+export async function browserProfiles(baseUrl?: string): Promise {
+ const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
+ withBaseUrl(baseUrl, `/profiles`),
+ {
+ timeoutMs: 3000,
+ },
+ );
return res.profiles ?? [];
}
-export async function browserStart(baseUrl: string, opts?: { profile?: string }): Promise {
+export async function browserStart(baseUrl?: string, opts?: { profile?: string }): Promise {
const q = buildProfileQuery(opts?.profile);
- await fetchBrowserJson(`${baseUrl}/start${q}`, {
+ await fetchBrowserJson(withBaseUrl(baseUrl, `/start${q}`), {
method: "POST",
timeoutMs: 15000,
});
}
-export async function browserStop(baseUrl: string, opts?: { profile?: string }): Promise {
+export async function browserStop(baseUrl?: string, opts?: { profile?: string }): Promise {
const q = buildProfileQuery(opts?.profile);
- await fetchBrowserJson(`${baseUrl}/stop${q}`, {
+ await fetchBrowserJson(withBaseUrl(baseUrl, `/stop${q}`), {
method: "POST",
timeoutMs: 15000,
});
}
export async function browserResetProfile(
- baseUrl: string,
+ baseUrl?: string,
opts?: { profile?: string },
): Promise {
const q = buildProfileQuery(opts?.profile);
- return await fetchBrowserJson(`${baseUrl}/reset-profile${q}`, {
- method: "POST",
- timeoutMs: 20000,
- });
+ return await fetchBrowserJson(
+ withBaseUrl(baseUrl, `/reset-profile${q}`),
+ {
+ method: "POST",
+ timeoutMs: 20000,
+ },
+ );
}
export type BrowserCreateProfileResult = {
@@ -154,7 +156,7 @@ export type BrowserCreateProfileResult = {
};
export async function browserCreateProfile(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
name: string;
color?: string;
@@ -162,17 +164,20 @@ export async function browserCreateProfile(
driver?: "clawd" | "extension";
},
): Promise {
- return await fetchBrowserJson(`${baseUrl}/profiles/create`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- name: opts.name,
- color: opts.color,
- cdpUrl: opts.cdpUrl,
- driver: opts.driver,
- }),
- timeoutMs: 10000,
- });
+ return await fetchBrowserJson(
+ withBaseUrl(baseUrl, `/profiles/create`),
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: opts.name,
+ color: opts.color,
+ cdpUrl: opts.cdpUrl,
+ driver: opts.driver,
+ }),
+ timeoutMs: 10000,
+ },
+ );
}
export type BrowserDeleteProfileResult = {
@@ -182,11 +187,11 @@ export type BrowserDeleteProfileResult = {
};
export async function browserDeleteProfile(
- baseUrl: string,
+ baseUrl: string | undefined,
profile: string,
): Promise {
return await fetchBrowserJson(
- `${baseUrl}/profiles/${encodeURIComponent(profile)}`,
+ withBaseUrl(baseUrl, `/profiles/${encodeURIComponent(profile)}`),
{
method: "DELETE",
timeoutMs: 20000,
@@ -195,24 +200,24 @@ export async function browserDeleteProfile(
}
export async function browserTabs(
- baseUrl: string,
+ baseUrl?: string,
opts?: { profile?: string },
): Promise {
const q = buildProfileQuery(opts?.profile);
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
- `${baseUrl}/tabs${q}`,
+ withBaseUrl(baseUrl, `/tabs${q}`),
{ timeoutMs: 3000 },
);
return res.tabs ?? [];
}
export async function browserOpenTab(
- baseUrl: string,
+ baseUrl: string | undefined,
url: string,
opts?: { profile?: string },
): Promise {
const q = buildProfileQuery(opts?.profile);
- return await fetchBrowserJson(`${baseUrl}/tabs/open${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/open${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
@@ -221,12 +226,12 @@ export async function browserOpenTab(
}
export async function browserFocusTab(
- baseUrl: string,
+ baseUrl: string | undefined,
targetId: string,
opts?: { profile?: string },
): Promise {
const q = buildProfileQuery(opts?.profile);
- await fetchBrowserJson(`${baseUrl}/tabs/focus${q}`, {
+ await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/focus${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId }),
@@ -235,19 +240,19 @@ export async function browserFocusTab(
}
export async function browserCloseTab(
- baseUrl: string,
+ baseUrl: string | undefined,
targetId: string,
opts?: { profile?: string },
): Promise {
const q = buildProfileQuery(opts?.profile);
- await fetchBrowserJson(`${baseUrl}/tabs/${encodeURIComponent(targetId)}${q}`, {
+ await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/${encodeURIComponent(targetId)}${q}`), {
method: "DELETE",
timeoutMs: 5000,
});
}
export async function browserTabAction(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
action: "list" | "new" | "close" | "select";
index?: number;
@@ -255,7 +260,7 @@ export async function browserTabAction(
},
): Promise {
const q = buildProfileQuery(opts.profile);
- return await fetchBrowserJson(`${baseUrl}/tabs/action${q}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/action${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -267,7 +272,7 @@ export async function browserTabAction(
}
export async function browserSnapshot(
- baseUrl: string,
+ baseUrl: string | undefined,
opts: {
format: "aria" | "ai";
targetId?: string;
@@ -301,7 +306,7 @@ export async function browserSnapshot(
if (opts.labels === true) q.set("labels", "1");
if (opts.mode) q.set("mode", opts.mode);
if (opts.profile) q.set("profile", opts.profile);
- return await fetchBrowserJson(`${baseUrl}/snapshot?${q.toString()}`, {
+ return await fetchBrowserJson(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), {
timeoutMs: 20000,
});
}
diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts
index 0304896dc..136846059 100644
--- a/src/browser/config.test.ts
+++ b/src/browser/config.test.ts
@@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest";
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
describe("browser config", () => {
- it("defaults to enabled with loopback control url and lobster-orange color", () => {
+ it("defaults to enabled with loopback defaults and lobster-orange color", () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.enabled).toBe(true);
expect(resolved.controlPort).toBe(18791);
- expect(resolved.controlHost).toBe("127.0.0.1");
expect(resolved.color).toBe("#FF4500");
expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
+ expect(resolved.cdpHost).toBe("127.0.0.1");
+ expect(resolved.cdpProtocol).toBe("http");
const profile = resolveProfile(resolved, resolved.defaultProfile);
expect(profile?.name).toBe("chrome");
expect(profile?.driver).toBe("extension");
@@ -46,9 +47,31 @@ describe("browser config", () => {
}
});
+ it("derives default ports from gateway.port when env is unset", () => {
+ const prev = process.env.CLAWDBOT_GATEWAY_PORT;
+ delete process.env.CLAWDBOT_GATEWAY_PORT;
+ try {
+ const resolved = resolveBrowserConfig(undefined, { gateway: { port: 19011 } });
+ expect(resolved.controlPort).toBe(19013);
+ const chrome = resolveProfile(resolved, "chrome");
+ expect(chrome?.driver).toBe("extension");
+ expect(chrome?.cdpPort).toBe(19014);
+ expect(chrome?.cdpUrl).toBe("http://127.0.0.1:19014");
+
+ const clawd = resolveProfile(resolved, "clawd");
+ expect(clawd?.cdpPort).toBe(19022);
+ expect(clawd?.cdpUrl).toBe("http://127.0.0.1:19022");
+ } finally {
+ if (prev === undefined) {
+ delete process.env.CLAWDBOT_GATEWAY_PORT;
+ } else {
+ process.env.CLAWDBOT_GATEWAY_PORT = prev;
+ }
+ }
+ });
+
it("normalizes hex colors", () => {
const resolved = resolveBrowserConfig({
- controlUrl: "http://localhost:18791",
color: "ff4500",
});
expect(resolved.color).toBe("#FF4500");
@@ -56,7 +79,6 @@ describe("browser config", () => {
it("supports custom remote CDP timeouts", () => {
const resolved = resolveBrowserConfig({
- controlUrl: "http://127.0.0.1:18791",
remoteCdpTimeoutMs: 2200,
remoteCdpHandshakeTimeoutMs: 5000,
});
@@ -66,31 +88,21 @@ describe("browser config", () => {
it("falls back to default color for invalid hex", () => {
const resolved = resolveBrowserConfig({
- controlUrl: "http://localhost:18791",
color: "#GGGGGG",
});
expect(resolved.color).toBe("#FF4500");
});
- it("treats non-loopback control urls as remote", () => {
+ it("treats non-loopback cdpUrl as remote", () => {
const resolved = resolveBrowserConfig({
- controlUrl: "http://example.com:18791",
+ cdpUrl: "http://example.com:9222",
});
- expect(shouldStartLocalBrowserServer(resolved)).toBe(false);
- });
-
- it("derives CDP host/protocol from control url when cdpUrl is unset", () => {
- const resolved = resolveBrowserConfig({
- controlUrl: "http://127.0.0.1:19000",
- });
- expect(resolved.controlPort).toBe(19000);
- expect(resolved.cdpHost).toBe("127.0.0.1");
- expect(resolved.cdpProtocol).toBe("http");
+ const profile = resolveProfile(resolved, "clawd");
+ expect(profile?.cdpIsLoopback).toBe(false);
});
it("supports explicit CDP URLs for the default profile", () => {
const resolved = resolveBrowserConfig({
- controlUrl: "http://127.0.0.1:18791",
cdpUrl: "http://example.com:9222",
});
const profile = resolveProfile(resolved, "clawd");
@@ -101,7 +113,6 @@ describe("browser config", () => {
it("uses profile cdpUrl when provided", () => {
const resolved = resolveBrowserConfig({
- controlUrl: "http://127.0.0.1:18791",
profiles: {
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
},
@@ -115,7 +126,6 @@ describe("browser config", () => {
it("uses base protocol for profiles with only cdpPort", () => {
const resolved = resolveBrowserConfig({
- controlUrl: "http://127.0.0.1:18791",
cdpUrl: "https://example.com:9443",
profiles: {
work: { cdpPort: 18801, color: "#0066CC" },
@@ -127,14 +137,11 @@ describe("browser config", () => {
});
it("rejects unsupported protocols", () => {
- expect(() => resolveBrowserConfig({ controlUrl: "ws://127.0.0.1:18791" })).toThrow(
- /must be http/i,
- );
+ expect(() => resolveBrowserConfig({ cdpUrl: "ws://127.0.0.1:18791" })).toThrow(/must be http/i);
});
it("does not add the built-in chrome extension profile if the derived relay port is already used", () => {
const resolved = resolveBrowserConfig({
- controlUrl: "http://127.0.0.1:18791",
profiles: {
clawd: { cdpPort: 18792, color: "#FF4500" },
},
diff --git a/src/browser/config.ts b/src/browser/config.ts
index e8e1bb128..4f862db51 100644
--- a/src/browser/config.ts
+++ b/src/browser/config.ts
@@ -1,11 +1,12 @@
-import type { BrowserConfig, BrowserProfileConfig } from "../config/config.js";
+import type { BrowserConfig, BrowserProfileConfig, ClawdbotConfig } from "../config/config.js";
import {
deriveDefaultBrowserCdpPortRange,
deriveDefaultBrowserControlPort,
+ DEFAULT_BROWSER_CONTROL_PORT,
} from "../config/port-defaults.js";
+import { resolveGatewayPort } from "../config/paths.js";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
- DEFAULT_CLAWD_BROWSER_CONTROL_URL,
DEFAULT_CLAWD_BROWSER_ENABLED,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
@@ -14,10 +15,7 @@ import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js";
export type ResolvedBrowserConfig = {
enabled: boolean;
- controlUrl: string;
- controlHost: string;
controlPort: number;
- controlToken?: string;
cdpProtocol: "http" | "https";
cdpHost: string;
cdpIsLoopback: boolean;
@@ -137,24 +135,13 @@ function ensureDefaultChromeExtensionProfile(
};
return result;
}
-export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBrowserConfig {
+export function resolveBrowserConfig(
+ cfg: BrowserConfig | undefined,
+ rootConfig?: ClawdbotConfig,
+): ResolvedBrowserConfig {
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
- const envControlUrl = process.env.CLAWDBOT_BROWSER_CONTROL_URL?.trim();
- const controlToken = cfg?.controlToken?.trim() || undefined;
- const derivedControlPort = (() => {
- const raw = process.env.CLAWDBOT_GATEWAY_PORT?.trim();
- if (!raw) return null;
- const gatewayPort = Number.parseInt(raw, 10);
- if (!Number.isFinite(gatewayPort) || gatewayPort <= 0) return null;
- return deriveDefaultBrowserControlPort(gatewayPort);
- })();
- const derivedControlUrl = derivedControlPort ? `http://127.0.0.1:${derivedControlPort}` : null;
-
- const controlInfo = parseHttpUrl(
- cfg?.controlUrl ?? envControlUrl ?? derivedControlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL,
- "browser.controlUrl",
- );
- const controlPort = controlInfo.port;
+ const gatewayPort = resolveGatewayPort(rootConfig);
+ const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
const defaultColor = normalizeHexColor(cfg?.color);
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
@@ -178,11 +165,10 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr
const derivedPort = controlPort + 1;
if (derivedPort > 65535) {
throw new Error(
- `browser.controlUrl port (${controlPort}) is too high; cannot derive CDP port (${derivedPort})`,
+ `Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`,
);
}
- const derived = new URL(controlInfo.normalized);
- derived.port = String(derivedPort);
+ const derived = new URL(`http://127.0.0.1:${derivedPort}`);
cdpInfo = {
parsed: derived,
port: derivedPort,
@@ -211,10 +197,7 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr
return {
enabled,
- controlUrl: controlInfo.normalized,
- controlHost: controlInfo.parsed.hostname,
controlPort,
- ...(controlToken ? { controlToken } : {}),
cdpProtocol,
cdpHost: cdpInfo.parsed.hostname,
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
@@ -269,6 +252,6 @@ export function resolveProfile(
};
}
-export function shouldStartLocalBrowserServer(resolved: ResolvedBrowserConfig) {
- return isLoopbackHost(resolved.controlHost);
+export function shouldStartLocalBrowserServer(_resolved: ResolvedBrowserConfig) {
+ return true;
}
diff --git a/src/browser/constants.ts b/src/browser/constants.ts
index 3e8cb60d2..88642a6c5 100644
--- a/src/browser/constants.ts
+++ b/src/browser/constants.ts
@@ -1,5 +1,4 @@
export const DEFAULT_CLAWD_BROWSER_ENABLED = true;
-export const DEFAULT_CLAWD_BROWSER_CONTROL_URL = "http://127.0.0.1:18791";
export const DEFAULT_CLAWD_BROWSER_COLOR = "#FF4500";
export const DEFAULT_CLAWD_BROWSER_PROFILE_NAME = "clawd";
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "chrome";
diff --git a/src/browser/control-service.ts b/src/browser/control-service.ts
new file mode 100644
index 000000000..6c8f3cef2
--- /dev/null
+++ b/src/browser/control-service.ts
@@ -0,0 +1,80 @@
+import { loadConfig } from "../config/config.js";
+import { createSubsystemLogger } from "../logging/subsystem.js";
+import { resolveBrowserConfig, resolveProfile } from "./config.js";
+import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
+import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
+
+let state: BrowserServerState | null = null;
+const log = createSubsystemLogger("browser");
+const logService = log.child("service");
+
+export function getBrowserControlState(): BrowserServerState | null {
+ return state;
+}
+
+export function createBrowserControlContext() {
+ return createBrowserRouteContext({
+ getState: () => state,
+ });
+}
+
+export async function startBrowserControlServiceFromConfig(): Promise {
+ if (state) return state;
+
+ const cfg = loadConfig();
+ const resolved = resolveBrowserConfig(cfg.browser, cfg);
+ if (!resolved.enabled) return null;
+
+ state = {
+ server: null,
+ port: resolved.controlPort,
+ resolved,
+ profiles: new Map(),
+ };
+
+ // If any profile uses the Chrome extension relay, start the local relay server eagerly
+ // so the extension can connect before the first browser action.
+ for (const name of Object.keys(resolved.profiles)) {
+ const profile = resolveProfile(resolved, name);
+ if (!profile || profile.driver !== "extension") continue;
+ await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => {
+ logService.warn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`);
+ });
+ }
+
+ logService.info(
+ `Browser control service ready (profiles=${Object.keys(resolved.profiles).length})`,
+ );
+ return state;
+}
+
+export async function stopBrowserControlService(): Promise {
+ const current = state;
+ if (!current) return;
+
+ const ctx = createBrowserRouteContext({
+ getState: () => state,
+ });
+
+ try {
+ for (const name of Object.keys(current.resolved.profiles)) {
+ try {
+ await ctx.forProfile(name).stopRunningBrowser();
+ } catch {
+ // ignore
+ }
+ }
+ } catch (err) {
+ logService.warn(`clawd browser stop failed: ${String(err)}`);
+ }
+
+ state = null;
+
+ // Optional: Playwright is not always available (e.g. embedded gateway builds).
+ try {
+ const mod = await import("./pw-ai.js");
+ await mod.closePlaywrightBrowserConnection();
+ } catch {
+ // ignore
+ }
+}
diff --git a/src/browser/profiles-service.test.ts b/src/browser/profiles-service.test.ts
index 4092cf655..1daeec2de 100644
--- a/src/browser/profiles-service.test.ts
+++ b/src/browser/profiles-service.test.ts
@@ -49,9 +49,7 @@ function createCtx(resolved: BrowserServerState["resolved"]) {
describe("BrowserProfilesService", () => {
it("allocates next local port for new profiles", async () => {
- const resolved = resolveBrowserConfig({
- controlUrl: "http://127.0.0.1:18791",
- });
+ const resolved = resolveBrowserConfig({});
const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
@@ -66,9 +64,7 @@ describe("BrowserProfilesService", () => {
});
it("accepts per-profile cdpUrl for remote Chrome", async () => {
- const resolved = resolveBrowserConfig({
- controlUrl: "http://127.0.0.1:18791",
- });
+ const resolved = resolveBrowserConfig({});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
@@ -97,7 +93,6 @@ describe("BrowserProfilesService", () => {
it("deletes remote profiles without stopping or removing local data", async () => {
const resolved = resolveBrowserConfig({
- controlUrl: "http://127.0.0.1:18791",
profiles: {
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
},
@@ -124,7 +119,6 @@ describe("BrowserProfilesService", () => {
it("deletes local profiles and moves data to Trash", async () => {
const resolved = resolveBrowserConfig({
- controlUrl: "http://127.0.0.1:18791",
profiles: {
work: { cdpPort: 18801, color: "#0066CC" },
},
diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts
index ea681bd25..ae8d274b7 100644
--- a/src/browser/routes/agent.act.ts
+++ b/src/browser/routes/agent.act.ts
@@ -1,5 +1,3 @@
-import type express from "express";
-
import type { BrowserFormField } from "../client-actions-core.js";
import type { BrowserRouteContext } from "../server-context.js";
import {
@@ -16,8 +14,12 @@ import {
SELECTOR_UNSUPPORTED_MESSAGE,
} from "./agent.shared.js";
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
+import type { BrowserRouteRegistrar } from "./types.js";
-export function registerBrowserAgentActRoutes(app: express.Express, ctx: BrowserRouteContext) {
+export function registerBrowserAgentActRoutes(
+ app: BrowserRouteRegistrar,
+ ctx: BrowserRouteContext,
+) {
app.post("/act", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
diff --git a/src/browser/routes/agent.debug.ts b/src/browser/routes/agent.debug.ts
index c71bd7b47..2533474f7 100644
--- a/src/browser/routes/agent.debug.ts
+++ b/src/browser/routes/agent.debug.ts
@@ -2,13 +2,15 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
-import type express from "express";
-
import type { BrowserRouteContext } from "../server-context.js";
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
import { toBoolean, toStringOrEmpty } from "./utils.js";
+import type { BrowserRouteRegistrar } from "./types.js";
-export function registerBrowserAgentDebugRoutes(app: express.Express, ctx: BrowserRouteContext) {
+export function registerBrowserAgentDebugRoutes(
+ app: BrowserRouteRegistrar,
+ ctx: BrowserRouteContext,
+) {
app.get("/console", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
diff --git a/src/browser/routes/agent.shared.ts b/src/browser/routes/agent.shared.ts
index da8c608e3..08b010149 100644
--- a/src/browser/routes/agent.shared.ts
+++ b/src/browser/routes/agent.shared.ts
@@ -1,9 +1,8 @@
-import type express from "express";
-
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import type { PwAiModule } from "../pw-ai-module.js";
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
import { getProfileContext, jsonError } from "./utils.js";
+import type { BrowserRequest, BrowserResponse } from "./types.js";
export const SELECTOR_UNSUPPORTED_MESSAGE = [
"Error: 'selector' is not supported. Use 'ref' from snapshot instead.",
@@ -15,21 +14,21 @@ export const SELECTOR_UNSUPPORTED_MESSAGE = [
"This is more reliable for modern SPAs.",
].join("\n");
-export function readBody(req: express.Request): Record {
+export function readBody(req: BrowserRequest): Record {
const body = req.body as Record | undefined;
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
return body;
}
-export function handleRouteError(ctx: BrowserRouteContext, res: express.Response, err: unknown) {
+export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse, err: unknown) {
const mapped = ctx.mapTabError(err);
if (mapped) return jsonError(res, mapped.status, mapped.message);
jsonError(res, 500, String(err));
}
export function resolveProfileContext(
- req: express.Request,
- res: express.Response,
+ req: BrowserRequest,
+ res: BrowserResponse,
ctx: BrowserRouteContext,
): ProfileContext | null {
const profileCtx = getProfileContext(req, ctx);
@@ -45,7 +44,7 @@ export async function getPwAiModule(): Promise {
}
export async function requirePwAi(
- res: express.Response,
+ res: BrowserResponse,
feature: string,
): Promise {
const mod = await getPwAiModule();
diff --git a/src/browser/routes/agent.snapshot.ts b/src/browser/routes/agent.snapshot.ts
index fdeb7f69e..41ade72ff 100644
--- a/src/browser/routes/agent.snapshot.ts
+++ b/src/browser/routes/agent.snapshot.ts
@@ -1,7 +1,5 @@
import path from "node:path";
-import type express from "express";
-
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
import { captureScreenshot, snapshotAria } from "../cdp.js";
import {
@@ -23,8 +21,12 @@ import {
resolveProfileContext,
} from "./agent.shared.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
+import type { BrowserRouteRegistrar } from "./types.js";
-export function registerBrowserAgentSnapshotRoutes(app: express.Express, ctx: BrowserRouteContext) {
+export function registerBrowserAgentSnapshotRoutes(
+ app: BrowserRouteRegistrar,
+ ctx: BrowserRouteContext,
+) {
app.post("/navigate", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
diff --git a/src/browser/routes/agent.storage.ts b/src/browser/routes/agent.storage.ts
index 1f5c137b6..2f1905c22 100644
--- a/src/browser/routes/agent.storage.ts
+++ b/src/browser/routes/agent.storage.ts
@@ -1,10 +1,12 @@
-import type express from "express";
-
import type { BrowserRouteContext } from "../server-context.js";
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
+import type { BrowserRouteRegistrar } from "./types.js";
-export function registerBrowserAgentStorageRoutes(app: express.Express, ctx: BrowserRouteContext) {
+export function registerBrowserAgentStorageRoutes(
+ app: BrowserRouteRegistrar,
+ ctx: BrowserRouteContext,
+) {
app.get("/cookies", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
diff --git a/src/browser/routes/agent.ts b/src/browser/routes/agent.ts
index b2cf4950c..dc5e65433 100644
--- a/src/browser/routes/agent.ts
+++ b/src/browser/routes/agent.ts
@@ -1,12 +1,11 @@
-import type express from "express";
-
import type { BrowserRouteContext } from "../server-context.js";
import { registerBrowserAgentActRoutes } from "./agent.act.js";
import { registerBrowserAgentDebugRoutes } from "./agent.debug.js";
import { registerBrowserAgentSnapshotRoutes } from "./agent.snapshot.js";
import { registerBrowserAgentStorageRoutes } from "./agent.storage.js";
+import type { BrowserRouteRegistrar } from "./types.js";
-export function registerBrowserAgentRoutes(app: express.Express, ctx: BrowserRouteContext) {
+export function registerBrowserAgentRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
registerBrowserAgentSnapshotRoutes(app, ctx);
registerBrowserAgentActRoutes(app, ctx);
registerBrowserAgentDebugRoutes(app, ctx);
diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts
index 4b1fcab5c..32be15704 100644
--- a/src/browser/routes/basic.ts
+++ b/src/browser/routes/basic.ts
@@ -1,11 +1,10 @@
-import type express from "express";
-
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext } from "../server-context.js";
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
+import type { BrowserRouteRegistrar } from "./types.js";
-export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRouteContext) {
+export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
// List all profiles with their status
app.get("/profiles", async (_req, res) => {
try {
@@ -53,7 +52,6 @@ export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRou
res.json({
enabled: current.resolved.enabled,
- controlUrl: current.resolved.controlUrl,
profile: profileCtx.profile.name,
running: cdpReady,
cdpReady,
diff --git a/src/browser/routes/dispatcher.ts b/src/browser/routes/dispatcher.ts
new file mode 100644
index 000000000..d5a26598d
--- /dev/null
+++ b/src/browser/routes/dispatcher.ts
@@ -0,0 +1,122 @@
+import type { BrowserRouteContext } from "../server-context.js";
+import { registerBrowserRoutes } from "./index.js";
+import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
+
+type BrowserDispatchRequest = {
+ method: "GET" | "POST" | "DELETE";
+ path: string;
+ query?: Record;
+ body?: unknown;
+};
+
+type BrowserDispatchResponse = {
+ status: number;
+ body: unknown;
+};
+
+type RouteEntry = {
+ method: BrowserDispatchRequest["method"];
+ path: string;
+ regex: RegExp;
+ paramNames: string[];
+ handler: (req: BrowserRequest, res: BrowserResponse) => void | Promise;
+};
+
+function escapeRegex(value: string) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function compileRoute(path: string): { regex: RegExp; paramNames: string[] } {
+ const paramNames: string[] = [];
+ const parts = path.split("/").map((part) => {
+ if (part.startsWith(":")) {
+ const name = part.slice(1);
+ paramNames.push(name);
+ return "([^/]+)";
+ }
+ return escapeRegex(part);
+ });
+ return { regex: new RegExp(`^${parts.join("/")}$`), paramNames };
+}
+
+function createRegistry() {
+ const routes: RouteEntry[] = [];
+ const register =
+ (method: RouteEntry["method"]) => (path: string, handler: RouteEntry["handler"]) => {
+ const { regex, paramNames } = compileRoute(path);
+ routes.push({ method, path, regex, paramNames, handler });
+ };
+ const router: BrowserRouteRegistrar = {
+ get: register("GET"),
+ post: register("POST"),
+ delete: register("DELETE"),
+ };
+ return { routes, router };
+}
+
+function normalizePath(path: string) {
+ if (!path) return "/";
+ return path.startsWith("/") ? path : `/${path}`;
+}
+
+export function createBrowserRouteDispatcher(ctx: BrowserRouteContext) {
+ const registry = createRegistry();
+ registerBrowserRoutes(registry.router, ctx);
+
+ return {
+ dispatch: async (req: BrowserDispatchRequest): Promise => {
+ const method = req.method;
+ const path = normalizePath(req.path);
+ const query = req.query ?? {};
+ const body = req.body;
+
+ const match = registry.routes.find((route) => {
+ if (route.method !== method) return false;
+ return route.regex.test(path);
+ });
+ if (!match) {
+ return { status: 404, body: { error: "Not Found" } };
+ }
+
+ const exec = match.regex.exec(path);
+ const params: Record = {};
+ if (exec) {
+ for (const [idx, name] of match.paramNames.entries()) {
+ const value = exec[idx + 1];
+ if (typeof value === "string") {
+ params[name] = decodeURIComponent(value);
+ }
+ }
+ }
+
+ let status = 200;
+ let payload: unknown = undefined;
+ const res: BrowserResponse = {
+ status(code) {
+ status = code;
+ return res;
+ },
+ json(bodyValue) {
+ payload = bodyValue;
+ },
+ };
+
+ try {
+ await match.handler(
+ {
+ params,
+ query,
+ body,
+ },
+ res,
+ );
+ } catch (err) {
+ return { status: 500, body: { error: String(err) } };
+ }
+
+ return { status, body: payload };
+ },
+ };
+}
+
+export type { BrowserDispatchRequest, BrowserDispatchResponse };
diff --git a/src/browser/routes/index.ts b/src/browser/routes/index.ts
index df435ffb8..3c20ef1c6 100644
--- a/src/browser/routes/index.ts
+++ b/src/browser/routes/index.ts
@@ -1,11 +1,10 @@
-import type express from "express";
-
import type { BrowserRouteContext } from "../server-context.js";
import { registerBrowserAgentRoutes } from "./agent.js";
import { registerBrowserBasicRoutes } from "./basic.js";
import { registerBrowserTabRoutes } from "./tabs.js";
+import type { BrowserRouteRegistrar } from "./types.js";
-export function registerBrowserRoutes(app: express.Express, ctx: BrowserRouteContext) {
+export function registerBrowserRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
registerBrowserBasicRoutes(app, ctx);
registerBrowserTabRoutes(app, ctx);
registerBrowserAgentRoutes(app, ctx);
diff --git a/src/browser/routes/tabs.ts b/src/browser/routes/tabs.ts
index 44596be85..e510dd521 100644
--- a/src/browser/routes/tabs.ts
+++ b/src/browser/routes/tabs.ts
@@ -1,9 +1,8 @@
-import type express from "express";
-
import type { BrowserRouteContext } from "../server-context.js";
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
+import type { BrowserRouteRegistrar } from "./types.js";
-export function registerBrowserTabRoutes(app: express.Express, ctx: BrowserRouteContext) {
+export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
app.get("/tabs", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
diff --git a/src/browser/routes/types.ts b/src/browser/routes/types.ts
new file mode 100644
index 000000000..76e6051c9
--- /dev/null
+++ b/src/browser/routes/types.ts
@@ -0,0 +1,21 @@
+export type BrowserRequest = {
+ params: Record;
+ query: Record;
+ body?: unknown;
+};
+
+export type BrowserResponse = {
+ status: (code: number) => BrowserResponse;
+ json: (body: unknown) => void;
+};
+
+export type BrowserRouteHandler = (
+ req: BrowserRequest,
+ res: BrowserResponse,
+) => void | Promise;
+
+export type BrowserRouteRegistrar = {
+ get: (path: string, handler: BrowserRouteHandler) => void;
+ post: (path: string, handler: BrowserRouteHandler) => void;
+ delete: (path: string, handler: BrowserRouteHandler) => void;
+};
diff --git a/src/browser/routes/utils.ts b/src/browser/routes/utils.ts
index 1c6e37fc5..ad510da4b 100644
--- a/src/browser/routes/utils.ts
+++ b/src/browser/routes/utils.ts
@@ -1,14 +1,13 @@
-import type express from "express";
-
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import { parseBooleanValue } from "../../utils/boolean.js";
+import type { BrowserRequest, BrowserResponse } from "./types.js";
/**
* Extract profile name from query string or body and get profile context.
* Query string takes precedence over body for consistency with GET routes.
*/
export function getProfileContext(
- req: express.Request,
+ req: BrowserRequest,
ctx: BrowserRouteContext,
): ProfileContext | { error: string; status: number } {
let profileName: string | undefined;
@@ -33,7 +32,7 @@ export function getProfileContext(
}
}
-export function jsonError(res: express.Response, status: number, message: string) {
+export function jsonError(res: BrowserResponse, status: number, message: string) {
res.status(status).json({ error: message });
}
diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts
index 4863cc1fe..5d9555387 100644
--- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts
+++ b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts
@@ -62,8 +62,6 @@ describe("browser server-context ensureTabAvailable", () => {
port: 0,
resolved: {
enabled: true,
- controlUrl: "http://127.0.0.1:18791",
- controlHost: "127.0.0.1",
controlPort: 18791,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
@@ -121,8 +119,6 @@ describe("browser server-context ensureTabAvailable", () => {
port: 0,
resolved: {
enabled: true,
- controlUrl: "http://127.0.0.1:18791",
- controlHost: "127.0.0.1",
controlPort: 18791,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
@@ -170,8 +166,6 @@ describe("browser server-context ensureTabAvailable", () => {
port: 0,
resolved: {
enabled: true,
- controlUrl: "http://127.0.0.1:18791",
- controlHost: "127.0.0.1",
controlPort: 18791,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
diff --git a/src/browser/server-context.remote-tab-ops.test.ts b/src/browser/server-context.remote-tab-ops.test.ts
index 379394497..b3cdb38f9 100644
--- a/src/browser/server-context.remote-tab-ops.test.ts
+++ b/src/browser/server-context.remote-tab-ops.test.ts
@@ -21,8 +21,6 @@ function makeState(
port: 0,
resolved: {
enabled: true,
- controlUrl: "http://127.0.0.1:18791",
- controlHost: "127.0.0.1",
controlPort: 18791,
cdpProtocol: profile === "remote" ? "https" : "http",
cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1",
diff --git a/src/browser/server-context.types.ts b/src/browser/server-context.types.ts
index 6532010e9..7fa6d273a 100644
--- a/src/browser/server-context.types.ts
+++ b/src/browser/server-context.types.ts
@@ -17,7 +17,7 @@ export type ProfileRuntimeState = {
};
export type BrowserServerState = {
- server: Server;
+ server?: Server | null;
port: number;
resolved: ResolvedBrowserConfig;
profiles: Map;
diff --git a/src/browser/server.agent-contract-form-layout-act-commands.test.ts b/src/browser/server.agent-contract-form-layout-act-commands.test.ts
index cbff6dca5..87293a9a3 100644
--- a/src/browser/server.agent-contract-form-layout-act-commands.test.ts
+++ b/src/browser/server.agent-contract-form-layout-act-commands.test.ts
@@ -8,6 +8,7 @@ let cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
+let prevGatewayPort: string | undefined;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
loadConfig: () => ({
browser: {
enabled: true,
- controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
@@ -197,6 +197,8 @@ describe("browser control server", () => {
testPort = await getFreePort();
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
+ prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
+ process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
@@ -248,6 +250,11 @@ describe("browser control server", () => {
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
+ if (prevGatewayPort === undefined) {
+ delete process.env.CLAWDBOT_GATEWAY_PORT;
+ } else {
+ process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
+ }
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
diff --git a/src/browser/server.agent-contract-snapshot-endpoints.test.ts b/src/browser/server.agent-contract-snapshot-endpoints.test.ts
index 65a86facb..494d64657 100644
--- a/src/browser/server.agent-contract-snapshot-endpoints.test.ts
+++ b/src/browser/server.agent-contract-snapshot-endpoints.test.ts
@@ -9,6 +9,7 @@ let cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
+let prevGatewayPort: string | undefined;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
@@ -89,7 +90,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
loadConfig: () => ({
browser: {
enabled: true,
- controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
@@ -198,6 +198,8 @@ describe("browser control server", () => {
testPort = await getFreePort();
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
+ prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
+ process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
@@ -249,6 +251,11 @@ describe("browser control server", () => {
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
+ if (prevGatewayPort === undefined) {
+ delete process.env.CLAWDBOT_GATEWAY_PORT;
+ } else {
+ process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
+ }
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
diff --git a/src/browser/server.covers-additional-endpoint-branches.test.ts b/src/browser/server.covers-additional-endpoint-branches.test.ts
index bc16040cf..ee5463ab5 100644
--- a/src/browser/server.covers-additional-endpoint-branches.test.ts
+++ b/src/browser/server.covers-additional-endpoint-branches.test.ts
@@ -8,6 +8,7 @@ let _cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
+let prevGatewayPort: string | undefined;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
loadConfig: () => ({
browser: {
enabled: true,
- controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
@@ -197,6 +197,8 @@ describe("browser control server", () => {
testPort = await getFreePort();
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
+ prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
+ process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
@@ -248,6 +250,11 @@ describe("browser control server", () => {
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
+ if (prevGatewayPort === undefined) {
+ delete process.env.CLAWDBOT_GATEWAY_PORT;
+ } else {
+ process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
+ }
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
diff --git a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts
index 4908a536c..f178858a7 100644
--- a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts
+++ b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts
@@ -8,6 +8,7 @@ let _cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
+let prevGatewayPort: string | undefined;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
loadConfig: () => ({
browser: {
enabled: true,
- controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
@@ -197,6 +197,8 @@ describe("browser control server", () => {
testPort = await getFreePort();
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
+ prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
+ process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
@@ -248,6 +250,11 @@ describe("browser control server", () => {
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
+ if (prevGatewayPort === undefined) {
+ delete process.env.CLAWDBOT_GATEWAY_PORT;
+ } else {
+ process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
+ }
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
diff --git a/src/browser/server.serves-status-starts-browser-requested.test.ts b/src/browser/server.serves-status-starts-browser-requested.test.ts
index 8cfcca40e..41ea6ab47 100644
--- a/src/browser/server.serves-status-starts-browser-requested.test.ts
+++ b/src/browser/server.serves-status-starts-browser-requested.test.ts
@@ -8,6 +8,7 @@ let _cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
+let prevGatewayPort: string | undefined;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
loadConfig: () => ({
browser: {
enabled: true,
- controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
@@ -197,6 +197,8 @@ describe("browser control server", () => {
testPort = await getFreePort();
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
+ prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
+ process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
@@ -248,6 +250,11 @@ describe("browser control server", () => {
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
+ if (prevGatewayPort === undefined) {
+ delete process.env.CLAWDBOT_GATEWAY_PORT;
+ } else {
+ process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
+ }
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
diff --git a/src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts b/src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts
index d50313413..ed4d4f9f5 100644
--- a/src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts
+++ b/src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts
@@ -8,6 +8,7 @@ let cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
+let prevGatewayPort: string | undefined;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
loadConfig: () => ({
browser: {
enabled: true,
- controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
@@ -197,6 +197,8 @@ describe("browser control server", () => {
testPort = await getFreePort();
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
+ prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
+ process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
@@ -248,6 +250,11 @@ describe("browser control server", () => {
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
+ if (prevGatewayPort === undefined) {
+ delete process.env.CLAWDBOT_GATEWAY_PORT;
+ } else {
+ process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
+ }
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
@@ -394,8 +401,6 @@ describe("browser control server", () => {
const bridge = await startBrowserBridgeServer({
resolved: {
enabled: true,
- controlUrl: "http://127.0.0.1:0",
- controlHost: "127.0.0.1",
controlPort: 0,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
diff --git a/src/browser/server.ts b/src/browser/server.ts
index 9fb9a5f81..8eda99508 100644
--- a/src/browser/server.ts
+++ b/src/browser/server.ts
@@ -3,9 +3,10 @@ import express from "express";
import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
-import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
+import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
import { registerBrowserRoutes } from "./routes/index.js";
+import type { BrowserRouteRegistrar } from "./routes/types.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
let state: BrowserServerState | null = null;
@@ -16,23 +17,16 @@ export async function startBrowserControlServerFromConfig(): Promise state,
});
- registerBrowserRoutes(app, ctx);
+ registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
const port = resolved.controlPort;
const server = await new Promise((resolve, reject) => {
@@ -89,9 +83,11 @@ export async function stopBrowserControlServer(): Promise {
logServer.warn(`clawd browser stop failed: ${String(err)}`);
}
- await new Promise((resolve) => {
- current.server.close(() => resolve());
- });
+ if (current.server) {
+ await new Promise((resolve) => {
+ current.server?.close(() => resolve());
+ });
+ }
state = null;
// Optional: Playwright is not always available (e.g. embedded gateway builds).
diff --git a/src/cli/browser-cli-actions-input/register.element.ts b/src/cli/browser-cli-actions-input/register.element.ts
index da313cf24..c018e7e24 100644
--- a/src/cli/browser-cli-actions-input/register.element.ts
+++ b/src/cli/browser-cli-actions-input/register.element.ts
@@ -1,9 +1,8 @@
import type { Command } from "commander";
-import { browserAct } from "../../browser/client-actions.js";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import type { BrowserParentOpts } from "../browser-cli-shared.js";
-import { requireRef, resolveBrowserActionContext } from "./shared.js";
+import { callBrowserAct, requireRef, resolveBrowserActionContext } from "./shared.js";
export function registerBrowserElementCommands(
browser: Command,
@@ -18,7 +17,7 @@ export function registerBrowserElementCommands(
.option("--button ", "Mouse button to use")
.option("--modifiers ", "Comma-separated modifiers (Shift,Alt,Meta)")
.action(async (ref: string | undefined, opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const refValue = requireRef(ref);
if (!refValue) return;
const modifiers = opts.modifiers
@@ -28,9 +27,10 @@ export function registerBrowserElementCommands(
.filter(Boolean)
: undefined;
try {
- const result = await browserAct(
- baseUrl,
- {
+ const result = await callBrowserAct({
+ parent,
+ profile,
+ body: {
kind: "click",
ref: refValue,
targetId: opts.targetId?.trim() || undefined,
@@ -38,8 +38,7 @@ export function registerBrowserElementCommands(
button: opts.button?.trim() || undefined,
modifiers,
},
- { profile },
- );
+ });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -61,13 +60,14 @@ export function registerBrowserElementCommands(
.option("--slowly", "Type slowly (human-like)", false)
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (ref: string | undefined, text: string, opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const refValue = requireRef(ref);
if (!refValue) return;
try {
- const result = await browserAct(
- baseUrl,
- {
+ const result = await callBrowserAct({
+ parent,
+ profile,
+ body: {
kind: "type",
ref: refValue,
text,
@@ -75,8 +75,7 @@ export function registerBrowserElementCommands(
slowly: Boolean(opts.slowly),
targetId: opts.targetId?.trim() || undefined,
},
- { profile },
- );
+ });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -94,13 +93,13 @@ export function registerBrowserElementCommands(
.argument("", "Key to press (e.g. Enter)")
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (key: string, opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
- const result = await browserAct(
- baseUrl,
- { kind: "press", key, targetId: opts.targetId?.trim() || undefined },
- { profile },
- );
+ const result = await callBrowserAct({
+ parent,
+ profile,
+ body: { kind: "press", key, targetId: opts.targetId?.trim() || undefined },
+ });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -118,13 +117,13 @@ export function registerBrowserElementCommands(
.argument("[", "Ref id from snapshot")
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (ref: string, opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
- const result = await browserAct(
- baseUrl,
- { kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
- { profile },
- );
+ const result = await callBrowserAct({
+ parent,
+ profile,
+ body: { kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
+ });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -145,20 +144,21 @@ export function registerBrowserElementCommands(
Number(v),
)
.action(async (ref: string | undefined, opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const refValue = requireRef(ref);
if (!refValue) return;
try {
- const result = await browserAct(
- baseUrl,
- {
+ const result = await callBrowserAct({
+ parent,
+ profile,
+ body: {
kind: "scrollIntoView",
ref: refValue,
targetId: opts.targetId?.trim() || undefined,
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
},
- { profile },
- );
+ timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
+ });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -177,18 +177,18 @@ export function registerBrowserElementCommands(
.argument("", "End ref id")
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (startRef: string, endRef: string, opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
- const result = await browserAct(
- baseUrl,
- {
+ const result = await callBrowserAct({
+ parent,
+ profile,
+ body: {
kind: "drag",
startRef,
endRef,
targetId: opts.targetId?.trim() || undefined,
},
- { profile },
- );
+ });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -207,18 +207,18 @@ export function registerBrowserElementCommands(
.argument("", "Option values to select")
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (ref: string, values: string[], opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
- const result = await browserAct(
- baseUrl,
- {
+ const result = await callBrowserAct({
+ parent,
+ profile,
+ body: {
kind: "select",
ref,
values,
targetId: opts.targetId?.trim() || undefined,
},
- { profile },
- );
+ });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
diff --git a/src/cli/browser-cli-actions-input/register.files-downloads.ts b/src/cli/browser-cli-actions-input/register.files-downloads.ts
index ffad3faa9..baa3c71a1 100644
--- a/src/cli/browser-cli-actions-input/register.files-downloads.ts
+++ b/src/cli/browser-cli-actions-input/register.files-downloads.ts
@@ -1,13 +1,7 @@
import type { Command } from "commander";
-import {
- browserArmDialog,
- browserArmFileChooser,
- browserDownload,
- browserWaitForDownload,
-} from "../../browser/client-actions.js";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
-import type { BrowserParentOpts } from "../browser-cli-shared.js";
+import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
import { resolveBrowserActionContext } from "./shared.js";
import { shortenHomePath } from "../../utils.js";
@@ -29,17 +23,26 @@ export function registerBrowserFilesAndDownloadsCommands(
(v: string) => Number(v),
)
.action(async (paths: string[], opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
- const result = await browserArmFileChooser(baseUrl, {
- paths,
- ref: opts.ref?.trim() || undefined,
- inputRef: opts.inputRef?.trim() || undefined,
- element: opts.element?.trim() || undefined,
- targetId: opts.targetId?.trim() || undefined,
- timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
- profile,
- });
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/hooks/file-chooser",
+ query: profile ? { profile } : undefined,
+ body: {
+ paths,
+ ref: opts.ref?.trim() || undefined,
+ inputRef: opts.inputRef?.trim() || undefined,
+ element: opts.element?.trim() || undefined,
+ targetId: opts.targetId?.trim() || undefined,
+ timeoutMs,
+ },
+ },
+ { timeoutMs: timeoutMs ?? 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -62,14 +65,23 @@ export function registerBrowserFilesAndDownloadsCommands(
(v: string) => Number(v),
)
.action(async (outPath: string | undefined, opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
- const result = await browserWaitForDownload(baseUrl, {
- path: outPath?.trim() || undefined,
- targetId: opts.targetId?.trim() || undefined,
- timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
- profile,
- });
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/wait/download",
+ query: profile ? { profile } : undefined,
+ body: {
+ path: outPath?.trim() || undefined,
+ targetId: opts.targetId?.trim() || undefined,
+ timeoutMs,
+ },
+ },
+ { timeoutMs: timeoutMs ?? 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -93,15 +105,24 @@ export function registerBrowserFilesAndDownloadsCommands(
(v: string) => Number(v),
)
.action(async (ref: string, outPath: string, opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
- const result = await browserDownload(baseUrl, {
- ref,
- path: outPath,
- targetId: opts.targetId?.trim() || undefined,
- timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
- profile,
- });
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/download",
+ query: profile ? { profile } : undefined,
+ body: {
+ ref,
+ path: outPath,
+ targetId: opts.targetId?.trim() || undefined,
+ timeoutMs,
+ },
+ },
+ { timeoutMs: timeoutMs ?? 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -126,7 +147,7 @@ export function registerBrowserFilesAndDownloadsCommands(
(v: string) => Number(v),
)
.action(async (opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const accept = opts.accept ? true : opts.dismiss ? false : undefined;
if (accept === undefined) {
defaultRuntime.error(danger("Specify --accept or --dismiss"));
@@ -134,13 +155,22 @@ export function registerBrowserFilesAndDownloadsCommands(
return;
}
try {
- const result = await browserArmDialog(baseUrl, {
- accept,
- promptText: opts.prompt?.trim() || undefined,
- targetId: opts.targetId?.trim() || undefined,
- timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
- profile,
- });
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/hooks/dialog",
+ query: profile ? { profile } : undefined,
+ body: {
+ accept,
+ promptText: opts.prompt?.trim() || undefined,
+ targetId: opts.targetId?.trim() || undefined,
+ timeoutMs,
+ },
+ },
+ { timeoutMs: timeoutMs ?? 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
diff --git a/src/cli/browser-cli-actions-input/register.form-wait-eval.ts b/src/cli/browser-cli-actions-input/register.form-wait-eval.ts
index 0a72423dc..f71662700 100644
--- a/src/cli/browser-cli-actions-input/register.form-wait-eval.ts
+++ b/src/cli/browser-cli-actions-input/register.form-wait-eval.ts
@@ -1,9 +1,8 @@
import type { Command } from "commander";
-import { browserAct } from "../../browser/client-actions.js";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import type { BrowserParentOpts } from "../browser-cli-shared.js";
-import { readFields, resolveBrowserActionContext } from "./shared.js";
+import { callBrowserAct, readFields, resolveBrowserActionContext } from "./shared.js";
export function registerBrowserFormWaitEvalCommands(
browser: Command,
@@ -16,21 +15,21 @@ export function registerBrowserFormWaitEvalCommands(
.option("--fields-file ", "Read JSON array from a file")
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const fields = await readFields({
fields: opts.fields,
fieldsFile: opts.fieldsFile,
});
- const result = await browserAct(
- baseUrl,
- {
+ const result = await callBrowserAct({
+ parent,
+ profile,
+ body: {
kind: "fill",
fields,
targetId: opts.targetId?.trim() || undefined,
},
- { profile },
- );
+ });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -59,16 +58,18 @@ export function registerBrowserFormWaitEvalCommands(
)
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (selector: string | undefined, opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const sel = selector?.trim() || undefined;
const load =
opts.load === "load" || opts.load === "domcontentloaded" || opts.load === "networkidle"
? (opts.load as "load" | "domcontentloaded" | "networkidle")
: undefined;
- const result = await browserAct(
- baseUrl,
- {
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
+ const result = await callBrowserAct({
+ parent,
+ profile,
+ body: {
kind: "wait",
timeMs: Number.isFinite(opts.time) ? opts.time : undefined,
text: opts.text?.trim() || undefined,
@@ -78,10 +79,10 @@ export function registerBrowserFormWaitEvalCommands(
loadState: load,
fn: opts.fn?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
- timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
+ timeoutMs,
},
- { profile },
- );
+ timeoutMs,
+ });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -100,23 +101,23 @@ export function registerBrowserFormWaitEvalCommands(
.option("--ref ", "Ref from snapshot")
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
if (!opts.fn) {
defaultRuntime.error(danger("Missing --fn"));
defaultRuntime.exit(1);
return;
}
try {
- const result = await browserAct(
- baseUrl,
- {
+ const result = await callBrowserAct({
+ parent,
+ profile,
+ body: {
kind: "evaluate",
fn: opts.fn,
ref: opts.ref?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
},
- { profile },
- );
+ });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
diff --git a/src/cli/browser-cli-actions-input/register.navigation.ts b/src/cli/browser-cli-actions-input/register.navigation.ts
index af7b76cd8..5ab7247c3 100644
--- a/src/cli/browser-cli-actions-input/register.navigation.ts
+++ b/src/cli/browser-cli-actions-input/register.navigation.ts
@@ -1,8 +1,7 @@
import type { Command } from "commander";
-import { browserAct, browserNavigate } from "../../browser/client-actions.js";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
-import type { BrowserParentOpts } from "../browser-cli-shared.js";
+import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
import { requireRef, resolveBrowserActionContext } from "./shared.js";
export function registerBrowserNavigationCommands(
@@ -15,13 +14,21 @@ export function registerBrowserNavigationCommands(
.argument("", "URL to navigate to")
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (url: string, opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
- const result = await browserNavigate(baseUrl, {
- url,
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest<{ url?: string }>(
+ parent,
+ {
+ method: "POST",
+ path: "/navigate",
+ query: profile ? { profile } : undefined,
+ body: {
+ url,
+ targetId: opts.targetId?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -40,22 +47,27 @@ export function registerBrowserNavigationCommands(
.argument("", "Viewport height", (v: string) => Number(v))
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (width: number, height: number, opts, cmd) => {
- const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
+ const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
if (!Number.isFinite(width) || !Number.isFinite(height)) {
defaultRuntime.error(danger("width and height must be numbers"));
defaultRuntime.exit(1);
return;
}
try {
- const result = await browserAct(
- baseUrl,
+ const result = await callBrowserRequest(
+ parent,
{
- kind: "resize",
- width,
- height,
- targetId: opts.targetId?.trim() || undefined,
+ method: "POST",
+ path: "/act",
+ query: profile ? { profile } : undefined,
+ body: {
+ kind: "resize",
+ width,
+ height,
+ targetId: opts.targetId?.trim() || undefined,
+ },
},
- { profile },
+ { timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
diff --git a/src/cli/browser-cli-actions-input/shared.ts b/src/cli/browser-cli-actions-input/shared.ts
index 7bed18736..a04449d54 100644
--- a/src/cli/browser-cli-actions-input/shared.ts
+++ b/src/cli/browser-cli-actions-input/shared.ts
@@ -1,13 +1,11 @@
import type { Command } from "commander";
-import { resolveBrowserControlUrl } from "../../browser/client.js";
import type { BrowserFormField } from "../../browser/client-actions-core.js";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
-import type { BrowserParentOpts } from "../browser-cli-shared.js";
+import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
export type BrowserActionContext = {
parent: BrowserParentOpts;
- baseUrl: string;
profile: string | undefined;
};
@@ -16,9 +14,26 @@ export function resolveBrowserActionContext(
parentOpts: (cmd: Command) => BrowserParentOpts,
): BrowserActionContext {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
- return { parent, baseUrl, profile };
+ return { parent, profile };
+}
+
+export async function callBrowserAct(params: {
+ parent: BrowserParentOpts;
+ profile?: string;
+ body: Record;
+ timeoutMs?: number;
+}): Promise {
+ return await callBrowserRequest(
+ params.parent,
+ {
+ method: "POST",
+ path: "/act",
+ query: params.profile ? { profile: params.profile } : undefined,
+ body: params.body,
+ },
+ { timeoutMs: params.timeoutMs ?? 20000 },
+ );
}
export function requireRef(ref: string | undefined) {
diff --git a/src/cli/browser-cli-actions-observe.ts b/src/cli/browser-cli-actions-observe.ts
index 66202e357..63abd357d 100644
--- a/src/cli/browser-cli-actions-observe.ts
+++ b/src/cli/browser-cli-actions-observe.ts
@@ -1,13 +1,7 @@
import type { Command } from "commander";
-import { resolveBrowserControlUrl } from "../browser/client.js";
-import {
- browserConsoleMessages,
- browserPdfSave,
- browserResponseBody,
-} from "../browser/client-actions.js";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
-import type { BrowserParentOpts } from "./browser-cli-shared.js";
+import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
import { shortenHomePath } from "../utils.js";
@@ -29,14 +23,21 @@ export function registerBrowserActionObserveCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserObserve(async () => {
- const result = await browserConsoleMessages(baseUrl, {
- level: opts.level?.trim() || undefined,
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest<{ messages: unknown[] }>(
+ parent,
+ {
+ method: "GET",
+ path: "/console",
+ query: {
+ level: opts.level?.trim() || undefined,
+ targetId: opts.targetId?.trim() || undefined,
+ profile,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -51,13 +52,18 @@ export function registerBrowserActionObserveCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserObserve(async () => {
- const result = await browserPdfSave(baseUrl, {
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest<{ path: string }>(
+ parent,
+ {
+ method: "POST",
+ path: "/pdf",
+ query: profile ? { profile } : undefined,
+ body: { targetId: opts.targetId?.trim() || undefined },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -81,16 +87,25 @@ export function registerBrowserActionObserveCommands(
)
.action(async (url: string, opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserObserve(async () => {
- const result = await browserResponseBody(baseUrl, {
- url,
- targetId: opts.targetId?.trim() || undefined,
- timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
- maxChars: Number.isFinite(opts.maxChars) ? opts.maxChars : undefined,
- profile,
- });
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
+ const maxChars = Number.isFinite(opts.maxChars) ? opts.maxChars : undefined;
+ const result = await callBrowserRequest<{ response: { body: string } }>(
+ parent,
+ {
+ method: "POST",
+ path: "/response/body",
+ query: profile ? { profile } : undefined,
+ body: {
+ url,
+ targetId: opts.targetId?.trim() || undefined,
+ timeoutMs,
+ maxChars,
+ },
+ },
+ { timeoutMs: timeoutMs ?? 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
diff --git a/src/cli/browser-cli-debug.ts b/src/cli/browser-cli-debug.ts
index daa5ba284..25ebab5a4 100644
--- a/src/cli/browser-cli-debug.ts
+++ b/src/cli/browser-cli-debug.ts
@@ -1,16 +1,8 @@
import type { Command } from "commander";
-import { resolveBrowserControlUrl } from "../browser/client.js";
-import {
- browserHighlight,
- browserPageErrors,
- browserRequests,
- browserTraceStart,
- browserTraceStop,
-} from "../browser/client-actions.js";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
-import type { BrowserParentOpts } from "./browser-cli-shared.js";
+import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
import { shortenHomePath } from "../utils.js";
@@ -32,14 +24,21 @@ export function registerBrowserDebugCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (ref: string, opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserDebug(async () => {
- const result = await browserHighlight(baseUrl, {
- ref: ref.trim(),
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/highlight",
+ query: profile ? { profile } : undefined,
+ body: {
+ ref: ref.trim(),
+ targetId: opts.targetId?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -55,14 +54,23 @@ export function registerBrowserDebugCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserDebug(async () => {
- const result = await browserPageErrors(baseUrl, {
- targetId: opts.targetId?.trim() || undefined,
- clear: Boolean(opts.clear),
- profile,
- });
+ const result = await callBrowserRequest<{
+ errors: Array<{ timestamp: string; name?: string; message: string }>;
+ }>(
+ parent,
+ {
+ method: "GET",
+ path: "/errors",
+ query: {
+ targetId: opts.targetId?.trim() || undefined,
+ clear: Boolean(opts.clear),
+ profile,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -87,15 +95,31 @@ export function registerBrowserDebugCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserDebug(async () => {
- const result = await browserRequests(baseUrl, {
- targetId: opts.targetId?.trim() || undefined,
- filter: opts.filter?.trim() || undefined,
- clear: Boolean(opts.clear),
- profile,
- });
+ const result = await callBrowserRequest<{
+ requests: Array<{
+ timestamp: string;
+ method: string;
+ status?: number;
+ ok?: boolean;
+ url: string;
+ failureText?: string;
+ }>;
+ }>(
+ parent,
+ {
+ method: "GET",
+ path: "/requests",
+ query: {
+ targetId: opts.targetId?.trim() || undefined,
+ filter: opts.filter?.trim() || undefined,
+ clear: Boolean(opts.clear),
+ profile,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -128,16 +152,23 @@ export function registerBrowserDebugCommands(
.option("--sources", "Include sources (bigger traces)", false)
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserDebug(async () => {
- const result = await browserTraceStart(baseUrl, {
- targetId: opts.targetId?.trim() || undefined,
- screenshots: Boolean(opts.screenshots),
- snapshots: Boolean(opts.snapshots),
- sources: Boolean(opts.sources),
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/trace/start",
+ query: profile ? { profile } : undefined,
+ body: {
+ targetId: opts.targetId?.trim() || undefined,
+ screenshots: Boolean(opts.screenshots),
+ snapshots: Boolean(opts.snapshots),
+ sources: Boolean(opts.sources),
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -153,14 +184,21 @@ export function registerBrowserDebugCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserDebug(async () => {
- const result = await browserTraceStop(baseUrl, {
- targetId: opts.targetId?.trim() || undefined,
- path: opts.out?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest<{ path: string }>(
+ parent,
+ {
+ method: "POST",
+ path: "/trace/stop",
+ query: profile ? { profile } : undefined,
+ body: {
+ targetId: opts.targetId?.trim() || undefined,
+ path: opts.out?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
diff --git a/src/cli/browser-cli-inspect.ts b/src/cli/browser-cli-inspect.ts
index 000d33be9..e36b5c797 100644
--- a/src/cli/browser-cli-inspect.ts
+++ b/src/cli/browser-cli-inspect.ts
@@ -1,12 +1,11 @@
import type { Command } from "commander";
-import { browserSnapshot, resolveBrowserControlUrl } from "../browser/client.js";
-import { browserScreenshotAction } from "../browser/client-actions.js";
+import type { SnapshotResult } from "../browser/client.js";
import { loadConfig } from "../config/config.js";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
-import type { BrowserParentOpts } from "./browser-cli-shared.js";
+import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
export function registerBrowserInspectCommands(
browser: Command,
@@ -22,17 +21,24 @@ export function registerBrowserInspectCommands(
.option("--type ", "Output type (default: png)", "png")
.action(async (targetId: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
- const result = await browserScreenshotAction(baseUrl, {
- targetId: targetId?.trim() || undefined,
- fullPage: Boolean(opts.fullPage),
- ref: opts.ref?.trim() || undefined,
- element: opts.element?.trim() || undefined,
- type: opts.type === "jpeg" ? "jpeg" : "png",
- profile,
- });
+ const result = await callBrowserRequest<{ path: string }>(
+ parent,
+ {
+ method: "POST",
+ path: "/screenshot",
+ query: profile ? { profile } : undefined,
+ body: {
+ targetId: targetId?.trim() || undefined,
+ fullPage: Boolean(opts.fullPage),
+ ref: opts.ref?.trim() || undefined,
+ element: opts.element?.trim() || undefined,
+ type: opts.type === "jpeg" ? "jpeg" : "png",
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -61,7 +67,6 @@ export function registerBrowserInspectCommands(
.option("--out ", "Write snapshot to a file")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
const format = opts.format === "aria" ? "aria" : "ai";
const configMode =
@@ -70,19 +75,28 @@ export function registerBrowserInspectCommands(
: undefined;
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode;
try {
- const result = await browserSnapshot(baseUrl, {
+ const query: Record = {
format,
targetId: opts.targetId?.trim() || undefined,
limit: Number.isFinite(opts.limit) ? opts.limit : undefined,
- interactive: Boolean(opts.interactive) || undefined,
- compact: Boolean(opts.compact) || undefined,
+ interactive: opts.interactive ? true : undefined,
+ compact: opts.compact ? true : undefined,
depth: Number.isFinite(opts.depth) ? opts.depth : undefined,
selector: opts.selector?.trim() || undefined,
frame: opts.frame?.trim() || undefined,
- labels: Boolean(opts.labels) || undefined,
+ labels: opts.labels ? true : undefined,
mode,
profile,
- });
+ };
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "GET",
+ path: "/snapshot",
+ query,
+ },
+ { timeoutMs: 20000 },
+ );
if (opts.out) {
const fs = await import("node:fs/promises");
diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts
index 612653616..44ff3836e 100644
--- a/src/cli/browser-cli-manage.ts
+++ b/src/cli/browser-cli-manage.ts
@@ -1,25 +1,16 @@
import type { Command } from "commander";
-import type { BrowserTab } from "../browser/client.js";
-import {
- browserCloseTab,
- browserCreateProfile,
- browserDeleteProfile,
- browserFocusTab,
- browserOpenTab,
- browserProfiles,
- browserResetProfile,
- browserStart,
- browserStatus,
- browserStop,
- browserTabAction,
- browserTabs,
- resolveBrowserControlUrl,
+import type {
+ BrowserCreateProfileResult,
+ BrowserDeleteProfileResult,
+ BrowserResetProfileResult,
+ BrowserStatus,
+ BrowserTab,
+ ProfileStatus,
} from "../browser/client.js";
-import { browserAct } from "../browser/client-actions-core.js";
import { danger, info } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
-import type { BrowserParentOpts } from "./browser-cli-shared.js";
+import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
function runBrowserCommand(action: () => Promise) {
@@ -38,11 +29,18 @@ export function registerBrowserManageCommands(
.description("Show browser status")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
await runBrowserCommand(async () => {
- const status = await browserStatus(baseUrl, {
- profile: parent?.browserProfile,
- });
+ const status = await callBrowserRequest(
+ parent,
+ {
+ method: "GET",
+ path: "/",
+ query: parent?.browserProfile ? { profile: parent.browserProfile } : undefined,
+ },
+ {
+ timeoutMs: 1500,
+ },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
@@ -54,7 +52,6 @@ export function registerBrowserManageCommands(
`profile: ${status.profile ?? "clawd"}`,
`enabled: ${status.enabled}`,
`running: ${status.running}`,
- `controlUrl: ${status.controlUrl}`,
`cdpPort: ${status.cdpPort}`,
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
`browser: ${status.chosenBrowser ?? "unknown"}`,
@@ -72,11 +69,26 @@ export function registerBrowserManageCommands(
.description("Start the browser (no-op if already running)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
- await browserStart(baseUrl, { profile });
- const status = await browserStatus(baseUrl, { profile });
+ await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/start",
+ query: profile ? { profile } : undefined,
+ },
+ { timeoutMs: 15000 },
+ );
+ const status = await callBrowserRequest(
+ parent,
+ {
+ method: "GET",
+ path: "/",
+ query: profile ? { profile } : undefined,
+ },
+ { timeoutMs: 1500 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
@@ -91,11 +103,26 @@ export function registerBrowserManageCommands(
.description("Stop the browser (best-effort)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
- await browserStop(baseUrl, { profile });
- const status = await browserStatus(baseUrl, { profile });
+ await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/stop",
+ query: profile ? { profile } : undefined,
+ },
+ { timeoutMs: 15000 },
+ );
+ const status = await callBrowserRequest(
+ parent,
+ {
+ method: "GET",
+ path: "/",
+ query: profile ? { profile } : undefined,
+ },
+ { timeoutMs: 1500 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
@@ -110,10 +137,17 @@ export function registerBrowserManageCommands(
.description("Reset browser profile (moves it to Trash)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
- const result = await browserResetProfile(baseUrl, { profile });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/reset-profile",
+ query: profile ? { profile } : undefined,
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -132,10 +166,18 @@ export function registerBrowserManageCommands(
.description("List open tabs")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
- const tabs = await browserTabs(baseUrl, { profile });
+ const result = await callBrowserRequest<{ running: boolean; tabs: BrowserTab[] }>(
+ parent,
+ {
+ method: "GET",
+ path: "/tabs",
+ query: profile ? { profile } : undefined,
+ },
+ { timeoutMs: 3000 },
+ );
+ const tabs = result.tabs ?? [];
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
return;
@@ -159,13 +201,20 @@ export function registerBrowserManageCommands(
.description("Tab shortcuts (index-based)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
- const result = (await browserTabAction(baseUrl, {
- action: "list",
- profile,
- })) as { ok: true; tabs: BrowserTab[] };
+ const result = await callBrowserRequest<{ ok: true; tabs: BrowserTab[] }>(
+ parent,
+ {
+ method: "POST",
+ path: "/tabs/action",
+ query: profile ? { profile } : undefined,
+ body: {
+ action: "list",
+ },
+ },
+ { timeoutMs: 10_000 },
+ );
const tabs = result.tabs ?? [];
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
@@ -190,13 +239,18 @@ export function registerBrowserManageCommands(
.description("Open a new tab (about:blank)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
- const result = await browserTabAction(baseUrl, {
- action: "new",
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/tabs/action",
+ query: profile ? { profile } : undefined,
+ body: { action: "new" },
+ },
+ { timeoutMs: 10_000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -211,7 +265,6 @@ export function registerBrowserManageCommands(
.argument("", "Tab index (1-based)", (v: string) => Number(v))
.action(async (index: number, _opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
if (!Number.isFinite(index) || index < 1) {
defaultRuntime.error(danger("index must be a positive number"));
@@ -219,11 +272,16 @@ export function registerBrowserManageCommands(
return;
}
await runBrowserCommand(async () => {
- const result = await browserTabAction(baseUrl, {
- action: "select",
- index: Math.floor(index) - 1,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/tabs/action",
+ query: profile ? { profile } : undefined,
+ body: { action: "select", index: Math.floor(index) - 1 },
+ },
+ { timeoutMs: 10_000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -238,7 +296,6 @@ export function registerBrowserManageCommands(
.argument("[index]", "Tab index (1-based)", (v: string) => Number(v))
.action(async (index: number | undefined, _opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
const idx =
typeof index === "number" && Number.isFinite(index) ? Math.floor(index) - 1 : undefined;
@@ -248,11 +305,16 @@ export function registerBrowserManageCommands(
return;
}
await runBrowserCommand(async () => {
- const result = await browserTabAction(baseUrl, {
- action: "close",
- index: idx,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/tabs/action",
+ query: profile ? { profile } : undefined,
+ body: { action: "close", index: idx },
+ },
+ { timeoutMs: 10_000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -267,10 +329,18 @@ export function registerBrowserManageCommands(
.argument("", "URL to open")
.action(async (url: string, _opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
- const tab = await browserOpenTab(baseUrl, url, { profile });
+ const tab = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/tabs/open",
+ query: profile ? { profile } : undefined,
+ body: { url },
+ },
+ { timeoutMs: 15000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(tab, null, 2));
return;
@@ -285,10 +355,18 @@ export function registerBrowserManageCommands(
.argument("", "Target id or unique prefix")
.action(async (targetId: string, _opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
- await browserFocusTab(baseUrl, targetId, { profile });
+ await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/tabs/focus",
+ query: profile ? { profile } : undefined,
+ body: { targetId },
+ },
+ { timeoutMs: 5000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
return;
@@ -303,13 +381,29 @@ export function registerBrowserManageCommands(
.argument("[targetId]", "Target id or unique prefix (optional)")
.action(async (targetId: string | undefined, _opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
if (targetId?.trim()) {
- await browserCloseTab(baseUrl, targetId.trim(), { profile });
+ await callBrowserRequest(
+ parent,
+ {
+ method: "DELETE",
+ path: `/tabs/${encodeURIComponent(targetId.trim())}`,
+ query: profile ? { profile } : undefined,
+ },
+ { timeoutMs: 5000 },
+ );
} else {
- await browserAct(baseUrl, { kind: "close" }, { profile });
+ await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/act",
+ query: profile ? { profile } : undefined,
+ body: { kind: "close" },
+ },
+ { timeoutMs: 20000 },
+ );
}
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
@@ -325,9 +419,16 @@ export function registerBrowserManageCommands(
.description("List all browser profiles")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
await runBrowserCommand(async () => {
- const profiles = await browserProfiles(baseUrl);
+ const result = await callBrowserRequest<{ profiles: ProfileStatus[] }>(
+ parent,
+ {
+ method: "GET",
+ path: "/profiles",
+ },
+ { timeoutMs: 3000 },
+ );
+ const profiles = result.profiles ?? [];
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ profiles }, null, 2));
return;
@@ -361,14 +462,21 @@ export function registerBrowserManageCommands(
.action(
async (opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
await runBrowserCommand(async () => {
- const result = await browserCreateProfile(baseUrl, {
- name: opts.name,
- color: opts.color,
- cdpUrl: opts.cdpUrl,
- driver: opts.driver === "extension" ? "extension" : undefined,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/profiles/create",
+ body: {
+ name: opts.name,
+ color: opts.color,
+ cdpUrl: opts.cdpUrl,
+ driver: opts.driver === "extension" ? "extension" : undefined,
+ },
+ },
+ { timeoutMs: 10_000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -391,9 +499,15 @@ export function registerBrowserManageCommands(
.requiredOption("--name ", "Profile name to delete")
.action(async (opts: { name: string }, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
await runBrowserCommand(async () => {
- const result = await browserDeleteProfile(baseUrl, opts.name);
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "DELETE",
+ path: `/profiles/${encodeURIComponent(opts.name)}`,
+ },
+ { timeoutMs: 20_000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
diff --git a/src/cli/browser-cli-serve.ts b/src/cli/browser-cli-serve.ts
deleted file mode 100644
index 179424ed1..000000000
--- a/src/cli/browser-cli-serve.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import type { Command } from "commander";
-
-import { loadConfig } from "../config/config.js";
-import { danger, info } from "../globals.js";
-import { defaultRuntime } from "../runtime.js";
-import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
-import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../browser/bridge-server.js";
-import { ensureChromeExtensionRelayServer } from "../browser/extension-relay.js";
-
-function isLoopbackBindHost(host: string) {
- const h = host.trim().toLowerCase();
- return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]";
-}
-
-function parsePort(raw: unknown): number | null {
- const v = typeof raw === "string" ? raw.trim() : "";
- if (!v) return null;
- const n = Number.parseInt(v, 10);
- if (!Number.isFinite(n) || n < 0 || n > 65535) return null;
- return n;
-}
-
-export function registerBrowserServeCommands(
- browser: Command,
- _parentOpts: (cmd: Command) => unknown,
-) {
- browser
- .command("serve")
- .description("Run a standalone browser control server (for remote gateways)")
- .option("--bind ", "Bind host (default: 127.0.0.1)")
- .option("--port ", "Bind port (default: from browser.controlUrl)")
- .option(
- "--token ",
- "Require Authorization: Bearer (required when binding non-loopback)",
- )
- .action(async (opts: { bind?: string; port?: string; token?: string }) => {
- const cfg = loadConfig();
- const resolved = resolveBrowserConfig(cfg.browser);
- if (!resolved.enabled) {
- defaultRuntime.error(
- danger("Browser control is disabled. Set browser.enabled=true and try again."),
- );
- defaultRuntime.exit(1);
- }
-
- const host = (opts.bind ?? "127.0.0.1").trim();
- const port = parsePort(opts.port) ?? resolved.controlPort;
-
- const envToken = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
- const authToken = (opts.token ?? envToken ?? resolved.controlToken)?.trim();
- if (!isLoopbackBindHost(host) && !authToken) {
- defaultRuntime.error(
- danger(
- `Refusing to bind browser control on ${host} without --token (or CLAWDBOT_BROWSER_CONTROL_TOKEN, or browser.controlToken).`,
- ),
- );
- defaultRuntime.exit(1);
- }
-
- const bridge = await startBrowserBridgeServer({
- resolved,
- host,
- port,
- ...(authToken ? { authToken } : {}),
- });
-
- // If any profile uses the Chrome extension relay, start the local relay server eagerly
- // so the extension can connect before the first browser action.
- for (const name of Object.keys(resolved.profiles)) {
- const profile = resolveProfile(resolved, name);
- if (!profile || profile.driver !== "extension") continue;
- await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => {
- defaultRuntime.error(
- danger(`Chrome extension relay init failed for profile "${name}": ${String(err)}`),
- );
- });
- }
-
- defaultRuntime.log(
- info(
- [
- `🦞 Browser control listening on ${bridge.baseUrl}/`,
- authToken ? "Auth: Bearer token required." : "Auth: off (loopback only).",
- "",
- "Paste on the Gateway (clawdbot.json):",
- JSON.stringify(
- {
- browser: {
- enabled: true,
- controlUrl: bridge.baseUrl,
- ...(authToken ? { controlToken: authToken } : {}),
- },
- },
- null,
- 2,
- ),
- ...(authToken
- ? [
- "",
- "Or use env on the Gateway (instead of controlToken in config):",
- `export CLAWDBOT_BROWSER_CONTROL_TOKEN=${JSON.stringify(authToken)}`,
- ]
- : []),
- ].join("\n"),
- ),
- );
-
- let shuttingDown = false;
- const shutdown = async (signal: string) => {
- if (shuttingDown) return;
- shuttingDown = true;
- defaultRuntime.log(info(`Shutting down (${signal})...`));
- await stopBrowserBridgeServer(bridge.server).catch(() => {});
- process.exit(0);
- };
- process.once("SIGINT", () => void shutdown("SIGINT"));
- process.once("SIGTERM", () => void shutdown("SIGTERM"));
-
- await new Promise(() => {});
- });
-}
diff --git a/src/cli/browser-cli-shared.ts b/src/cli/browser-cli-shared.ts
index 2e110f186..aa78eff27 100644
--- a/src/cli/browser-cli-shared.ts
+++ b/src/cli/browser-cli-shared.ts
@@ -1,5 +1,58 @@
-export type BrowserParentOpts = {
- url?: string;
+import type { GatewayRpcOpts } from "./gateway-rpc.js";
+import { callGatewayFromCli } from "./gateway-rpc.js";
+
+export type BrowserParentOpts = GatewayRpcOpts & {
json?: boolean;
browserProfile?: string;
};
+
+type BrowserRequestParams = {
+ method: "GET" | "POST" | "DELETE";
+ path: string;
+ query?: Record;
+ body?: unknown;
+};
+
+function normalizeQuery(query: BrowserRequestParams["query"]): Record | undefined {
+ if (!query) return undefined;
+ const out: Record = {};
+ for (const [key, value] of Object.entries(query)) {
+ if (value === undefined) continue;
+ out[key] = String(value);
+ }
+ return Object.keys(out).length ? out : undefined;
+}
+
+export async function callBrowserRequest(
+ opts: BrowserParentOpts,
+ params: BrowserRequestParams,
+ extra?: { timeoutMs?: number; progress?: boolean },
+): Promise {
+ const resolvedTimeoutMs =
+ typeof extra?.timeoutMs === "number" && Number.isFinite(extra.timeoutMs)
+ ? Math.max(1, Math.floor(extra.timeoutMs))
+ : typeof opts.timeout === "string"
+ ? Number.parseInt(opts.timeout, 10)
+ : undefined;
+ const resolvedTimeout =
+ typeof resolvedTimeoutMs === "number" && Number.isFinite(resolvedTimeoutMs)
+ ? resolvedTimeoutMs
+ : undefined;
+ const timeout = typeof resolvedTimeout === "number" ? String(resolvedTimeout) : opts.timeout;
+ const payload = await callGatewayFromCli(
+ "browser.request",
+ { ...opts, timeout },
+ {
+ method: params.method,
+ path: params.path,
+ query: normalizeQuery(params.query),
+ body: params.body,
+ timeoutMs: resolvedTimeout,
+ },
+ { progress: extra?.progress },
+ );
+ if (payload === undefined) {
+ throw new Error("Unexpected browser.request response");
+ }
+ return payload as T;
+}
diff --git a/src/cli/browser-cli-state.cookies-storage.ts b/src/cli/browser-cli-state.cookies-storage.ts
index 2cabdec37..a9db259cf 100644
--- a/src/cli/browser-cli-state.cookies-storage.ts
+++ b/src/cli/browser-cli-state.cookies-storage.ts
@@ -1,17 +1,8 @@
import type { Command } from "commander";
-import { resolveBrowserControlUrl } from "../browser/client.js";
-import {
- browserCookies,
- browserCookiesClear,
- browserCookiesSet,
- browserStorageClear,
- browserStorageGet,
- browserStorageSet,
-} from "../browser/client-actions.js";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
-import type { BrowserParentOpts } from "./browser-cli-shared.js";
+import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
export function registerBrowserCookiesAndStorageCommands(
browser: Command,
@@ -23,13 +14,20 @@ export function registerBrowserCookiesAndStorageCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
- const result = await browserCookies(baseUrl, {
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest<{ cookies?: unknown[] }>(
+ parent,
+ {
+ method: "GET",
+ path: "/cookies",
+ query: {
+ targetId: opts.targetId?.trim() || undefined,
+ profile,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -50,14 +48,21 @@ export function registerBrowserCookiesAndStorageCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (name: string, value: string, opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
- const result = await browserCookiesSet(baseUrl, {
- targetId: opts.targetId?.trim() || undefined,
- cookie: { name, value, url: opts.url },
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/cookies/set",
+ query: profile ? { profile } : undefined,
+ body: {
+ targetId: opts.targetId?.trim() || undefined,
+ cookie: { name, value, url: opts.url },
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -75,13 +80,20 @@ export function registerBrowserCookiesAndStorageCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
- const result = await browserCookiesClear(baseUrl, {
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/cookies/clear",
+ query: profile ? { profile } : undefined,
+ body: {
+ targetId: opts.targetId?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -105,15 +117,21 @@ export function registerBrowserCookiesAndStorageCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (key: string | undefined, opts, cmd2) => {
const parent = parentOpts(cmd2);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
- const result = await browserStorageGet(baseUrl, {
- kind,
- key: key?.trim() || undefined,
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest<{ values?: Record }>(
+ parent,
+ {
+ method: "GET",
+ path: `/storage/${kind}`,
+ query: {
+ key: key?.trim() || undefined,
+ targetId: opts.targetId?.trim() || undefined,
+ profile,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -133,16 +151,22 @@ export function registerBrowserCookiesAndStorageCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (key: string, value: string, opts, cmd2) => {
const parent = parentOpts(cmd2);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
- const result = await browserStorageSet(baseUrl, {
- kind,
- key,
- value,
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: `/storage/${kind}/set`,
+ query: profile ? { profile } : undefined,
+ body: {
+ key,
+ value,
+ targetId: opts.targetId?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -160,14 +184,20 @@ export function registerBrowserCookiesAndStorageCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (opts, cmd2) => {
const parent = parentOpts(cmd2);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
- const result = await browserStorageClear(baseUrl, {
- kind,
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: `/storage/${kind}/clear`,
+ query: profile ? { profile } : undefined,
+ body: {
+ targetId: opts.targetId?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
diff --git a/src/cli/browser-cli-state.ts b/src/cli/browser-cli-state.ts
index ea98901e1..335f9547a 100644
--- a/src/cli/browser-cli-state.ts
+++ b/src/cli/browser-cli-state.ts
@@ -1,21 +1,9 @@
import type { Command } from "commander";
-import { resolveBrowserControlUrl } from "../browser/client.js";
-import {
- browserSetDevice,
- browserSetGeolocation,
- browserSetHeaders,
- browserSetHttpCredentials,
- browserSetLocale,
- browserSetMedia,
- browserSetOffline,
- browserSetTimezone,
-} from "../browser/client-actions.js";
-import { browserAct } from "../browser/client-actions-core.js";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { parseBooleanValue } from "../utils/boolean.js";
-import type { BrowserParentOpts } from "./browser-cli-shared.js";
+import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js";
import { runCommandWithRuntime } from "./cli-utils.js";
@@ -47,7 +35,6 @@ export function registerBrowserStateCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (width: number, height: number, opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
if (!Number.isFinite(width) || !Number.isFinite(height)) {
defaultRuntime.error(danger("width and height must be numbers"));
@@ -55,15 +42,20 @@ export function registerBrowserStateCommands(
return;
}
await runBrowserCommand(async () => {
- const result = await browserAct(
- baseUrl,
+ const result = await callBrowserRequest(
+ parent,
{
- kind: "resize",
- width,
- height,
- targetId: opts.targetId?.trim() || undefined,
+ method: "POST",
+ path: "/act",
+ query: profile ? { profile } : undefined,
+ body: {
+ kind: "resize",
+ width,
+ height,
+ targetId: opts.targetId?.trim() || undefined,
+ },
},
- { profile },
+ { timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -80,7 +72,6 @@ export function registerBrowserStateCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (value: string, opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
const offline = parseOnOff(value);
if (offline === null) {
@@ -89,11 +80,19 @@ export function registerBrowserStateCommands(
return;
}
await runBrowserCommand(async () => {
- const result = await browserSetOffline(baseUrl, {
- offline,
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/set/offline",
+ query: profile ? { profile } : undefined,
+ body: {
+ offline,
+ targetId: opts.targetId?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -109,7 +108,6 @@ export function registerBrowserStateCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const parsed = JSON.parse(String(opts.json)) as unknown;
@@ -120,11 +118,19 @@ export function registerBrowserStateCommands(
for (const [k, v] of Object.entries(parsed as Record)) {
if (typeof v === "string") headers[k] = v;
}
- const result = await browserSetHeaders(baseUrl, {
- headers,
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/set/headers",
+ query: profile ? { profile } : undefined,
+ body: {
+ headers,
+ targetId: opts.targetId?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -142,16 +148,23 @@ export function registerBrowserStateCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (username: string | undefined, password: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
- const result = await browserSetHttpCredentials(baseUrl, {
- username: username?.trim() || undefined,
- password,
- clear: Boolean(opts.clear),
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/set/credentials",
+ query: profile ? { profile } : undefined,
+ body: {
+ username: username?.trim() || undefined,
+ password,
+ clear: Boolean(opts.clear),
+ targetId: opts.targetId?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -171,18 +184,25 @@ export function registerBrowserStateCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (latitude: number | undefined, longitude: number | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
- const result = await browserSetGeolocation(baseUrl, {
- latitude: Number.isFinite(latitude) ? latitude : undefined,
- longitude: Number.isFinite(longitude) ? longitude : undefined,
- accuracy: Number.isFinite(opts.accuracy) ? opts.accuracy : undefined,
- origin: opts.origin?.trim() || undefined,
- clear: Boolean(opts.clear),
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/set/geolocation",
+ query: profile ? { profile } : undefined,
+ body: {
+ latitude: Number.isFinite(latitude) ? latitude : undefined,
+ longitude: Number.isFinite(longitude) ? longitude : undefined,
+ accuracy: Number.isFinite(opts.accuracy) ? opts.accuracy : undefined,
+ origin: opts.origin?.trim() || undefined,
+ clear: Boolean(opts.clear),
+ targetId: opts.targetId?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -198,7 +218,6 @@ export function registerBrowserStateCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (value: string, opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
const v = value.trim().toLowerCase();
const colorScheme =
@@ -209,11 +228,19 @@ export function registerBrowserStateCommands(
return;
}
await runBrowserCommand(async () => {
- const result = await browserSetMedia(baseUrl, {
- colorScheme,
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/set/media",
+ query: profile ? { profile } : undefined,
+ body: {
+ colorScheme,
+ targetId: opts.targetId?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -229,14 +256,21 @@ export function registerBrowserStateCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (timezoneId: string, opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
- const result = await browserSetTimezone(baseUrl, {
- timezoneId,
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/set/timezone",
+ query: profile ? { profile } : undefined,
+ body: {
+ timezoneId,
+ targetId: opts.targetId?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -252,14 +286,21 @@ export function registerBrowserStateCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (locale: string, opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
- const result = await browserSetLocale(baseUrl, {
- locale,
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/set/locale",
+ query: profile ? { profile } : undefined,
+ body: {
+ locale,
+ targetId: opts.targetId?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -275,14 +316,21 @@ export function registerBrowserStateCommands(
.option("--target-id ", "CDP target id (or unique prefix)")
.action(async (name: string, opts, cmd) => {
const parent = parentOpts(cmd);
- const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
- const result = await browserSetDevice(baseUrl, {
- name,
- targetId: opts.targetId?.trim() || undefined,
- profile,
- });
+ const result = await callBrowserRequest(
+ parent,
+ {
+ method: "POST",
+ path: "/set/device",
+ query: profile ? { profile } : undefined,
+ body: {
+ name,
+ targetId: opts.targetId?.trim() || undefined,
+ },
+ },
+ { timeoutMs: 20000 },
+ );
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
diff --git a/src/cli/browser-cli.ts b/src/cli/browser-cli.ts
index b3b456dea..50c71ef01 100644
--- a/src/cli/browser-cli.ts
+++ b/src/cli/browser-cli.ts
@@ -13,15 +13,14 @@ import { browserActionExamples, browserCoreExamples } from "./browser-cli-exampl
import { registerBrowserExtensionCommands } from "./browser-cli-extension.js";
import { registerBrowserInspectCommands } from "./browser-cli-inspect.js";
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
-import { registerBrowserServeCommands } from "./browser-cli-serve.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { registerBrowserStateCommands } from "./browser-cli-state.js";
+import { addGatewayClientOptions } from "./gateway-rpc.js";
export function registerBrowserCli(program: Command) {
const browser = program
.command("browser")
.description("Manage clawd's dedicated browser (Chrome/Chromium)")
- .option("--url ", "Override browser control URL (default from ~/.clawdbot/clawdbot.json)")
.option("--browser-profile ", "Browser profile name (default from config)")
.option("--json", "Output machine-readable JSON", false)
.addHelpText(
@@ -43,11 +42,12 @@ export function registerBrowserCli(program: Command) {
defaultRuntime.exit(1);
});
+ addGatewayClientOptions(browser);
+
const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts;
registerBrowserManageCommands(browser, parentOpts);
registerBrowserExtensionCommands(browser, parentOpts);
- registerBrowserServeCommands(browser, parentOpts);
registerBrowserInspectCommands(browser, parentOpts);
registerBrowserActionInputCommands(browser, parentOpts);
registerBrowserActionObserveCommands(browser, parentOpts);
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 3261b5170..f05ac277c 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -279,7 +279,6 @@ const FIELD_LABELS: Record = {
"ui.seamColor": "Accent Color",
"ui.assistant.name": "Assistant Name",
"ui.assistant.avatar": "Assistant Avatar",
- "browser.controlUrl": "Browser Control URL",
"browser.snapshotDefaults": "Browser Snapshot Defaults",
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts
index 1c39c050a..cbd006589 100644
--- a/src/config/types.browser.ts
+++ b/src/config/types.browser.ts
@@ -14,16 +14,7 @@ export type BrowserSnapshotDefaults = {
};
export type BrowserConfig = {
enabled?: boolean;
- /** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
- controlUrl?: string;
- /**
- * Shared token for the browser control server.
- * If set, clients must send `Authorization: Bearer `.
- *
- * Prefer `CLAWDBOT_BROWSER_CONTROL_TOKEN` env for ephemeral setups; use this for "works after reboot".
- */
- controlToken?: string;
- /** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */
+ /** Base URL of the CDP endpoint (for remote browsers). Default: loopback CDP on the derived port. */
cdpUrl?: string;
/** Remote CDP HTTP timeout (ms). Default: 1500. */
remoteCdpTimeoutMs?: number;
diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts
index c7566260f..4f5f83810 100644
--- a/src/config/types.sandbox.ts
+++ b/src/config/types.sandbox.ts
@@ -58,21 +58,6 @@ export type SandboxBrowserSettings = {
* Default: false.
*/
allowHostControl?: boolean;
- /**
- * Allowlist of exact control URLs for target="custom".
- * When set, any custom controlUrl must match this list.
- */
- allowedControlUrls?: string[];
- /**
- * Allowlist of hostnames for control URLs (hostname only, no ports).
- * When set, controlUrl hostname must match.
- */
- allowedControlHosts?: string[];
- /**
- * Allowlist of ports for control URLs.
- * When set, controlUrl port must match (defaults: http=80, https=443).
- */
- allowedControlPorts?: number[];
/**
* When true (default), sandboxed browser control will try to start/reattach to
* the sandbox browser container when a tool call needs it.
diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts
index b5a03a3ea..7a63e307d 100644
--- a/src/config/zod-schema.agent-runtime.ts
+++ b/src/config/zod-schema.agent-runtime.ts
@@ -130,9 +130,6 @@ export const SandboxBrowserSchema = z
headless: z.boolean().optional(),
enableNoVnc: z.boolean().optional(),
allowHostControl: z.boolean().optional(),
- allowedControlUrls: z.array(z.string()).optional(),
- allowedControlHosts: z.array(z.string()).optional(),
- allowedControlPorts: z.array(z.number().int().positive()).optional(),
autoStart: z.boolean().optional(),
autoStartTimeoutMs: z.number().int().positive().optional(),
})
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index f39b001fa..75f438c1e 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -134,8 +134,6 @@ export const ClawdbotSchema = z
browser: z
.object({
enabled: z.boolean().optional(),
- controlUrl: z.string().optional(),
- controlToken: z.string().optional(),
cdpUrl: z.string().optional(),
remoteCdpTimeoutMs: z.number().int().nonnegative().optional(),
remoteCdpHandshakeTimeoutMs: z.number().int().nonnegative().optional(),
diff --git a/src/gateway/server-browser.ts b/src/gateway/server-browser.ts
index 35cf02af1..f525348bb 100644
--- a/src/gateway/server-browser.ts
+++ b/src/gateway/server-browser.ts
@@ -9,7 +9,19 @@ export async function startBrowserControlServerIfEnabled(): Promise Promise })
+ .startBrowserControlServiceFromConfig
+ : (mod as { startBrowserControlServerFromConfig?: () => Promise })
+ .startBrowserControlServerFromConfig;
+ const stop =
+ typeof (mod as { stopBrowserControlService?: unknown }).stopBrowserControlService === "function"
+ ? (mod as { stopBrowserControlService: () => Promise }).stopBrowserControlService
+ : (mod as { stopBrowserControlServer?: () => Promise }).stopBrowserControlServer;
+ if (!start) return null;
+ await start();
+ return { stop: stop ?? (async () => {}) };
}
diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts
index 28ee5be54..098384d44 100644
--- a/src/gateway/server-methods-list.ts
+++ b/src/gateway/server-methods-list.ts
@@ -77,6 +77,7 @@ const BASE_METHODS = [
"agent",
"agent.identity.get",
"agent.wait",
+ "browser.request",
// WebChat WebSocket-native chat methods
"chat.history",
"chat.abort",
diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts
index 48bf32b59..66492b976 100644
--- a/src/gateway/server-methods.ts
+++ b/src/gateway/server-methods.ts
@@ -1,6 +1,7 @@
import { ErrorCodes, errorShape } from "./protocol/index.js";
import { agentHandlers } from "./server-methods/agent.js";
import { agentsHandlers } from "./server-methods/agents.js";
+import { browserHandlers } from "./server-methods/browser.js";
import { channelsHandlers } from "./server-methods/channels.js";
import { chatHandlers } from "./server-methods/chat.js";
import { configHandlers } from "./server-methods/config.js";
@@ -86,6 +87,7 @@ const WRITE_METHODS = new Set([
"node.invoke",
"chat.send",
"chat.abort",
+ "browser.request",
]);
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
@@ -168,6 +170,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
...usageHandlers,
...agentHandlers,
...agentsHandlers,
+ ...browserHandlers,
};
export async function handleGatewayRequest(
diff --git a/src/gateway/server-methods/browser.ts b/src/gateway/server-methods/browser.ts
new file mode 100644
index 000000000..16811fbcf
--- /dev/null
+++ b/src/gateway/server-methods/browser.ts
@@ -0,0 +1,253 @@
+import crypto from "node:crypto";
+import {
+ createBrowserControlContext,
+ startBrowserControlServiceFromConfig,
+} from "../../browser/control-service.js";
+import { createBrowserRouteDispatcher } from "../../browser/routes/dispatcher.js";
+import { loadConfig } from "../../config/config.js";
+import { saveMediaBuffer } from "../../media/store.js";
+import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
+import type { NodeSession } from "../node-registry.js";
+import { ErrorCodes, errorShape } from "../protocol/index.js";
+import { safeParseJson } from "./nodes.helpers.js";
+import type { GatewayRequestHandlers } from "./types.js";
+
+type BrowserRequestParams = {
+ method?: string;
+ path?: string;
+ query?: Record;
+ body?: unknown;
+ timeoutMs?: number;
+};
+
+type BrowserProxyFile = {
+ path: string;
+ base64: string;
+ mimeType?: string;
+};
+
+type BrowserProxyResult = {
+ result: unknown;
+ files?: BrowserProxyFile[];
+};
+
+function isBrowserNode(node: NodeSession) {
+ const caps = Array.isArray(node.caps) ? node.caps : [];
+ const commands = Array.isArray(node.commands) ? node.commands : [];
+ return caps.includes("browser") || commands.includes("browser.proxy");
+}
+
+function normalizeNodeKey(value: string) {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "");
+}
+
+function resolveBrowserNode(nodes: NodeSession[], query: string): NodeSession | null {
+ const q = query.trim();
+ if (!q) return null;
+ const qNorm = normalizeNodeKey(q);
+ const matches = nodes.filter((node) => {
+ if (node.nodeId === q) return true;
+ if (typeof node.remoteIp === "string" && node.remoteIp === q) return true;
+ const name = typeof node.displayName === "string" ? node.displayName : "";
+ if (name && normalizeNodeKey(name) === qNorm) return true;
+ if (q.length >= 6 && node.nodeId.startsWith(q)) return true;
+ return false;
+ });
+ if (matches.length === 1) return matches[0] ?? null;
+ if (matches.length === 0) return null;
+ throw new Error(
+ `ambiguous node: ${q} (matches: ${matches
+ .map((node) => node.displayName || node.remoteIp || node.nodeId)
+ .join(", ")})`,
+ );
+}
+
+function resolveBrowserNodeTarget(params: {
+ cfg: ReturnType;
+ nodes: NodeSession[];
+}): NodeSession | null {
+ const policy = params.cfg.gateway?.nodes?.browser;
+ const mode = policy?.mode ?? "auto";
+ if (mode === "off") return null;
+ const browserNodes = params.nodes.filter((node) => isBrowserNode(node));
+ if (browserNodes.length === 0) {
+ if (policy?.node?.trim()) {
+ throw new Error("No connected browser-capable nodes.");
+ }
+ return null;
+ }
+ const requested = policy?.node?.trim() || "";
+ if (requested) {
+ const resolved = resolveBrowserNode(browserNodes, requested);
+ if (!resolved) {
+ throw new Error(`Configured browser node not connected: ${requested}`);
+ }
+ return resolved;
+ }
+ if (mode === "manual") return null;
+ if (browserNodes.length === 1) return browserNodes[0] ?? null;
+ return null;
+}
+
+async function persistProxyFiles(files: BrowserProxyFile[] | undefined) {
+ if (!files || files.length === 0) return new Map();
+ const mapping = new Map();
+ for (const file of files) {
+ const buffer = Buffer.from(file.base64, "base64");
+ const saved = await saveMediaBuffer(buffer, file.mimeType, "browser", buffer.byteLength);
+ mapping.set(file.path, saved.path);
+ }
+ return mapping;
+}
+
+function applyProxyPaths(result: unknown, mapping: Map) {
+ if (!result || typeof result !== "object") return;
+ const obj = result as Record;
+ if (typeof obj.path === "string" && mapping.has(obj.path)) {
+ obj.path = mapping.get(obj.path);
+ }
+ if (typeof obj.imagePath === "string" && mapping.has(obj.imagePath)) {
+ obj.imagePath = mapping.get(obj.imagePath);
+ }
+ const download = obj.download;
+ if (download && typeof download === "object") {
+ const d = download as Record;
+ if (typeof d.path === "string" && mapping.has(d.path)) {
+ d.path = mapping.get(d.path);
+ }
+ }
+}
+
+export const browserHandlers: GatewayRequestHandlers = {
+ "browser.request": async ({ params, respond, context }) => {
+ const typed = params as BrowserRequestParams;
+ const methodRaw = typeof typed.method === "string" ? typed.method.trim().toUpperCase() : "";
+ const path = typeof typed.path === "string" ? typed.path.trim() : "";
+ const query = typed.query && typeof typed.query === "object" ? typed.query : undefined;
+ const body = typed.body;
+ const timeoutMs =
+ typeof typed.timeoutMs === "number" && Number.isFinite(typed.timeoutMs)
+ ? Math.max(1, Math.floor(typed.timeoutMs))
+ : undefined;
+
+ if (!methodRaw || !path) {
+ respond(
+ false,
+ undefined,
+ errorShape(ErrorCodes.INVALID_REQUEST, "method and path are required"),
+ );
+ return;
+ }
+ if (methodRaw !== "GET" && methodRaw !== "POST" && methodRaw !== "DELETE") {
+ respond(
+ false,
+ undefined,
+ errorShape(ErrorCodes.INVALID_REQUEST, "method must be GET, POST, or DELETE"),
+ );
+ return;
+ }
+
+ const cfg = loadConfig();
+ let nodeTarget: NodeSession | null = null;
+ try {
+ nodeTarget = resolveBrowserNodeTarget({
+ cfg,
+ nodes: context.nodeRegistry.listConnected(),
+ });
+ } catch (err) {
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
+ return;
+ }
+
+ if (nodeTarget) {
+ const allowlist = resolveNodeCommandAllowlist(cfg, nodeTarget);
+ const allowed = isNodeCommandAllowed({
+ command: "browser.proxy",
+ declaredCommands: nodeTarget.commands,
+ allowlist,
+ });
+ if (!allowed.ok) {
+ respond(
+ false,
+ undefined,
+ errorShape(ErrorCodes.INVALID_REQUEST, "node command not allowed", {
+ details: { reason: allowed.reason, command: "browser.proxy" },
+ }),
+ );
+ return;
+ }
+
+ const proxyParams = {
+ method: methodRaw,
+ path,
+ query,
+ body,
+ timeoutMs,
+ profile: typeof query?.profile === "string" ? query.profile : undefined,
+ };
+ const res = await context.nodeRegistry.invoke({
+ nodeId: nodeTarget.nodeId,
+ command: "browser.proxy",
+ params: proxyParams,
+ timeoutMs,
+ idempotencyKey: crypto.randomUUID(),
+ });
+ if (!res.ok) {
+ respond(
+ false,
+ undefined,
+ errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
+ details: { nodeError: res.error ?? null },
+ }),
+ );
+ return;
+ }
+ const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
+ const proxy = payload && typeof payload === "object" ? (payload as BrowserProxyResult) : null;
+ if (!proxy || !("result" in proxy)) {
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "browser proxy failed"));
+ return;
+ }
+ const mapping = await persistProxyFiles(proxy.files);
+ applyProxyPaths(proxy.result, mapping);
+ respond(true, proxy.result);
+ return;
+ }
+
+ const ready = await startBrowserControlServiceFromConfig();
+ if (!ready) {
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "browser control is disabled"));
+ return;
+ }
+
+ let dispatcher;
+ try {
+ dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
+ } catch (err) {
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
+ return;
+ }
+
+ const result = await dispatcher.dispatch({
+ method: methodRaw,
+ path,
+ query,
+ body,
+ });
+
+ if (result.status >= 400) {
+ const message =
+ result.body && typeof result.body === "object" && "error" in result.body
+ ? String((result.body as { error?: unknown }).error)
+ : `browser request failed (${result.status})`;
+ const code = result.status >= 500 ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST;
+ respond(false, undefined, errorShape(code, message, { details: result.body }));
+ return;
+ }
+
+ respond(true, result.body);
+ },
+};
diff --git a/src/gateway/server.reload.e2e.test.ts b/src/gateway/server.reload.e2e.test.ts
index 8fe8eece1..b9fc76d98 100644
--- a/src/gateway/server.reload.e2e.test.ts
+++ b/src/gateway/server.reload.e2e.test.ts
@@ -206,7 +206,7 @@ describe("gateway hot reload", () => {
},
cron: { enabled: true, store: "/tmp/cron.json" },
agents: { defaults: { heartbeat: { every: "1m" }, maxConcurrent: 2 } },
- browser: { enabled: true, controlUrl: "http://127.0.0.1:18791" },
+ browser: { enabled: true },
web: { enabled: true },
channels: {
telegram: { botToken: "token" },
diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts
index f76065883..278244a78 100644
--- a/src/node-host/runner.ts
+++ b/src/node-host/runner.ts
@@ -33,7 +33,12 @@ import {
import { getMachineDisplayName } from "../infra/machine-name.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { loadConfig } from "../config/config.js";
-import { resolveBrowserConfig, shouldStartLocalBrowserServer } from "../browser/config.js";
+import { resolveBrowserConfig } from "../browser/config.js";
+import {
+ createBrowserControlContext,
+ startBrowserControlServiceFromConfig,
+} from "../browser/control-service.js";
+import { createBrowserRouteDispatcher } from "../browser/routes/dispatcher.js";
import { detectMime } from "../media/mime.js";
import { resolveAgentConfig } from "../agents/agent-scope.js";
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
@@ -235,23 +240,39 @@ function resolveBrowserProxyConfig() {
let browserControlReady: Promise | null = null;
-async function ensureBrowserControlServer(): Promise {
+async function ensureBrowserControlService(): Promise {
if (browserControlReady) return browserControlReady;
browserControlReady = (async () => {
const cfg = loadConfig();
- const resolved = resolveBrowserConfig(cfg.browser);
+ const resolved = resolveBrowserConfig(cfg.browser, cfg);
if (!resolved.enabled) {
throw new Error("browser control disabled");
}
- if (!shouldStartLocalBrowserServer(resolved)) {
- throw new Error("browser control URL is non-loopback");
- }
- const mod = await import("../browser/server.js");
- await mod.startBrowserControlServerFromConfig();
+ const started = await startBrowserControlServiceFromConfig();
+ if (!started) throw new Error("browser control disabled");
})();
return browserControlReady;
}
+async function withTimeout(promise: Promise, timeoutMs?: number, label?: string): Promise {
+ const resolved =
+ typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
+ ? Math.max(1, Math.floor(timeoutMs))
+ : undefined;
+ if (!resolved) return await promise;
+ let timer: ReturnType | undefined;
+ const timeoutPromise = new Promise((_, reject) => {
+ timer = setTimeout(() => {
+ reject(new Error(`${label ?? "request"} timed out`));
+ }, resolved);
+ });
+ try {
+ return await Promise.race([promise, timeoutPromise]);
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+}
+
function isProfileAllowed(params: { allowProfiles: string[]; profile?: string | null }) {
const { allowProfiles, profile } = params;
if (!allowProfiles.length) return true;
@@ -488,11 +509,8 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise]