Compare commits

..
Author SHA1 Message Date
Gustavo Madeira Santana a087540ef3 fix: skip cooldowned providers during model failover (#2143) (thanks @YiWang24) 2026-01-26 21:59:20 -05:00
Gustavo Madeira Santana ff0cbee297 Agents: skip cooldowned providers in fallback 2026-01-26 21:56:20 -05:00
YiWang24 9227026af3 fix(agents): correct imports and API usage for cooldown check 2026-01-26 21:56:20 -05:00
YiWang24 23f65cc90b feat(agents): skip cooldowned providers during failover
When all auth profiles for a provider are in cooldown, the failover
mechanism now skips that provider immediately rather than attempting
and waiting for the cooldown error. This prevents long delays when
multiple OAuth providers fail in sequence.
2026-01-26 21:56:20 -05:00
128 changed files with 22598 additions and 2418 deletions

No files matched your search

-2
View File
@@ -40,8 +40,6 @@ apps/ios/*.xcfilelist
# Vendor build artifacts
vendor/a2ui/renderers/lit/dist/
src/canvas-host/a2ui/*.bundle.js
src/canvas-host/a2ui/*.map
.bundle.hash
# fastlane (iOS)
+2 -6
View File
@@ -6,7 +6,6 @@ Docs: https://docs.clawd.bot
Status: unreleased.
### Changes
- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk).
- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
@@ -37,10 +36,7 @@ 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.
- Build: bundle A2UI assets during build and stop tracking generated bundles. (#2455) Thanks @0oAstro.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
@@ -709,7 +705,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 support.
- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`.
- 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.
@@ -727,7 +723,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 (standalone server + token auth).
- 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`.
### Fixes
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
+1
View File
@@ -384,6 +384,7 @@ Browser control (optional):
{
browser: {
enabled: true,
controlUrl: "http://127.0.0.1:18791",
color: "#FF4500"
}
}
-4
View File
@@ -13,10 +13,6 @@ For threat model + hardening guidance (including `clawdbot security audit --deep
- `https://docs.clawd.bot/gateway/security`
### Web Interface Safety
Clawdbot's web interface is intended for local use only. Do **not** bind it to the public internet; it is not hardened for public exposure.
## Runtime Requirements
### Node.js Version
@@ -83,10 +83,7 @@ enum CommandResolver {
"/usr/bin",
"/bin",
]
#if DEBUG
// Dev-only convenience. Avoid project-local PATH hijacking in release builds.
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
#endif
let clawdbotPaths = self.clawdbotManagedPaths(home: home)
if !clawdbotPaths.isEmpty {
extras.insert(contentsOf: clawdbotPaths, at: 1)
@@ -192,13 +189,9 @@ enum CommandResolver {
}
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
#if DEBUG
let root = projectRoot ?? self.projectRoot()
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
#else
return nil
#endif
}
static func nodeCliPath() -> String? {
@@ -39,7 +39,7 @@ export default defineConfig({
output: {
file: outputFile,
format: "esm",
codeSplitting: false,
inlineDynamicImports: true,
sourcemap: false,
},
});
+2 -1
View File
@@ -168,7 +168,8 @@
<h2>Getting started</h2>
<p>
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
Start Clawdbot’s browser relay on this machine (Gateway or node host), then click the toolbar button again.
Start Clawdbot’s browser relay on this machine (Gateway or <code>clawdbot browser serve</code>),
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>
+1
View File
@@ -0,0 +1 @@
@import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap";:root{--bg: #0c0d12;--bg-accent: #0d0e14;--bg-elevated: #181a21;--bg-hover: #252830;--bg-muted: #252830;--card: #13151c;--card-foreground: #f8fafc;--card-highlight: rgba(255, 255, 255, .04);--popover: #13151c;--popover-foreground: #f8fafc;--panel: #0c0d12;--panel-strong: #181a21;--panel-hover: #252830;--chrome: rgba(12, 13, 18, .95);--chrome-strong: rgba(12, 13, 18, .98);--text: #f8fafc;--text-strong: #ffffff;--chat-text: #f8fafc;--muted: #94a3b8;--muted-strong: #64748b;--muted-foreground: #94a3b8;--border: #333842;--border-strong: #454d5c;--border-hover: #5a6373;--input: #333842;--ring: #ff4d4d;--accent: #ff4d4d;--accent-hover: #ff6666;--accent-muted: #ff4d4d;--accent-subtle: rgba(255, 77, 77, .12);--accent-foreground: #f8fafc;--primary: #ff4d4d;--primary-foreground: #ffffff;--secondary: #252830;--secondary-foreground: #f8fafc;--accent-2: #3b82f6;--accent-2-muted: rgba(59, 130, 246, .7);--ok: #22c55e;--ok-muted: rgba(34, 197, 94, .7);--ok-subtle: rgba(34, 197, 94, .1);--destructive: #ef4444;--destructive-foreground: #fafafa;--warn: #eab308;--warn-muted: rgba(234, 179, 8, .7);--warn-subtle: rgba(234, 179, 8, .1);--danger: #ef4444;--danger-muted: rgba(239, 68, 68, .7);--danger-subtle: rgba(239, 68, 68, .1);--info: #3b82f6;--focus: rgba(255, 77, 77, .2);--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);--grid-line: rgba(255, 255, 255, .03);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .05);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1), 0 4px 6px -4px rgba(0, 0, 0, .1);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .1), 0 8px 10px -6px rgba(0, 0, 0, .1);--radius-sm: 4px;--radius-md: 6px;--radius-lg: 8px;--radius-xl: 12px;--radius-full: 9999px;--radius: 6px;--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-in-out: cubic-bezier(.4, 0, .2, 1);--duration-fast: .15s;--duration-normal: .2s;--duration-slow: .3s;color-scheme:dark}:root[data-theme=light]{--bg: #f8f8f7;--bg-accent: #f3f2f0;--bg-elevated: #ffffff;--bg-hover: #eae8e6;--bg-muted: #eae8e6;--bg-content: #f0efed;--card: #ffffff;--card-foreground: #1c1917;--card-highlight: rgba(0, 0, 0, .04);--popover: #ffffff;--popover-foreground: #1c1917;--panel: #f8f8f7;--panel-strong: #f0efed;--panel-hover: #e5e3e1;--chrome: rgba(248, 248, 247, .95);--chrome-strong: rgba(248, 248, 247, .98);--text: #44403c;--text-strong: #292524;--chat-text: #44403c;--muted: #5c5856;--muted-strong: #44403c;--muted-foreground: #5c5856;--border: #e0dedc;--border-strong: #d6d3d1;--border-hover: #a8a5a0;--input: #e0dedc;--accent: #b91c1c;--accent-hover: #dc2626;--accent-muted: #b91c1c;--accent-subtle: rgba(185, 28, 28, .18);--accent-foreground: #ffffff;--primary: #b91c1c;--primary-foreground: #ffffff;--secondary: #eae8e6;--secondary-foreground: #44403c;--ok: #15803d;--ok-muted: rgba(21, 128, 61, .75);--ok-subtle: rgba(21, 128, 61, .12);--destructive: #b91c1c;--destructive-foreground: #fafafa;--warn: #a16207;--warn-muted: rgba(161, 98, 7, .75);--warn-subtle: rgba(161, 98, 7, .12);--danger: #b91c1c;--danger-muted: rgba(185, 28, 28, .75);--danger-subtle: rgba(185, 28, 28, .12);--info: #1d4ed8;--focus: rgba(185, 28, 28, .25);--grid-line: rgba(0, 0, 0, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:400 14px/1.5 var(--font-body);letter-spacing:-.011em;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .4s var(--ease-out) forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button,input,textarea,select{font:inherit;color:inherit}::selection{background:var(--accent-subtle);color:var(--text-strong)}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:var(--radius-full)}::-webLine truncated
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index-DQcOTEYz.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/icons.ts","../../../src/shared/text/reasoning-tags.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.googlechat.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyLine truncated
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clawdbot Control</title>
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="./favicon.ico" sizes="any" />
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
</head>
<body>
<clawdbot-app></clawdbot-app>
</body>
</html>
+12 -10
View File
@@ -1,8 +1,8 @@
---
summary: "CLI reference for `clawdbot browser` (profiles, tabs, actions, extension relay)"
summary: "CLI reference for `clawdbot browser` (profiles, tabs, actions, extension relay, remote serve)"
read_when:
- You use `clawdbot browser` and want examples for common tasks
- You want to control a browser running on another machine via a node host
- You want to control a remote browser via `browser.controlUrl`
- You want to use the Chrome extension relay (attach/detach via toolbar button)
---
@@ -16,10 +16,8 @@ Related:
## Common flags
- `--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).
- `--url <controlUrl>`: override `browser.controlUrl` for this command invocation.
- `--browser-profile <name>`: choose a browser profile (default comes from config).
- `--json`: machine-readable output (where supported).
## Quick start (local)
@@ -95,10 +93,14 @@ Then Chrome → `chrome://extensions` → enable “Developer mode” → “Loa
Full guide: [Chrome extension](/tools/chrome-extension)
## Remote browser control (node host proxy)
## Remote browser control (`clawdbot browser serve`)
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).
If the Gateway runs on a different machine than the browser, run a standalone browser control server on the machine that runs Chrome:
Use `gateway.nodes.browser.mode` to control auto-routing and `gateway.nodes.browser.node` to pin a specific node if multiple are connected.
```bash
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
```
Security + remote setup: [Browser tool](/tools/browser), [Remote access](/gateway/remote), [Tailscale](/gateway/tailscale), [Security](/gateway/security)
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)
+2 -1
View File
@@ -859,8 +859,9 @@ Location:
Browser control CLI (dedicated Chrome/Brave/Edge/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
Common options:
- `--url`, `--token`, `--timeout`, `--json`
- `--url <controlUrl>`
- `--browser-profile <name>`
- `--json`
Manage:
- `browser status`
-1
View File
@@ -956,7 +956,6 @@
"gateway/doctor",
"gateway/logging",
"gateway/security",
"gateway/security-formal-verification",
"gateway/sandbox-vs-tool-policy-vs-elevated",
"gateway/sandboxing",
"gateway/troubleshooting",
+4 -3
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 service.
Clawdbot can start a **dedicated, isolated** Chrome/Brave/Edge/Chromium instance for clawd and expose a small loopback control server.
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 service: loopback only (port derived from `gateway.port`, default `18791`)
- CDP URL: `http://127.0.0.1:18792` (control service + 1, legacy single-profile)
- 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)
- 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,6 +2778,7 @@ 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: {
+2 -2
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 control service port = `19003` (derived: `gateway.port+2`, loopback only)
- `browser.controlUrl=http://127.0.0.1:19003` (derived: `gateway.port+2`)
- `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 control service port = base + 2 (loopback only)
- `browser.controlUrl port = base + 2` (or `CLAWDBOT_BROWSER_CONTROL_URL` / config override)
- `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).
+3 -3
View File
@@ -73,7 +73,7 @@ clawdbot --profile rescue gateway install
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).
- browser control service port = base + 2 (loopback only)
- `browser.controlUrl port = base + 2`
- `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.cdpUrl` to the same values on multiple instances.
- Each instance needs its own browser control port and CDP range (derived from its gateway port).
- 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.
- If you need explicit CDP ports, set `browser.profiles.<name>.cdpPort` per instance.
- Remote Chrome: use `browser.profiles.<name>.cdpUrl` (per profile, per instance).
+1 -1
View File
@@ -117,6 +117,6 @@ Short version: **keep the Gateway loopback-only** unless you’re sure you need
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
Set it to `false` if you want tokens/passwords instead.
- Treat browser control like operator access: tailnet-only + deliberate node pairing.
- Treat `browser.controlUrl` like an admin API: tailnet-only + token auth.
Deep dive: [Security](/gateway/security).
@@ -1,107 +0,0 @@
---
title: Formal Verification (Security Models)
summary: Machine-checked security models for Clawdbot’s highest-risk paths.
permalink: /gateway/security-formal-verification/
---
# Formal Verification (Security Models)
This page tracks Clawdbot’s **formal security models** (TLA+/TLC today; more as needed).
**Goal (north star):** provide a machine-checked argument that Clawdbot enforces its
intended security policy (authorization, session isolation, tool gating, and
misconfiguration safety), under explicit assumptions.
**What this is (today):** an executable, attacker-driven **security regression suite**:
- Each claim has a runnable model-check over a finite state space.
- Many claims have a paired **negative model** that produces a counterexample trace for a realistic bug class.
**What this is not (yet):** a proof that “Clawdbot is secure in all respects” or that the full TypeScript implementation is correct.
## Where the models live
Models are maintained in a separate repo: [github.com/vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models).
## Important caveats
- These are **models**, not the full TypeScript implementation. Drift between model and code is possible.
- Results are bounded by the state space explored by TLC; “green” does not imply security beyond the modeled assumptions and bounds.
- Some claims rely on explicit environmental assumptions (e.g., correct deployment, correct configuration inputs).
## Reproducing results
Today, results are reproduced by cloning the models repo locally and running TLC (see below). A future iteration could offer:
- CI-run models with public artifacts (counterexample traces, run logs)
- a hosted “run this model” workflow for small, bounded checks
Getting started:
```bash
git clone https://github.com/vignesh07/clawdbot-formal-models
cd clawdbot-formal-models
# Java 11+ required (TLC runs on the JVM).
# The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets.
make <target>
```
### Gateway exposure and open gateway misconfiguration
**Claim:** binding beyond loopback without auth can make remote compromise possible / increases exposure; token/password blocks unauth attackers (per the model assumptions).
- Green runs:
- `make gateway-exposure-v2`
- `make gateway-exposure-v2-protected`
- Red (expected):
- `make gateway-exposure-v2-negative`
See also: `docs/gateway-exposure-matrix.md` in the models repo.
### Nodes.run pipeline (highest-risk capability)
**Claim:** `nodes.run` requires (a) node command allowlist plus declared commands and (b) live approval when configured; approvals are tokenized to prevent replay (in the model).
- Green runs:
- `make nodes-pipeline`
- `make approvals-token`
- Red (expected):
- `make nodes-pipeline-negative`
- `make approvals-token-negative`
### Pairing store (DM gating)
**Claim:** pairing requests respect TTL and pending-request caps.
- Green runs:
- `make pairing`
- `make pairing-cap`
- Red (expected):
- `make pairing-negative`
- `make pairing-cap-negative`
### Ingress gating (mentions + control-command bypass)
**Claim:** in group contexts requiring mention, an unauthorized “control command” cannot bypass mention gating.
- Green:
- `make ingress-gating`
- Red (expected):
- `make ingress-gating-negative`
### Routing/session-key isolation
**Claim:** DMs from distinct peers do not collapse into the same session unless explicitly linked/configured.
- Green:
- `make routing-isolation`
- Red (expected):
- `make routing-isolation-negative`
## Roadmap
Next models to deepen fidelity:
- Pairing store concurrency/locking/idempotency
- Provider-specific ingress preflight modeling
- Routing identity-links + dmScope variants + binding precedence
- Gateway auth conformance (proxy/tailscale specifics)
+21 -15
View File
@@ -7,8 +7,6 @@ read_when:
## Quick check: `clawdbot security audit`
See also: [Formal Verification (Security Models)](/gateway/security-formal-verification/)
Run this regularly (especially after changing config or exposing network surfaces):
```bash
@@ -38,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 nodes, relay ports, remote CDP endpoints).
- **Browser control exposure** (remote controlUrl without token, HTTP, token reuse).
- **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).
@@ -63,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 operator access (tailnet-only, pair nodes deliberately, avoid public exposure).
3. **Browser control remote exposure**: treat it like a remote admin API (token required; HTTPS/tailnet-only).
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.
@@ -279,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 `hooks.token` (if used) and revoke any suspicious node pairings.
- Rotate `browser.controlToken` and `hooks.token` (if used).
- Revoke/rotate model provider credentials (API keys / OAuth).
3. **Review artifacts**
- Check Gateway logs and recent sessions/transcripts for unexpected tool calls.
@@ -432,19 +430,26 @@ Trusted proxies:
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
### 0.6.1) Browser control via node host (recommended)
### 0.6.1) Browser control server over Tailscale (recommended)
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.
If your Gateway is remote but the browser runs on another machine, you’ll often run a **separate browser control server**
on the browser machine (see [Browser tool](/tools/browser)). Treat this like an admin API.
Recommended pattern:
- Keep the Gateway and node host on the same tailnet (Tailscale).
- Pair the node intentionally; disable browser proxy routing if you don’t need it.
```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)
Avoid:
- Exposing relay/control ports over LAN or public Internet.
- Tailscale Funnel for browser control endpoints (public exposure).
- `--bind 0.0.0.0` (LAN-visible surface)
- Tailscale Funnel for browser control endpoints (public exposure)
### 0.7) Secrets on disk (what’s sensitive)
@@ -576,8 +581,9 @@ 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.
- Keep the Gateway and node hosts tailnet-only; avoid exposing relay/control ports to LAN or public Internet.
- Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`).
- 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.
- 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)
+27 -5
View File
@@ -100,13 +100,35 @@ 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 (remote Gateway + local browser)
## Browser control server (remote Gateway + local browser)
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.
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):
Avoid Funnel for browser control; treat node pairing like operator access.
```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.
## Tailscale prerequisites + limits
+3 -4
View File
@@ -1093,10 +1093,9 @@ clawdbot browser extension path
Then Chrome → `chrome://extensions` → enable “Developer mode” → “Load unpacked” → pick that folder.
Full guide (including remote Gateway + security notes): [Chrome extension](/tools/chrome-extension)
Full guide (including remote Gateway via Tailscale + security notes): [Chrome extension](/tools/chrome-extension)
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.
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
You still need to click the extension button on the tab you want to control (it doesn’t auto-attach).
## Sandboxing and memory
@@ -1480,7 +1479,7 @@ setup is an always‑on host plus your laptop as a node.
- **Safer execution controls.** `system.run` is gated by node allowlists/approvals on that laptop.
- **More device tools.** Nodes expose `canvas`, `camera`, and `screen` in addition to `system.run`.
- **Local browser automation.** Keep the Gateway on a VPS, but run Chrome locally and relay control
with the Chrome extension + a node host on the laptop.
with the Chrome extension + `clawdbot browser serve`.
SSH is fine for ad‑hoc shell access, but nodes are simpler for ongoing agent workflows and
device automation.
-5
View File
@@ -1,5 +0,0 @@
---
permalink: /security/formal-verification/
---
This page moved to: [/gateway/security-formal-verification/](/gateway/security-formal-verification/)
-1
View File
@@ -175,7 +175,6 @@ clawdbot onboard --install-daemon
```
If you don’t have a global install yet, run the onboarding step via `pnpm clawdbot ...` from the repo.
`pnpm build` also bundles A2UI assets; if you need to run just that step, use `pnpm canvas:a2ui:bundle`.
Gateway (from this repo):
+136 -27
View File
@@ -1,5 +1,5 @@
---
summary: "Integrated browser control service + action commands"
summary: "Integrated browser control server + 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 service inside the Gateway (loopback only).
control server.
Beginner view:
- Think of it as a **separate, agent-only browser**.
@@ -57,7 +57,8 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
{
browser: {
enabled: true, // default: true
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
controlUrl: "http://127.0.0.1:18791",
cdpUrl: "http://127.0.0.1:18792", // defaults to controlUrl + 1
remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms)
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
defaultProfile: "chrome",
@@ -76,11 +77,10 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
```
Notes:
- 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`).
- `controlUrl` defaults to `http://127.0.0.1:18791`.
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
the derived browser ports shift to stay in the same “family”.
- `cdpUrl` defaults to the relay port when unset.
the default browser ports shift to stay in the same “family” (control = gateway + 2).
- `cdpUrl` defaults to `controlUrl + 1` 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,11 +126,38 @@ clawdbot config set browser.executablePath "/usr/bin/google-chrome"
## Local vs remote control
- **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.
- **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.
- **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`)
@@ -139,11 +166,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 extra browser config.
This is the default path for remote gateways.
auto-route browser tool calls to that node without any custom `controlUrl`
setup. This is the default path for remote gateways.
Notes:
- The node host exposes its local browser control server via a **proxy command**.
@@ -152,7 +179,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
@@ -180,16 +207,94 @@ 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
Key ideas:
- Browser control is loopback-only; access flows through the Gateway’s auth or node pairing.
- Keep the Gateway and any node hosts on a private network (Tailscale); avoid public exposure.
- Treat remote CDP URLs/tokens as secrets; prefer env vars or a secrets manager.
This section covers the **browser control server** (`browser.controlUrl`) used for agent browser automation.
Remote CDP tips:
- Prefer HTTPS endpoints and short-lived tokens where possible.
- Avoid embedding long-lived tokens directly in config files.
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.
### Tokens (what is shared with what?)
- `browser.controlToken` / `CLAWDBOT_BROWSER_CONTROL_TOKEN` is **only** for authenticating browser control HTTP requests to `browser.controlUrl`.
- It is **not** the Gateway token (`gateway.auth.token`) and **not** a node pairing token.
- You *can* reuse the same string value, but it’s better to keep them separate to reduce blast radius.
### Binding (don’t expose to your LAN by accident)
Recommended:
- Keep `clawdbot browser serve` bound to loopback (`127.0.0.1`) and publish it via Tailscale.
- Or bind to a Tailnet IP only (never `0.0.0.0`) and require a token.
Avoid:
- `--bind 0.0.0.0` (LAN-visible). Even with token auth, traffic is plain HTTP unless you also add TLS.
### TLS / HTTPS (recommended approach: terminate in front)
Best practice here: keep `clawdbot browser serve` on HTTP and terminate TLS in front.
If you’re already using Tailscale, you have two good options:
1) **Tailnet-only, still HTTP** (transport is encrypted by Tailscale):
- Keep `controlUrl` as `http://…` but ensure it’s only reachable over your tailnet.
2) **Serve HTTPS via Tailscale** (nice UX: `https://…` URL):
```bash
# on the browser machine
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <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).
## Profiles (multi-browser)
@@ -213,12 +318,13 @@ Clawdbot can also drive **your existing Chrome tabs** (no separate “clawd” C
Full guide: [Chrome extension](/tools/chrome-extension)
Flow:
- The Gateway runs locally (same machine) or a node host runs on the browser machine.
- You run a **browser control server** (Gateway on the same machine, or `clawdbot browser serve`).
- 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 elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions.
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).
### Sandboxed sessions
@@ -281,7 +387,8 @@ Platforms:
## Control API (optional)
For local integrations only, the Gateway exposes a small loopback HTTP API:
If you want to integrate directly, the browser control server exposes a small
HTTP API:
- Status/start/stop: `GET /`, `POST /start`, `POST /stop`
- Tabs: `GET /tabs`, `POST /tabs/open`, `POST /tabs/focus`, `DELETE /tabs/:targetId`
@@ -506,7 +613,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 the Gateway/node host private (loopback or tailnet-only).
- Keep control URLs loopback-only unless you intentionally expose the server.
- Remote CDP endpoints are powerful; tunnel and protect them.
## Troubleshooting
@@ -524,10 +631,12 @@ 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 (clawd, chrome, or remote CDP).
- `target` (`sandbox` | `host` | `node`) to select where the browser lives.
- `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).
- 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`.
- If a browser-capable node is connected, the tool may auto-route to it unless you pin `target="host"` or `target="node"`.
- Sandbox allowlists can restrict `target: "custom"` to specific URLs/hosts/ports.
- Defaults: allowlists unset (no restriction), and sandbox host control is disabled.
This keeps the agent deterministic and avoids brittle selectors.
+32 -15
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 service** (Gateway or node): the API the agent/tool calls (via the Gateway)
- **Browser control server** (HTTP): the API the agent/tool calls (`browser.controlUrl`)
- **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,22 +87,23 @@ clawdbot browser create-profile \
- `!`: relay not reachable (most common: browser relay server isn’t running on this machine).
If you see `!`:
- Make sure the Gateway is running locally (default setup), or run a node host on this machine if the Gateway runs elsewhere.
- Make sure the Gateway is running locally (default setup), or run `clawdbot browser serve` on this machine (remote gateway setup).
- Open the extension Options page; it shows whether the relay is reachable.
## Remote Gateway (use a node host)
## Do I need `clawdbot browser serve`?
### Local Gateway (same machine as Chrome) — usually **no extra steps**
### Local Gateway (same machine as Chrome) — usually **no**
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.
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`.
### Remote Gateway (Gateway runs elsewhere) — **run a node host**
The Gateway’s built-in browser control server will start on `http://127.0.0.1:18791/` and Clawdbot will
auto-start the local relay server on `http://127.0.0.1:18792/`.
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.
### Remote Gateway (Gateway runs elsewhere) — **yes**
If multiple nodes are connected, pin one with `gateway.nodes.browser.node` or set `gateway.nodes.browser.mode`.
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.
## Sandboxing (tool containers)
@@ -133,10 +134,26 @@ Then ensure the tool isn’t denied by tool policy, and (if needed) call `browse
Debugging: `clawdbot sandbox explain`
## Remote access tips
## Remote Gateway (recommended: Tailscale Serve)
- Keep the Gateway and node host on the same tailnet; avoid exposing relay ports to LAN or public Internet.
- Pair nodes intentionally; disable browser proxy routing if you don’t want remote control (`gateway.nodes.browser.mode="off"`).
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.
## How “extension path” works
@@ -159,8 +176,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 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).
- 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).
Related:
- Browser tool overview: [Browser](/tools/browser)
+1 -2
View File
@@ -67,8 +67,7 @@ Example:
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
Clawdbot prepends `env.PATH` after profile sourcing via an internal env var (no shell interpolation);
`tools.exec.pathPrepend` applies here too.
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
if the exec call already sets `env.PATH`. Headless node hosts accept `PATH` only when it prepends
the node host PATH (no replacement). macOS nodes drop `PATH` overrides entirely.
+3 -6
View File
@@ -249,17 +249,16 @@ 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.
@@ -411,9 +410,7 @@ Gateway-backed tools (`canvas`, `nodes`, `cron`):
- `timeoutMs`
Browser tool:
- `profile` (optional; defaults to `browser.defaultProfile`)
- `target` (`sandbox` | `host` | `node`)
- `node` (optional; pin a specific node id/name)
- `controlUrl` (defaults from config)
## Recommended agent flows
+1 -1
View File
@@ -82,7 +82,7 @@
"docs:bin": "node scripts/build-docs-list.mjs",
"docs:dev": "cd docs && mint dev",
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
"build": "pnpm canvas:a2ui:bundle && tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
"build": "tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
"release:check": "node --import tsx scripts/release-check.ts",
"ui:install": "node scripts/ui.js install",
+1 -8
View File
@@ -1,15 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
on_error() {
echo "A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle" >&2
echo "If this persists, verify pnpm deps and try again." >&2
}
trap on_error ERR
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash"
OUTPUT_FILE="$ROOT_DIR/src/canvas-host/a2ui/a2ui.bundle.js"
INPUT_PATHS=(
"$ROOT_DIR/package.json"
@@ -40,7 +33,7 @@ compute_hash() {
current_hash="$(compute_hash)"
if [[ -f "$HASH_FILE" ]]; then
previous_hash="$(cat "$HASH_FILE")"
if [[ "$previous_hash" == "$current_hash" && -f "$OUTPUT_FILE" ]]; then
if [[ "$previous_hash" == "$current_hash" ]]; then
echo "A2UI bundle up to date; skipping."
exit 0
fi
+10 -35
View File
@@ -1,44 +1,19 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const srcDir = path.join(repoRoot, "src", "canvas-host", "a2ui");
const outDir = path.join(repoRoot, "dist", "canvas-host", "a2ui");
export function getA2uiPaths(env = process.env) {
const srcDir =
env.CLAWDBOT_A2UI_SRC_DIR ?? path.join(repoRoot, "src", "canvas-host", "a2ui");
const outDir =
env.CLAWDBOT_A2UI_OUT_DIR ?? path.join(repoRoot, "dist", "canvas-host", "a2ui");
return { srcDir, outDir };
}
export async function copyA2uiAssets({
srcDir,
outDir,
}: {
srcDir: string;
outDir: string;
}) {
try {
await fs.stat(path.join(srcDir, "index.html"));
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
} catch (err) {
const message =
'Missing A2UI bundle assets. Run "pnpm canvas:a2ui:bundle" and retry.';
throw new Error(message, { cause: err });
}
async function main() {
await fs.stat(path.join(srcDir, "index.html"));
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
await fs.mkdir(path.dirname(outDir), { recursive: true });
await fs.cp(srcDir, outDir, { recursive: true });
}
async function main() {
const { srcDir, outDir } = getA2uiPaths();
await copyA2uiAssets({ srcDir, outDir });
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch((err) => {
console.error(String(err));
process.exit(1);
});
}
main().catch((err) => {
console.error(String(err));
process.exit(1);
});
+1 -8
View File
@@ -60,18 +60,11 @@ export function buildDockerExecArgs(params: {
for (const [key, value] of Object.entries(params.env)) {
args.push("-e", `${key}=${value}`);
}
const hasCustomPath = typeof params.env.PATH === "string" && params.env.PATH.length > 0;
if (hasCustomPath) {
// Avoid interpolating PATH into the shell command; pass it via env instead.
args.push("-e", `CLAWDBOT_PREPEND_PATH=${params.env.PATH}`);
}
// Login shell (-l) sources /etc/profile which resets PATH to a minimal set,
// overriding both Docker ENV and -e PATH=... environment variables.
// Prepend custom PATH after profile sourcing to ensure custom tools are accessible
// while preserving system paths that /etc/profile may have added.
const pathExport = hasCustomPath
? 'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; '
: "";
const pathExport = params.env.PATH ? `export PATH="${params.env.PATH}:$PATH"; ` : "";
args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`);
return args;
}
+2 -23
View File
@@ -318,30 +318,9 @@ describe("buildDockerExecArgs", () => {
});
const commandArg = args[args.length - 1];
expect(args).toContain("CLAWDBOT_PREPEND_PATH=/custom/bin:/usr/local/bin:/usr/bin");
expect(commandArg).toContain('export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"');
expect(commandArg).toContain('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"');
expect(commandArg).toContain("echo hello");
expect(commandArg).toBe(
'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; echo hello',
);
});
it("does not interpolate PATH into the shell command", () => {
const injectedPath = "$(touch /tmp/clawdbot-path-injection)";
const args = buildDockerExecArgs({
containerName: "test-container",
command: "echo hello",
env: {
PATH: injectedPath,
HOME: "/home/user",
},
tty: false,
});
const commandArg = args[args.length - 1];
expect(args).toContain(`CLAWDBOT_PREPEND_PATH=${injectedPath}`);
expect(commandArg).not.toContain(injectedPath);
expect(commandArg).toContain("CLAWDBOT_PREPEND_PATH");
expect(commandArg).toBe('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"; echo hello');
});
it("does not add PATH export when PATH is not in env", () => {
+1 -3
View File
@@ -47,9 +47,7 @@ describe("gateway tool", () => {
payload?: { kind?: string; doctorHint?: string | null };
};
expect(parsed.payload?.kind).toBe("restart");
expect(parsed.payload?.doctorHint).toBe(
"Run: clawdbot --profile isolated doctor --non-interactive",
);
expect(parsed.payload?.doctorHint).toBe("Run: clawdbot doctor --non-interactive");
expect(kill).not.toHaveBeenCalled();
await vi.runAllTimersAsync();
+8 -2
View File
@@ -20,8 +20,11 @@ import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
import { createTtsTool } from "./tools/tts-tool.js";
export function createClawdbotTools(options?: {
sandboxBrowserBridgeUrl?: string;
browserControlUrl?: string;
allowHostBrowserControl?: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
agentSessionKey?: string;
agentChannel?: GatewayMessageChannel;
agentAccountId?: string;
@@ -72,8 +75,11 @@ export function createClawdbotTools(options?: {
});
const tools: AnyAgentTool[] = [
createBrowserTool({
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
defaultControlUrl: options?.browserControlUrl,
allowHostControl: options?.allowHostBrowserControl,
allowedControlUrls: options?.allowedControlUrls,
allowedControlHosts: options?.allowedControlHosts,
allowedControlPorts: options?.allowedControlPorts,
}),
createCanvasTool(),
createNodesTool({
@@ -127,7 +127,7 @@ describe("buildEmbeddedSandboxInfo", () => {
},
browserAllowHostControl: true,
browser: {
bridgeUrl: "http://localhost:9222",
controlUrl: "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,
browserBridgeUrl: "http://localhost:9222",
browserControlUrl: "http://localhost:9222",
browserNoVncUrl: "http://localhost:6080",
hostBrowserAllowed: true,
});
@@ -13,9 +13,12 @@ export function buildEmbeddedSandboxInfo(
workspaceDir: sandbox.workspaceDir,
workspaceAccess: sandbox.workspaceAccess,
agentWorkspaceMount: sandbox.workspaceAccess === "ro" ? "/agent" : undefined,
browserBridgeUrl: sandbox.browser?.bridgeUrl,
browserControlUrl: sandbox.browser?.controlUrl,
browserNoVncUrl: sandbox.browser?.noVncUrl,
hostBrowserAllowed: sandbox.browserAllowHostControl,
allowedControlUrls: sandbox.browserAllowedControlUrls,
allowedControlHosts: sandbox.browserAllowedControlHosts,
allowedControlPorts: sandbox.browserAllowedControlPorts,
...(elevatedAllowed
? {
elevated: {
+4 -1
View File
@@ -69,9 +69,12 @@ export type EmbeddedSandboxInfo = {
workspaceDir?: string;
workspaceAccess?: "none" | "ro" | "rw";
agentWorkspaceMount?: string;
browserBridgeUrl?: string;
browserControlUrl?: string;
browserNoVncUrl?: string;
hostBrowserAllowed?: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off" | "ask" | "full";
@@ -96,6 +96,7 @@ 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");
+4 -1
View File
@@ -294,8 +294,11 @@ export function createClawdbotCodingTools(options?: {
// Channel docking: include channel-defined agent tools (login, etc.).
...listChannelAgentTools({ cfg: options?.config }),
...createClawdbotTools({
sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl,
browserControlUrl: sandbox?.browser?.controlUrl,
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
allowedControlUrls: sandbox?.browserAllowedControlUrls,
allowedControlHosts: sandbox?.browserAllowedControlHosts,
allowedControlPorts: sandbox?.browserAllowedControlPorts,
agentSessionKey: options?.sessionKey,
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
agentAccountId: options?.agentAccountId,
+5 -1
View File
@@ -40,9 +40,13 @@ 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,
@@ -200,7 +204,7 @@ export async function ensureSandboxBrowser(params: {
: undefined;
return {
bridgeUrl: resolvedBridge.baseUrl,
controlUrl: resolvedBridge.baseUrl,
noVncUrl,
containerName,
};
+17
View File
@@ -86,6 +86,11 @@ 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,
@@ -100,6 +105,18 @@ 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 ??
+3
View File
@@ -87,6 +87,9 @@ 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,
};
}
+7 -1
View File
@@ -37,6 +37,9 @@ export type SandboxBrowserConfig = {
headless: boolean;
enableNoVnc: boolean;
allowHostControl: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
autoStart: boolean;
autoStartTimeoutMs: number;
};
@@ -60,7 +63,7 @@ export type SandboxConfig = {
};
export type SandboxBrowserContext = {
bridgeUrl: string;
controlUrl: string;
noVncUrl?: string;
containerName: string;
};
@@ -76,6 +79,9 @@ export type SandboxContext = {
docker: SandboxDockerConfig;
tools: SandboxToolPolicy;
browserAllowHostControl: boolean;
browserAllowedControlUrls?: string[];
browserAllowedControlHosts?: string[];
browserAllowedControlPorts?: number[];
browser?: SandboxBrowserContext;
};
+1 -1
View File
@@ -104,7 +104,7 @@ describe("acquireSessionWriteLock", () => {
});
it("cleans up locks on SIGINT without removing other handlers", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
const originalKill = process.kill.bind(process) as typeof process.kill;
const originalKill = process.kill.bind(process);
const killCalls: Array<NodeJS.Signals | undefined> = [];
let otherHandlerCalled = false;
+16 -2
View File
@@ -165,9 +165,12 @@ export function buildAgentSystemPrompt(params: {
workspaceDir?: string;
workspaceAccess?: "none" | "ro" | "rw";
agentWorkspaceMount?: string;
browserBridgeUrl?: string;
browserControlUrl?: string;
browserNoVncUrl?: string;
hostBrowserAllowed?: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off" | "ask" | "full";
@@ -416,7 +419,9 @@ export function buildAgentSystemPrompt(params: {
: ""
}`
: "",
params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "",
params.sandboxInfo.browserControlUrl
? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}`
: "",
params.sandboxInfo.browserNoVncUrl
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
: "",
@@ -425,6 +430,15 @@ 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."
: "",
+2 -1
View File
@@ -35,7 +35,7 @@ const BROWSER_TOOL_ACTIONS = [
"act",
] as const;
const BROWSER_TARGETS = ["sandbox", "host", "node"] as const;
const BROWSER_TARGETS = ["sandbox", "host", "custom", "node"] as const;
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
@@ -86,6 +86,7 @@ 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()),
+26 -10
View File
@@ -28,7 +28,23 @@ 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);
@@ -83,7 +99,7 @@ describe("browser tool snapshot maxChars", () => {
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
"http://127.0.0.1:18791",
expect.objectContaining({
format: "ai",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
@@ -101,7 +117,7 @@ describe("browser tool snapshot maxChars", () => {
});
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
"http://127.0.0.1:18791",
expect.objectContaining({
maxChars: override,
}),
@@ -125,7 +141,7 @@ describe("browser tool snapshot maxChars", () => {
const tool = createBrowserTool();
await tool.execute?.(null, { action: "profiles" });
expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith(undefined);
expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith("http://127.0.0.1:18791");
});
it("passes refs mode through to browser snapshot", async () => {
@@ -133,7 +149,7 @@ describe("browser tool snapshot maxChars", () => {
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", refs: "aria" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
"http://127.0.0.1:18791",
expect.objectContaining({
format: "ai",
refs: "aria",
@@ -149,7 +165,7 @@ describe("browser tool snapshot maxChars", () => {
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
"http://127.0.0.1:18791",
expect.objectContaining({
mode: "efficient",
}),
@@ -169,11 +185,11 @@ describe("browser tool snapshot maxChars", () => {
});
it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => {
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
await tool.execute?.(null, { action: "snapshot", profile: "chrome", snapshotFormat: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
"http://127.0.0.1:18791",
expect.objectContaining({
profile: "chrome",
}),
@@ -204,7 +220,7 @@ describe("browser tool snapshot maxChars", () => {
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
});
it("keeps sandbox bridge url when node proxy is available", async () => {
it("keeps sandbox control url when node proxy is available", async () => {
nodesUtilsMocks.listNodes.mockResolvedValue([
{
nodeId: "node-1",
@@ -214,7 +230,7 @@ describe("browser tool snapshot maxChars", () => {
commands: ["browser.proxy"],
},
]);
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
await tool.execute?.(null, { action: "status" });
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
@@ -238,7 +254,7 @@ describe("browser tool snapshot maxChars", () => {
await tool.execute?.(null, { action: "status", profile: "chrome" });
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
undefined,
"http://127.0.0.1:18791",
expect.objectContaining({ profile: "chrome" }),
);
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
+99 -22
View File
@@ -55,8 +55,9 @@ function isBrowserNode(node: NodeListNode) {
async function resolveBrowserNodeTarget(params: {
requestedNode?: string;
target?: "sandbox" | "host" | "node";
sandboxBridgeUrl?: string;
target?: "sandbox" | "host" | "custom" | "node";
controlUrl?: string;
defaultControlUrl?: string;
}): Promise<BrowserNodeTarget | null> {
const cfg = loadConfig();
const policy = cfg.gateway?.nodes?.browser;
@@ -67,9 +68,10 @@ async function resolveBrowserNodeTarget(params: {
}
return null;
}
if (params.sandboxBridgeUrl?.trim() && params.target !== "node" && !params.requestedNode) {
if (params.defaultControlUrl?.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;
@@ -185,22 +187,70 @@ function applyProxyPaths(result: unknown, mapping: Map<string, string>) {
}
function resolveBrowserBaseUrl(params: {
target?: "sandbox" | "host";
sandboxBridgeUrl?: string;
target?: "sandbox" | "host" | "custom";
controlUrl?: string;
defaultControlUrl?: string;
allowHostControl?: boolean;
}): string | undefined {
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
}) {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const normalizedSandbox = params.sandboxBridgeUrl?.trim() ?? "";
const target = params.target ?? (normalizedSandbox ? "sandbox" : "host");
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;
}
if (target === "sandbox") {
if (!normalizedSandbox) {
if (!normalizedDefault) {
throw new Error(
'Sandbox browser is unavailable. Enable agents.defaults.sandbox.browser.enabled or use target="host" if allowed.',
);
}
return normalizedSandbox.replace(/\/$/, "");
return normalizedDefault.replace(/\/$/, "");
}
if (params.allowHostControl === false) {
@@ -211,16 +261,27 @@ function resolveBrowserBaseUrl(params: {
"Browser control is disabled. Set browser.enabled=true in ~/.clawdbot/clawdbot.json.",
);
}
return undefined;
const normalized = resolved.controlUrl.replace(/\/$/, "");
assertAllowedControlUrl(normalized);
return normalized;
}
export function createBrowserTool(opts?: {
sandboxBridgeUrl?: string;
defaultControlUrl?: string;
allowHostControl?: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
}): AnyAgentTool {
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
const targetDefault = opts?.defaultControlUrl ? "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",
@@ -233,22 +294,33 @@ 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|node). Default: ${targetDefault}.`,
`target selects browser location (sandbox|host|custom|node). Default: ${targetDefault}.`,
"controlUrl implies target=custom (remote control server).",
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" | "node" | undefined;
let target = readStringParam(params, "target") as
| "sandbox"
| "host"
| "custom"
| "node"
| undefined;
if (requestedNode && target && target !== "node") {
throw new Error('node is only supported with target="node".');
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 (!target && !requestedNode && profile === "chrome") {
if (!target && !controlUrl?.trim() && !requestedNode && profile === "chrome") {
// Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node.
target = "host";
}
@@ -256,16 +328,21 @@ export function createBrowserTool(opts?: {
const nodeTarget = await resolveBrowserNodeTarget({
requestedNode: requestedNode ?? undefined,
target,
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
controlUrl,
defaultControlUrl: opts?.defaultControlUrl,
});
const resolvedTarget = target === "node" ? undefined : target;
const baseUrl = nodeTarget
? undefined
? ""
: resolveBrowserBaseUrl({
target: resolvedTarget,
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
controlUrl,
defaultControlUrl: opts?.defaultControlUrl,
allowHostControl: opts?.allowHostControl,
allowedControlUrls: opts?.allowedControlUrls,
allowedControlHosts: opts?.allowedControlHosts,
allowedControlPorts: opts?.allowedControlPorts,
});
const proxyRequest = nodeTarget
+4 -3
View File
@@ -4,7 +4,6 @@ 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,
@@ -51,7 +50,7 @@ export async function startBrowserBridgeServer(params: {
getState: () => state,
onEnsureAttachTarget: params.onEnsureAttachTarget,
});
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
registerBrowserRoutes(app, ctx);
const server = await new Promise<Server>((resolve, reject) => {
const s = app.listen(port, host, () => resolve(s));
@@ -62,9 +61,11 @@ 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 = `http://${host}:${resolvedPort}`;
const baseUrl = state.resolved.controlUrl;
return { server, port: resolvedPort, baseUrl, state };
}
+15 -25
View File
@@ -9,12 +9,6 @@ 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;
@@ -98,15 +92,11 @@ export type BrowserDownloadPayload = {
};
export async function browserNavigate(
baseUrl: string | undefined,
opts: {
url: string;
targetId?: string;
profile?: string;
},
baseUrl: string,
opts: { url: string; targetId?: string; profile?: string },
): Promise<BrowserActionTabResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTabResult>(withBaseUrl(baseUrl, `/navigate${q}`), {
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/navigate${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
@@ -115,7 +105,7 @@ export async function browserNavigate(
}
export async function browserArmDialog(
baseUrl: string | undefined,
baseUrl: string,
opts: {
accept: boolean;
promptText?: string;
@@ -125,7 +115,7 @@ export async function browserArmDialog(
},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/dialog${q}`), {
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/dialog${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -139,7 +129,7 @@ export async function browserArmDialog(
}
export async function browserArmFileChooser(
baseUrl: string | undefined,
baseUrl: string,
opts: {
paths: string[];
ref?: string;
@@ -151,7 +141,7 @@ export async function browserArmFileChooser(
},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/file-chooser${q}`), {
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/file-chooser${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -167,7 +157,7 @@ export async function browserArmFileChooser(
}
export async function browserWaitForDownload(
baseUrl: string | undefined,
baseUrl: string,
opts: {
path?: string;
targetId?: string;
@@ -180,7 +170,7 @@ export async function browserWaitForDownload(
ok: true;
targetId: string;
download: BrowserDownloadPayload;
}>(withBaseUrl(baseUrl, `/wait/download${q}`), {
}>(`${baseUrl}/wait/download${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -193,7 +183,7 @@ export async function browserWaitForDownload(
}
export async function browserDownload(
baseUrl: string | undefined,
baseUrl: string,
opts: {
ref: string;
path: string;
@@ -207,7 +197,7 @@ export async function browserDownload(
ok: true;
targetId: string;
download: BrowserDownloadPayload;
}>(withBaseUrl(baseUrl, `/download${q}`), {
}>(`${baseUrl}/download${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -221,12 +211,12 @@ export async function browserDownload(
}
export async function browserAct(
baseUrl: string | undefined,
baseUrl: string,
req: BrowserActRequest,
opts?: { profile?: string },
): Promise<BrowserActResponse> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserActResponse>(withBaseUrl(baseUrl, `/act${q}`), {
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
@@ -235,7 +225,7 @@ export async function browserAct(
}
export async function browserScreenshotAction(
baseUrl: string | undefined,
baseUrl: string,
opts: {
targetId?: string;
fullPage?: boolean;
@@ -246,7 +236,7 @@ export async function browserScreenshotAction(
},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/screenshot${q}`), {
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/screenshot${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+16 -22
View File
@@ -10,14 +10,8 @@ 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 | undefined,
baseUrl: string,
opts: { level?: string; targetId?: string; profile?: string } = {},
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
const q = new URLSearchParams();
@@ -29,15 +23,15 @@ export async function browserConsoleMessages(
ok: true;
messages: BrowserConsoleMessage[];
targetId: string;
}>(withBaseUrl(baseUrl, `/console${suffix}`), { timeoutMs: 20000 });
}>(`${baseUrl}/console${suffix}`, { timeoutMs: 20000 });
}
export async function browserPdfSave(
baseUrl: string | undefined,
baseUrl: string,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/pdf${q}`), {
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/pdf${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
@@ -46,7 +40,7 @@ export async function browserPdfSave(
}
export async function browserPageErrors(
baseUrl: string | undefined,
baseUrl: string,
opts: { targetId?: string; clear?: boolean; profile?: string } = {},
): Promise<{ ok: true; targetId: string; errors: BrowserPageError[] }> {
const q = new URLSearchParams();
@@ -58,11 +52,11 @@ export async function browserPageErrors(
ok: true;
targetId: string;
errors: BrowserPageError[];
}>(withBaseUrl(baseUrl, `/errors${suffix}`), { timeoutMs: 20000 });
}>(`${baseUrl}/errors${suffix}`, { timeoutMs: 20000 });
}
export async function browserRequests(
baseUrl: string | undefined,
baseUrl: string,
opts: {
targetId?: string;
filter?: string;
@@ -80,11 +74,11 @@ export async function browserRequests(
ok: true;
targetId: string;
requests: BrowserNetworkRequest[];
}>(withBaseUrl(baseUrl, `/requests${suffix}`), { timeoutMs: 20000 });
}>(`${baseUrl}/requests${suffix}`, { timeoutMs: 20000 });
}
export async function browserTraceStart(
baseUrl: string | undefined,
baseUrl: string,
opts: {
targetId?: string;
screenshots?: boolean;
@@ -94,7 +88,7 @@ export async function browserTraceStart(
} = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/trace/start${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/trace/start${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -108,11 +102,11 @@ export async function browserTraceStart(
}
export async function browserTraceStop(
baseUrl: string | undefined,
baseUrl: string,
opts: { targetId?: string; path?: string; profile?: string } = {},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/trace/stop${q}`), {
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/trace/stop${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
@@ -121,11 +115,11 @@ export async function browserTraceStop(
}
export async function browserHighlight(
baseUrl: string | undefined,
baseUrl: string,
opts: { ref: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/highlight${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/highlight${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
@@ -134,7 +128,7 @@ export async function browserHighlight(
}
export async function browserResponseBody(
baseUrl: string | undefined,
baseUrl: string,
opts: {
url: string;
targetId?: string;
@@ -164,7 +158,7 @@ export async function browserResponseBody(
body: string;
truncated?: boolean;
};
}>(withBaseUrl(baseUrl, `/response/body${q}`), {
}>(`${baseUrl}/response/body${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+61 -76
View File
@@ -5,14 +5,8 @@ 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 | undefined,
baseUrl: string,
opts: { targetId?: string; profile?: string } = {},
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
const q = new URLSearchParams();
@@ -23,11 +17,11 @@ export async function browserCookies(
ok: true;
targetId: string;
cookies: unknown[];
}>(withBaseUrl(baseUrl, `/cookies${suffix}`), { timeoutMs: 20000 });
}>(`${baseUrl}/cookies${suffix}`, { timeoutMs: 20000 });
}
export async function browserCookiesSet(
baseUrl: string | undefined,
baseUrl: string,
opts: {
cookie: Record<string, unknown>;
targetId?: string;
@@ -35,7 +29,7 @@ export async function browserCookiesSet(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/set${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/set${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
@@ -44,11 +38,11 @@ export async function browserCookiesSet(
}
export async function browserCookiesClear(
baseUrl: string | undefined,
baseUrl: string,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/clear${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/clear${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
@@ -57,7 +51,7 @@ export async function browserCookiesClear(
}
export async function browserStorageGet(
baseUrl: string | undefined,
baseUrl: string,
opts: {
kind: "local" | "session";
key?: string;
@@ -74,11 +68,11 @@ export async function browserStorageGet(
ok: true;
targetId: string;
values: Record<string, string>;
}>(withBaseUrl(baseUrl, `/storage/${opts.kind}${suffix}`), { timeoutMs: 20000 });
}>(`${baseUrl}/storage/${opts.kind}${suffix}`, { timeoutMs: 20000 });
}
export async function browserStorageSet(
baseUrl: string | undefined,
baseUrl: string,
opts: {
kind: "local" | "session";
key: string;
@@ -88,28 +82,25 @@ export async function browserStorageSet(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
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,
},
);
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,
});
}
export async function browserStorageClear(
baseUrl: string | undefined,
baseUrl: string,
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/storage/${opts.kind}/clear${q}`),
`${baseUrl}/storage/${opts.kind}/clear${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -120,11 +111,11 @@ export async function browserStorageClear(
}
export async function browserSetOffline(
baseUrl: string | undefined,
baseUrl: string,
opts: { offline: boolean; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/offline${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/offline${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
@@ -133,7 +124,7 @@ export async function browserSetOffline(
}
export async function browserSetHeaders(
baseUrl: string | undefined,
baseUrl: string,
opts: {
headers: Record<string, string>;
targetId?: string;
@@ -141,7 +132,7 @@ export async function browserSetHeaders(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/headers${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/headers${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
@@ -150,7 +141,7 @@ export async function browserSetHeaders(
}
export async function browserSetHttpCredentials(
baseUrl: string | undefined,
baseUrl: string,
opts: {
username?: string;
password?: string;
@@ -160,24 +151,21 @@ export async function browserSetHttpCredentials(
} = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
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,
},
);
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,
});
}
export async function browserSetGeolocation(
baseUrl: string | undefined,
baseUrl: string,
opts: {
latitude?: number;
longitude?: number;
@@ -189,26 +177,23 @@ export async function browserSetGeolocation(
} = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
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,
},
);
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,
});
}
export async function browserSetMedia(
baseUrl: string | undefined,
baseUrl: string,
opts: {
colorScheme: "dark" | "light" | "no-preference" | "none";
targetId?: string;
@@ -216,7 +201,7 @@ export async function browserSetMedia(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/media${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/media${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -228,11 +213,11 @@ export async function browserSetMedia(
}
export async function browserSetTimezone(
baseUrl: string | undefined,
baseUrl: string,
opts: { timezoneId: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/timezone${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/timezone${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -244,11 +229,11 @@ export async function browserSetTimezone(
}
export async function browserSetLocale(
baseUrl: string | undefined,
baseUrl: string,
opts: { locale: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/locale${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/locale${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
@@ -257,11 +242,11 @@ export async function browserSetLocale(
}
export async function browserSetDevice(
baseUrl: string | undefined,
baseUrl: string,
opts: { name: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/device${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/device${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
@@ -270,11 +255,11 @@ export async function browserSetDevice(
}
export async function browserClearPermissions(
baseUrl: string | undefined,
baseUrl: string,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/set/geolocation${q}`), {
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/set/geolocation${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
+68 -81
View File
@@ -1,44 +1,57 @@
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
import { loadConfig } from "../config/config.js";
import { formatCliCommand } from "../cli/command-format.js";
import {
createBrowserControlContext,
startBrowserControlServiceFromConfig,
} from "./control-service.js";
import { createBrowserRouteDispatcher } from "./routes/dispatcher.js";
import { resolveBrowserConfig } from "./config.js";
function isAbsoluteHttp(url: string): boolean {
return /^https?:\/\//i.test(url.trim());
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 enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
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")) {
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") {
return new Error(
`Can't reach the clawd browser control service (timed out after ${timeoutMs}ms). ${hint}`,
`Can't reach the clawd browser control server at ${url} (connection refused). ${hint}`,
);
}
return new Error(`Can't reach the clawd browser control service. ${hint} (${msg})`);
}
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);
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}`,
);
}
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}`,
);
}
return new Error(`Can't reach the clawd browser control server at ${url}. ${hint} (${msg})`);
}
export async function fetchBrowserJson<T>(
@@ -46,58 +59,32 @@ 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 {
if (isAbsoluteHttp(url)) {
return await fetchHttpJson<T>(url, init ? { ...init, timeoutMs } : { 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
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}`);
}
}
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;
return h;
})();
res = await fetch(url, {
...init,
...(mergedHeaders ? { headers: mergedHeaders } : {}),
signal: ctrl.signal,
} as RequestInit);
} 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;
}
+39 -3
View File
@@ -16,7 +16,7 @@ describe("browser client", () => {
vi.unstubAllGlobals();
});
it("wraps connection failures with a sandbox hint", async () => {
it("wraps connection failures with a gateway 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(/sandboxed session/i);
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/Start .*gateway/i);
});
it("adds useful timeout messaging for abort-like failures", async () => {
@@ -34,6 +34,41 @@ 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",
@@ -46,7 +81,7 @@ describe("browser client", () => {
await expect(
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
).rejects.toThrow(/conflict/i);
).rejects.toThrow(/409: conflict/i);
});
it("adds labels + efficient mode query params to snapshots", async () => {
@@ -220,6 +255,7 @@ describe("browser client", () => {
ok: true,
json: async () => ({
enabled: true,
controlUrl: "http://127.0.0.1:18791",
running: true,
pid: 1,
cdpPort: 18792,
+51 -56
View File
@@ -1,7 +1,10 @@
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;
@@ -86,64 +89,59 @@ 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>(withBaseUrl(baseUrl, `/${q}`), {
return await fetchBrowserJson<BrowserStatus>(`${baseUrl}/${q}`, {
timeoutMs: 1500,
});
}
export async function browserProfiles(baseUrl?: string): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
withBaseUrl(baseUrl, `/profiles`),
{
timeoutMs: 3000,
},
);
export async function browserProfiles(baseUrl: string): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(`${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(withBaseUrl(baseUrl, `/start${q}`), {
await fetchBrowserJson(`${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(withBaseUrl(baseUrl, `/stop${q}`), {
await fetchBrowserJson(`${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>(
withBaseUrl(baseUrl, `/reset-profile${q}`),
{
method: "POST",
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserResetProfileResult>(`${baseUrl}/reset-profile${q}`, {
method: "POST",
timeoutMs: 20000,
});
}
export type BrowserCreateProfileResult = {
@@ -156,7 +154,7 @@ export type BrowserCreateProfileResult = {
};
export async function browserCreateProfile(
baseUrl: string | undefined,
baseUrl: string,
opts: {
name: string;
color?: string;
@@ -164,20 +162,17 @@ export async function browserCreateProfile(
driver?: "clawd" | "extension";
},
): Promise<BrowserCreateProfileResult> {
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,
},
);
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,
});
}
export type BrowserDeleteProfileResult = {
@@ -187,11 +182,11 @@ export type BrowserDeleteProfileResult = {
};
export async function browserDeleteProfile(
baseUrl: string | undefined,
baseUrl: string,
profile: string,
): Promise<BrowserDeleteProfileResult> {
return await fetchBrowserJson<BrowserDeleteProfileResult>(
withBaseUrl(baseUrl, `/profiles/${encodeURIComponent(profile)}`),
`${baseUrl}/profiles/${encodeURIComponent(profile)}`,
{
method: "DELETE",
timeoutMs: 20000,
@@ -200,24 +195,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[] }>(
withBaseUrl(baseUrl, `/tabs${q}`),
`${baseUrl}/tabs${q}`,
{ timeoutMs: 3000 },
);
return res.tabs ?? [];
}
export async function browserOpenTab(
baseUrl: string | undefined,
baseUrl: string,
url: string,
opts?: { profile?: string },
): Promise<BrowserTab> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserTab>(withBaseUrl(baseUrl, `/tabs/open${q}`), {
return await fetchBrowserJson<BrowserTab>(`${baseUrl}/tabs/open${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
@@ -226,12 +221,12 @@ export async function browserOpenTab(
}
export async function browserFocusTab(
baseUrl: string | undefined,
baseUrl: string,
targetId: string,
opts?: { profile?: string },
): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/focus${q}`), {
await fetchBrowserJson(`${baseUrl}/tabs/focus${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId }),
@@ -240,19 +235,19 @@ export async function browserFocusTab(
}
export async function browserCloseTab(
baseUrl: string | undefined,
baseUrl: string,
targetId: string,
opts?: { profile?: string },
): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/${encodeURIComponent(targetId)}${q}`), {
await fetchBrowserJson(`${baseUrl}/tabs/${encodeURIComponent(targetId)}${q}`, {
method: "DELETE",
timeoutMs: 5000,
});
}
export async function browserTabAction(
baseUrl: string | undefined,
baseUrl: string,
opts: {
action: "list" | "new" | "close" | "select";
index?: number;
@@ -260,7 +255,7 @@ export async function browserTabAction(
},
): Promise<unknown> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/action${q}`), {
return await fetchBrowserJson(`${baseUrl}/tabs/action${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -272,7 +267,7 @@ export async function browserTabAction(
}
export async function browserSnapshot(
baseUrl: string | undefined,
baseUrl: string,
opts: {
format: "aria" | "ai";
targetId?: string;
@@ -306,7 +301,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>(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), {
return await fetchBrowserJson<SnapshotResult>(`${baseUrl}/snapshot?${q.toString()}`, {
timeoutMs: 20000,
});
}
+24 -31
View File
@@ -2,14 +2,13 @@ import { describe, expect, it } from "vitest";
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
describe("browser config", () => {
it("defaults to enabled with loopback defaults and lobster-orange color", () => {
it("defaults to enabled with loopback control url 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");
@@ -47,31 +46,9 @@ 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");
@@ -79,6 +56,7 @@ describe("browser config", () => {
it("supports custom remote CDP timeouts", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
remoteCdpTimeoutMs: 2200,
remoteCdpHandshakeTimeoutMs: 5000,
});
@@ -88,21 +66,31 @@ 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 cdpUrl as remote", () => {
it("treats non-loopback control urls as remote", () => {
const resolved = resolveBrowserConfig({
cdpUrl: "http://example.com:9222",
controlUrl: "http://example.com:18791",
});
const profile = resolveProfile(resolved, "clawd");
expect(profile?.cdpIsLoopback).toBe(false);
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");
});
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");
@@ -113,6 +101,7 @@ 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" },
},
@@ -126,6 +115,7 @@ 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" },
@@ -137,11 +127,14 @@ describe("browser config", () => {
});
it("rejects unsupported protocols", () => {
expect(() => resolveBrowserConfig({ cdpUrl: "ws://127.0.0.1:18791" })).toThrow(/must be http/i);
expect(() => resolveBrowserConfig({ controlUrl: "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" },
},
+30 -13
View File
@@ -1,12 +1,11 @@
import type { BrowserConfig, BrowserProfileConfig, ClawdbotConfig } from "../config/config.js";
import type { BrowserConfig, BrowserProfileConfig } 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,
@@ -15,7 +14,10 @@ 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;
@@ -135,13 +137,24 @@ function ensureDefaultChromeExtensionProfile(
};
return result;
}
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
rootConfig?: ClawdbotConfig,
): ResolvedBrowserConfig {
export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBrowserConfig {
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
const gatewayPort = resolveGatewayPort(rootConfig);
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
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 defaultColor = normalizeHexColor(cfg?.color);
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
@@ -165,10 +178,11 @@ export function resolveBrowserConfig(
const derivedPort = controlPort + 1;
if (derivedPort > 65535) {
throw new Error(
`Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`,
`browser.controlUrl port (${controlPort}) is too high; cannot derive CDP port (${derivedPort})`,
);
}
const derived = new URL(`http://127.0.0.1:${derivedPort}`);
const derived = new URL(controlInfo.normalized);
derived.port = String(derivedPort);
cdpInfo = {
parsed: derived,
port: derivedPort,
@@ -197,7 +211,10 @@ export function resolveBrowserConfig(
return {
enabled,
controlUrl: controlInfo.normalized,
controlHost: controlInfo.parsed.hostname,
controlPort,
...(controlToken ? { controlToken } : {}),
cdpProtocol,
cdpHost: cdpInfo.parsed.hostname,
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
@@ -252,6 +269,6 @@ export function resolveProfile(
};
}
export function shouldStartLocalBrowserServer(_resolved: ResolvedBrowserConfig) {
return true;
export function shouldStartLocalBrowserServer(resolved: ResolvedBrowserConfig) {
return isLoopbackHost(resolved.controlHost);
}
+1
View File
@@ -1,4 +1,5 @@
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";
-80
View File
@@ -1,80 +0,0 @@
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
}
}
+8 -2
View File
@@ -49,7 +49,9 @@ function createCtx(resolved: BrowserServerState["resolved"]) {
describe("BrowserProfilesService", () => {
it("allocates next local port for new profiles", async () => {
const resolved = resolveBrowserConfig({});
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
});
const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
@@ -64,7 +66,9 @@ describe("BrowserProfilesService", () => {
});
it("accepts per-profile cdpUrl for remote Chrome", async () => {
const resolved = resolveBrowserConfig({});
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
@@ -93,6 +97,7 @@ 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" },
},
@@ -119,6 +124,7 @@ 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" },
},
+3 -5
View File
@@ -1,3 +1,5 @@
import type express from "express";
import type { BrowserFormField } from "../client-actions-core.js";
import type { BrowserRouteContext } from "../server-context.js";
import {
@@ -14,12 +16,8 @@ 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: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
export function registerBrowserAgentActRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.post("/act", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
+3 -5
View File
@@ -2,15 +2,13 @@ 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: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
export function registerBrowserAgentDebugRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.get("/console", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
+7 -6
View File
@@ -1,8 +1,9 @@
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.",
@@ -14,21 +15,21 @@ export const SELECTOR_UNSUPPORTED_MESSAGE = [
"This is more reliable for modern SPAs.",
].join("\n");
export function readBody(req: BrowserRequest): Record<string, unknown> {
export function readBody(req: express.Request): 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: BrowserResponse, err: unknown) {
export function handleRouteError(ctx: BrowserRouteContext, res: express.Response, 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: BrowserRequest,
res: BrowserResponse,
req: express.Request,
res: express.Response,
ctx: BrowserRouteContext,
): ProfileContext | null {
const profileCtx = getProfileContext(req, ctx);
@@ -44,7 +45,7 @@ export async function getPwAiModule(): Promise<PwAiModule | null> {
}
export async function requirePwAi(
res: BrowserResponse,
res: express.Response,
feature: string,
): Promise<PwAiModule | null> {
const mod = await getPwAiModule();
+3 -5
View File
@@ -1,5 +1,7 @@
import path from "node:path";
import type express from "express";
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
import { captureScreenshot, snapshotAria } from "../cdp.js";
import {
@@ -21,12 +23,8 @@ import {
resolveProfileContext,
} from "./agent.shared.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserAgentSnapshotRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
export function registerBrowserAgentSnapshotRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.post("/navigate", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
+3 -5
View File
@@ -1,12 +1,10 @@
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: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
export function registerBrowserAgentStorageRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.get("/cookies", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
+3 -2
View File
@@ -1,11 +1,12 @@
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: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
export function registerBrowserAgentRoutes(app: express.Express, ctx: BrowserRouteContext) {
registerBrowserAgentSnapshotRoutes(app, ctx);
registerBrowserAgentActRoutes(app, ctx);
registerBrowserAgentDebugRoutes(app, ctx);
+4 -2
View File
@@ -1,10 +1,11 @@
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: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRouteContext) {
// List all profiles with their status
app.get("/profiles", async (_req, res) => {
try {
@@ -52,6 +53,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
res.json({
enabled: current.resolved.enabled,
controlUrl: current.resolved.controlUrl,
profile: profileCtx.profile.name,
running: cdpReady,
cdpReady,
-122
View File
@@ -1,122 +0,0 @@
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 };
+3 -2
View File
@@ -1,10 +1,11 @@
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: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
export function registerBrowserRoutes(app: express.Express, ctx: BrowserRouteContext) {
registerBrowserBasicRoutes(app, ctx);
registerBrowserTabRoutes(app, ctx);
registerBrowserAgentRoutes(app, ctx);
+3 -2
View File
@@ -1,8 +1,9 @@
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: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
export function registerBrowserTabRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.get("/tabs", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
-21
View File
@@ -1,21 +0,0 @@
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;
};
+4 -3
View File
@@ -1,13 +1,14 @@
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: BrowserRequest,
req: express.Request,
ctx: BrowserRouteContext,
): ProfileContext | { error: string; status: number } {
let profileName: string | undefined;
@@ -32,7 +33,7 @@ export function getProfileContext(
}
}
export function jsonError(res: BrowserResponse, status: number, message: string) {
export function jsonError(res: express.Response, status: number, message: string) {
res.status(status).json({ error: message });
}
@@ -62,6 +62,8 @@ 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",
@@ -119,6 +121,8 @@ 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",
@@ -166,6 +170,8 @@ 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",
@@ -21,6 +21,8 @@ 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",
+1 -1
View File
@@ -17,7 +17,7 @@ export type ProfileRuntimeState = {
};
export type BrowserServerState = {
server?: Server | null;
server: Server;
port: number;
resolved: ResolvedBrowserConfig;
profiles: Map<string, ProfileRuntimeState>;
@@ -8,7 +8,6 @@ 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,6 +88,7 @@ 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,8 +197,6 @@ 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;
@@ -250,11 +248,6 @@ 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();
});
@@ -9,7 +9,6 @@ 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 () => {
@@ -90,6 +89,7 @@ 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,8 +198,6 @@ 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;
@@ -251,11 +249,6 @@ 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();
});
@@ -8,7 +8,6 @@ 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,6 +88,7 @@ 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,8 +197,6 @@ 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;
@@ -250,11 +248,6 @@ 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();
});
@@ -8,7 +8,6 @@ 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,6 +88,7 @@ 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,8 +197,6 @@ 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;
@@ -250,11 +248,6 @@ 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();
});
@@ -8,7 +8,6 @@ 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,6 +88,7 @@ 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,8 +197,6 @@ 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;
@@ -250,11 +248,6 @@ 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();
});
@@ -8,7 +8,6 @@ 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,6 +88,7 @@ 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,8 +197,6 @@ 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;
@@ -250,11 +248,6 @@ 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();
});
@@ -401,6 +394,8 @@ 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",
+13 -9
View File
@@ -3,10 +3,9 @@ import express from "express";
import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } 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;
@@ -17,16 +16,23 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
if (state) return state;
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const resolved = resolveBrowserConfig(cfg.browser);
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 as unknown as BrowserRouteRegistrar, ctx);
registerBrowserRoutes(app, ctx);
const port = resolved.controlPort;
const server = await new Promise<Server>((resolve, reject) => {
@@ -83,11 +89,9 @@ export async function stopBrowserControlServer(): Promise<void> {
logServer.warn(`clawd browser stop failed: ${String(err)}`);
}
if (current.server) {
await new Promise<void>((resolve) => {
current.server?.close(() => resolve());
});
}
await new Promise<void>((resolve) => {
current.server.close(() => resolve());
});
state = null;
// Optional: Playwright is not always available (e.g. embedded gateway builds).
+1 -1
View File
@@ -1 +1 @@
6d63b866aa0e917b278c6bef42229e8cd1f43c8ba31c845a96b4d9d5ce780265
a99455ba0c4d0aad0a110bf25440c208b798198d5524b269f0f2d3f984262ae4
File diff suppressed because it is too large. Load diff
+20 -30
View File
@@ -8,7 +8,6 @@ import type { Duplex } from "node:stream";
import chokidar from "chokidar";
import { type WebSocket, WebSocketServer } from "ws";
import { isTruthyEnvValue } from "../infra/env.js";
import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js";
import { detectMime } from "../media/mime.js";
import type { RuntimeEnv } from "../runtime.js";
import { ensureDir, resolveUserPath } from "../utils.js";
@@ -146,31 +145,30 @@ async function resolveFilePath(rootReal: string, urlPath: string) {
const rel = normalized.replace(/^\/+/, "");
if (rel.split("/").some((p) => p === "..")) return null;
const tryOpen = async (relative: string) => {
try {
return await openFileWithinRoot({ rootDir: rootReal, relativePath: relative });
} catch (err) {
if (err instanceof SafeOpenError) return null;
throw err;
}
};
let candidate = path.join(rootReal, rel);
if (normalized.endsWith("/")) {
return await tryOpen(path.posix.join(rel, "index.html"));
candidate = path.join(candidate, "index.html");
}
const candidate = path.join(rootReal, rel);
try {
const st = await fs.lstat(candidate);
if (st.isSymbolicLink()) return null;
const st = await fs.stat(candidate);
if (st.isDirectory()) {
return await tryOpen(path.posix.join(rel, "index.html"));
candidate = path.join(candidate, "index.html");
}
} catch {
// ignore
}
return await tryOpen(rel);
const rootPrefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`;
try {
const lstat = await fs.lstat(candidate);
if (lstat.isSymbolicLink()) return null;
const real = await fs.realpath(candidate);
if (!real.startsWith(rootPrefix)) return null;
return real;
} catch {
return null;
}
}
function isDisabledByEnv() {
@@ -313,8 +311,8 @@ export async function createCanvasHostHandler(
return true;
}
const opened = await resolveFilePath(rootReal, urlPath);
if (!opened) {
const filePath = await resolveFilePath(rootReal, urlPath);
if (!filePath) {
if (urlPath === "/" || urlPath.endsWith("/")) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/html; charset=utf-8");
@@ -329,30 +327,22 @@ export async function createCanvasHostHandler(
return true;
}
const { handle, realPath } = opened;
let data: Buffer;
try {
data = await handle.readFile();
} finally {
await handle.close().catch(() => {});
}
const lower = realPath.toLowerCase();
const lower = filePath.toLowerCase();
const mime =
lower.endsWith(".html") || lower.endsWith(".htm")
? "text/html"
: ((await detectMime({ filePath: realPath })) ?? "application/octet-stream");
: ((await detectMime({ filePath })) ?? "application/octet-stream");
res.setHeader("Cache-Control", "no-store");
if (mime === "text/html") {
const html = data.toString("utf8");
const html = await fs.readFile(filePath, "utf8");
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(liveReload ? injectCanvasLiveReload(html) : html);
return true;
}
res.setHeader("Content-Type", mime);
res.end(data);
res.end(await fs.readFile(filePath));
return true;
} catch (err) {
opts.runtime.error(`canvasHost request failed: ${String(err)}`);
@@ -1,8 +1,9 @@
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 { callBrowserAct, requireRef, resolveBrowserActionContext } from "./shared.js";
import { requireRef, resolveBrowserActionContext } from "./shared.js";
export function registerBrowserElementCommands(
browser: Command,
@@ -17,7 +18,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, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
const refValue = requireRef(ref);
if (!refValue) return;
const modifiers = opts.modifiers
@@ -27,10 +28,9 @@ export function registerBrowserElementCommands(
.filter(Boolean)
: undefined;
try {
const result = await callBrowserAct<{ url?: string }>({
parent,
profile,
body: {
const result = await browserAct(
baseUrl,
{
kind: "click",
ref: refValue,
targetId: opts.targetId?.trim() || undefined,
@@ -38,7 +38,8 @@ export function registerBrowserElementCommands(
button: opts.button?.trim() || undefined,
modifiers,
},
});
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -60,14 +61,13 @@ 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, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
const refValue = requireRef(ref);
if (!refValue) return;
try {
const result = await callBrowserAct({
parent,
profile,
body: {
const result = await browserAct(
baseUrl,
{
kind: "type",
ref: refValue,
text,
@@ -75,7 +75,8 @@ export function registerBrowserElementCommands(
slowly: Boolean(opts.slowly),
targetId: opts.targetId?.trim() || undefined,
},
});
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -93,13 +94,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, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const result = await callBrowserAct({
parent,
profile,
body: { kind: "press", key, targetId: opts.targetId?.trim() || undefined },
});
const result = await browserAct(
baseUrl,
{ kind: "press", key, targetId: opts.targetId?.trim() || undefined },
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -117,13 +118,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, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const result = await callBrowserAct({
parent,
profile,
body: { kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
});
const result = await browserAct(
baseUrl,
{ kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -144,21 +145,20 @@ export function registerBrowserElementCommands(
Number(v),
)
.action(async (ref: string | undefined, opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
const refValue = requireRef(ref);
if (!refValue) return;
try {
const result = await callBrowserAct({
parent,
profile,
body: {
const result = await browserAct(
baseUrl,
{
kind: "scrollIntoView",
ref: refValue,
targetId: opts.targetId?.trim() || undefined,
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
},
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
});
{ profile },
);
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, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const result = await callBrowserAct({
parent,
profile,
body: {
const result = await browserAct(
baseUrl,
{
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, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const result = await callBrowserAct({
parent,
profile,
body: {
const result = await browserAct(
baseUrl,
{
kind: "select",
ref,
values,
targetId: opts.targetId?.trim() || undefined,
},
});
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -1,7 +1,13 @@
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 { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
import type { BrowserParentOpts } from "../browser-cli-shared.js";
import { resolveBrowserActionContext } from "./shared.js";
import { shortenHomePath } from "../../utils.js";
@@ -23,26 +29,17 @@ export function registerBrowserFilesAndDownloadsCommands(
(v: string) => Number(v),
)
.action(async (paths: string[], opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
const result = await callBrowserRequest<{ download: { path: string } }>(
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 },
);
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,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -65,23 +62,14 @@ export function registerBrowserFilesAndDownloadsCommands(
(v: string) => Number(v),
)
.action(async (outPath: string | undefined, opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
const result = await callBrowserRequest<{ download: { path: string } }>(
parent,
{
method: "POST",
path: "/wait/download",
query: profile ? { profile } : undefined,
body: {
path: outPath?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
timeoutMs,
},
},
{ timeoutMs: timeoutMs ?? 20000 },
);
const result = await browserWaitForDownload(baseUrl, {
path: outPath?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -105,24 +93,15 @@ export function registerBrowserFilesAndDownloadsCommands(
(v: string) => Number(v),
)
.action(async (ref: string, outPath: string, opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
const result = await callBrowserRequest<{ download: { path: string } }>(
parent,
{
method: "POST",
path: "/download",
query: profile ? { profile } : undefined,
body: {
ref,
path: outPath,
targetId: opts.targetId?.trim() || undefined,
timeoutMs,
},
},
{ timeoutMs: timeoutMs ?? 20000 },
);
const result = await browserDownload(baseUrl, {
ref,
path: outPath,
targetId: opts.targetId?.trim() || undefined,
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -147,7 +126,7 @@ export function registerBrowserFilesAndDownloadsCommands(
(v: string) => Number(v),
)
.action(async (opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
const accept = opts.accept ? true : opts.dismiss ? false : undefined;
if (accept === undefined) {
defaultRuntime.error(danger("Specify --accept or --dismiss"));
@@ -155,22 +134,13 @@ export function registerBrowserFilesAndDownloadsCommands(
return;
}
try {
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 },
);
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,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -1,8 +1,9 @@
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 { callBrowserAct, readFields, resolveBrowserActionContext } from "./shared.js";
import { readFields, resolveBrowserActionContext } from "./shared.js";
export function registerBrowserFormWaitEvalCommands(
browser: Command,
@@ -15,21 +16,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, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const fields = await readFields({
fields: opts.fields,
fieldsFile: opts.fieldsFile,
});
const result = await callBrowserAct<{ result?: unknown }>({
parent,
profile,
body: {
const result = await browserAct(
baseUrl,
{
kind: "fill",
fields,
targetId: opts.targetId?.trim() || undefined,
},
});
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -58,18 +59,16 @@ export function registerBrowserFormWaitEvalCommands(
)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (selector: string | undefined, opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, 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 timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
const result = await callBrowserAct<{ result?: unknown }>({
parent,
profile,
body: {
const result = await browserAct(
baseUrl,
{
kind: "wait",
timeMs: Number.isFinite(opts.time) ? opts.time : undefined,
text: opts.text?.trim() || undefined,
@@ -79,10 +78,10 @@ export function registerBrowserFormWaitEvalCommands(
loadState: load,
fn: opts.fn?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
timeoutMs,
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
},
timeoutMs,
});
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -101,23 +100,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, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
if (!opts.fn) {
defaultRuntime.error(danger("Missing --fn"));
defaultRuntime.exit(1);
return;
}
try {
const result = await callBrowserAct<{ result?: unknown }>({
parent,
profile,
body: {
const result = await browserAct(
baseUrl,
{
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;
@@ -1,7 +1,8 @@
import type { Command } from "commander";
import { browserAct, browserNavigate } from "../../browser/client-actions.js";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
import type { BrowserParentOpts } from "../browser-cli-shared.js";
import { requireRef, resolveBrowserActionContext } from "./shared.js";
export function registerBrowserNavigationCommands(
@@ -14,21 +15,13 @@ 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, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const result = await callBrowserRequest<{ url?: string }>(
parent,
{
method: "POST",
path: "/navigate",
query: profile ? { profile } : undefined,
body: {
url,
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
);
const result = await browserNavigate(baseUrl, {
url,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -47,27 +40,22 @@ 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, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { parent, baseUrl, 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 callBrowserRequest(
parent,
const result = await browserAct(
baseUrl,
{
method: "POST",
path: "/act",
query: profile ? { profile } : undefined,
body: {
kind: "resize",
width,
height,
targetId: opts.targetId?.trim() || undefined,
},
kind: "resize",
width,
height,
targetId: opts.targetId?.trim() || undefined,
},
{ timeoutMs: 20000 },
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
+5 -20
View File
@@ -1,11 +1,13 @@
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 { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
import type { BrowserParentOpts } from "../browser-cli-shared.js";
export type BrowserActionContext = {
parent: BrowserParentOpts;
baseUrl: string;
profile: string | undefined;
};
@@ -14,26 +16,9 @@ export function resolveBrowserActionContext(
parentOpts: (cmd: Command) => BrowserParentOpts,
): BrowserActionContext {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
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 },
);
return { parent, baseUrl, profile };
}
export function requireRef(ref: string | undefined) {
+26 -41
View File
@@ -1,7 +1,13 @@
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 { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
import { shortenHomePath } from "../utils.js";
@@ -23,21 +29,14 @@ 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 callBrowserRequest<{ messages: unknown[] }>(
parent,
{
method: "GET",
path: "/console",
query: {
level: opts.level?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
profile,
},
},
{ timeoutMs: 20000 },
);
const result = await browserConsoleMessages(baseUrl, {
level: opts.level?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -52,18 +51,13 @@ 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 callBrowserRequest<{ path: string }>(
parent,
{
method: "POST",
path: "/pdf",
query: profile ? { profile } : undefined,
body: { targetId: opts.targetId?.trim() || undefined },
},
{ timeoutMs: 20000 },
);
const result = await browserPdfSave(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -87,25 +81,16 @@ 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 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 },
);
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,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
+42 -80
View File
@@ -1,8 +1,16 @@
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 { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
import { shortenHomePath } from "../utils.js";
@@ -24,21 +32,14 @@ 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 callBrowserRequest(
parent,
{
method: "POST",
path: "/highlight",
query: profile ? { profile } : undefined,
body: {
ref: ref.trim(),
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
);
const result = await browserHighlight(baseUrl, {
ref: ref.trim(),
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -54,23 +55,14 @@ 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 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 },
);
const result = await browserPageErrors(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
clear: Boolean(opts.clear),
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -95,31 +87,15 @@ 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 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 },
);
const result = await browserRequests(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
filter: opts.filter?.trim() || undefined,
clear: Boolean(opts.clear),
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -152,23 +128,16 @@ 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 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 },
);
const result = await browserTraceStart(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
screenshots: Boolean(opts.screenshots),
snapshots: Boolean(opts.snapshots),
sources: Boolean(opts.sources),
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -184,21 +153,14 @@ 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 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 },
);
const result = await browserTraceStop(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
path: opts.out?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
+18 -32
View File
@@ -1,11 +1,12 @@
import type { Command } from "commander";
import type { SnapshotResult } from "../browser/client.js";
import { browserSnapshot, resolveBrowserControlUrl } from "../browser/client.js";
import { browserScreenshotAction } from "../browser/client-actions.js";
import { loadConfig } from "../config/config.js";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
export function registerBrowserInspectCommands(
browser: Command,
@@ -21,24 +22,17 @@ 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 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 },
);
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,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -67,6 +61,7 @@ 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 =
@@ -75,28 +70,19 @@ export function registerBrowserInspectCommands(
: undefined;
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode;
try {
const query: Record<string, string | number | boolean | undefined> = {
const result = await browserSnapshot(baseUrl, {
format,
targetId: opts.targetId?.trim() || undefined,
limit: Number.isFinite(opts.limit) ? opts.limit : undefined,
interactive: opts.interactive ? true : undefined,
compact: opts.compact ? true : undefined,
interactive: Boolean(opts.interactive) || undefined,
compact: Boolean(opts.compact) || undefined,
depth: Number.isFinite(opts.depth) ? opts.depth : undefined,
selector: opts.selector?.trim() || undefined,
frame: opts.frame?.trim() || undefined,
labels: opts.labels ? true : undefined,
labels: Boolean(opts.labels) || 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");
+72 -186
View File
@@ -1,16 +1,25 @@
import type { Command } from "commander";
import type {
BrowserCreateProfileResult,
BrowserDeleteProfileResult,
BrowserResetProfileResult,
BrowserStatus,
BrowserTab,
ProfileStatus,
import type { BrowserTab } from "../browser/client.js";
import {
browserCloseTab,
browserCreateProfile,
browserDeleteProfile,
browserFocusTab,
browserOpenTab,
browserProfiles,
browserResetProfile,
browserStart,
browserStatus,
browserStop,
browserTabAction,
browserTabs,
resolveBrowserControlUrl,
} 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 { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
function runBrowserCommand(action: () => Promise<void>) {
@@ -29,18 +38,11 @@ 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 callBrowserRequest<BrowserStatus>(
parent,
{
method: "GET",
path: "/",
query: parent?.browserProfile ? { profile: parent.browserProfile } : undefined,
},
{
timeoutMs: 1500,
},
);
const status = await browserStatus(baseUrl, {
profile: parent?.browserProfile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
@@ -52,6 +54,7 @@ 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"}`,
@@ -69,26 +72,11 @@ 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 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 },
);
await browserStart(baseUrl, { profile });
const status = await browserStatus(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
@@ -103,26 +91,11 @@ 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 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 },
);
await browserStop(baseUrl, { profile });
const status = await browserStatus(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
@@ -137,17 +110,10 @@ 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 callBrowserRequest<BrowserResetProfileResult>(
parent,
{
method: "POST",
path: "/reset-profile",
query: profile ? { profile } : undefined,
},
{ timeoutMs: 20000 },
);
const result = await browserResetProfile(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -166,18 +132,10 @@ 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 result = await callBrowserRequest<{ running: boolean; tabs: BrowserTab[] }>(
parent,
{
method: "GET",
path: "/tabs",
query: profile ? { profile } : undefined,
},
{ timeoutMs: 3000 },
);
const tabs = result.tabs ?? [];
const tabs = await browserTabs(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
return;
@@ -201,20 +159,13 @@ 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 callBrowserRequest<{ ok: true; tabs: BrowserTab[] }>(
parent,
{
method: "POST",
path: "/tabs/action",
query: profile ? { profile } : undefined,
body: {
action: "list",
},
},
{ timeoutMs: 10_000 },
);
const result = (await browserTabAction(baseUrl, {
action: "list",
profile,
})) as { ok: true; tabs: BrowserTab[] };
const tabs = result.tabs ?? [];
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
@@ -239,18 +190,13 @@ 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 callBrowserRequest(
parent,
{
method: "POST",
path: "/tabs/action",
query: profile ? { profile } : undefined,
body: { action: "new" },
},
{ timeoutMs: 10_000 },
);
const result = await browserTabAction(baseUrl, {
action: "new",
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -265,6 +211,7 @@ 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"));
@@ -272,16 +219,11 @@ export function registerBrowserManageCommands(
return;
}
await runBrowserCommand(async () => {
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 },
);
const result = await browserTabAction(baseUrl, {
action: "select",
index: Math.floor(index) - 1,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -296,6 +238,7 @@ 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;
@@ -305,16 +248,11 @@ export function registerBrowserManageCommands(
return;
}
await runBrowserCommand(async () => {
const result = await callBrowserRequest(
parent,
{
method: "POST",
path: "/tabs/action",
query: profile ? { profile } : undefined,
body: { action: "close", index: idx },
},
{ timeoutMs: 10_000 },
);
const result = await browserTabAction(baseUrl, {
action: "close",
index: idx,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -329,18 +267,10 @@ 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 callBrowserRequest<BrowserTab>(
parent,
{
method: "POST",
path: "/tabs/open",
query: profile ? { profile } : undefined,
body: { url },
},
{ timeoutMs: 15000 },
);
const tab = await browserOpenTab(baseUrl, url, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(tab, null, 2));
return;
@@ -355,18 +285,10 @@ 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 callBrowserRequest(
parent,
{
method: "POST",
path: "/tabs/focus",
query: profile ? { profile } : undefined,
body: { targetId },
},
{ timeoutMs: 5000 },
);
await browserFocusTab(baseUrl, targetId, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
return;
@@ -381,29 +303,13 @@ 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 callBrowserRequest(
parent,
{
method: "DELETE",
path: `/tabs/${encodeURIComponent(targetId.trim())}`,
query: profile ? { profile } : undefined,
},
{ timeoutMs: 5000 },
);
await browserCloseTab(baseUrl, targetId.trim(), { profile });
} else {
await callBrowserRequest(
parent,
{
method: "POST",
path: "/act",
query: profile ? { profile } : undefined,
body: { kind: "close" },
},
{ timeoutMs: 20000 },
);
await browserAct(baseUrl, { kind: "close" }, { profile });
}
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
@@ -419,16 +325,9 @@ 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 result = await callBrowserRequest<{ profiles: ProfileStatus[] }>(
parent,
{
method: "GET",
path: "/profiles",
},
{ timeoutMs: 3000 },
);
const profiles = result.profiles ?? [];
const profiles = await browserProfiles(baseUrl);
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ profiles }, null, 2));
return;
@@ -462,21 +361,14 @@ 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 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 },
);
const result = await browserCreateProfile(baseUrl, {
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
driver: opts.driver === "extension" ? "extension" : undefined,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -499,15 +391,9 @@ 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 callBrowserRequest<BrowserDeleteProfileResult>(
parent,
{
method: "DELETE",
path: `/profiles/${encodeURIComponent(opts.name)}`,
},
{ timeoutMs: 20_000 },
);
const result = await browserDeleteProfile(baseUrl, opts.name);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
+121
View File
@@ -0,0 +1,121 @@
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(() => {});
});
}
+2 -55
View File
@@ -1,58 +1,5 @@
import type { GatewayRpcOpts } from "./gateway-rpc.js";
import { callGatewayFromCli } from "./gateway-rpc.js";
export type BrowserParentOpts = GatewayRpcOpts & {
export type BrowserParentOpts = {
url?: string;
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;
}
+47 -77
View File
@@ -1,8 +1,17 @@
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 { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
export function registerBrowserCookiesAndStorageCommands(
browser: Command,
@@ -14,20 +23,13 @@ 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 callBrowserRequest<{ cookies?: unknown[] }>(
parent,
{
method: "GET",
path: "/cookies",
query: {
targetId: opts.targetId?.trim() || undefined,
profile,
},
},
{ timeoutMs: 20000 },
);
const result = await browserCookies(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -48,21 +50,14 @@ 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 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 },
);
const result = await browserCookiesSet(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
cookie: { name, value, url: opts.url },
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -80,20 +75,13 @@ 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 callBrowserRequest(
parent,
{
method: "POST",
path: "/cookies/clear",
query: profile ? { profile } : undefined,
body: {
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
);
const result = await browserCookiesClear(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -117,21 +105,15 @@ 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 callBrowserRequest<{ values?: Record<string, string> }>(
parent,
{
method: "GET",
path: `/storage/${kind}`,
query: {
key: key?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
profile,
},
},
{ timeoutMs: 20000 },
);
const result = await browserStorageGet(baseUrl, {
kind,
key: key?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -151,22 +133,16 @@ 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 callBrowserRequest(
parent,
{
method: "POST",
path: `/storage/${kind}/set`,
query: profile ? { profile } : undefined,
body: {
key,
value,
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
);
const result = await browserStorageSet(baseUrl, {
kind,
key,
value,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -184,20 +160,14 @@ 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 callBrowserRequest(
parent,
{
method: "POST",
path: `/storage/${kind}/clear`,
query: profile ? { profile } : undefined,
body: {
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
);
const result = await browserStorageClear(baseUrl, {
kind,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
+75 -123
View File
@@ -1,9 +1,21 @@
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 { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js";
import { runCommandWithRuntime } from "./cli-utils.js";
@@ -35,6 +47,7 @@ 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"));
@@ -42,20 +55,15 @@ export function registerBrowserStateCommands(
return;
}
await runBrowserCommand(async () => {
const result = await callBrowserRequest(
parent,
const result = await browserAct(
baseUrl,
{
method: "POST",
path: "/act",
query: profile ? { profile } : undefined,
body: {
kind: "resize",
width,
height,
targetId: opts.targetId?.trim() || undefined,
},
kind: "resize",
width,
height,
targetId: opts.targetId?.trim() || undefined,
},
{ timeoutMs: 20000 },
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -72,6 +80,7 @@ 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) {
@@ -80,19 +89,11 @@ export function registerBrowserStateCommands(
return;
}
await runBrowserCommand(async () => {
const result = await callBrowserRequest(
parent,
{
method: "POST",
path: "/set/offline",
query: profile ? { profile } : undefined,
body: {
offline,
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
);
const result = await browserSetOffline(baseUrl, {
offline,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -108,6 +109,7 @@ 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;
@@ -118,19 +120,11 @@ 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 callBrowserRequest(
parent,
{
method: "POST",
path: "/set/headers",
query: profile ? { profile } : undefined,
body: {
headers,
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
);
const result = await browserSetHeaders(baseUrl, {
headers,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -148,23 +142,16 @@ 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 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 },
);
const result = await browserSetHttpCredentials(baseUrl, {
username: username?.trim() || undefined,
password,
clear: Boolean(opts.clear),
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -184,25 +171,18 @@ 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 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 },
);
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,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -218,6 +198,7 @@ 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 =
@@ -228,19 +209,11 @@ export function registerBrowserStateCommands(
return;
}
await runBrowserCommand(async () => {
const result = await callBrowserRequest(
parent,
{
method: "POST",
path: "/set/media",
query: profile ? { profile } : undefined,
body: {
colorScheme,
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
);
const result = await browserSetMedia(baseUrl, {
colorScheme,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -256,21 +229,14 @@ 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 callBrowserRequest(
parent,
{
method: "POST",
path: "/set/timezone",
query: profile ? { profile } : undefined,
body: {
timezoneId,
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
);
const result = await browserSetTimezone(baseUrl, {
timezoneId,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -286,21 +252,14 @@ 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 callBrowserRequest(
parent,
{
method: "POST",
path: "/set/locale",
query: profile ? { profile } : undefined,
body: {
locale,
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
);
const result = await browserSetLocale(baseUrl, {
locale,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -316,21 +275,14 @@ 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 callBrowserRequest(
parent,
{
method: "POST",
path: "/set/device",
query: profile ? { profile } : undefined,
body: {
name,
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
);
const result = await browserSetDevice(baseUrl, {
name,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
+3 -3
View File
@@ -13,14 +13,15 @@ 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(
@@ -42,12 +43,11 @@ 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);
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more