refactor: route browser control via gateway/node

This commit is contained in:
Peter Steinberger
2026-01-27 03:23:42 +00:00
parent b151b8d196
commit e7fdccce39
91 changed files with 1909 additions and 1608 deletions

View File

@@ -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.

View File

@@ -384,7 +384,6 @@ Browser control (optional):
{
browser: {
enabled: true,
controlUrl: "http://127.0.0.1:18791",
color: "#FF4500"
}
}

View File

@@ -168,8 +168,7 @@
<h2>Getting started</h2>
<p>
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
Start Clawdbots browser relay on this machine (Gateway or <code>clawdbot browser serve</code>),
then click the toolbar button again.
Start Clawdbots browser relay on this machine (Gateway or node host), then click the toolbar button again.
</p>
<p>
Full guide (install, remote Gateway, security): <a href="https://docs.clawd.bot/tools/chrome-extension" target="_blank" rel="noreferrer">docs.clawd.bot/tools/chrome-extension</a>

View File

@@ -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 <controlUrl>`: override `browser.controlUrl` for this command invocation.
- `--browser-profile <name>`: choose a browser profile (default comes from config).
- `--url <gatewayWsUrl>`: Gateway WebSocket URL (defaults to config).
- `--token <token>`: Gateway token (if required).
- `--timeout <ms>`: request timeout (ms).
- `--browser-profile <name>`: 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 <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)

View File

@@ -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 <controlUrl>`
- `--url`, `--token`, `--timeout`, `--json`
- `--browser-profile <name>`
- `--json`
Manage:
- `browser status`

View File

@@ -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.<name>.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: {

View File

@@ -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).

View File

@@ -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.<name>.cdpPort` per instance.
- Remote Chrome: use `browser.profiles.<name>.cdpUrl` (per profile, per instance).

View File

@@ -117,6 +117,6 @@ Short version: **keep the Gateway loopback-only** unless youre 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).

View File

@@ -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, youll 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 <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 dont 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 (whats 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 dont 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)

View File

@@ -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 <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://<magicdns>/"
}
}
```
And authenticate from the Gateway with the same token (prefer env):
```bash
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<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

View File

@@ -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 doesnt auto-attach).
## Sandboxing and memory
@@ -1479,7 +1480,7 @@ setup is an alwayson 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 adhoc shell access, but nodes are simpler for ongoing agent workflows and
device automation.

View File

@@ -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.<name>.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.<name>.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=<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 `<BROWSERLESS_API_KEY>` 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 <browser-host> --port 18791 --token <token>
```
Then point your Gateway at it:
```json5
{
browser: {
enabled: true,
controlUrl: "http://<browser-host>:18791",
// Option A (recommended): keep token in env on the Gateway
// (avoid writing secrets into config files)
// controlToken: "<token>"
}
}
```
And set the auth token in the Gateway environment:
```bash
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
```
Option B: store the token in the Gateway config instead (same shared token):
```json5
{
browser: {
enabled: true,
controlUrl: "http://<browser-host>:18791",
controlToken: "<token>"
}
}
```
## 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 Gateways 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 its better to keep them separate to reduce blast radius.
### Binding (dont 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 youre already using Tailscale, you have two good options:
1) **Tailnet-only, still HTTP** (transport is encrypted by Tailscale):
- Keep `controlUrl` as `http://…` but ensure its 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 <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.

View File

@@ -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 isnt 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 Gateways 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 isnt 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 <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="<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 dont 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)

View File

@@ -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

View File

@@ -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({

View File

@@ -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,
});

View File

@@ -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: {

View File

@@ -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";

View File

@@ -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");

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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 ??

View File

@@ -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,
};
}

View File

@@ -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;
};

View File

@@ -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."
: "",

View File

@@ -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()),

View File

@@ -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();

View File

@@ -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<BrowserNodeTarget | null> {
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<string, string>) {
}
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<string, unknown>;
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

View File

@@ -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<Server>((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 };
}

View File

@@ -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<BrowserActionTabResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/navigate${q}`, {
return await fetchBrowserJson<BrowserActionTabResult>(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<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/dialog${q}`, {
return await fetchBrowserJson<BrowserActionOk>(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<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/file-chooser${q}`, {
return await fetchBrowserJson<BrowserActionOk>(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<BrowserActResponse> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act${q}`, {
return await fetchBrowserJson<BrowserActResponse>(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<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/screenshot${q}`, {
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/screenshot${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({

View File

@@ -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<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/pdf${q}`, {
return await fetchBrowserJson<BrowserActionPathResult>(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<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/trace/start${q}`, {
return await fetchBrowserJson<BrowserActionTargetOk>(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<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/trace/stop${q}`, {
return await fetchBrowserJson<BrowserActionPathResult>(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<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/highlight${q}`, {
return await fetchBrowserJson<BrowserActionTargetOk>(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({

View File

@@ -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<string, unknown>;
targetId?: string;
@@ -29,7 +35,7 @@ export async function browserCookiesSet(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/set${q}`, {
return await fetchBrowserJson<BrowserActionTargetOk>(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<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/clear${q}`, {
return await fetchBrowserJson<BrowserActionTargetOk>(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<string, string>;
}>(`${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<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(`${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<BrowserActionTargetOk>(
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<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${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<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/offline${q}`, {
return await fetchBrowserJson<BrowserActionTargetOk>(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<string, string>;
targetId?: string;
@@ -132,7 +141,7 @@ export async function browserSetHeaders(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/headers${q}`, {
return await fetchBrowserJson<BrowserActionTargetOk>(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<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(`${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<BrowserActionTargetOk>(
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<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(`${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<BrowserActionTargetOk>(
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<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/media${q}`, {
return await fetchBrowserJson<BrowserActionTargetOk>(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<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/timezone${q}`, {
return await fetchBrowserJson<BrowserActionTargetOk>(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<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/locale${q}`, {
return await fetchBrowserJson<BrowserActionTargetOk>(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<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/device${q}`, {
return await fetchBrowserJson<BrowserActionTargetOk>(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<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/set/geolocation${q}`, {
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/set/geolocation${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, clear: true }),

View File

@@ -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<T>(
url: string,
init: RequestInit & { timeoutMs?: number },
): Promise<T> {
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<T>(
@@ -59,32 +46,58 @@ export async function fetchBrowserJson<T>(
init?: RequestInit & { timeoutMs?: number },
): Promise<T> {
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<T>(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<string, unknown> = {};
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<never>((_, 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;
}

View File

@@ -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,

View File

@@ -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<BrowserStatus> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserStatus>(`${baseUrl}/${q}`, {
return await fetchBrowserJson<BrowserStatus>(withBaseUrl(baseUrl, `/${q}`), {
timeoutMs: 1500,
});
}
export async function browserProfiles(baseUrl: string): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(`${baseUrl}/profiles`, {
timeoutMs: 3000,
});
export async function browserProfiles(baseUrl?: string): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
withBaseUrl(baseUrl, `/profiles`),
{
timeoutMs: 3000,
},
);
return res.profiles ?? [];
}
export async function browserStart(baseUrl: string, opts?: { profile?: string }): Promise<void> {
export async function browserStart(baseUrl?: string, opts?: { profile?: string }): Promise<void> {
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<void> {
export async function browserStop(baseUrl?: string, opts?: { profile?: string }): Promise<void> {
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<BrowserResetProfileResult> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserResetProfileResult>(`${baseUrl}/reset-profile${q}`, {
method: "POST",
timeoutMs: 20000,
});
return await fetchBrowserJson<BrowserResetProfileResult>(
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<BrowserCreateProfileResult> {
return await fetchBrowserJson<BrowserCreateProfileResult>(`${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<BrowserCreateProfileResult>(
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<BrowserDeleteProfileResult> {
return await fetchBrowserJson<BrowserDeleteProfileResult>(
`${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<BrowserTab[]> {
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<BrowserTab> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserTab>(`${baseUrl}/tabs/open${q}`, {
return await fetchBrowserJson<BrowserTab>(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<void> {
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<void> {
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<unknown> {
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<SnapshotResult>(`${baseUrl}/snapshot?${q.toString()}`, {
return await fetchBrowserJson<SnapshotResult>(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), {
timeoutMs: 20000,
});
}

View File

@@ -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" },
},

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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<BrowserServerState | null> {
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<void> {
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
}
}

View File

@@ -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" },
},

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<string, unknown> {
export function readBody(req: BrowserRequest): Record<string, unknown> {
const body = req.body as Record<string, unknown> | 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<PwAiModule | null> {
}
export async function requirePwAi(
res: express.Response,
res: BrowserResponse,
feature: string,
): Promise<PwAiModule | null> {
const mod = await getPwAiModule();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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,

View File

@@ -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<string, unknown>;
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<void>;
};
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<BrowserDispatchResponse> => {
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<string, string> = {};
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 };

View File

@@ -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);

View File

@@ -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);

View File

@@ -0,0 +1,21 @@
export type BrowserRequest = {
params: Record<string, string>;
query: Record<string, unknown>;
body?: unknown;
};
export type BrowserResponse = {
status: (code: number) => BrowserResponse;
json: (body: unknown) => void;
};
export type BrowserRouteHandler = (
req: BrowserRequest,
res: BrowserResponse,
) => void | Promise<void>;
export type BrowserRouteRegistrar = {
get: (path: string, handler: BrowserRouteHandler) => void;
post: (path: string, handler: BrowserRouteHandler) => void;
delete: (path: string, handler: BrowserRouteHandler) => void;
};

View File

@@ -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 });
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -17,7 +17,7 @@ export type ProfileRuntimeState = {
};
export type BrowserServerState = {
server: Server;
server?: Server | null;
port: number;
resolved: ResolvedBrowserConfig;
profiles: Map<string, ProfileRuntimeState>;

View File

@@ -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();
});

View File

@@ -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();
});

View File

@@ -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();
});

View File

@@ -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();
});

View File

@@ -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();
});

View File

@@ -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",

View File

@@ -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<BrowserServ
if (state) return state;
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser);
const resolved = resolveBrowserConfig(cfg.browser, cfg);
if (!resolved.enabled) return null;
if (!shouldStartLocalBrowserServer(resolved)) {
logServer.info(
`browser control URL is non-loopback (${resolved.controlUrl}); skipping local server start`,
);
return null;
}
const app = express();
app.use(express.json({ limit: "1mb" }));
const ctx = createBrowserRouteContext({
getState: () => state,
});
registerBrowserRoutes(app, ctx);
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
const port = resolved.controlPort;
const server = await new Promise<Server>((resolve, reject) => {
@@ -89,9 +83,11 @@ export async function stopBrowserControlServer(): Promise<void> {
logServer.warn(`clawd browser stop failed: ${String(err)}`);
}
await new Promise<void>((resolve) => {
current.server.close(() => resolve());
});
if (current.server) {
await new Promise<void>((resolve) => {
current.server?.close(() => resolve());
});
}
state = null;
// Optional: Playwright is not always available (e.g. embedded gateway builds).

View File

@@ -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 <left|right|middle>", "Mouse button to use")
.option("--modifiers <list>", "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 <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>", "Key to press (e.g. Enter)")
.option("--target-id <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>", "Ref id from snapshot")
.option("--target-id <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("<endRef>", "End ref id")
.option("--target-id <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("<values...>", "Option values to select")
.option("--target-id <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;

View File

@@ -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;

View File

@@ -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 <path>", "Read JSON array from a file")
.option("--target-id <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 <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 <id>", "Ref from snapshot")
.option("--target-id <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;

View File

@@ -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>", "URL to navigate to")
.option("--target-id <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("<height>", "Viewport height", (v: string) => Number(v))
.option("--target-id <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));

View File

@@ -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<T = unknown>(params: {
parent: BrowserParentOpts;
profile?: string;
body: Record<string, unknown>;
timeoutMs?: number;
}): Promise<T> {
return await callBrowserRequest<T>(
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) {

View File

@@ -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 <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 <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;

View File

@@ -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 <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 <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 <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 <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;

View File

@@ -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 <png|jpeg>", "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 <path>", "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<string, string | number | boolean | undefined> = {
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<SnapshotResult>(
parent,
{
method: "GET",
path: "/snapshot",
query,
},
{ timeoutMs: 20000 },
);
if (opts.out) {
const fs = await import("node:fs/promises");

View File

@@ -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<void>) {
@@ -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<BrowserStatus>(
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<BrowserStatus>(
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<BrowserStatus>(
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<BrowserResetProfileResult>(
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("<index>", "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>", "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<BrowserTab>(
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("<targetId>", "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<BrowserCreateProfileResult>(
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 <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<BrowserDeleteProfileResult>(
parent,
{
method: "DELETE",
path: `/profiles/${encodeURIComponent(opts.name)}`,
},
{ timeoutMs: 20_000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;

View File

@@ -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 <host>", "Bind host (default: 127.0.0.1)")
.option("--port <port>", "Bind port (default: from browser.controlUrl)")
.option(
"--token <token>",
"Require Authorization: Bearer <token> (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(() => {});
});
}

View File

@@ -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<string, string | number | boolean | undefined>;
body?: unknown;
};
function normalizeQuery(query: BrowserRequestParams["query"]): Record<string, string> | undefined {
if (!query) return undefined;
const out: Record<string, string> = {};
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<T>(
opts: BrowserParentOpts,
params: BrowserRequestParams,
extra?: { timeoutMs?: number; progress?: boolean },
): Promise<T> {
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;
}

View File

@@ -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 <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 <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 <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 <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<string, string> }>(
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 <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 <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;

View File

@@ -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 <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 <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 <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<string, unknown>)) {
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 <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 <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 <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 <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 <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 <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;

View File

@@ -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 <url>", "Override browser control URL (default from ~/.clawdbot/clawdbot.json)")
.option("--browser-profile <name>", "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);

View File

@@ -279,7 +279,6 @@ const FIELD_LABELS: Record<string, string> = {
"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)",

View File

@@ -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 <token>`.
*
* 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;

View File

@@ -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.

View File

@@ -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(),
})

View File

@@ -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(),

View File

@@ -9,7 +9,19 @@ export async function startBrowserControlServerIfEnabled(): Promise<BrowserContr
// Lazy import: keeps startup fast, but still bundles for the embedded
// gateway (bun --compile) via the static specifier path.
const override = process.env.CLAWDBOT_BROWSER_CONTROL_MODULE?.trim();
const mod = override ? await import(override) : await import("../browser/server.js");
await mod.startBrowserControlServerFromConfig();
return { stop: mod.stopBrowserControlServer };
const mod = override ? await import(override) : await import("../browser/control-service.js");
const start =
typeof (mod as { startBrowserControlServiceFromConfig?: unknown })
.startBrowserControlServiceFromConfig === "function"
? (mod as { startBrowserControlServiceFromConfig: () => Promise<unknown> })
.startBrowserControlServiceFromConfig
: (mod as { startBrowserControlServerFromConfig?: () => Promise<unknown> })
.startBrowserControlServerFromConfig;
const stop =
typeof (mod as { stopBrowserControlService?: unknown }).stopBrowserControlService === "function"
? (mod as { stopBrowserControlService: () => Promise<void> }).stopBrowserControlService
: (mod as { stopBrowserControlServer?: () => Promise<void> }).stopBrowserControlServer;
if (!start) return null;
await start();
return { stop: stop ?? (async () => {}) };
}

View File

@@ -77,6 +77,7 @@ const BASE_METHODS = [
"agent",
"agent.identity.get",
"agent.wait",
"browser.request",
// WebChat WebSocket-native chat methods
"chat.history",
"chat.abort",

View File

@@ -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(

View File

@@ -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<string, unknown>;
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<typeof loadConfig>;
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<string, string>();
const mapping = new Map<string, string>();
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<string, string>) {
if (!result || typeof result !== "object") return;
const obj = result as Record<string, unknown>;
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<string, unknown>;
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);
},
};

View File

@@ -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" },

View File

@@ -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<void> | null = null;
async function ensureBrowserControlServer(): Promise<void> {
async function ensureBrowserControlService(): Promise<void> {
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<T>(promise: Promise<T>, timeoutMs?: number, label?: string): Promise<T> {
const resolved =
typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
? Math.max(1, Math.floor(timeoutMs))
: undefined;
if (!resolved) return await promise;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, 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<void> {
const cfg = loadConfig();
const browserProxy = resolveBrowserProxyConfig();
const resolvedBrowser = resolveBrowserConfig(cfg.browser);
const browserProxyEnabled =
browserProxy.enabled &&
resolvedBrowser.enabled &&
shouldStartLocalBrowserServer(resolvedBrowser);
const resolvedBrowser = resolveBrowserConfig(cfg.browser, cfg);
const browserProxyEnabled = browserProxy.enabled && resolvedBrowser.enabled;
const isRemoteMode = cfg.gateway?.mode === "remote";
const token =
process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
@@ -584,9 +602,11 @@ async function handleInvoke(
payloadJSON: JSON.stringify(payload),
});
} catch (err) {
const message = String(err);
const code = message.toLowerCase().includes("timed out") ? "TIMEOUT" : "INVALID_REQUEST";
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "INVALID_REQUEST", message: String(err) },
error: { code, message },
});
}
return;
@@ -667,8 +687,9 @@ async function handleInvoke(
if (!proxyConfig.enabled) {
throw new Error("UNAVAILABLE: node browser proxy disabled");
}
await ensureBrowserControlServer();
const resolved = resolveBrowserConfig(loadConfig().browser);
await ensureBrowserControlService();
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const requestedProfile = typeof params.profile === "string" ? params.profile.trim() : "";
const allowedProfiles = proxyConfig.allowProfiles;
if (allowedProfiles.length > 0) {
@@ -684,54 +705,38 @@ async function handleInvoke(
}
}
const url = new URL(
pathValue.startsWith("/") ? pathValue : `/${pathValue}`,
resolved.controlUrl,
);
if (requestedProfile) {
url.searchParams.set("profile", requestedProfile);
}
const query = params.query ?? {};
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue;
url.searchParams.set(key, String(value));
}
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`;
const body = params.body;
const ctrl = new AbortController();
const timeoutMs =
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? Math.max(1, Math.floor(params.timeoutMs))
: 20_000;
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
const headers = new Headers();
let bodyJson: string | undefined;
if (body !== undefined) {
headers.set("Content-Type", "application/json");
bodyJson = JSON.stringify(body);
const query: Record<string, unknown> = {};
if (requestedProfile) {
query.profile = requestedProfile;
}
const token =
process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim() || resolved.controlToken?.trim();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
const rawQuery = params.query ?? {};
for (const [key, value] of Object.entries(rawQuery)) {
if (value === undefined || value === null) continue;
query[key] = typeof value === "string" ? value : String(value);
}
let res: Response;
try {
res = await fetch(url.toString(), {
method,
headers,
body: bodyJson,
signal: ctrl.signal,
});
} finally {
clearTimeout(timer);
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
const response = await withTimeout(
dispatcher.dispatch({
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
path,
query,
body,
}),
params.timeoutMs,
"browser proxy request",
);
if (response.status >= 400) {
const message =
response.body && typeof response.body === "object" && "error" in response.body
? String((response.body as { error?: unknown }).error)
: `HTTP ${response.status}`;
throw new Error(message);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`);
}
const result = (await res.json()) as unknown;
if (allowedProfiles.length > 0 && url.pathname === "/profiles") {
const result = response.body as unknown;
if (allowedProfiles.length > 0 && path === "/profiles") {
const obj =
typeof result === "object" && result !== null ? (result as Record<string, unknown>) : {};
const profiles = Array.isArray(obj.profiles) ? obj.profiles : [];

View File

@@ -73,7 +73,7 @@ export function collectAttackSurfaceSummaryFindings(cfg: ClawdbotConfig): Securi
const group = summarizeGroupPolicy(cfg);
const elevated = cfg.tools?.elevated?.enabled !== false;
const hooksEnabled = cfg.hooks?.enabled === true;
const browserEnabled = Boolean(cfg.browser?.enabled ?? cfg.browser?.controlUrl);
const browserEnabled = cfg.browser?.enabled ?? true;
const detail =
`groups: open=${group.open}, allowlist=${group.allowlist}` +
@@ -143,20 +143,6 @@ export function collectSecretsInConfigFindings(cfg: ClawdbotConfig): SecurityAud
});
}
const browserToken =
typeof cfg.browser?.controlToken === "string" ? cfg.browser.controlToken.trim() : "";
if (browserToken && !looksLikeEnvRef(browserToken)) {
findings.push({
checkId: "config.secrets.browser_control_token_in_config",
severity: "warn",
title: "Browser control token is stored in config",
detail:
"browser.controlToken is set in the config file; prefer environment variables for secrets when possible.",
remediation:
"Prefer CLAWDBOT_BROWSER_CONTROL_TOKEN (env) and remove browser.controlToken from disk.",
});
}
const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(hooksToken)) {
findings.push({
@@ -206,21 +192,6 @@ export function collectHooksHardeningFindings(cfg: ClawdbotConfig): SecurityAudi
});
}
const browserToken =
typeof cfg.browser?.controlToken === "string" && cfg.browser.controlToken.trim()
? cfg.browser.controlToken.trim()
: process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim() || null;
if (token && browserToken && token === browserToken) {
findings.push({
checkId: "hooks.token_reuse_browser_token",
severity: "warn",
title: "Hooks token reuses the browser control token",
detail:
"hooks.token matches browser control token; compromise of hooks may enable browser control endpoints.",
remediation: "Use a separate hooks.token dedicated to hook ingress.",
});
}
const rawPath = typeof cfg.hooks?.path === "string" ? cfg.hooks.path.trim() : "";
if (rawPath === "/") {
findings.push({
@@ -457,7 +428,7 @@ function isWebFetchEnabled(cfg: ClawdbotConfig): boolean {
function isBrowserEnabled(cfg: ClawdbotConfig): boolean {
try {
return resolveBrowserConfig(cfg.browser).enabled;
return resolveBrowserConfig(cfg.browser, cfg).enabled;
} catch {
return true;
}

View File

@@ -274,41 +274,13 @@ describe("security audit", () => {
);
});
it("flags remote browser control without token as critical", async () => {
const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
try {
const cfg: ClawdbotConfig = {
browser: {
controlUrl: "http://example.com:18791",
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "browser.control_remote_no_token",
severity: "critical",
}),
]),
);
} finally {
if (prev === undefined) delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
else process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev;
}
});
it("warns when browser control token matches gateway auth token", async () => {
const token = "0123456789abcdef0123456789abcdef";
it("warns when remote CDP uses HTTP", async () => {
const cfg: ClawdbotConfig = {
gateway: { auth: { token } },
browser: { controlUrl: "https://browser.example.com", controlToken: token },
browser: {
profiles: {
remote: { cdpUrl: "http://example.com:9222", color: "#0066CC" },
},
},
};
const res = await runSecurityAudit({
@@ -319,42 +291,11 @@ describe("security audit", () => {
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "browser.control_token_reuse_gateway_token",
severity: "warn",
}),
expect.objectContaining({ checkId: "browser.remote_cdp_http", severity: "warn" }),
]),
);
});
it("warns when remote browser control uses HTTP", async () => {
const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
try {
const cfg: ClawdbotConfig = {
browser: {
controlUrl: "http://example.com:18791",
controlToken: "0123456789abcdef01234567",
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "browser.control_remote_http", severity: "warn" }),
]),
);
} finally {
if (prev === undefined) delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
else process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev;
}
});
it("warns when control UI allows insecure auth", async () => {
const cfg: ClawdbotConfig = {
gateway: {

View File

@@ -356,82 +356,41 @@ function collectGatewayConfigFindings(
return findings;
}
function isLoopbackClientHost(hostname: string): boolean {
const h = hostname.trim().toLowerCase();
return h === "localhost" || h === "127.0.0.1" || h === "::1";
}
function collectBrowserControlFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
let resolved: ReturnType<typeof resolveBrowserConfig>;
try {
resolved = resolveBrowserConfig(cfg.browser);
resolved = resolveBrowserConfig(cfg.browser, cfg);
} catch (err) {
findings.push({
checkId: "browser.control_invalid_config",
severity: "warn",
title: "Browser control config looks invalid",
detail: String(err),
remediation: `Fix browser.controlUrl/browser.cdpUrl in ${resolveConfigPath()} and re-run "${formatCliCommand("clawdbot security audit --deep")}".`,
remediation: `Fix browser.cdpUrl in ${resolveConfigPath()} and re-run "${formatCliCommand("clawdbot security audit --deep")}".`,
});
return findings;
}
if (!resolved.enabled) return findings;
const url = new URL(resolved.controlUrl);
const isLoopback = isLoopbackClientHost(url.hostname);
const envToken = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
const controlToken = (envToken || resolved.controlToken)?.trim() || null;
if (!isLoopback) {
if (!controlToken) {
findings.push({
checkId: "browser.control_remote_no_token",
severity: "critical",
title: "Remote browser control is missing an auth token",
detail: `browser.controlUrl is non-loopback (${resolved.controlUrl}) but no browser.controlToken (or CLAWDBOT_BROWSER_CONTROL_TOKEN) is configured.`,
remediation:
"Set browser.controlToken (or export CLAWDBOT_BROWSER_CONTROL_TOKEN) and prefer serving over Tailscale Serve or HTTPS reverse proxy.",
});
for (const name of Object.keys(resolved.profiles)) {
const profile = resolveProfile(resolved, name);
if (!profile || profile.cdpIsLoopback) continue;
let url: URL;
try {
url = new URL(profile.cdpUrl);
} catch {
continue;
}
if (url.protocol === "http:") {
findings.push({
checkId: "browser.control_remote_http",
checkId: "browser.remote_cdp_http",
severity: "warn",
title: "Remote browser control uses HTTP",
detail: `browser.controlUrl=${resolved.controlUrl} is http; this is OK only if it's tailnet-only (Tailscale) or behind another encrypted tunnel.`,
remediation: `Prefer HTTPS termination (Tailscale Serve) and keep the endpoint tailnet-only.`,
});
}
if (controlToken && controlToken.length < 24) {
findings.push({
checkId: "browser.control_token_too_short",
severity: "warn",
title: "Browser control token looks short",
detail: `browser control token is ${controlToken.length} chars; prefer a long random token.`,
});
}
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const gatewayAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode });
const gatewayToken =
gatewayAuth.mode === "token" &&
typeof gatewayAuth.token === "string" &&
gatewayAuth.token.trim()
? gatewayAuth.token.trim()
: null;
if (controlToken && gatewayToken && controlToken === gatewayToken) {
findings.push({
checkId: "browser.control_token_reuse_gateway_token",
severity: "warn",
title: "Browser control token reuses the Gateway token",
detail: `browser.controlToken matches gateway.auth token; compromise of browser control expands blast radius to the Gateway API.`,
remediation: `Use a separate browser.controlToken dedicated to browser control.`,
title: "Remote CDP uses HTTP",
detail: `browser profile "${name}" uses http CDP (${profile.cdpUrl}); this is OK only if it's tailnet-only or behind an encrypted tunnel.`,
remediation: `Prefer HTTPS/TLS or a tailnet-only endpoint for remote CDP.`,
});
}
}