diff --git a/CHANGELOG.md b/CHANGELOG.md index ef5ef6417..c45c8cbe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,13 @@ - Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors. - Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging. - Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`. +- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks). - Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. - Docs: expand gateway security hardening guidance and incident response checklist. - Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf. - 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. +- 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 - Browser: add tests for snapshot labels/efficient query params and labeled image responses. diff --git a/assets/chrome-extension/README.md b/assets/chrome-extension/README.md new file mode 100644 index 000000000..670089321 --- /dev/null +++ b/assets/chrome-extension/README.md @@ -0,0 +1,22 @@ +# Clawdbot Chrome Extension (Browser Relay) + +Purpose: attach Clawdbot to an existing Chrome tab so the Gateway can automate it (via the local CDP relay server). + +## Dev / load unpacked + +1. Build/run Clawdbot Gateway with browser control enabled. +2. Ensure the relay server is reachable at `http://127.0.0.1:18792/` (default). +3. Install the extension to a stable path: + + ```bash + clawdbot browser extension install + clawdbot browser extension path + ``` + +4. Chrome → `chrome://extensions` → enable “Developer mode”. +5. “Load unpacked” → select the path printed above. +6. Pin the extension. Click the icon on a tab to attach/detach. + +## Options + +- `Relay port`: defaults to `18792`. diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js new file mode 100644 index 000000000..519a30a81 --- /dev/null +++ b/assets/chrome-extension/background.js @@ -0,0 +1,407 @@ +const DEFAULT_PORT = 18792 + +const BADGE = { + on: { text: 'ON', color: '#0B6E4F' }, + off: { text: '', color: '#000000' }, + connecting: { text: '…', color: '#B45309' }, + error: { text: '!', color: '#B91C1C' }, +} + +/** @type {WebSocket|null} */ +let relayWs = null +/** @type {Promise|null} */ +let relayConnectPromise = null + +let debuggerListenersInstalled = false + +let nextSession = 1 + +/** @type {Map} */ +const tabs = new Map() +/** @type {Map} */ +const tabBySession = new Map() +/** @type {Map} */ +const childSessionToTab = new Map() + +/** @type {Mapvoid, reject:(e:Error)=>void}>} */ +const pending = new Map() + +function nowStack() { + try { + return new Error().stack || '' + } catch { + return '' + } +} + +async function getRelayPort() { + const stored = await chrome.storage.local.get(['relayPort']) + const raw = stored.relayPort + const n = Number.parseInt(String(raw || ''), 10) + if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT + return n +} + +function setBadge(tabId, kind) { + const cfg = BADGE[kind] + void chrome.action.setBadgeText({ tabId, text: cfg.text }) + void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color }) +} + +async function ensureRelayConnection() { + if (relayWs && relayWs.readyState === WebSocket.OPEN) return + if (relayConnectPromise) return await relayConnectPromise + + relayConnectPromise = (async () => { + const port = await getRelayPort() + const httpBase = `http://127.0.0.1:${port}` + const wsUrl = `ws://127.0.0.1:${port}/extension` + + // Fast preflight: is the relay server up? + try { + await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) }) + } catch (err) { + throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) + } + + const ws = new WebSocket(wsUrl) + relayWs = ws + + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000) + ws.onopen = () => { + clearTimeout(t) + resolve() + } + ws.onerror = () => { + clearTimeout(t) + reject(new Error('WebSocket connect failed')) + } + ws.onclose = (ev) => { + clearTimeout(t) + reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`)) + } + }) + + ws.onmessage = (event) => void onRelayMessage(String(event.data || '')) + ws.onclose = () => onRelayClosed('closed') + ws.onerror = () => onRelayClosed('error') + + if (!debuggerListenersInstalled) { + debuggerListenersInstalled = true + chrome.debugger.onEvent.addListener(onDebuggerEvent) + chrome.debugger.onDetach.addListener(onDebuggerDetach) + } + })() + + try { + await relayConnectPromise + } finally { + relayConnectPromise = null + } +} + +function onRelayClosed(reason) { + relayWs = null + for (const [id, p] of pending.entries()) { + pending.delete(id) + p.reject(new Error(`Relay disconnected (${reason})`)) + } + + for (const tabId of tabs.keys()) { + void chrome.debugger.detach({ tabId }).catch(() => {}) + setBadge(tabId, 'connecting') + } + tabs.clear() + tabBySession.clear() + childSessionToTab.clear() +} + +function sendToRelay(payload) { + const ws = relayWs + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error('Relay not connected') + } + ws.send(JSON.stringify(payload)) +} + +function requestFromRelay(command) { + const id = command.id + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }) + try { + sendToRelay(command) + } catch (err) { + pending.delete(id) + reject(err instanceof Error ? err : new Error(String(err))) + } + }) +} + +async function onRelayMessage(text) { + /** @type {any} */ + let msg + try { + msg = JSON.parse(text) + } catch { + return + } + + if (msg && msg.method === 'ping') { + try { + sendToRelay({ method: 'pong' }) + } catch { + // ignore + } + return + } + + if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) { + const p = pending.get(msg.id) + if (!p) return + pending.delete(msg.id) + if (msg.error) p.reject(new Error(String(msg.error))) + else p.resolve(msg.result) + return + } + + if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') { + try { + const result = await handleForwardCdpCommand(msg) + sendToRelay({ id: msg.id, result }) + } catch (err) { + sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) }) + } + } +} + +function getTabBySessionId(sessionId) { + const direct = tabBySession.get(sessionId) + if (direct) return { tabId: direct, kind: 'main' } + const child = childSessionToTab.get(sessionId) + if (child) return { tabId: child, kind: 'child' } + return null +} + +function getTabByTargetId(targetId) { + for (const [tabId, tab] of tabs.entries()) { + if (tab.targetId === targetId) return tabId + } + return null +} + +async function attachTab(tabId, opts = {}) { + const debuggee = { tabId } + await chrome.debugger.attach(debuggee, '1.3') + await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {}) + + const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo')) + const targetInfo = info?.targetInfo + const targetId = String(targetInfo?.targetId || '').trim() + if (!targetId) { + throw new Error('Target.getTargetInfo returned no targetId') + } + + const sessionId = `cb-tab-${nextSession++}` + const attachOrder = nextSession + + tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder }) + tabBySession.set(sessionId, tabId) + + if (!opts.skipAttachedEvent) { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.attachedToTarget', + params: { + sessionId, + targetInfo: { ...targetInfo, attached: true }, + waitingForDebugger: false, + }, + }, + }) + } + + setBadge(tabId, 'on') + return { sessionId, targetId } +} + +async function detachTab(tabId, reason) { + const tab = tabs.get(tabId) + if (tab?.sessionId && tab?.targetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: tab.sessionId, targetId: tab.targetId, reason }, + }, + }) + } catch { + // ignore + } + } + + if (tab?.sessionId) tabBySession.delete(tab.sessionId) + tabs.delete(tabId) + + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + + try { + await chrome.debugger.detach({ tabId }) + } catch { + // ignore + } + + setBadge(tabId, 'off') +} + +async function connectOrToggleForActiveTab() { + const [active] = await chrome.tabs.query({ active: true, currentWindow: true }) + const tabId = active?.id + if (!tabId) return + + const existing = tabs.get(tabId) + if (existing?.state === 'connected') { + await detachTab(tabId, 'toggle') + return + } + + tabs.set(tabId, { state: 'connecting' }) + setBadge(tabId, 'connecting') + + try { + await ensureRelayConnection() + await attachTab(tabId) + } catch (err) { + tabs.delete(tabId) + setBadge(tabId, 'error') + const message = err instanceof Error ? err.message : String(err) + // Service worker: best-effort surface via title. + void chrome.action.setTitle({ tabId, title: `Clawdbot: ${message}` }) + // Extra breadcrumbs in chrome://extensions service worker logs. + console.warn('attach failed', message, nowStack()) + } +} + +async function handleForwardCdpCommand(msg) { + const method = String(msg?.params?.method || '').trim() + const params = msg?.params?.params || undefined + const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined + + // Map command to tab + const bySession = sessionId ? getTabBySessionId(sessionId) : null + const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined + const tabId = + bySession?.tabId || + (targetId ? getTabByTargetId(targetId) : null) || + (() => { + // No sessionId: pick the first connected tab (stable-ish). + for (const [id, tab] of tabs.entries()) { + if (tab.state === 'connected') return id + } + return null + })() + + if (!tabId) throw new Error(`No attached tab for method ${method}`) + + /** @type {chrome.debugger.DebuggerSession} */ + const debuggee = { tabId } + + if (method === 'Runtime.enable') { + try { + await chrome.debugger.sendCommand(debuggee, 'Runtime.disable') + await new Promise((r) => setTimeout(r, 50)) + } catch { + // ignore + } + return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params) + } + + if (method === 'Target.createTarget') { + const url = typeof params?.url === 'string' ? params.url : 'about:blank' + const tab = await chrome.tabs.create({ url, active: false }) + if (!tab.id) throw new Error('Failed to create tab') + await new Promise((r) => setTimeout(r, 100)) + const attached = await attachTab(tab.id) + return { targetId: attached.targetId } + } + + if (method === 'Target.closeTarget') { + const target = typeof params?.targetId === 'string' ? params.targetId : '' + const toClose = target ? getTabByTargetId(target) : tabId + if (!toClose) return { success: false } + try { + await chrome.tabs.remove(toClose) + } catch { + return { success: false } + } + return { success: true } + } + + if (method === 'Target.activateTarget') { + const target = typeof params?.targetId === 'string' ? params.targetId : '' + const toActivate = target ? getTabByTargetId(target) : tabId + if (!toActivate) return {} + const tab = await chrome.tabs.get(toActivate).catch(() => null) + if (!tab) return {} + if (tab.windowId) { + await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}) + } + await chrome.tabs.update(toActivate, { active: true }).catch(() => {}) + return {} + } + + const tabState = tabs.get(tabId) + const mainSessionId = tabState?.sessionId + const debuggerSession = + sessionId && mainSessionId && sessionId !== mainSessionId + ? { ...debuggee, sessionId } + : debuggee + + return await chrome.debugger.sendCommand(debuggerSession, method, params) +} + +function onDebuggerEvent(source, method, params) { + const tabId = source.tabId + if (!tabId) return + const tab = tabs.get(tabId) + if (!tab?.sessionId) return + + if (method === 'Target.attachedToTarget' && params?.sessionId) { + childSessionToTab.set(String(params.sessionId), tabId) + } + + if (method === 'Target.detachedFromTarget' && params?.sessionId) { + childSessionToTab.delete(String(params.sessionId)) + } + + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + sessionId: source.sessionId || tab.sessionId, + method, + params, + }, + }) + } catch { + // ignore + } +} + +function onDebuggerDetach(source, reason) { + const tabId = source.tabId + if (!tabId) return + if (!tabs.has(tabId)) return + void detachTab(tabId, reason) +} + +chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab()) + +chrome.runtime.onInstalled.addListener(() => { + // Useful: first-time instructions. + void chrome.runtime.openOptionsPage() +}) diff --git a/assets/chrome-extension/manifest.json b/assets/chrome-extension/manifest.json new file mode 100644 index 000000000..b2d010be3 --- /dev/null +++ b/assets/chrome-extension/manifest.json @@ -0,0 +1,12 @@ +{ + "manifest_version": 3, + "name": "Clawdbot Browser Relay", + "version": "0.1.0", + "description": "Attach Clawdbot to your existing Chrome tab via a local CDP relay server.", + "permissions": ["debugger", "tabs", "activeTab", "storage"], + "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"], + "background": { "service_worker": "background.js", "type": "module" }, + "action": { "default_title": "Attach Clawdbot" }, + "options_ui": { "page": "options.html", "open_in_tab": true } +} + diff --git a/assets/chrome-extension/options.html b/assets/chrome-extension/options.html new file mode 100644 index 000000000..43cb70d86 --- /dev/null +++ b/assets/chrome-extension/options.html @@ -0,0 +1,75 @@ + + + + + + Clawdbot Browser Relay + + + +

