feat: add Chrome extension browser relay
This commit is contained in:
@@ -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.
|
||||
|
||||
22
assets/chrome-extension/README.md
Normal file
22
assets/chrome-extension/README.md
Normal file
@@ -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`.
|
||||
407
assets/chrome-extension/background.js
Normal file
407
assets/chrome-extension/background.js
Normal file
@@ -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<void>|null} */
|
||||
let relayConnectPromise = null
|
||||
|
||||
let debuggerListenersInstalled = false
|
||||
|
||||
let nextSession = 1
|
||||
|
||||
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
|
||||
const tabs = new Map()
|
||||
/** @type {Map<string, number>} */
|
||||
const tabBySession = new Map()
|
||||
/** @type {Map<string, number>} */
|
||||
const childSessionToTab = new Map()
|
||||
|
||||
/** @type {Map<number, {resolve:(v:any)=>void, 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()
|
||||
})
|
||||
12
assets/chrome-extension/manifest.json
Normal file
12
assets/chrome-extension/manifest.json
Normal file
@@ -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 }
|
||||
}
|
||||
|
||||
75
assets/chrome-extension/options.html
Normal file
75
assets/chrome-extension/options.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Clawdbot Browser Relay</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
|
||||
line-height: 1.4;
|
||||
}
|
||||
body {
|
||||
margin: 24px;
|
||||
max-width: 720px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input {
|
||||
width: 140px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in oklab, currentColor 20%, transparent);
|
||||
background: color-mix(in oklab, currentColor 3%, transparent);
|
||||
}
|
||||
button {
|
||||
margin-left: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in oklab, currentColor 20%, transparent);
|
||||
background: color-mix(in oklab, currentColor 10%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
code {
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Clawdbot Browser Relay</h1>
|
||||
|
||||
<div>
|
||||
<label for="port">Relay port</label>
|
||||
<div class="row">
|
||||
<input id="port" inputmode="numeric" pattern="[0-9]*" />
|
||||
<button id="save">Save</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
Default: <code>18792</code>. Relay base URL: <code>http://127.0.0.1:<port>/</code>.
|
||||
</div>
|
||||
<div class="hint" id="status"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
30
assets/chrome-extension/options.js
Normal file
30
assets/chrome-extension/options.js
Normal file
@@ -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()
|
||||
|
||||
@@ -893,6 +893,7 @@
|
||||
"tools/apply-patch",
|
||||
"tools/elevated",
|
||||
"tools/browser",
|
||||
"tools/chrome-extension",
|
||||
"tools/browser-linux-troubleshooting",
|
||||
"tools/slash-commands",
|
||||
"tools/thinking",
|
||||
|
||||
@@ -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 <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)
|
||||
|
||||
|
||||
@@ -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 <token>
|
||||
tailscale serve https / http://127.0.0.1:18791
|
||||
```
|
||||
|
||||
Then point the Gateway config at the HTTPS URL:
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "https://<magicdns>/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And authenticate from the Gateway with the same token (prefer env):
|
||||
|
||||
```bash
|
||||
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
|
||||
```
|
||||
|
||||
Avoid Funnel for browser control endpoints unless you explicitly want public exposure.
|
||||
|
||||
## Tailscale prerequisites + limits
|
||||
|
||||
- Serve requires HTTPS enabled for your tailnet; the CLI prompts if it is missing.
|
||||
|
||||
@@ -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 `<workspace>/skills` on the next session. For shared skills across agents, place them in `~/.clawdbot/skills/<name>/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?
|
||||
|
||||
@@ -106,6 +106,95 @@ Example:
|
||||
Use `profiles.<name>.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 <browser-host> --port 18791 --token <token>
|
||||
```
|
||||
|
||||
Then point your Gateway at it:
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "http://<browser-host>:18791",
|
||||
|
||||
// Option A (recommended): keep token in env on the Gateway
|
||||
// (avoid writing secrets into config files)
|
||||
// controlToken: "<token>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And set the auth token in the Gateway environment:
|
||||
|
||||
```bash
|
||||
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
|
||||
```
|
||||
|
||||
Option B: store the token in the Gateway config instead (same shared token):
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "http://<browser-host>:18791",
|
||||
controlToken: "<token>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
This section covers the **browser control server** (`browser.controlUrl`) used for agent browser automation.
|
||||
|
||||
Key ideas:
|
||||
- Treat the browser control server like an admin API: **private network only**.
|
||||
- Use **token auth** always when the server is reachable off-machine.
|
||||
- Prefer **Tailnet-only** connectivity over LAN exposure.
|
||||
|
||||
### Tokens (what is shared with what?)
|
||||
|
||||
- `browser.controlToken` / `CLAWDBOT_BROWSER_CONTROL_TOKEN` is **only** for authenticating browser control HTTP requests to `browser.controlUrl`.
|
||||
- It is **not** the Gateway token (`gateway.auth.token`) and **not** a node pairing token.
|
||||
- You *can* reuse the same string value, but it’s better to keep them separate to reduce blast radius.
|
||||
|
||||
### Binding (don’t expose to your LAN by accident)
|
||||
|
||||
Recommended:
|
||||
- Keep `clawdbot browser serve` bound to loopback (`127.0.0.1`) and publish it via Tailscale.
|
||||
- Or bind to a Tailnet IP only (never `0.0.0.0`) and require a token.
|
||||
|
||||
Avoid:
|
||||
- `--bind 0.0.0.0` (LAN-visible). Even with token auth, traffic is plain HTTP unless you also add TLS.
|
||||
|
||||
### TLS / HTTPS (recommended approach: terminate in front)
|
||||
|
||||
Best practice here: keep `clawdbot browser serve` on HTTP and terminate TLS in front.
|
||||
|
||||
If you’re already using Tailscale, you have two good options:
|
||||
|
||||
1) **Tailnet-only, still HTTP** (transport is encrypted by Tailscale):
|
||||
- Keep `controlUrl` as `http://…` but ensure it’s only reachable over your tailnet.
|
||||
|
||||
2) **Serve HTTPS via Tailscale** (nice UX: `https://…` URL):
|
||||
|
||||
```bash
|
||||
# on the browser machine
|
||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
||||
tailscale serve https / http://127.0.0.1:18791
|
||||
```
|
||||
|
||||
Then set your Gateway config `browser.controlUrl` to the HTTPS URL (MagicDNS/ts.net) and keep using the same token.
|
||||
|
||||
Notes:
|
||||
- Do **not** use Tailscale Funnel for this unless you explicitly want to make the endpoint public.
|
||||
- For Tailnet setup/background, see [Gateway web surfaces](/web/index) and the [Gateway CLI](/cli/gateway).
|
||||
|
||||
## Profiles (multi-browser)
|
||||
|
||||
Clawdbot supports multiple named profiles. Each profile has its own:
|
||||
@@ -120,6 +209,44 @@ Defaults:
|
||||
|
||||
All control endpoints accept `?profile=<name>`; 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=<name>`.
|
||||
|
||||
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)
|
||||
|
||||
|
||||
121
docs/tools/chrome-extension.md
Normal file
121
docs/tools/chrome-extension.md
Normal file
@@ -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 <token>
|
||||
tailscale serve https / http://127.0.0.1:18791
|
||||
```
|
||||
|
||||
On the **Gateway machine**:
|
||||
- Set `browser.controlUrl` to the HTTPS Serve URL (MagicDNS/ts.net).
|
||||
- Provide the token (prefer env):
|
||||
|
||||
```bash
|
||||
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
|
||||
```
|
||||
|
||||
Then the agent can drive the browser by calling the remote `browser.controlUrl` API, while the extension + relay stay local on the browser machine.
|
||||
|
||||
## How “extension path” works
|
||||
|
||||
`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)
|
||||
@@ -40,6 +40,8 @@
|
||||
"dist/*.js",
|
||||
"dist/*.json",
|
||||
"docs/**",
|
||||
"extensions/**",
|
||||
"assets/**",
|
||||
"skills/**",
|
||||
"README.md",
|
||||
"README-header.png",
|
||||
|
||||
@@ -20,6 +20,7 @@ const BROWSER_TOOL_ACTIONS = [
|
||||
"status",
|
||||
"start",
|
||||
"stop",
|
||||
"profiles",
|
||||
"tabs",
|
||||
"open",
|
||||
"focus",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function startBrowserBridgeServer(params: {
|
||||
resolved: ResolvedBrowserConfig;
|
||||
host?: string;
|
||||
port?: number;
|
||||
authToken?: string;
|
||||
onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise<void>;
|
||||
}): Promise<BrowserBridge> {
|
||||
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,
|
||||
|
||||
@@ -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<string, RawAXNode>();
|
||||
for (const n of nodes) {
|
||||
if (n.nodeId) byId.set(n.nodeId, n);
|
||||
|
||||
@@ -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<T>(
|
||||
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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<BrowserCreateProfileResult> {
|
||||
return await fetchBrowserJson<BrowserCreateProfileResult>(`${baseUrl}/profiles/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: opts.name,
|
||||
color: opts.color,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
}),
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
return await fetchBrowserJson<BrowserCreateProfileResult>(
|
||||
`${baseUrl}/profiles/create`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: opts.name,
|
||||
color: opts.color,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
driver: opts.driver,
|
||||
}),
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type BrowserDeleteProfileResult = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
202
src/browser/extension-relay.test.ts
Normal file
202
src/browser/extension-relay.test.ts
Normal file
@@ -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<number> {
|
||||
while (true) {
|
||||
const port = await new Promise<number>((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<void>((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<string>((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);
|
||||
});
|
||||
680
src/browser/extension-relay.ts
Normal file
680
src/browser/extension-relay.ts
Normal file
@@ -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<void>;
|
||||
};
|
||||
|
||||
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<number, ChromeExtensionRelayServer>();
|
||||
|
||||
export async function ensureChromeExtensionRelayServer(opts: {
|
||||
cdpUrl: string;
|
||||
}): Promise<ChromeExtensionRelayServer> {
|
||||
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<WebSocket>();
|
||||
const connectedTargets = new Map<string, ConnectedTarget>();
|
||||
|
||||
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<unknown> => {
|
||||
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<unknown>((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<unknown> => {
|
||||
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<string, unknown> = {
|
||||
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<void>((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<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
wssExtension.close();
|
||||
wssCdp.close();
|
||||
},
|
||||
};
|
||||
|
||||
serversByPort.set(port, relay);
|
||||
return relay;
|
||||
}
|
||||
|
||||
export async function stopChromeExtensionRelayServer(opts: {
|
||||
cdpUrl: string;
|
||||
}): Promise<boolean> {
|
||||
const info = parseBaseUrl(opts.cdpUrl);
|
||||
const existing = serversByPort.get(info.port);
|
||||
if (!existing) return false;
|
||||
await existing.stop();
|
||||
return true;
|
||||
}
|
||||
@@ -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<CreateProfileResult> => {
|
||||
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 = {
|
||||
|
||||
@@ -41,6 +41,7 @@ export {
|
||||
setOfflineViaPlaywright,
|
||||
setTimezoneViaPlaywright,
|
||||
snapshotAiViaPlaywright,
|
||||
snapshotAriaViaPlaywright,
|
||||
snapshotRoleViaPlaywright,
|
||||
screenshotWithLabelsViaPlaywright,
|
||||
storageClearViaPlaywright,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void> => {
|
||||
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).`,
|
||||
|
||||
@@ -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<BrowserServ
|
||||
profiles: new Map(),
|
||||
};
|
||||
|
||||
// If any profile uses the Chrome extension relay, start the local relay server eagerly
|
||||
// so the extension can connect before the first browser action.
|
||||
for (const name of Object.keys(resolved.profiles)) {
|
||||
const profile = resolveProfile(resolved, name);
|
||||
if (!profile || profile.driver !== "extension") continue;
|
||||
await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(
|
||||
(err) => {
|
||||
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;
|
||||
}
|
||||
|
||||
20
src/cli/browser-cli-extension.test.ts
Normal file
20
src/cli/browser-cli-extension.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
97
src/cli/browser-cli-extension.ts
Normal file
97
src/cli/browser-cli-extension.ts
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -382,28 +382,41 @@ export function registerBrowserManageCommands(
|
||||
.requiredOption("--name <name>", "Profile name (lowercase, numbers, hyphens)")
|
||||
.option("--color <hex>", "Profile color (hex format, e.g. #0066CC)")
|
||||
.option("--cdp-url <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 <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")
|
||||
|
||||
121
src/cli/browser-cli-serve.ts
Normal file
121
src/cli/browser-cli-serve.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { danger, info } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
|
||||
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../browser/bridge-server.js";
|
||||
import { ensureChromeExtensionRelayServer } from "../browser/extension-relay.js";
|
||||
|
||||
function isLoopbackBindHost(host: string) {
|
||||
const h = host.trim().toLowerCase();
|
||||
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]";
|
||||
}
|
||||
|
||||
function parsePort(raw: unknown): number | null {
|
||||
const v = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!v) return null;
|
||||
const n = Number.parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 65535) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
export function registerBrowserServeCommands(
|
||||
browser: Command,
|
||||
_parentOpts: (cmd: Command) => unknown,
|
||||
) {
|
||||
browser
|
||||
.command("serve")
|
||||
.description("Run a standalone browser control server (for remote gateways)")
|
||||
.option("--bind <host>", "Bind host (default: 127.0.0.1)")
|
||||
.option("--port <port>", "Bind port (default: from browser.controlUrl)")
|
||||
.option(
|
||||
"--token <token>",
|
||||
"Require Authorization: Bearer <token> (required when binding non-loopback)",
|
||||
)
|
||||
.action(async (opts: { bind?: string; port?: string; token?: string }) => {
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser);
|
||||
if (!resolved.enabled) {
|
||||
defaultRuntime.error(
|
||||
danger("Browser control is disabled. Set browser.enabled=true and try again."),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
const host = (opts.bind ?? "127.0.0.1").trim();
|
||||
const port = parsePort(opts.port) ?? resolved.controlPort;
|
||||
|
||||
const envToken = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
|
||||
const authToken = (opts.token ?? envToken ?? resolved.controlToken)?.trim();
|
||||
if (!isLoopbackBindHost(host) && !authToken) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Refusing to bind browser control on ${host} without --token (or CLAWDBOT_BROWSER_CONTROL_TOKEN, or browser.controlToken).`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
const bridge = await startBrowserBridgeServer({
|
||||
resolved,
|
||||
host,
|
||||
port,
|
||||
...(authToken ? { authToken } : {}),
|
||||
});
|
||||
|
||||
// If any profile uses the Chrome extension relay, start the local relay server eagerly
|
||||
// so the extension can connect before the first browser action.
|
||||
for (const name of Object.keys(resolved.profiles)) {
|
||||
const profile = resolveProfile(resolved, name);
|
||||
if (!profile || profile.driver !== "extension") continue;
|
||||
await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => {
|
||||
defaultRuntime.error(
|
||||
danger(`Chrome extension relay init failed for profile "${name}": ${String(err)}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
[
|
||||
`🦞 Browser control listening on ${bridge.baseUrl}/`,
|
||||
authToken ? "Auth: Bearer token required." : "Auth: off (loopback only).",
|
||||
"",
|
||||
"Paste on the Gateway (clawdbot.json):",
|
||||
JSON.stringify(
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: bridge.baseUrl,
|
||||
...(authToken ? { controlToken: authToken } : {}),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
...(authToken
|
||||
? [
|
||||
"",
|
||||
"Or use env on the Gateway (instead of controlToken in config):",
|
||||
`export CLAWDBOT_BROWSER_CONTROL_TOKEN=${JSON.stringify(authToken)}`,
|
||||
]
|
||||
: []),
|
||||
].join("\n"),
|
||||
),
|
||||
);
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
defaultRuntime.log(info(`Shutting down (${signal})...`));
|
||||
await stopBrowserBridgeServer(bridge.server).catch(() => {});
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
await new Promise(() => {});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <token>`.
|
||||
*
|
||||
* Prefer `CLAWDBOT_BROWSER_CONTROL_TOKEN` env for ephemeral setups; use this for "works after reboot".
|
||||
*/
|
||||
controlToken?: string;
|
||||
/** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */
|
||||
cdpUrl?: string;
|
||||
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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" } };
|
||||
|
||||
|
||||
@@ -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<typeof resolveBrowserConfig>;
|
||||
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<Secu
|
||||
const configPath = opts.configPath ?? resolveConfigPath();
|
||||
|
||||
findings.push(...collectGatewayConfigFindings(cfg));
|
||||
findings.push(...collectBrowserControlFindings(cfg));
|
||||
findings.push(...collectLoggingFindings(cfg));
|
||||
findings.push(...collectElevatedFindings(cfg));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user