mirror of
https://github.com/usestrix/strix.git
synced 2026-09-15 23:31:27 +00:00
slim down cdp functionality
This commit is contained in:
parent
b434d87601
commit
57ddce161f
7 changed files with 102 additions and 132 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) ----------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from .browser_actions import browser_actions
|
||||
from .browser_actions import browser_action
|
||||
|
||||
|
||||
__all__ = ["browser_actions"]
|
||||
__all__ = ["browser_action"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue