slim down cdp functionality

This commit is contained in:
STJ 2026-03-13 18:59:30 -07:00
parent b434d87601
commit 57ddce161f
7 changed files with 102 additions and 132 deletions

View file

@ -151,7 +151,7 @@ sudo -u pentester certutil -N -d sql:/home/pentester/.pki/nssdb --empty-password
sudo -u pentester certutil -A -n "Testing Root CA" -t "C,," -i /app/certs/ca.crt -d sql:/home/pentester/.pki/nssdb
echo "✅ CA added to browser trust store"
# Chromium binds CDP to 127.0.0.1, tool server proxies via /cdp/proxy/
# Chromium binds CDP to 127.0.0.1, tool server proxies WS via /cdp/ws
CDP_INTERNAL_PORT=19222
CHROMIUM_BIN=$(find /usr/lib/chromium* /usr/bin -name "chromium" -o -name "chromium-browser" -o -name "chrome" 2>/dev/null | head -1)
[ -z "$CHROMIUM_BIN" ] && CHROMIUM_BIN=$(find /home/pentester/.cache/ms-playwright -name "chrome" -type f 2>/dev/null | head -1)
@ -204,7 +204,7 @@ sudo -E -u pentester \
TOOL_SERVER_PID=$!
for i in {1..10}; do
if curl -s -H "Authorization: Bearer ${TOOL_SERVER_TOKEN}" "http://127.0.0.1:$TOOL_SERVER_PORT/health" | grep -q '"status":"healthy"'; then
if curl -s "http://127.0.0.1:$TOOL_SERVER_PORT/health" | grep -q '"status":"healthy"'; then
echo "✅ Tool server healthy on port $TOOL_SERVER_PORT"
break
fi

View file

@ -24,7 +24,7 @@ def _get_style_colors() -> dict[Any, str]:
@register_tool_renderer
class BrowserRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "browser_actions"
tool_name: ClassVar[str] = "browser_action"
css_classes: ClassVar[list[str]] = ["tool-call", "browser-tool"]
# -- palette (used only for highlights) ----------------------------

View file

@ -10,8 +10,7 @@ from typing import Any
import httpx
import uvicorn
import websockets
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, status
from fastapi.responses import Response
from fastapi import Depends, FastAPI, HTTPException, WebSocket, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ValidationError
@ -152,20 +151,13 @@ async def register_agent(
return {"status": "registered", "agent_id": agent_id}
async def _check_cdp_health() -> dict[str, Any]:
try:
async with httpx.AsyncClient(timeout=3, trust_env=False) as client:
resp = await client.get(f"{CDP_UPSTREAM}/json/version")
if resp.status_code == 200:
return {"status": "healthy"}
return {"status": "unhealthy"}
except Exception: # noqa: BLE001
return {"status": "unhealthy"}
def _check_cdp_health() -> dict[str, str]:
return {"status": "healthy" if _cdp_ws_internal else "unhealthy"}
@app.get("/health")
async def health_check() -> dict[str, Any]:
cdp_health = await _check_cdp_health()
cdp_health = _check_cdp_health()
return {
"status": "healthy",
"sandbox_mode": str(SANDBOX_MODE),
@ -177,23 +169,52 @@ async def health_check() -> dict[str, Any]:
}
# -- CDP auth proxy ----------------------------------------------------------
# Proxies HTTP and WebSocket traffic to the container-local Chromium CDP.
# -- CDP proxy ---------------------------------------------------------------
# Resolves the internal Chromium WS debugger URL on startup and pins the
# WebSocket proxy to that exact path. No catch-all proxy — avoids SSRF.
_cdp_ws_internal: str | None = None
def _cdp_upstream_url(path: str) -> str:
# Strip the /cdp/proxy prefix to get the upstream path
suffix = path.removeprefix("/cdp/proxy")
return f"{CDP_UPSTREAM}{suffix}"
async def _resolve_cdp_ws() -> None:
global _cdp_ws_internal # noqa: PLW0603
async with httpx.AsyncClient(trust_env=False, timeout=5) as client:
for _ in range(30):
try:
resp = await client.get(f"{CDP_UPSTREAM}/json/version")
if resp.status_code == 200 and "webSocketDebuggerUrl" in resp.text:
_cdp_ws_internal = resp.json()["webSocketDebuggerUrl"]
return
except httpx.HTTPError:
pass
await asyncio.sleep(1)
@app.websocket("/cdp/proxy/{path:path}")
async def cdp_proxy_ws(ws: WebSocket, path: str) -> None: # noqa: ARG001
@app.on_event("startup")
async def startup() -> None:
await _resolve_cdp_ws()
@app.get("/cdp/info")
async def cdp_info(
credentials: HTTPAuthorizationCredentials = security_dependency,
) -> dict[str, Any]:
verify_token(credentials)
if not _cdp_ws_internal:
raise HTTPException(status_code=503, detail="CDP not ready")
return {"ws_url": "/cdp/ws", "status": "ready"}
@app.websocket("/cdp/ws")
async def cdp_proxy_ws(ws: WebSocket) -> None:
verify_ws_token(ws)
url = _cdp_upstream_url(ws.url.path).replace("http", "ws", 1)
if not _cdp_ws_internal:
await ws.close(code=1013, reason="CDP not ready")
return
await ws.accept()
async with websockets.connect(url) as upstream:
async with websockets.connect(_cdp_ws_internal) as upstream:
async def relay_client_to_upstream() -> None:
async for msg in ws.iter_text():
@ -217,36 +238,6 @@ async def cdp_proxy_ws(ws: WebSocket, path: str) -> None: # noqa: ARG001
t.cancel()
@app.api_route(
"/cdp/proxy/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
)
async def cdp_proxy_http(
request: Request,
path: str, # noqa: ARG001
credentials: HTTPAuthorizationCredentials = security_dependency,
) -> Response:
verify_token(credentials)
url = _cdp_upstream_url(request.url.path)
headers = {
k: v for k, v in request.headers.items() if k.lower() not in ("host", "authorization")
}
async with httpx.AsyncClient() as client:
resp = await client.request(
request.method,
url,
headers=headers,
content=await request.body(),
)
return Response(
content=resp.content,
status_code=resp.status_code,
headers={k: v for k, v in resp.headers.items() if k.lower() != "transfer-encoding"},
)
def signal_handler(_signum: int, _frame: Any) -> None:
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_IGN)

View file

@ -1,4 +1,4 @@
from .browser_actions import browser_actions
from .browser_actions import browser_action
__all__ = ["browser_actions"]
__all__ = ["browser_action"]

View file

@ -1,5 +1,5 @@
import asyncio
import json
import base64
import logging
import re
from functools import partial
@ -72,7 +72,7 @@ def _resolve_cdp_url(agent_state: Any) -> tuple[str, str]:
if not api_url:
raise ValueError("Missing api_url in sandbox_info")
return f"{api_url}/cdp/proxy", info.get("auth_token", "")
return api_url, info.get("auth_token", "")
async def _execute_task(session: BrowserSession, operation: Any, desc: str) -> dict[str, Any]:
@ -120,6 +120,17 @@ async def _run_browser_agent(
use_vision=vision,
)
# browseruse's setup_logging() adds a StreamHandler and disables propagation;
# undo that so logs flow to our root file handler instead of the console
for _name in ("browser_use", "bubus"):
_bl = logging.getLogger(_name)
_bl.handlers = [
h
for h in _bl.handlers
if not isinstance(h, logging.StreamHandler) or isinstance(h, logging.FileHandler)
]
_bl.propagate = True
async def log_step(step: Any) -> None:
logger.info("Agent step completed: %s", step)
@ -152,21 +163,6 @@ async def _run_browser_agent(
return out
def _fix_json_in_xml(kws: dict[str, Any]) -> dict[str, Any]:
result = {}
for k, v in kws.items():
if v is None:
continue
if isinstance(v, str) and v.startswith(("[", "{")):
try:
result[k] = json.loads(v)
except (json.JSONDecodeError, ValueError):
result[k] = v
else:
result[k] = v
return result
async def _run_browser_tool(
session: BrowserSession,
action: str,
@ -214,8 +210,9 @@ async def _run_browser_tool(
async def populate_response(session: BrowserSession, response: dict[str, Any]) -> dict[str, Any]:
try:
screenshot = await session.browser.take_screenshot()
screenshot_b64 = base64.b64encode(screenshot).decode("utf-8")
except Exception as e: # noqa: BLE001
screenshot = None
screenshot_b64 = None
response["screenshot_error"] = str(e)
try:
@ -238,7 +235,7 @@ async def populate_response(session: BrowserSession, response: dict[str, Any]) -
return {
**response,
"screenshot": screenshot,
"screenshot": screenshot_b64,
"url": url,
"title": title,
"viewport": viewport,
@ -247,7 +244,7 @@ async def populate_response(session: BrowserSession, response: dict[str, Any]) -
@register_tool(sandbox_execution=False)
async def browser_actions(
async def browser_action(
action: BrowserUseLocalAction,
task: str | None = None,
profile_directory: str | None = None,
@ -305,8 +302,7 @@ async def browser_actions(
runner = partial(_run_browser_agent, session, task, return_fields, metadata)
desc = task
else:
params = _fix_json_in_xml(kwargs)
runner = partial(_run_browser_tool, session, action, params, metadata)
runner = partial(_run_browser_tool, session, action, kwargs, metadata)
desc = f"{action}({list(kwargs.keys())[:3]})"
task_output = await _execute_task(session, runner, desc)
@ -317,7 +313,7 @@ async def browser_actions(
return await populate_response(session, task_output)
except Exception as error:
logger.exception("browser_actions error: %s", action)
logger.exception("browser_action error: %s", action)
return {
"error": str(error),
"is_running": False,

View file

@ -1,5 +1,5 @@
<tools>
<tool name="browser_actions">
<tool name="browser_action">
<description>Control a browser via natural language or granular commands.
The browser runs inside the sandbox container (with Caido proxy intercepting all traffic)
@ -188,74 +188,74 @@
</notes>
<examples>
# Launch the sandbox browser (default, must be done first)
<function=browser_actions>
<function=browser_action>
<parameter=action>launch</parameter>
</function>
# Run a natural-language browser task
<function=browser_actions>
<function=browser_action>
<parameter=action>run</parameter>
<parameter=task>Go to https://example.com/login, fill in username "admin" and password "secret", then click the login button</parameter>
</function>
# Navigate to a URL
<function=browser_actions>
<function=browser_action>
<parameter=action>navigate</parameter>
<parameter=url>https://example.com</parameter>
</function>
# Go back in history
<function=browser_actions>
<function=browser_action>
<parameter=action>go_back</parameter>
</function>
# Wait 2 seconds
<function=browser_actions>
<function=browser_action>
<parameter=action>wait</parameter>
<parameter=seconds>2</parameter>
</function>
# Click element by index
<function=browser_actions>
<function=browser_action>
<parameter=action>click</parameter>
<parameter=index>5</parameter>
</function>
# Type into an input field
<function=browser_actions>
<function=browser_action>
<parameter=action>input</parameter>
<parameter=index>3</parameter>
<parameter=text>admin@example.com</parameter>
</function>
# Scroll down
<function=browser_actions>
<function=browser_action>
<parameter=action>scroll</parameter>
<parameter=down>true</parameter>
<parameter=pages>1.5</parameter>
</function>
# Scroll to text
<function=browser_actions>
<function=browser_action>
<parameter=action>find_text</parameter>
<parameter=text>Contact Us</parameter>
</function>
# Send keyboard keys
<function=browser_actions>
<function=browser_action>
<parameter=action>send_keys</parameter>
<parameter=keys>Enter</parameter>
</function>
# Extract structured data from page
<function=browser_actions>
<function=browser_action>
<parameter=action>extract</parameter>
<parameter=query>Extract all product names and prices</parameter>
<parameter=extract_links>true</parameter>
</function>
# Search within the page
<function=browser_actions>
<function=browser_action>
<parameter=action>search_page</parameter>
<parameter=pattern>error|warning</parameter>
<parameter=regex>true</parameter>
@ -263,51 +263,51 @@
</function>
# Find elements by CSS selector
<function=browser_actions>
<function=browser_action>
<parameter=action>find_elements</parameter>
<parameter=selector>a.product-link</parameter>
<parameter=attributes>["href", "title"]</parameter>
</function>
# Take a screenshot
<function=browser_actions>
<function=browser_action>
<parameter=action>screenshot</parameter>
<parameter=file_name>page_screenshot.png</parameter>
</function>
# Get dropdown options
<function=browser_actions>
<function=browser_action>
<parameter=action>dropdown_options</parameter>
<parameter=index>10</parameter>
</function>
# Select dropdown option
<function=browser_actions>
<function=browser_action>
<parameter=action>select_dropdown</parameter>
<parameter=index>10</parameter>
<parameter=text>Option 2</parameter>
</function>
# Execute JavaScript
<function=browser_actions>
<function=browser_action>
<parameter=action>evaluate</parameter>
<parameter=code>(function(){return document.title})()</parameter>
</function>
# Switch to another tab
<function=browser_actions>
<function=browser_action>
<parameter=action>switch</parameter>
<parameter=tab_id>a3f2</parameter>
</function>
# Close a tab
<function=browser_actions>
<function=browser_action>
<parameter=action>close_tab</parameter>
<parameter=tab_id>a3f2</parameter>
</function>
# Close the browser when completely done
<function=browser_actions>
<function=browser_action>
<parameter=action>close_browser</parameter>
</function>
</examples>

View file

@ -3,7 +3,6 @@ import atexit
import contextlib
import logging
from typing import Any
from urllib.parse import urlparse
import httpx
from browser_use import Browser
@ -130,60 +129,44 @@ class _CDPNotReadyError(Exception):
reraise=True,
)
async def _wait_for_cdp(
cdp_url: str,
api_url: str,
auth_token: str = "", # nosec B107
) -> tuple[str, dict[str, Any]]:
version_url = cdp_url.rstrip("/") + "/json/version"
) -> str:
"""Poll the tool server's /cdp/info until the WS proxy is ready."""
headers = {"Authorization": f"Bearer {auth_token}"} if auth_token else {}
async with httpx.AsyncClient(trust_env=False, timeout=5) as client:
try:
resp = await client.get(version_url, headers=headers)
resp = await client.get(f"{api_url}/cdp/info", headers=headers)
except httpx.HTTPError as e:
raise _CDPNotReadyError(f"{type(e).__name__}: {e}") from e
if resp.status_code != 200 or "webSocketDebuggerUrl" not in resp.text:
if resp.status_code != 200:
raise _CDPNotReadyError(f"HTTP {resp.status_code}")
info = resp.json()
# [info] convert http://localhost:9117 or whatever to this format:
# > ws://localhost:9117/browser/proxy/<debugger url>?token=
# for the debugger url to work (since its randomly generated)
ws_url = _rewrite_ws_url(cdp_url, info.get("webSocketDebuggerUrl", ""), auth_token)
logger.info("CDP ready: %s", info.get("Browser", "?"))
return ws_url, info
def _rewrite_ws_url(cdp_url: str, raw_ws: str, auth_token: str) -> str:
parsed_cdp = urlparse(cdp_url)
parsed_ws = urlparse(raw_ws)
ws_url = parsed_ws._replace(
netloc=parsed_cdp.netloc,
path=parsed_cdp.path.rstrip("/") + parsed_ws.path,
).geturl()
# Build the full WS URL from the tool server's relative path
ws_path: str = resp.json()["ws_url"]
ws_url = api_url.replace("http", "ws", 1) + ws_path
if auth_token:
sep = "&" if "?" in ws_url else "?"
ws_url = f"{ws_url}{sep}token={auth_token}"
ws_url = f"{ws_url}?token={auth_token}"
logger.info("CDP ready via %s", ws_url.split("?")[0])
return ws_url
async def _launch_browser(cdp_url: str, agent_id: str, auth_token: str = "") -> BrowserSession: # nosec B107
async def _launch_browser(api_url: str, agent_id: str, auth_token: str = "") -> BrowserSession: # nosec B107
if session := _manager.get(agent_id):
return session
ws_url, _ = await _wait_for_cdp(cdp_url, auth_token)
ws_url = await _wait_for_cdp(api_url, auth_token)
browser = Browser(cdp_url=ws_url)
if session := _manager.get(agent_id):
# [lint] ruff requires us to store background future tasks
task = asyncio.ensure_future(_close_browser(browser))
_manager.background_tasks.add(task)
task.add_done_callback(_manager.background_tasks.discard)
return session
return _manager.create(agent_id, browser, cdp_url, ws_url, auth_token=auth_token)
return _manager.create(agent_id, browser, api_url, ws_url, auth_token=auth_token)
async def _launch_local_browser(