From 57ddce161fb75303fdfe33097e2030fa755aea63 Mon Sep 17 00:00:00 2001 From: STJ Date: Fri, 13 Mar 2026 18:59:30 -0700 Subject: [PATCH] slim down cdp functionality --- containers/docker-entrypoint.sh | 4 +- .../tool_components/browser_renderer.py | 2 +- strix/runtime/tool_server.py | 95 +++++++++---------- strix/tools/browser/__init__.py | 4 +- strix/tools/browser/browser_actions.py | 42 ++++---- .../tools/browser/browser_actions_schema.xml | 42 ++++---- strix/tools/browser/browser_manager.py | 45 +++------ 7 files changed, 102 insertions(+), 132 deletions(-) diff --git a/containers/docker-entrypoint.sh b/containers/docker-entrypoint.sh index b8a79923..38090dbc 100644 --- a/containers/docker-entrypoint.sh +++ b/containers/docker-entrypoint.sh @@ -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 diff --git a/strix/interface/tool_components/browser_renderer.py b/strix/interface/tool_components/browser_renderer.py index d66f9352..a9bc8bf9 100644 --- a/strix/interface/tool_components/browser_renderer.py +++ b/strix/interface/tool_components/browser_renderer.py @@ -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) ---------------------------- diff --git a/strix/runtime/tool_server.py b/strix/runtime/tool_server.py index d9579f8a..f391558a 100644 --- a/strix/runtime/tool_server.py +++ b/strix/runtime/tool_server.py @@ -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) diff --git a/strix/tools/browser/__init__.py b/strix/tools/browser/__init__.py index 763a3a43..0b8c6f66 100644 --- a/strix/tools/browser/__init__.py +++ b/strix/tools/browser/__init__.py @@ -1,4 +1,4 @@ -from .browser_actions import browser_actions +from .browser_actions import browser_action -__all__ = ["browser_actions"] +__all__ = ["browser_action"] diff --git a/strix/tools/browser/browser_actions.py b/strix/tools/browser/browser_actions.py index cb327de8..bb845826 100644 --- a/strix/tools/browser/browser_actions.py +++ b/strix/tools/browser/browser_actions.py @@ -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, diff --git a/strix/tools/browser/browser_actions_schema.xml b/strix/tools/browser/browser_actions_schema.xml index cd12318e..1fcf9655 100644 --- a/strix/tools/browser/browser_actions_schema.xml +++ b/strix/tools/browser/browser_actions_schema.xml @@ -1,5 +1,5 @@ - + 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 @@ # Launch the sandbox browser (default, must be done first) - + launch # Run a natural-language browser task - + run Go to https://example.com/login, fill in username "admin" and password "secret", then click the login button # Navigate to a URL - + navigate https://example.com # Go back in history - + go_back # Wait 2 seconds - + wait 2 # Click element by index - + click 5 # Type into an input field - + input 3 admin@example.com # Scroll down - + scroll true 1.5 # Scroll to text - + find_text Contact Us # Send keyboard keys - + send_keys Enter # Extract structured data from page - + extract Extract all product names and prices true # Search within the page - + search_page error|warning true @@ -263,51 +263,51 @@ # Find elements by CSS selector - + find_elements a.product-link ["href", "title"] # Take a screenshot - + screenshot page_screenshot.png # Get dropdown options - + dropdown_options 10 # Select dropdown option - + select_dropdown 10 Option 2 # Execute JavaScript - + evaluate (function(){return document.title})() # Switch to another tab - + switch a3f2 # Close a tab - + close_tab a3f2 # Close the browser when completely done - + close_browser diff --git a/strix/tools/browser/browser_manager.py b/strix/tools/browser/browser_manager.py index d3dfb009..ba5c5ff5 100644 --- a/strix/tools/browser/browser_manager.py +++ b/strix/tools/browser/browser_manager.py @@ -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/?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(