Clawdbot Browser Relay

+ +
+ +
+ + +
+
+ Default: 18792. Relay base URL: http://127.0.0.1:<port>/. +
+
+
+ + + + + diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js new file mode 100644 index 000000000..826c251d9 --- /dev/null +++ b/assets/chrome-extension/options.js @@ -0,0 +1,30 @@ +const DEFAULT_PORT = 18792 + +function clampPort(value) { + const n = Number.parseInt(String(value || ''), 10) + if (!Number.isFinite(n)) return DEFAULT_PORT + if (n <= 0 || n > 65535) return DEFAULT_PORT + return n +} + +async function load() { + const stored = await chrome.storage.local.get(['relayPort']) + const port = clampPort(stored.relayPort) + document.getElementById('port').value = String(port) +} + +async function save() { + const input = document.getElementById('port') + const port = clampPort(input.value) + await chrome.storage.local.set({ relayPort: port }) + input.value = String(port) + const status = document.getElementById('status') + status.textContent = `Saved. Using http://127.0.0.1:${port}/` + setTimeout(() => { + status.textContent = '' + }, 2000) +} + +document.getElementById('save').addEventListener('click', () => void save()) +void load() + diff --git a/docs/docs.json b/docs/docs.json index 931620a66..6a7a6ac53 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -893,6 +893,7 @@ "tools/apply-patch", "tools/elevated", "tools/browser", + "tools/chrome-extension", "tools/browser-linux-troubleshooting", "tools/slash-commands", "tools/thinking", diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 82d534654..e6041e3d9 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -12,6 +12,17 @@ Clawdbot is both a product and an experiment: you’re wiring frontier-model beh - where the bot is allowed to act - what the bot can touch +## Quick check: `clawdbot security audit` + +Run this regularly (especially after changing config or exposing network surfaces): + +```bash +clawdbot security audit +clawdbot security audit --deep +``` + +It flags common footguns (Gateway auth exposure, browser control exposure, elevated allowlists, filesystem permissions). + ## The Threat Model Your AI assistant can: @@ -190,6 +201,27 @@ you terminate TLS or proxy in front of the gateway, disable See [Tailscale](/gateway/tailscale) and [Web overview](/web). +### 0.6.1) Browser control server over Tailscale (recommended) + +If your Gateway is remote but the browser runs on another machine, you’ll often run a **separate browser control server** +on the browser machine (see [Browser tool](/tools/browser)). Treat this like an admin API. + +Recommended pattern: + +```bash +# on the machine that runs Chrome +clawdbot browser serve --bind 127.0.0.1 --port 18791 --token +tailscale serve https / http://127.0.0.1:18791 +``` + +Then on the Gateway, set: +- `browser.controlUrl` to the `https://…` Serve URL (MagicDNS/ts.net) +- and authenticate with the same token (`CLAWDBOT_BROWSER_CONTROL_TOKEN` env preferred) + +Avoid: +- `--bind 0.0.0.0` (LAN-visible surface) +- Tailscale Funnel for browser control endpoints (public exposure) + ### 0.7) Secrets on disk (what’s sensitive) Assume anything under `~/.clawdbot/` (or `$CLAWDBOT_STATE_DIR/`) may contain secrets or private data: @@ -320,6 +352,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. +- 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). +- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach. ## Per-agent access profiles (multi-agent) diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md index 1acbb2305..a7b9d6fbe 100644 --- a/docs/gateway/tailscale.md +++ b/docs/gateway/tailscale.md @@ -76,6 +76,36 @@ clawdbot gateway --tailscale funnel --auth password - Serve/Funnel only expose the **Gateway control UI + WS**. Node **bridge** traffic uses the separate bridge port (default `18790`) and is **not** proxied by Serve. +## Browser control server (remote Gateway + local browser) + +If you run the Gateway on one machine but want to drive a browser on another machine, use a **separate browser control server** +and publish it through Tailscale **Serve** (tailnet-only): + +```bash +# on the machine that runs Chrome +clawdbot browser serve --bind 127.0.0.1 --port 18791 --token +tailscale serve https / http://127.0.0.1:18791 +``` + +Then point the Gateway config at the HTTPS URL: + +```json5 +{ + browser: { + enabled: true, + controlUrl: "https:///" + } +} +``` + +And authenticate from the Gateway with the same token (prefer env): + +```bash +export CLAWDBOT_BROWSER_CONTROL_TOKEN="" +``` + +Avoid Funnel for browser control endpoints unless you explicitly want public exposure. + ## Tailscale prerequisites + limits - Serve requires HTTPS enabled for your tailnet; the CLI prompts if it is missing. diff --git a/docs/start/faq.md b/docs/start/faq.md index f8d6039b5..ff763a36d 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -34,6 +34,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks) - [How do I install skills on Linux?](#how-do-i-install-skills-on-linux) - [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration) + - [How do I install the Chrome extension for browser takeover?](#how-do-i-install-the-chrome-extension-for-browser-takeover) - [Sandboxing and memory](#sandboxing-and-memory) - [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc) - [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox) @@ -399,6 +400,19 @@ clawdhub update --all ClawdHub installs into `./skills` under your current directory (or falls back to your configured Clawdbot workspace); Clawdbot treats that as `/skills` on the next session. For shared skills across agents, place them in `~/.clawdbot/skills//SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawdHub](/tools/clawdhub). +### How do I install the Chrome extension for browser takeover? + +Use the built-in installer, then load the unpacked extension in Chrome: + +```bash +clawdbot browser extension install +clawdbot browser extension path +``` + +Then Chrome → `chrome://extensions` → enable “Developer mode” → “Load unpacked” → pick that folder. + +Full guide (including remote Gateway via Tailscale + security notes): [Chrome extension](/tools/chrome-extension) + ## Sandboxing and memory ### Is there a dedicated sandboxing doc? diff --git a/docs/tools/browser.md b/docs/tools/browser.md index b7b7149f4..92a5950ec 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -106,6 +106,95 @@ Example: Use `profiles..cdpUrl` for **remote CDP** if you want the Gateway to talk directly to a Chrome instance without a remote control server. +### 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 +clawdbot browser serve --bind --port 18791 --token +``` + +Then point your Gateway at it: + +```json5 +{ + browser: { + enabled: true, + controlUrl: "http://:18791", + + // Option A (recommended): keep token in env on the Gateway + // (avoid writing secrets into config files) + // controlToken: "" + } +} +``` + +And set the auth token in the Gateway environment: + +```bash +export CLAWDBOT_BROWSER_CONTROL_TOKEN="" +``` + +Option B: store the token in the Gateway config instead (same shared token): + +```json5 +{ + browser: { + enabled: true, + controlUrl: "http://:18791", + controlToken: "" + } +} +``` + +## Security + +This section covers the **browser control server** (`browser.controlUrl`) used for agent browser automation. + +Key ideas: +- Treat the browser control server like an admin API: **private network only**. +- Use **token auth** always when the server is reachable off-machine. +- Prefer **Tailnet-only** connectivity over LAN exposure. + +### Tokens (what is shared with what?) + +- `browser.controlToken` / `CLAWDBOT_BROWSER_CONTROL_TOKEN` is **only** for authenticating browser control HTTP requests to `browser.controlUrl`. +- It is **not** the Gateway token (`gateway.auth.token`) and **not** a node pairing token. +- You *can* reuse the same string value, but it’s better to keep them separate to reduce blast radius. + +### Binding (don’t expose to your LAN by accident) + +Recommended: +- Keep `clawdbot browser serve` bound to loopback (`127.0.0.1`) and publish it via Tailscale. +- Or bind to a Tailnet IP only (never `0.0.0.0`) and require a token. + +Avoid: +- `--bind 0.0.0.0` (LAN-visible). Even with token auth, traffic is plain HTTP unless you also add TLS. + +### TLS / HTTPS (recommended approach: terminate in front) + +Best practice here: keep `clawdbot browser serve` on HTTP and terminate TLS in front. + +If you’re already using Tailscale, you have two good options: + +1) **Tailnet-only, still HTTP** (transport is encrypted by Tailscale): +- Keep `controlUrl` as `http://…` but ensure it’s only reachable over your tailnet. + +2) **Serve HTTPS via Tailscale** (nice UX: `https://…` URL): + +```bash +# on the browser machine +clawdbot browser serve --bind 127.0.0.1 --port 18791 --token +tailscale serve https / http://127.0.0.1:18791 +``` + +Then set your Gateway config `browser.controlUrl` to the HTTPS URL (MagicDNS/ts.net) and keep using the same token. + +Notes: +- Do **not** use Tailscale Funnel for this unless you explicitly want to make the endpoint public. +- For Tailnet setup/background, see [Gateway web surfaces](/web/index) and the [Gateway CLI](/cli/gateway). + ## Profiles (multi-browser) Clawdbot supports multiple named profiles. Each profile has its own: @@ -120,6 +209,44 @@ Defaults: All control endpoints accept `?profile=`; the CLI uses `--browser-profile`. +## Chrome extension relay (use your existing Chrome) + +Clawdbot can also drive **your existing Chrome tabs** (no separate “clawd” Chrome instance) via a local CDP relay + a Chrome extension. + +Full guide: [Chrome extension](/tools/chrome-extension) + +Flow: +- 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. +- The agent controls that tab via the normal `browser` tool, by selecting the right profile. + +### Setup + +1) Create a profile that uses the extension driver: + +```bash +clawdbot browser create-profile \ + --name chrome \ + --driver extension \ + --cdp-url http://127.0.0.1:18792 \ + --color "#00AA00" +``` + +2) Load the extension (dev/unpacked): +- Chrome → `chrome://extensions` → enable “Developer mode” +- `clawdbot browser extension install` +- “Load unpacked” → select the directory printed by `clawdbot browser extension path` +- Pin the extension, then click it on the tab you want to control (badge shows `ON`). + +3) Use it: +- CLI: `clawdbot browser --browser-profile chrome tabs` +- Agent tool: `browser` with `profile="chrome"` + +Notes: +- This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions). +- Detach by clicking the extension icon again. + ## Isolation guarantees - **Dedicated user data dir**: never touches your personal Chrome profile. @@ -164,7 +291,8 @@ All endpoints accept `?profile=`. Some features (navigate/act/AI snapshot/role snapshot, element screenshots, PDF) require Playwright. If Playwright isn’t installed, those endpoints return a clear 501 -error. ARIA snapshots and basic screenshots still work. +error. ARIA snapshots and basic screenshots still work for clawd-managed Chrome. +For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright. ## How it works (internal) diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md new file mode 100644 index 000000000..67054400e --- /dev/null +++ b/docs/tools/chrome-extension.md @@ -0,0 +1,121 @@ +--- +summary: "Chrome extension: let Clawdbot drive your existing Chrome tab" +read_when: + - You want the agent to drive an existing Chrome tab (toolbar button) + - You need remote Gateway + local browser automation via Tailscale + - You want to understand the security implications of browser takeover +--- + +# Chrome extension (browser relay) + +The Clawdbot Chrome extension lets the agent control your **existing Chrome tabs** (your normal Chrome window) instead of launching a separate clawd-managed Chrome profile. + +Attach/detach happens via a **single Chrome toolbar button**. + +## What it is (concept) + +There are three parts: +- **Browser control server** (HTTP): the API the agent/tool calls (`browser.controlUrl`) +- **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 + +Clawdbot then controls the attached tab through the normal `browser` tool surface (selecting the right profile). + +## Install / load (unpacked) + +1) Install the extension to a stable local path: + +```bash +clawdbot browser extension install +``` + +2) Print the installed extension directory path: + +```bash +clawdbot browser extension path +``` + +3) Chrome → `chrome://extensions` +- Enable “Developer mode” +- “Load unpacked” → select the directory printed above + +4) Pin the extension. + +## Updates (no build step) + +The extension ships inside the Clawdbot release (npm package) as static files. There is no separate “build” step. + +After upgrading Clawdbot: +- Re-run `clawdbot browser extension install` to refresh the installed files under your Clawdbot state directory. +- Chrome → `chrome://extensions` → click “Reload” on the extension. + +## Create a browser profile for the extension + +```bash +clawdbot browser create-profile \ + --name chrome \ + --driver extension \ + --cdp-url http://127.0.0.1:18792 \ + --color "#00AA00" +``` + +Then target it: +- CLI: `clawdbot browser --browser-profile chrome tabs` +- Agent tool: `browser` with `profile="chrome"` + +## Attach / detach (toolbar button) + +- Open the tab you want Clawdbot to control. +- Click the extension icon. + - Badge shows `ON` when attached. +- Click again to detach. + +## Remote Gateway (recommended: Tailscale Serve) + +Goal: Gateway runs on one machine, but Chrome runs somewhere else. + +On the **browser machine**: + +```bash +clawdbot browser serve --bind 127.0.0.1 --port 18791 --token +tailscale serve https / http://127.0.0.1:18791 +``` + +On the **Gateway machine**: +- Set `browser.controlUrl` to the HTTPS Serve URL (MagicDNS/ts.net). +- Provide the token (prefer env): + +```bash +export CLAWDBOT_BROWSER_CONTROL_TOKEN="" +``` + +Then the agent can drive the browser by calling the remote `browser.controlUrl` API, while the extension + relay stay local on the browser machine. + +## How “extension path” works + +`clawdbot browser extension path` prints the **installed** on-disk directory containing the extension files. + +The CLI intentionally does **not** print a `node_modules` path. Always run `clawdbot browser extension install` first to copy the extension to a stable location under your Clawdbot state directory. + +If you move or delete that install directory, Chrome will mark the extension as broken until you reload it from a valid path. + +## Security implications (read this) + +This is powerful and risky. Treat it like giving the model “hands on your browser”. + +- The extension uses Chrome’s debugger API (`chrome.debugger`). When attached, the model can: + - click/type/navigate in that tab + - read page content + - access whatever the tab’s logged-in session can access +- **This is not isolated** like the dedicated clawd-managed profile. + - If you attach to your daily-driver profile/tab, you’re granting access to that account state. + +Recommendations: +- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage. +- Keep the browser control server tailnet-only (Tailscale) and require a token. +- Avoid exposing browser control over LAN (`0.0.0.0`) and avoid Funnel (public). + +Related: +- Browser tool overview: [Browser](/tools/browser) +- Security audit: [Security](/gateway/security) +- Tailscale setup: [Tailscale](/gateway/tailscale) diff --git a/package.json b/package.json index dc6c0a521..731f1feee 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "dist/*.js", "dist/*.json", "docs/**", + "extensions/**", + "assets/**", "skills/**", "README.md", "README-header.png", diff --git a/src/agents/tools/browser-tool.schema.ts b/src/agents/tools/browser-tool.schema.ts index e431509b3..e012ce221 100644 --- a/src/agents/tools/browser-tool.schema.ts +++ b/src/agents/tools/browser-tool.schema.ts @@ -20,6 +20,7 @@ const BROWSER_TOOL_ACTIONS = [ "status", "start", "stop", + "profiles", "tabs", "open", "focus", diff --git a/src/agents/tools/browser-tool.test.ts b/src/agents/tools/browser-tool.test.ts index 542d7de8c..a1e695fde 100644 --- a/src/agents/tools/browser-tool.test.ts +++ b/src/agents/tools/browser-tool.test.ts @@ -4,6 +4,7 @@ const browserClientMocks = vi.hoisted(() => ({ browserCloseTab: vi.fn(async () => ({})), browserFocusTab: vi.fn(async () => ({})), browserOpenTab: vi.fn(async () => ({})), + browserProfiles: vi.fn(async () => []), browserSnapshot: vi.fn(async () => ({ ok: true, format: "ai", @@ -113,6 +114,13 @@ describe("browser tool snapshot maxChars", () => { const [, opts] = browserClientMocks.browserSnapshot.mock.calls.at(-1) ?? []; expect(Object.hasOwn(opts ?? {}, "maxChars")).toBe(false); }); + + it("lists profiles", async () => { + const tool = createBrowserTool(); + await tool.execute?.(null, { action: "profiles" }); + + expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith("http://127.0.0.1:18791"); + }); }); describe("browser tool snapshot labels", () => { diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index 3a64744c3..5e794fae3 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -2,6 +2,7 @@ import { browserCloseTab, browserFocusTab, browserOpenTab, + browserProfiles, browserSnapshot, browserStart, browserStatus, @@ -123,7 +124,7 @@ export function createBrowserTool(opts?: { label: "Browser", name: "browser", description: [ - "Control clawd's dedicated browser (status/start/stop/tabs/open/snapshot/screenshot/actions).", + "Control clawd's dedicated browser (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).", "Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.", `target selects browser location (sandbox|host|custom). Default: ${targetDefault}.`, "controlUrl implies target=custom (remote control server).", @@ -156,6 +157,8 @@ export function createBrowserTool(opts?: { case "stop": await browserStop(baseUrl, { profile }); return jsonResult(await browserStatus(baseUrl, { profile })); + case "profiles": + return jsonResult({ profiles: await browserProfiles(baseUrl) }); case "tabs": return jsonResult({ tabs: await browserTabs(baseUrl, { profile }) }); case "open": { diff --git a/src/browser/bridge-server.ts b/src/browser/bridge-server.ts index 9ee993acc..6bfe190a5 100644 --- a/src/browser/bridge-server.ts +++ b/src/browser/bridge-server.ts @@ -21,6 +21,7 @@ export async function startBrowserBridgeServer(params: { resolved: ResolvedBrowserConfig; host?: string; port?: number; + authToken?: string; onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise; }): Promise { const host = params.host ?? "127.0.0.1"; @@ -29,6 +30,15 @@ export async function startBrowserBridgeServer(params: { const app = express(); app.use(express.json({ limit: "1mb" })); + const authToken = params.authToken?.trim(); + if (authToken) { + app.use((req, res, next) => { + const auth = String(req.headers.authorization ?? "").trim(); + if (auth === `Bearer ${authToken}`) return next(); + res.status(401).send("Unauthorized"); + }); + } + const state: BrowserServerState = { server: null as unknown as Server, port, diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 053cc1acb..25a2964e6 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -139,7 +139,7 @@ export type AriaSnapshotNode = { depth: number; }; -type RawAXNode = { +export type RawAXNode = { nodeId?: string; role?: { value?: string }; name?: { value?: string }; @@ -159,7 +159,10 @@ function axValue(v: unknown): string { return ""; } -function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnapshotNode[] { +export function formatAriaSnapshot( + nodes: RawAXNode[], + limit: number, +): AriaSnapshotNode[] { const byId = new Map(); for (const n of nodes) { if (n.nodeId) byId.set(n.nodeId, n); diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts index caf07d8ba..3f9cb00d3 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -1,4 +1,24 @@ import { extractErrorCode, formatErrorMessage } from "../infra/errors.js"; +import { loadConfig } from "../config/config.js"; +import { resolveBrowserConfig } from "./config.js"; + +let cachedConfigToken: string | null | undefined = undefined; + +function getBrowserControlToken(): string | null { + const env = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim(); + if (env) return env; + + if (cachedConfigToken !== undefined) return cachedConfigToken; + try { + const cfg = loadConfig(); + const resolved = resolveBrowserConfig(cfg.browser); + const token = resolved.controlToken?.trim() || ""; + cachedConfigToken = token ? token : null; + } catch { + cachedConfigToken = null; + } + return cachedConfigToken; +} function unwrapCause(err: unknown): unknown { if (!err || typeof err !== "object") return null; @@ -43,7 +63,23 @@ export async function fetchBrowserJson( const t = setTimeout(() => ctrl.abort(), timeoutMs); let res: Response; try { - res = await fetch(url, { ...init, signal: ctrl.signal } as RequestInit); + 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}`); + } + return h; + })(); + res = await fetch( + url, + { + ...init, + ...(mergedHeaders ? { headers: mergedHeaders } : {}), + signal: ctrl.signal, + } as RequestInit, + ); } catch (err) { throw enhanceBrowserFetchError(url, err, timeoutMs); } finally { diff --git a/src/browser/client.test.ts b/src/browser/client.test.ts index f85f3756c..54876820d 100644 --- a/src/browser/client.test.ts +++ b/src/browser/client.test.ts @@ -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", diff --git a/src/browser/client.ts b/src/browser/client.ts index 260c2082d..e88501a7a 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -152,18 +152,27 @@ export type BrowserCreateProfileResult = { export async function browserCreateProfile( baseUrl: string, - opts: { name: string; color?: string; cdpUrl?: string }, + opts: { + name: string; + color?: string; + cdpUrl?: string; + driver?: "clawd" | "extension"; + }, ): Promise { - return await fetchBrowserJson(`${baseUrl}/profiles/create`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: opts.name, - color: opts.color, - cdpUrl: opts.cdpUrl, - }), - timeoutMs: 10000, - }); + return await fetchBrowserJson( + `${baseUrl}/profiles/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: opts.name, + color: opts.color, + cdpUrl: opts.cdpUrl, + driver: opts.driver, + }), + timeoutMs: 10000, + }, + ); } export type BrowserDeleteProfileResult = { diff --git a/src/browser/config.ts b/src/browser/config.ts index 5d45d6b5f..51c181162 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -16,6 +16,7 @@ export type ResolvedBrowserConfig = { controlUrl: string; controlHost: string; controlPort: number; + controlToken?: string; cdpProtocol: "http" | "https"; cdpHost: string; cdpIsLoopback: boolean; @@ -35,6 +36,7 @@ export type ResolvedBrowserProfile = { cdpHost: string; cdpIsLoopback: boolean; color: string; + driver: "clawd" | "extension"; }; function isLoopbackHost(host: string) { @@ -105,6 +107,7 @@ function ensureDefaultProfile( export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBrowserConfig { const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED; const envControlUrl = process.env.CLAWDBOT_BROWSER_CONTROL_URL?.trim(); + const controlToken = cfg?.controlToken?.trim() || undefined; const derivedControlPort = (() => { const raw = process.env.CLAWDBOT_GATEWAY_PORT?.trim(); if (!raw) return null; @@ -170,6 +173,7 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr controlUrl: controlInfo.normalized, controlHost: controlInfo.parsed.hostname, controlPort, + ...(controlToken ? { controlToken } : {}), cdpProtocol, cdpHost: cdpInfo.parsed.hostname, cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname), @@ -198,6 +202,7 @@ export function resolveProfile( let cdpHost = resolved.cdpHost; let cdpPort = profile.cdpPort ?? 0; let cdpUrl = ""; + const driver = profile.driver === "extension" ? "extension" : "clawd"; if (rawProfileUrl) { const parsed = parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`); @@ -217,6 +222,7 @@ export function resolveProfile( cdpHost, cdpIsLoopback: isLoopbackHost(cdpHost), color: profile.color, + driver, }; } diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts new file mode 100644 index 000000000..5b8601b6b --- /dev/null +++ b/src/browser/extension-relay.test.ts @@ -0,0 +1,202 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import WebSocket from "ws"; + +import { + ensureChromeExtensionRelayServer, + stopChromeExtensionRelayServer, +} from "./extension-relay.js"; + +async function getFreePort(): Promise { + while (true) { + const port = await new Promise((resolve, reject) => { + const s = createServer(); + s.once("error", reject); + s.listen(0, "127.0.0.1", () => { + const assigned = (s.address() as AddressInfo).port; + s.close((err) => (err ? reject(err) : resolve(assigned))); + }); + }); + if (port < 65535) return port; + } +} + +function waitForOpen(ws: WebSocket) { + return new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); +} + +function createMessageQueue(ws: WebSocket) { + const queue: string[] = []; + let waiter: ((value: string) => void) | null = null; + let waiterReject: ((err: Error) => void) | null = null; + let waiterTimer: NodeJS.Timeout | null = null; + + const flushWaiter = (value: string) => { + if (!waiter) return false; + const resolve = waiter; + waiter = null; + const reject = waiterReject; + waiterReject = null; + if (waiterTimer) clearTimeout(waiterTimer); + waiterTimer = null; + if (reject) { + // no-op (kept for symmetry) + } + resolve(value); + return true; + }; + + ws.on("message", (data) => { + const text = + typeof data === "string" + ? data + : Buffer.isBuffer(data) + ? data.toString("utf8") + : Array.isArray(data) + ? Buffer.concat(data).toString("utf8") + : Buffer.from(data).toString("utf8"); + if (flushWaiter(text)) return; + queue.push(text); + }); + + ws.on("error", (err) => { + if (!waiterReject) return; + const reject = waiterReject; + waiterReject = null; + waiter = null; + if (waiterTimer) clearTimeout(waiterTimer); + waiterTimer = null; + reject(err instanceof Error ? err : new Error(String(err))); + }); + + const next = (timeoutMs = 5000) => + new Promise((resolve, reject) => { + const existing = queue.shift(); + if (existing !== undefined) return resolve(existing); + waiter = resolve; + waiterReject = reject; + waiterTimer = setTimeout(() => { + waiter = null; + waiterReject = null; + waiterTimer = null; + reject(new Error("timeout")); + }, timeoutMs); + }); + + return { next }; +} + +describe("chrome extension relay server", () => { + let cdpUrl = ""; + + afterEach(async () => { + if (cdpUrl) { + await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {}); + cdpUrl = ""; + } + }); + + it("advertises CDP WS only when extension is connected", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + await ensureChromeExtensionRelayServer({ cdpUrl }); + + const v1 = (await fetch(`${cdpUrl}/json/version`).then((r) => + r.json(), + )) as { + webSocketDebuggerUrl?: string; + }; + expect(v1.webSocketDebuggerUrl).toBeUndefined(); + + const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`); + await waitForOpen(ext); + + const v2 = (await fetch(`${cdpUrl}/json/version`).then((r) => + r.json(), + )) as { + webSocketDebuggerUrl?: string; + }; + expect(String(v2.webSocketDebuggerUrl ?? "")).toContain(`/cdp`); + + ext.close(); + }); + + it("tracks attached page targets and exposes them via CDP + /json/list", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + await ensureChromeExtensionRelayServer({ cdpUrl }); + + const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`); + await waitForOpen(ext); + + // Simulate a tab attach coming from the extension. + ext.send( + JSON.stringify({ + method: "forwardCDPEvent", + params: { + method: "Target.attachedToTarget", + params: { + sessionId: "cb-tab-1", + targetInfo: { + targetId: "t1", + type: "page", + title: "Example", + url: "https://example.com", + }, + waitingForDebugger: false, + }, + }, + }), + ); + + const list = (await fetch(`${cdpUrl}/json/list`).then((r) => + r.json(), + )) as Array<{ + id?: string; + url?: string; + }>; + expect( + list.some((t) => t.id === "t1" && t.url === "https://example.com"), + ).toBe(true); + + const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`); + await waitForOpen(cdp); + const q = createMessageQueue(cdp); + + cdp.send(JSON.stringify({ id: 1, method: "Target.getTargets" })); + const res1 = JSON.parse(await q.next()) as { id: number; result?: unknown }; + expect(res1.id).toBe(1); + expect(JSON.stringify(res1.result ?? {})).toContain("t1"); + + cdp.send( + JSON.stringify({ + id: 2, + method: "Target.attachToTarget", + params: { targetId: "t1" }, + }), + ); + const received: Array<{ + id?: number; + method?: string; + result?: unknown; + params?: unknown; + }> = []; + received.push(JSON.parse(await q.next()) as never); + received.push(JSON.parse(await q.next()) as never); + + const res2 = received.find((m) => m.id === 2); + expect(res2?.id).toBe(2); + expect(JSON.stringify(res2?.result ?? {})).toContain("cb-tab-1"); + + const evt = received.find((m) => m.method === "Target.attachedToTarget"); + expect(evt?.method).toBe("Target.attachedToTarget"); + expect(JSON.stringify(evt?.params ?? {})).toContain("t1"); + + cdp.close(); + ext.close(); + }, 15_000); +}); diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts new file mode 100644 index 000000000..6d66ed82a --- /dev/null +++ b/src/browser/extension-relay.ts @@ -0,0 +1,680 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import type { Duplex } from "node:stream"; + +import WebSocket, { WebSocketServer } from "ws"; + +import { rawDataToString } from "../infra/ws.js"; + +type CdpCommand = { + id: number; + method: string; + params?: unknown; + sessionId?: string; +}; + +type CdpResponse = { + id: number; + result?: unknown; + error?: { message: string }; + sessionId?: string; +}; + +type CdpEvent = { + method: string; + params?: unknown; + sessionId?: string; +}; + +type ExtensionForwardCommandMessage = { + id: number; + method: "forwardCDPCommand"; + params: { method: string; params?: unknown; sessionId?: string }; +}; + +type ExtensionResponseMessage = { + id: number; + result?: unknown; + error?: string; +}; + +type ExtensionForwardEventMessage = { + method: "forwardCDPEvent"; + params: { method: string; params?: unknown; sessionId?: string }; +}; + +type ExtensionPingMessage = { method: "ping" }; +type ExtensionPongMessage = { method: "pong" }; + +type ExtensionMessage = + | ExtensionResponseMessage + | ExtensionForwardEventMessage + | ExtensionPongMessage; + +type TargetInfo = { + targetId: string; + type?: string; + title?: string; + url?: string; + attached?: boolean; +}; + +type AttachedToTargetEvent = { + sessionId: string; + targetInfo: TargetInfo; + waitingForDebugger?: boolean; +}; + +type DetachedFromTargetEvent = { + sessionId: string; + targetId?: string; +}; + +type ConnectedTarget = { + sessionId: string; + targetId: string; + targetInfo: TargetInfo; +}; + +export type ChromeExtensionRelayServer = { + host: string; + port: number; + baseUrl: string; + cdpWsUrl: string; + extensionConnected: () => boolean; + stop: () => Promise; +}; + +function isLoopbackHost(host: string) { + const h = host.trim().toLowerCase(); + return ( + h === "localhost" || + h === "127.0.0.1" || + h === "0.0.0.0" || + h === "[::1]" || + h === "::1" || + h === "[::]" || + h === "::" + ); +} + +function isLoopbackAddress(ip: string | undefined): boolean { + if (!ip) return false; + if (ip === "127.0.0.1") return true; + if (ip.startsWith("127.")) return true; + if (ip === "::1") return true; + if (ip.startsWith("::ffff:127.")) return true; + return false; +} + +function parseBaseUrl(raw: string): { + host: string; + port: number; + baseUrl: string; +} { + const parsed = new URL(raw.trim().replace(/\/$/, "")); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error( + `extension relay cdpUrl must be http(s), got ${parsed.protocol}`, + ); + } + const host = parsed.hostname; + const port = + parsed.port?.trim() !== "" + ? Number(parsed.port) + : parsed.protocol === "https:" + ? 443 + : 80; + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + throw new Error( + `extension relay cdpUrl has invalid port: ${parsed.port || "(empty)"}`, + ); + } + return { host, port, baseUrl: parsed.toString().replace(/\/$/, "") }; +} + +function text(res: Duplex, status: number, bodyText: string) { + const body = Buffer.from(bodyText); + res.write( + `HTTP/1.1 ${status} ${status === 200 ? "OK" : "ERR"}\r\n` + + "Content-Type: text/plain; charset=utf-8\r\n" + + `Content-Length: ${body.length}\r\n` + + "Connection: close\r\n" + + "\r\n", + ); + res.write(body); + res.end(); +} + +function rejectUpgrade(socket: Duplex, status: number, bodyText: string) { + text(socket, status, bodyText); + try { + socket.destroy(); + } catch { + // ignore + } +} + +const serversByPort = new Map(); + +export async function ensureChromeExtensionRelayServer(opts: { + cdpUrl: string; +}): Promise { + const info = parseBaseUrl(opts.cdpUrl); + if (!isLoopbackHost(info.host)) { + throw new Error( + `extension relay requires loopback cdpUrl host (got ${info.host})`, + ); + } + + const existing = serversByPort.get(info.port); + if (existing) return existing; + + let extensionWs: WebSocket | null = null; + const cdpClients = new Set(); + const connectedTargets = new Map(); + + const pendingExtension = new Map< + number, + { + resolve: (v: unknown) => void; + reject: (e: Error) => void; + timer: NodeJS.Timeout; + } + >(); + let nextExtensionId = 1; + + const sendToExtension = async ( + payload: ExtensionForwardCommandMessage, + ): Promise => { + const ws = extensionWs; + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error("Chrome extension not connected"); + } + ws.send(JSON.stringify(payload)); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingExtension.delete(payload.id); + reject( + new Error(`extension request timeout: ${payload.params.method}`), + ); + }, 30_000); + pendingExtension.set(payload.id, { resolve, reject, timer }); + }); + }; + + const broadcastToCdpClients = (evt: CdpEvent) => { + const msg = JSON.stringify(evt); + for (const ws of cdpClients) { + if (ws.readyState !== WebSocket.OPEN) continue; + ws.send(msg); + } + }; + + const sendResponseToCdp = (ws: WebSocket, res: CdpResponse) => { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify(res)); + }; + + const ensureTargetEventsForClient = ( + ws: WebSocket, + mode: "autoAttach" | "discover", + ) => { + for (const target of connectedTargets.values()) { + if (mode === "autoAttach") { + ws.send( + JSON.stringify({ + method: "Target.attachedToTarget", + params: { + sessionId: target.sessionId, + targetInfo: { ...target.targetInfo, attached: true }, + waitingForDebugger: false, + }, + } satisfies CdpEvent), + ); + } else { + ws.send( + JSON.stringify({ + method: "Target.targetCreated", + params: { targetInfo: { ...target.targetInfo, attached: true } }, + } satisfies CdpEvent), + ); + } + } + }; + + const routeCdpCommand = async (cmd: CdpCommand): Promise => { + switch (cmd.method) { + case "Browser.getVersion": + return { + protocolVersion: "1.3", + product: "Chrome/Clawdbot-Extension-Relay", + revision: "0", + userAgent: "Clawdbot-Extension-Relay", + jsVersion: "V8", + }; + case "Browser.setDownloadBehavior": + return {}; + case "Target.setAutoAttach": + case "Target.setDiscoverTargets": + return {}; + case "Target.getTargets": + return { + targetInfos: Array.from(connectedTargets.values()).map((t) => ({ + ...t.targetInfo, + attached: true, + })), + }; + case "Target.getTargetInfo": { + const params = (cmd.params ?? {}) as { targetId?: string }; + const targetId = + typeof params.targetId === "string" ? params.targetId : undefined; + if (targetId) { + for (const t of connectedTargets.values()) { + if (t.targetId === targetId) return { targetInfo: t.targetInfo }; + } + } + if (cmd.sessionId && connectedTargets.has(cmd.sessionId)) { + const t = connectedTargets.get(cmd.sessionId); + if (t) return { targetInfo: t.targetInfo }; + } + const first = Array.from(connectedTargets.values())[0]; + return { targetInfo: first?.targetInfo }; + } + case "Target.attachToTarget": { + const params = (cmd.params ?? {}) as { targetId?: string }; + const targetId = + typeof params.targetId === "string" ? params.targetId : undefined; + if (!targetId) throw new Error("targetId required"); + for (const t of connectedTargets.values()) { + if (t.targetId === targetId) return { sessionId: t.sessionId }; + } + throw new Error("target not found"); + } + default: { + const id = nextExtensionId++; + return await sendToExtension({ + id, + method: "forwardCDPCommand", + params: { + method: cmd.method, + sessionId: cmd.sessionId, + params: cmd.params, + }, + }); + } + } + }; + + const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", info.baseUrl); + const path = url.pathname; + + if (req.method === "HEAD" && path === "/") { + res.writeHead(200); + res.end(); + return; + } + + if (path === "/") { + res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("OK"); + return; + } + + if (path === "/extension/status") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ connected: Boolean(extensionWs) })); + return; + } + + const hostHeader = req.headers.host?.trim() || `${info.host}:${info.port}`; + const wsHost = `ws://${hostHeader}`; + const cdpWsUrl = `${wsHost}/cdp`; + + if ( + (path === "/json/version" || path === "/json/version/") && + (req.method === "GET" || req.method === "PUT") + ) { + const payload: Record = { + Browser: "Clawdbot/extension-relay", + "Protocol-Version": "1.3", + }; + // Only advertise the WS URL if a real extension is connected. + if (extensionWs) payload.webSocketDebuggerUrl = cdpWsUrl; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload)); + return; + } + + const listPaths = new Set(["/json", "/json/", "/json/list", "/json/list/"]); + if (listPaths.has(path) && (req.method === "GET" || req.method === "PUT")) { + const list = Array.from(connectedTargets.values()).map((t) => ({ + id: t.targetId, + type: t.targetInfo.type ?? "page", + title: t.targetInfo.title ?? "", + description: t.targetInfo.title ?? "", + url: t.targetInfo.url ?? "", + webSocketDebuggerUrl: cdpWsUrl, + devtoolsFrontendUrl: `/devtools/inspector.html?ws=${cdpWsUrl.replace("ws://", "")}`, + })); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(list)); + return; + } + + const activateMatch = path.match(/^\/json\/activate\/(.+)$/); + if (activateMatch && (req.method === "GET" || req.method === "PUT")) { + const targetId = decodeURIComponent(activateMatch[1] ?? "").trim(); + if (!targetId) { + res.writeHead(400); + res.end("targetId required"); + return; + } + void (async () => { + try { + await sendToExtension({ + id: nextExtensionId++, + method: "forwardCDPCommand", + params: { method: "Target.activateTarget", params: { targetId } }, + }); + } catch { + // ignore + } + })(); + res.writeHead(200); + res.end("OK"); + return; + } + + const closeMatch = path.match(/^\/json\/close\/(.+)$/); + if (closeMatch && (req.method === "GET" || req.method === "PUT")) { + const targetId = decodeURIComponent(closeMatch[1] ?? "").trim(); + if (!targetId) { + res.writeHead(400); + res.end("targetId required"); + return; + } + void (async () => { + try { + await sendToExtension({ + id: nextExtensionId++, + method: "forwardCDPCommand", + params: { method: "Target.closeTarget", params: { targetId } }, + }); + } catch { + // ignore + } + })(); + res.writeHead(200); + res.end("OK"); + return; + } + + res.writeHead(404); + res.end("not found"); + }); + + const wssExtension = new WebSocketServer({ noServer: true }); + const wssCdp = new WebSocketServer({ noServer: true }); + + server.on("upgrade", (req, socket, head) => { + const url = new URL(req.url ?? "/", info.baseUrl); + const pathname = url.pathname; + const remote = req.socket.remoteAddress; + + if (!isLoopbackAddress(remote)) { + rejectUpgrade(socket, 403, "Forbidden"); + return; + } + + if (pathname === "/extension") { + if (extensionWs) { + rejectUpgrade(socket, 409, "Extension already connected"); + return; + } + wssExtension.handleUpgrade(req, socket, head, (ws) => { + wssExtension.emit("connection", ws, req); + }); + return; + } + + if (pathname === "/cdp") { + if (!extensionWs) { + rejectUpgrade(socket, 503, "Extension not connected"); + return; + } + wssCdp.handleUpgrade(req, socket, head, (ws) => { + wssCdp.emit("connection", ws, req); + }); + return; + } + + rejectUpgrade(socket, 404, "Not Found"); + }); + + wssExtension.on("connection", (ws) => { + extensionWs = ws; + + const ping = setInterval(() => { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send( + JSON.stringify({ method: "ping" } satisfies ExtensionPingMessage), + ); + }, 5000); + + ws.on("message", (data) => { + let parsed: ExtensionMessage | null = null; + try { + parsed = JSON.parse(rawDataToString(data)) as ExtensionMessage; + } catch { + return; + } + + if ( + parsed && + typeof parsed === "object" && + "id" in parsed && + typeof parsed.id === "number" + ) { + const pending = pendingExtension.get(parsed.id); + if (!pending) return; + pendingExtension.delete(parsed.id); + clearTimeout(pending.timer); + if ( + "error" in parsed && + typeof parsed.error === "string" && + parsed.error.trim() + ) { + pending.reject(new Error(parsed.error)); + } else { + pending.resolve((parsed as ExtensionResponseMessage).result); + } + return; + } + + if (parsed && typeof parsed === "object" && "method" in parsed) { + if ((parsed as ExtensionPongMessage).method === "pong") return; + if ( + (parsed as ExtensionForwardEventMessage).method !== "forwardCDPEvent" + ) + return; + const evt = parsed as ExtensionForwardEventMessage; + const method = evt.params?.method; + const params = evt.params?.params; + const sessionId = evt.params?.sessionId; + if (!method || typeof method !== "string") return; + + if (method === "Target.attachedToTarget") { + const attached = (params ?? {}) as AttachedToTargetEvent; + const targetType = attached?.targetInfo?.type ?? "page"; + if (targetType !== "page") return; + if (attached?.sessionId && attached?.targetInfo?.targetId) { + const already = connectedTargets.has(attached.sessionId); + connectedTargets.set(attached.sessionId, { + sessionId: attached.sessionId, + targetId: attached.targetInfo.targetId, + targetInfo: attached.targetInfo, + }); + if (!already) { + broadcastToCdpClients({ method, params, sessionId }); + } + return; + } + } + + if (method === "Target.detachedFromTarget") { + const detached = (params ?? {}) as DetachedFromTargetEvent; + if (detached?.sessionId) connectedTargets.delete(detached.sessionId); + broadcastToCdpClients({ method, params, sessionId }); + return; + } + + broadcastToCdpClients({ method, params, sessionId }); + } + }); + + ws.on("close", () => { + clearInterval(ping); + extensionWs = null; + for (const [, pending] of pendingExtension) { + clearTimeout(pending.timer); + pending.reject(new Error("extension disconnected")); + } + pendingExtension.clear(); + connectedTargets.clear(); + + for (const client of cdpClients) { + try { + client.close(1011, "extension disconnected"); + } catch { + // ignore + } + } + cdpClients.clear(); + }); + }); + + wssCdp.on("connection", (ws) => { + cdpClients.add(ws); + + ws.on("message", async (data) => { + let cmd: CdpCommand | null = null; + try { + cmd = JSON.parse(rawDataToString(data)) as CdpCommand; + } catch { + return; + } + if (!cmd || typeof cmd !== "object") return; + if (typeof cmd.id !== "number" || typeof cmd.method !== "string") return; + + if (!extensionWs) { + sendResponseToCdp(ws, { + id: cmd.id, + sessionId: cmd.sessionId, + error: { message: "Extension not connected" }, + }); + return; + } + + try { + const result = await routeCdpCommand(cmd); + + if (cmd.method === "Target.setAutoAttach" && !cmd.sessionId) { + ensureTargetEventsForClient(ws, "autoAttach"); + } + if (cmd.method === "Target.setDiscoverTargets") { + const discover = (cmd.params ?? {}) as { discover?: boolean }; + if (discover.discover === true) { + ensureTargetEventsForClient(ws, "discover"); + } + } + if (cmd.method === "Target.attachToTarget") { + const params = (cmd.params ?? {}) as { targetId?: string }; + const targetId = + typeof params.targetId === "string" ? params.targetId : undefined; + if (targetId) { + const target = Array.from(connectedTargets.values()).find( + (t) => t.targetId === targetId, + ); + if (target) { + ws.send( + JSON.stringify({ + method: "Target.attachedToTarget", + params: { + sessionId: target.sessionId, + targetInfo: { ...target.targetInfo, attached: true }, + waitingForDebugger: false, + }, + } satisfies CdpEvent), + ); + } + } + } + + sendResponseToCdp(ws, { id: cmd.id, sessionId: cmd.sessionId, result }); + } catch (err) { + sendResponseToCdp(ws, { + id: cmd.id, + sessionId: cmd.sessionId, + error: { message: err instanceof Error ? err.message : String(err) }, + }); + } + }); + + ws.on("close", () => { + cdpClients.delete(ws); + }); + }); + + await new Promise((resolve, reject) => { + server.listen(info.port, info.host, () => resolve()); + server.once("error", reject); + }); + + const addr = server.address() as AddressInfo | null; + const port = addr?.port ?? info.port; + const host = info.host; + const baseUrl = `${new URL(info.baseUrl).protocol}//${host}:${port}`; + + const relay: ChromeExtensionRelayServer = { + host, + port, + baseUrl, + cdpWsUrl: `ws://${host}:${port}/cdp`, + extensionConnected: () => Boolean(extensionWs), + stop: async () => { + serversByPort.delete(port); + try { + extensionWs?.close(1001, "server stopping"); + } catch { + // ignore + } + for (const ws of cdpClients) { + try { + ws.close(1001, "server stopping"); + } catch { + // ignore + } + } + await new Promise((resolve) => { + server.close(() => resolve()); + }); + wssExtension.close(); + wssCdp.close(); + }, + }; + + serversByPort.set(port, relay); + return relay; +} + +export async function stopChromeExtensionRelayServer(opts: { + cdpUrl: string; +}): Promise { + const info = parseBaseUrl(opts.cdpUrl); + const existing = serversByPort.get(info.port); + if (!existing) return false; + await existing.stop(); + return true; +} diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts index 489c56ca3..75118ca17 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -20,6 +20,7 @@ export type CreateProfileParams = { name: string; color?: string; cdpUrl?: string; + driver?: "clawd" | "extension"; }; export type CreateProfileResult = { @@ -47,6 +48,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { const createProfile = async (params: CreateProfileParams): Promise => { const name = params.name.trim(); const rawCdpUrl = params.cdpUrl?.trim() || undefined; + const driver = params.driver === "extension" ? "extension" : undefined; if (!isValidProfileName(name)) { throw new Error("invalid profile name: use lowercase letters, numbers, and hyphens only"); @@ -71,7 +73,11 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { let profileConfig: BrowserProfileConfig; if (rawCdpUrl) { const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl"); - profileConfig = { cdpUrl: parsed.normalized, color: profileColor }; + profileConfig = { + cdpUrl: parsed.normalized, + ...(driver ? { driver } : {}), + color: profileColor, + }; } else { const usedPorts = getUsedPorts(resolvedProfiles); const range = deriveDefaultBrowserCdpPortRange(state.resolved.controlPort); @@ -79,7 +85,11 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { if (cdpPort === null) { throw new Error("no available CDP ports in range"); } - profileConfig = { cdpPort, color: profileColor }; + profileConfig = { + cdpPort, + ...(driver ? { driver } : {}), + color: profileColor, + }; } const nextConfig: ClawdbotConfig = { diff --git a/src/browser/pw-ai.ts b/src/browser/pw-ai.ts index fccc130fd..4b572c47d 100644 --- a/src/browser/pw-ai.ts +++ b/src/browser/pw-ai.ts @@ -41,6 +41,7 @@ export { setOfflineViaPlaywright, setTimezoneViaPlaywright, snapshotAiViaPlaywright, + snapshotAriaViaPlaywright, snapshotRoleViaPlaywright, screenshotWithLabelsViaPlaywright, storageClearViaPlaywright, diff --git a/src/browser/pw-tools-core.snapshot.ts b/src/browser/pw-tools-core.snapshot.ts index 41abba84e..f7f4c3a08 100644 --- a/src/browser/pw-tools-core.snapshot.ts +++ b/src/browser/pw-tools-core.snapshot.ts @@ -1,5 +1,6 @@ import type { Page } from "playwright-core"; +import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js"; import { buildRoleSnapshotFromAriaSnapshot, getRoleSnapshotStats, @@ -7,6 +8,30 @@ import { } from "./pw-role-snapshot.js"; import { ensurePageState, getPageForTargetId, type WithSnapshotForAI } from "./pw-session.js"; +export async function snapshotAriaViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + limit?: number; +}): Promise<{ nodes: AriaSnapshotNode[] }> { + const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500))); + const page = await getPageForTargetId({ + cdpUrl: opts.cdpUrl, + targetId: opts.targetId, + }); + ensurePageState(page); + const session = await page.context().newCDPSession(page); + try { + await session.send("Accessibility.enable").catch(() => {}); + const res = (await session.send("Accessibility.getFullAXTree")) as { + nodes?: RawAXNode[]; + }; + const nodes = Array.isArray(res?.nodes) ? res.nodes : []; + return { nodes: formatAriaSnapshot(nodes, limit) }; + } finally { + await session.detach().catch(() => {}); + } +} + export async function snapshotAiViaPlaywright(opts: { cdpUrl: string; targetId?: string; diff --git a/src/browser/routes/agent.snapshot.ts b/src/browser/routes/agent.snapshot.ts index 89cd590d0..66a39b2a1 100644 --- a/src/browser/routes/agent.snapshot.ts +++ b/src/browser/routes/agent.snapshot.ts @@ -95,8 +95,10 @@ export function registerBrowserAgentSnapshotRoutes(app: express.Express, ctx: Br try { const tab = await profileCtx.ensureTabAvailable(targetId); let buffer: Buffer; - if (ref || element) { - const pw = await requirePwAi(res, "element/ref screenshot"); + const shouldUsePlaywright = + profileCtx.profile.driver === "extension" || !tab.wsUrl || Boolean(ref) || Boolean(element); + if (shouldUsePlaywright) { + const pw = await requirePwAi(res, "screenshot"); if (!pw) return; const snap = await pw.takeScreenshotViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, @@ -268,16 +270,30 @@ export function registerBrowserAgentSnapshotRoutes(app: express.Express, ctx: Br }); } - const snap = await snapshotAria({ - wsUrl: tab.wsUrl ?? "", - limit, - }); + const snap = + profileCtx.profile.driver === "extension" || !tab.wsUrl + ? (() => { + // Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session. + // Also covers cases where wsUrl is missing/unusable. + return requirePwAi(res, "aria snapshot").then(async (pw) => { + if (!pw) return null; + return await pw.snapshotAriaViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + limit, + }); + }); + })() + : snapshotAria({ wsUrl: tab.wsUrl ?? "", limit }); + + const resolved = await Promise.resolve(snap); + if (!resolved) return; return res.json({ ok: true, format, targetId: tab.targetId, url: tab.url, - ...snap, + ...resolved, }); } catch (err) { handleRouteError(ctx, res, err); diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts index 7fa2515a6..0567e4274 100644 --- a/src/browser/routes/basic.ts +++ b/src/browser/routes/basic.ts @@ -111,6 +111,9 @@ export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRou const name = toStringOrEmpty((req.body as { name?: unknown })?.name); const color = toStringOrEmpty((req.body as { color?: unknown })?.color); const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl); + const driver = toStringOrEmpty( + (req.body as { driver?: unknown })?.driver, + ) as "clawd" | "extension" | ""; if (!name) return jsonError(res, 400, "name is required"); @@ -120,6 +123,7 @@ export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRou name, color: color || undefined, cdpUrl: cdpUrl || undefined, + driver: driver === "extension" ? "extension" : undefined, }); res.json(result); } catch (err) { diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 4f8bf8060..1ccddbccf 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -18,6 +18,10 @@ import type { ProfileRuntimeState, ProfileStatus, } from "./server-context.types.js"; +import { + ensureChromeExtensionRelayServer, + stopChromeExtensionRelayServer, +} from "./extension-relay.js"; import { resolveTargetIdFromTabs } from "./target-id.js"; import { movePathToTrash } from "./trash.js"; @@ -187,9 +191,35 @@ function createProfileContext( const ensureBrowserAvailable = async (): Promise => { const current = state(); const remoteCdp = !profile.cdpIsLoopback; + const isExtension = profile.driver === "extension"; const profileState = getProfileState(); const httpReachable = await isHttpReachable(); + if (isExtension && remoteCdp) { + throw new Error( + `Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`, + ); + } + + if (isExtension) { + if (!httpReachable) { + await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }); + if (await isHttpReachable(1200)) { + // continue: we still need the extension to connect for CDP websocket. + } else { + throw new Error( + `Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`, + ); + } + } + + if (await isReachable(600)) return; + // Relay server is up, but no attached tab yet. Prompt user to attach. + throw new Error( + `Chrome extension relay is running, but no tab is connected. Click the Clawdbot Chrome extension icon on a tab to attach it (profile "${profile.name}").`, + ); + } + if (!httpReachable) { if ((current.resolved.attachOnly || remoteCdp) && opts.onEnsureAttachTarget) { await opts.onEnsureAttachTarget(profile); @@ -297,6 +327,12 @@ function createProfileContext( }; const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => { + if (profile.driver === "extension") { + const stopped = await stopChromeExtensionRelayServer({ + cdpUrl: profile.cdpUrl, + }); + return { stopped }; + } const profileState = getProfileState(); if (!profileState.running) return { stopped: false }; await stopClawdChrome(profileState.running); @@ -305,6 +341,12 @@ function createProfileContext( }; const resetProfile = async () => { + if (profile.driver === "extension") { + await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch( + () => {}, + ); + return { moved: false, from: profile.cdpUrl }; + } if (!profile.cdpIsLoopback) { throw new Error( `reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`, diff --git a/src/browser/server.ts b/src/browser/server.ts index d2a96387f..74f4dbd9f 100644 --- a/src/browser/server.ts +++ b/src/browser/server.ts @@ -3,7 +3,12 @@ import express from "express"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging.js"; -import { resolveBrowserConfig, shouldStartLocalBrowserServer } from "./config.js"; +import { + resolveBrowserConfig, + resolveProfile, + shouldStartLocalBrowserServer, +} from "./config.js"; +import { ensureChromeExtensionRelayServer } from "./extension-relay.js"; import { registerBrowserRoutes } from "./routes/index.js"; import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js"; @@ -51,6 +56,20 @@ export async function startBrowserControlServerFromConfig(): Promise { + logServer.warn( + `Chrome extension relay init failed for profile "${name}": ${String(err)}`, + ); + }, + ); + } + logServer.info(`Browser control listening on http://127.0.0.1:${port}/`); return state; } diff --git a/src/cli/browser-cli-extension.test.ts b/src/cli/browser-cli-extension.test.ts new file mode 100644 index 000000000..78f2e61e0 --- /dev/null +++ b/src/cli/browser-cli-extension.test.ts @@ -0,0 +1,20 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +describe("browser extension install", () => { + it("installs into the state dir (never node_modules)", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-ext-")); + const { installChromeExtension } = await import("./browser-cli-extension.js"); + + const sourceDir = path.resolve(process.cwd(), "assets/chrome-extension"); + const result = await installChromeExtension({ stateDir: tmp, sourceDir }); + + expect(result.path).toBe(path.join(tmp, "browser", "chrome-extension")); + expect(fs.existsSync(path.join(result.path, "manifest.json"))).toBe(true); + expect(result.path.includes("node_modules")).toBe(false); + }); +}); + diff --git a/src/cli/browser-cli-extension.ts b/src/cli/browser-cli-extension.ts new file mode 100644 index 000000000..a4e392e93 --- /dev/null +++ b/src/cli/browser-cli-extension.ts @@ -0,0 +1,97 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { Command } from "commander"; + +import { STATE_DIR_CLAWDBOT } from "../config/paths.js"; +import { danger } from "../globals.js"; +import { defaultRuntime } from "../runtime.js"; +import { movePathToTrash } from "../browser/trash.js"; + +function bundledExtensionRootDir() { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, "../../assets/chrome-extension"); +} + +function installedExtensionRootDir() { + return path.join(STATE_DIR_CLAWDBOT, "browser", "chrome-extension"); +} + +function hasManifest(dir: string) { + return fs.existsSync(path.join(dir, "manifest.json")); +} + +export async function installChromeExtension(opts?: { + stateDir?: string; + sourceDir?: string; +}): Promise<{ path: string }> { + const src = opts?.sourceDir ?? bundledExtensionRootDir(); + if (!hasManifest(src)) { + throw new Error("Bundled Chrome extension is missing. Reinstall Clawdbot and try again."); + } + + const stateDir = opts?.stateDir ?? STATE_DIR_CLAWDBOT; + const dest = path.join(stateDir, "browser", "chrome-extension"); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + + if (fs.existsSync(dest)) { + await movePathToTrash(dest).catch(() => { + const backup = `${dest}.old-${Date.now()}`; + fs.renameSync(dest, backup); + }); + } + + await fs.promises.cp(src, dest, { recursive: true }); + if (!hasManifest(dest)) { + throw new Error("Chrome extension install failed (manifest.json missing). Try again."); + } + + return { path: dest }; +} + +export function registerBrowserExtensionCommands( + browser: Command, + parentOpts: (cmd: Command) => { json?: boolean }, +) { + const ext = browser.command("extension").description("Chrome extension helpers"); + + ext + .command("install") + .description("Install the Chrome extension to a stable local path") + .action(async (_opts, cmd) => { + const parent = parentOpts(cmd); + let installed: { path: string }; + try { + installed = await installChromeExtension(); + } catch (err) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + } + + if (parent?.json) { + defaultRuntime.log(JSON.stringify({ ok: true, path: installed.path }, null, 2)); + return; + } + defaultRuntime.log(installed.path); + }); + + ext + .command("path") + .description("Print the path to the installed Chrome extension (load unpacked)") + .action((_opts, cmd) => { + const parent = parentOpts(cmd); + const dir = installedExtensionRootDir(); + if (!hasManifest(dir)) { + defaultRuntime.error( + danger('Chrome extension is not installed. Run: "clawdbot browser extension install"'), + ); + defaultRuntime.exit(1); + } + if (parent?.json) { + defaultRuntime.log(JSON.stringify({ path: dir }, null, 2)); + return; + } + defaultRuntime.log(dir); + }); +} diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index 28462706c..476d42253 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -382,28 +382,41 @@ export function registerBrowserManageCommands( .requiredOption("--name ", "Profile name (lowercase, numbers, hyphens)") .option("--color ", "Profile color (hex format, e.g. #0066CC)") .option("--cdp-url ", "CDP URL for remote Chrome (http/https)") - .action(async (opts: { name: string; color?: string; cdpUrl?: string }, cmd) => { - const parent = parentOpts(cmd); - const baseUrl = resolveBrowserControlUrl(parent?.url); - try { - const result = await browserCreateProfile(baseUrl, { - name: opts.name, - color: opts.color, - cdpUrl: opts.cdpUrl, - }); - if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); - return; + .option("--driver ", "Profile driver (clawd|extension). Default: clawd") + .action( + async ( + opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, + cmd, + ) => { + const parent = parentOpts(cmd); + const baseUrl = resolveBrowserControlUrl(parent?.url); + try { + 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; + } + const loc = result.isRemote + ? ` cdpUrl: ${result.cdpUrl}` + : ` port: ${result.cdpPort}`; + defaultRuntime.log( + info( + `🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${ + opts.driver === "extension" ? "\n driver: extension" : "" + }`, + ), + ); + } catch (err) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); } - const loc = result.isRemote ? ` cdpUrl: ${result.cdpUrl}` : ` port: ${result.cdpPort}`; - defaultRuntime.log( - info(`🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}`), - ); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); + }, + ); browser .command("delete-profile") diff --git a/src/cli/browser-cli-serve.ts b/src/cli/browser-cli-serve.ts new file mode 100644 index 000000000..179424ed1 --- /dev/null +++ b/src/cli/browser-cli-serve.ts @@ -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 ", "Bind host (default: 127.0.0.1)") + .option("--port ", "Bind port (default: from browser.controlUrl)") + .option( + "--token ", + "Require Authorization: Bearer (required when binding non-loopback)", + ) + .action(async (opts: { bind?: string; port?: string; token?: string }) => { + const cfg = loadConfig(); + const resolved = resolveBrowserConfig(cfg.browser); + if (!resolved.enabled) { + defaultRuntime.error( + danger("Browser control is disabled. Set browser.enabled=true and try again."), + ); + defaultRuntime.exit(1); + } + + const host = (opts.bind ?? "127.0.0.1").trim(); + const port = parsePort(opts.port) ?? resolved.controlPort; + + const envToken = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim(); + const authToken = (opts.token ?? envToken ?? resolved.controlToken)?.trim(); + if (!isLoopbackBindHost(host) && !authToken) { + defaultRuntime.error( + danger( + `Refusing to bind browser control on ${host} without --token (or CLAWDBOT_BROWSER_CONTROL_TOKEN, or browser.controlToken).`, + ), + ); + defaultRuntime.exit(1); + } + + const bridge = await startBrowserBridgeServer({ + resolved, + host, + port, + ...(authToken ? { authToken } : {}), + }); + + // If any profile uses the Chrome extension relay, start the local relay server eagerly + // so the extension can connect before the first browser action. + for (const name of Object.keys(resolved.profiles)) { + const profile = resolveProfile(resolved, name); + if (!profile || profile.driver !== "extension") continue; + await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => { + defaultRuntime.error( + danger(`Chrome extension relay init failed for profile "${name}": ${String(err)}`), + ); + }); + } + + defaultRuntime.log( + info( + [ + `🦞 Browser control listening on ${bridge.baseUrl}/`, + authToken ? "Auth: Bearer token required." : "Auth: off (loopback only).", + "", + "Paste on the Gateway (clawdbot.json):", + JSON.stringify( + { + browser: { + enabled: true, + controlUrl: bridge.baseUrl, + ...(authToken ? { controlToken: authToken } : {}), + }, + }, + null, + 2, + ), + ...(authToken + ? [ + "", + "Or use env on the Gateway (instead of controlToken in config):", + `export CLAWDBOT_BROWSER_CONTROL_TOKEN=${JSON.stringify(authToken)}`, + ] + : []), + ].join("\n"), + ), + ); + + let shuttingDown = false; + const shutdown = async (signal: string) => { + if (shuttingDown) return; + shuttingDown = true; + defaultRuntime.log(info(`Shutting down (${signal})...`)); + await stopBrowserBridgeServer(bridge.server).catch(() => {}); + process.exit(0); + }; + process.once("SIGINT", () => void shutdown("SIGINT")); + process.once("SIGTERM", () => void shutdown("SIGTERM")); + + await new Promise(() => {}); + }); +} diff --git a/src/cli/browser-cli.ts b/src/cli/browser-cli.ts index 89a2c1241..626163211 100644 --- a/src/cli/browser-cli.ts +++ b/src/cli/browser-cli.ts @@ -8,8 +8,10 @@ import { registerBrowserActionInputCommands } from "./browser-cli-actions-input. import { registerBrowserActionObserveCommands } from "./browser-cli-actions-observe.js"; import { registerBrowserDebugCommands } from "./browser-cli-debug.js"; import { browserActionExamples, browserCoreExamples } from "./browser-cli-examples.js"; +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"; @@ -37,6 +39,8 @@ export function registerBrowserCli(program: Command) { const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts; registerBrowserManageCommands(browser, parentOpts); + registerBrowserExtensionCommands(browser, parentOpts); + registerBrowserServeCommands(browser, parentOpts); registerBrowserInspectCommands(browser, parentOpts); registerBrowserActionInputCommands(browser, parentOpts); registerBrowserActionObserveCommands(browser, parentOpts); diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index e5fe99e8c..e937a9f07 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -3,6 +3,8 @@ export type BrowserProfileConfig = { cdpPort?: number; /** CDP URL for this profile (use for remote Chrome). */ cdpUrl?: string; + /** Profile driver (default: clawd). */ + driver?: "clawd" | "extension"; /** Profile color (hex). Auto-assigned at creation. */ color: string; }; @@ -10,6 +12,13 @@ export type BrowserConfig = { enabled?: boolean; /** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */ controlUrl?: string; + /** + * Shared token for the browser control server. + * If set, clients must send `Authorization: Bearer `. + * + * Prefer `CLAWDBOT_BROWSER_CONTROL_TOKEN` env for ephemeral setups; use this for "works after reboot". + */ + controlToken?: string; /** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */ cdpUrl?: string; /** Accent color for the clawd browser profile (hex). Default: #FF4500 */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 359ceae51..7c4b2f3e2 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -65,6 +65,7 @@ export const ClawdbotSchema = z .object({ enabled: z.boolean().optional(), controlUrl: z.string().optional(), + controlToken: z.string().optional(), cdpUrl: z.string().optional(), color: z.string().optional(), executablePath: z.string().optional(), @@ -81,6 +82,9 @@ export const ClawdbotSchema = z .object({ cdpPort: z.number().int().min(1).max(65535).optional(), cdpUrl: z.string().optional(), + driver: z + .union([z.literal("clawd"), z.literal("extension")]) + .optional(), color: HexColorSchema, }) .refine((value) => value.cdpPort || value.cdpUrl, { diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 47925baeb..b139b2fbd 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -66,6 +66,84 @@ describe("security audit", () => { ); }); + it("flags remote browser control without token as critical", async () => { + const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN; + delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN; + try { + const cfg: ClawdbotConfig = { + browser: { + controlUrl: "http://example.com:18791", + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "browser.control_remote_no_token", severity: "critical" }), + ]), + ); + } finally { + if (prev === undefined) delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN; + else process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev; + } + }); + + it("warns when browser control token matches gateway auth token", async () => { + const token = "0123456789abcdef0123456789abcdef"; + const cfg: ClawdbotConfig = { + gateway: { auth: { token } }, + browser: { controlUrl: "https://browser.example.com", controlToken: token }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "browser.control_token_reuse_gateway_token", + severity: "warn", + }), + ]), + ); + }); + + it("warns when remote browser control uses HTTP", async () => { + const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN; + delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN; + try { + const cfg: ClawdbotConfig = { + browser: { + controlUrl: "http://example.com:18791", + controlToken: "0123456789abcdef01234567", + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "browser.control_remote_http", severity: "warn" }), + ]), + ); + } finally { + if (prev === undefined) delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN; + else process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev; + } + }); + it("adds a warning when deep probe fails", async () => { const cfg: ClawdbotConfig = { gateway: { mode: "local" } }; diff --git a/src/security/audit.ts b/src/security/audit.ts index 54f1ce38f..8e9844b08 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -4,6 +4,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { ClawdbotConfig } from "../config/config.js"; +import { resolveBrowserConfig } from "../browser/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; @@ -45,9 +46,9 @@ export type SecurityAuditOptions = { deep?: boolean; includeFilesystem?: boolean; includeChannelSecurity?: boolean; - /** Override where to check state (default: CONFIG_DIR). */ + /** Override where to check state (default: resolveStateDir()). */ stateDir?: string; - /** Override config path check (default: CONFIG_PATH_CLAWDBOT). */ + /** Override config path check (default: resolveConfigPath()). */ configPath?: string; /** Time limit for deep gateway probe. */ deepTimeoutMs?: number; @@ -287,6 +288,87 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding return findings; } +function isLoopbackClientHost(hostname: string): boolean { + const h = hostname.trim().toLowerCase(); + return h === "localhost" || h === "127.0.0.1" || h === "::1"; +} + +function collectBrowserControlFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + + let resolved: ReturnType; + try { + resolved = resolveBrowserConfig(cfg.browser); + } catch (err) { + findings.push({ + checkId: "browser.control_invalid_config", + severity: "warn", + title: "Browser control config looks invalid", + detail: String(err), + remediation: `Fix browser.controlUrl/browser.cdpUrl in ${resolveConfigPath()} and re-run "clawdbot security audit --deep".`, + }); + return findings; + } + + if (!resolved.enabled) return findings; + + const url = new URL(resolved.controlUrl); + const isLoopback = isLoopbackClientHost(url.hostname); + const envToken = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim(); + const controlToken = (envToken || resolved.controlToken)?.trim() || null; + + if (!isLoopback) { + if (!controlToken) { + findings.push({ + checkId: "browser.control_remote_no_token", + severity: "critical", + title: "Remote browser control is missing an auth token", + detail: `browser.controlUrl is non-loopback (${resolved.controlUrl}) but no browser.controlToken (or CLAWDBOT_BROWSER_CONTROL_TOKEN) is configured.`, + remediation: + "Set browser.controlToken (or export CLAWDBOT_BROWSER_CONTROL_TOKEN) and prefer serving over Tailscale Serve or HTTPS reverse proxy.", + }); + } + + if (url.protocol === "http:") { + findings.push({ + checkId: "browser.control_remote_http", + severity: "warn", + title: "Remote browser control uses HTTP", + detail: `browser.controlUrl=${resolved.controlUrl} is http; this is OK only if it's tailnet-only (Tailscale) or behind another encrypted tunnel.`, + remediation: `Prefer HTTPS termination (Tailscale Serve) and keep the endpoint tailnet-only.`, + }); + } + + if (controlToken && controlToken.length < 24) { + findings.push({ + checkId: "browser.control_token_too_short", + severity: "warn", + title: "Browser control token looks short", + detail: `browser control token is ${controlToken.length} chars; prefer a long random token.`, + }); + } + + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + const gatewayAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode }); + const gatewayToken = + gatewayAuth.mode === "token" && typeof gatewayAuth.token === "string" && gatewayAuth.token.trim() + ? gatewayAuth.token.trim() + : null; + + if (controlToken && gatewayToken && controlToken === gatewayToken) { + findings.push({ + checkId: "browser.control_token_reuse_gateway_token", + severity: "warn", + title: "Browser control token reuses the Gateway token", + detail: `browser.controlToken matches gateway.auth token; compromise of browser control expands blast radius to the Gateway API.`, + remediation: `Use a separate browser.controlToken dedicated to browser control.`, + }); + } + } + + return findings; +} + function collectLoggingFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { const redact = cfg.logging?.redactSensitive; if (redact !== "off") return []; @@ -500,6 +582,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise