From 9836e3fc84a71d7cb6b62c81fdaa777ff5ff1b0a Mon Sep 17 00:00:00 2001 From: STJ Date: Thu, 12 Mar 2026 19:44:44 -0700 Subject: [PATCH] streamline into tool-server --- containers/Dockerfile | 3 +- containers/browser-server.py | 88 ------------- containers/docker-entrypoint.sh | 42 +----- containers/healthcheck.sh | 3 +- pyproject.toml | 1 + .../tool_components/browser_renderer.py | 6 +- strix/runtime/docker_runtime.py | 17 --- strix/runtime/runtime.py | 1 - strix/runtime/tool_server.py | 122 +++++++++++++----- strix/tools/__init__.py | 5 +- strix/tools/browser/browser_actions.py | 20 +-- .../tools/browser/browser_actions_schema.xml | 22 +--- strix/tools/browser/browser_manager.py | 10 +- strix/tools/executor.py | 8 -- 14 files changed, 118 insertions(+), 230 deletions(-) delete mode 100644 containers/browser-server.py diff --git a/containers/Dockerfile b/containers/Dockerfile index c29c20c8..d385c7e7 100644 --- a/containers/Dockerfile +++ b/containers/Dockerfile @@ -216,8 +216,7 @@ RUN echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/p USER root COPY containers/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY containers/healthcheck.sh /usr/local/bin/healthcheck.sh -COPY containers/proxy.py /usr/local/bin/proxy.py -RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/healthcheck.sh /usr/local/bin/proxy.py +RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/healthcheck.sh HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=3 \ CMD healthcheck.sh diff --git a/containers/browser-server.py b/containers/browser-server.py deleted file mode 100644 index d055156e..00000000 --- a/containers/browser-server.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import logging -import os -import re -from typing import Any - -import aiohttp -from aiohttp import WSMsgType, web - - -logger = logging.getLogger(__name__) - -TOKEN = os.environ["TOOL_SERVER_TOKEN"] -LISTEN_PORT = int(os.environ.get("CDP_PORT", "9222")) -UPSTREAM = f"http://127.0.0.1:{os.environ.get('CDP_INTERNAL_PORT', '19222')}" - - -def _is_authorized(req: web.Request) -> bool: - auth_header: str = req.headers.get("Authorization", "") - token_param: str | None = req.query.get("token") - return auth_header == f"Bearer {TOKEN}" or token_param == TOKEN - - -def _strip_token(url: str) -> str: - return re.sub(r"[?&]token=[^&]*", "", url).replace("?&", "?").rstrip("?") - - -async def _proxy_ws(req: web.Request, url: str, headers: dict[str, Any]) -> web.WebSocketResponse: - client_ws = web.WebSocketResponse() - await client_ws.prepare(req) - - async with ( - aiohttp.ClientSession() as session, - session.ws_connect(url.replace("http", "ws", 1), headers=headers) as upstream_ws, - ): - - async def relay(src: Any, dst: Any) -> None: - async for msg in src: - if msg.type == WSMsgType.TEXT: - await dst.send_str(msg.data) - elif msg.type == WSMsgType.BINARY: - await dst.send_bytes(msg.data) - else: - logger.warning("Unexpected WebSocket message type: %s", msg.type) - break - - await asyncio.gather( - relay(upstream_ws, client_ws), - relay(client_ws, upstream_ws), - return_exceptions=True, - ) - return client_ws - - -async def _proxy_http(req: web.Request, url: str, headers: dict[str, Any]) -> web.StreamResponse: - async with ( - aiohttp.ClientSession() as session, - session.request(req.method, url, headers=headers, data=await req.read()) as resp, - ): - out = web.StreamResponse( - status=resp.status, - headers={k: v for k, v in resp.headers.items() if k.lower() != "transfer-encoding"}, - ) - await out.prepare(req) - async for chunk in resp.content.iter_any(): - await out.write(chunk) - return out - - -async def _handle(req: web.Request) -> web.StreamResponse: - if not _is_authorized(req): - return web.Response(status=401, text="Unauthorized") - - url = _strip_token(f"{UPSTREAM}{req.path_qs}") - headers = {k: v for k, v in req.headers.items() if k.lower() not in ("host", "authorization")} - - if req.headers.get("Upgrade", "").lower() == "websocket": - return await _proxy_ws(req, url, headers) - return await _proxy_http(req, url, headers) - - -app = web.Application() -app.router.add_route("*", "/{path:.*}", _handle) - -if __name__ == "__main__": - logger.info("CDP auth proxy: 0.0.0.0:%s -> %s", LISTEN_PORT, UPSTREAM) - web.run_app(app, host="0.0.0.0", port=LISTEN_PORT, print=None) diff --git a/containers/docker-entrypoint.sh b/containers/docker-entrypoint.sh index fb86e357..6b9b413f 100644 --- a/containers/docker-entrypoint.sh +++ b/containers/docker-entrypoint.sh @@ -151,15 +151,14 @@ 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" -CDP_PORT="${BROWSER_CDP_PORT:-9222}" -# Chromium always binds CDP to 127.0.0.1 regardless of --remote-debugging-address. -# We launch it on an internal port and use an auth proxy to expose it on 0.0.0.0. +# Chromium always binds CDP to 127.0.0.1. +# The tool server proxies CDP traffic via /cdp/proxy/. CDP_INTERNAL_PORT=19222 CHROMIUM_BIN="" CHROMIUM_RESTART_COUNT=0 CHROMIUM_MAX_RESTARTS=10 -echo "Launching Chromium with CDP (internal port $CDP_INTERNAL_PORT, exposed on $CDP_PORT)..." +echo "Launching Chromium with CDP on internal port $CDP_INTERNAL_PORT..." CHROMIUM_BIN=$(find /usr/lib/chromium* /usr/bin -name "chromium" -o -name "chromium-browser" -o -name "chrome" 2>/dev/null | head -1) if [ -z "$CHROMIUM_BIN" ]; then # Playwright-installed Chromium @@ -215,34 +214,6 @@ start_chromium() { return 1 fi - # Kill any leftover CDP auth proxy from a previous run - if [ -n "${CDP_PROXY_PID:-}" ] && kill -0 "$CDP_PROXY_PID" 2>/dev/null; then - kill "$CDP_PROXY_PID" 2>/dev/null || true - wait "$CDP_PROXY_PID" 2>/dev/null || true - fi - - # Expose CDP on 0.0.0.0 via an authenticated proxy (replaces raw socat). - python3 /usr/local/bin/proxy.py & - CDP_PROXY_PID=$! - echo "Started CDP auth proxy (PID $CDP_PROXY_PID): 0.0.0.0:${CDP_PORT} -> 127.0.0.1:${CDP_INTERNAL_PORT}" - - # Verify the proxied CDP port is reachable (with auth) - local fwd_ready=false - for i in 1 2 3 4 5; do - if curl -s -H "Authorization: Bearer ${TOOL_SERVER_TOKEN}" "http://127.0.0.1:${CDP_PORT}/json/version" | grep -q "webSocketDebuggerUrl"; then - echo "✅ CDP auth proxy reachable on port ${CDP_PORT} (attempt $i)" - fwd_ready=true - break - fi - sleep 1 - done - if [ "$fwd_ready" = false ]; then - echo "WARNING: CDP not reachable via auth proxy on port ${CDP_PORT}" - echo " proxy PID $CDP_PROXY_PID alive: $(kill -0 $CDP_PROXY_PID 2>&1 && echo yes || echo no)" - echo " ss output: $(ss -tlnp 2>/dev/null | grep ${CDP_PORT} || echo 'port not listening')" - return 1 - fi - return 0 } @@ -304,8 +275,6 @@ cd /app export PYTHONPATH=/app export STRIX_SANDBOX_MODE=true export TOOL_SERVER_TIMEOUT="${STRIX_SANDBOX_EXECUTION_TIMEOUT:-120}" -export CDP_INTERNAL_PORT -export CDP_PORT TOOL_SERVER_LOG="/tmp/tool_server.log" sudo -E -u pentester \ @@ -313,12 +282,13 @@ sudo -E -u pentester \ --token="$TOOL_SERVER_TOKEN" \ --host=0.0.0.0 \ --port="$TOOL_SERVER_PORT" \ - --timeout="$TOOL_SERVER_TIMEOUT" > "$TOOL_SERVER_LOG" 2>&1 & + --timeout="$TOOL_SERVER_TIMEOUT" \ + --cdp-upstream="http://127.0.0.1:$CDP_INTERNAL_PORT" > "$TOOL_SERVER_LOG" 2>&1 & TOOL_SERVER_PID=$! for i in {1..10}; do - if curl -s "http://127.0.0.1:$TOOL_SERVER_PORT/health" | grep -q '"status":"healthy"'; then + if curl -s -H "Authorization: Bearer ${TOOL_SERVER_TOKEN}" "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/containers/healthcheck.sh b/containers/healthcheck.sh index 53cd8978..5bf40d5e 100755 --- a/containers/healthcheck.sh +++ b/containers/healthcheck.sh @@ -7,10 +7,9 @@ set -e TOOL_SERVER_PORT="${TOOL_SERVER_PORT:-48081}" CAIDO_PORT=48080 -CDP_PORT="${BROWSER_CDP_PORT:-9222}" # 1. Tool server must respond healthy -if ! curl -sf --max-time 3 "http://127.0.0.1:${TOOL_SERVER_PORT}/health" | grep -q '"status":"healthy"'; then +if ! curl -sf --max-time 3 -H "Authorization: Bearer ${TOOL_SERVER_TOKEN}" "http://127.0.0.1:${TOOL_SERVER_PORT}/health" | grep -q '"status":"healthy"'; then echo "UNHEALTHY: tool server not responding on port ${TOOL_SERVER_PORT}" exit 1 fi diff --git a/pyproject.toml b/pyproject.toml index d1bd597c..0b5c2e84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,6 +141,7 @@ module = [ "browser_use.*", "cdp_use.*", "aiohttp.*", + "websockets.*", ] ignore_missing_imports = true diff --git a/strix/interface/tool_components/browser_renderer.py b/strix/interface/tool_components/browser_renderer.py index bf62b8c7..799ce1f7 100644 --- a/strix/interface/tool_components/browser_renderer.py +++ b/strix/interface/tool_components/browser_renderer.py @@ -112,7 +112,7 @@ class BrowserRenderer(BaseToolRenderer): # Dispatch to action-specific builders builders: dict[str, Callable[[], Text]] = { "run": lambda: cls._build_run(args, status, result), - "launch": lambda: cls._build_launch(args, status, result), + "launch": lambda: cls._build_launch(status, result), "navigate": lambda: cls._build_navigate(args, status), "search": lambda: cls._build_search(args, status), "click": lambda: cls._build_click(args, status), @@ -159,8 +159,8 @@ class BrowserRenderer(BaseToolRenderer): return text @classmethod - def _build_launch(cls, args: dict[str, Any], status: str, result: Any) -> Text: - mode = "local" if args.get("use_local") else "sandboxed" + def _build_launch(cls, status: str, result: Any) -> Text: + mode = result.get("mode", "sandboxed") if isinstance(result, dict) else "sandboxed" text = Text("◈ ", style=cls.LIFE) text.append("launching browser", style=f"bold {cls.LIFE}") text.append(f" {mode}", style=cls.DIM) diff --git a/strix/runtime/docker_runtime.py b/strix/runtime/docker_runtime.py index 8873e55f..eda0281f 100644 --- a/strix/runtime/docker_runtime.py +++ b/strix/runtime/docker_runtime.py @@ -23,7 +23,6 @@ HOST_GATEWAY_HOSTNAME = "host.docker.internal" DOCKER_TIMEOUT = 60 CONTAINER_TOOL_SERVER_PORT = 48081 CONTAINER_CAIDO_PORT = 48080 -CONTAINER_BROWSER_CDP_PORT = 9222 class DockerRuntime(AbstractRuntime): @@ -40,7 +39,6 @@ class DockerRuntime(AbstractRuntime): self._tool_server_port: int | None = None self._tool_server_token: str | None = None self._caido_port: int | None = None - self._browser_cdp_port: int | None = None def _find_available_port(self) -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: @@ -86,10 +84,6 @@ class DockerRuntime(AbstractRuntime): if port_bindings.get(caido_port_key): self._caido_port = int(port_bindings[caido_port_key][0]["HostPort"]) - cdp_port_key = f"{CONTAINER_BROWSER_CDP_PORT}/tcp" - if port_bindings.get(cdp_port_key): - self._browser_cdp_port = int(port_bindings[cdp_port_key][0]["HostPort"]) - def _wait_for_tool_server(self, max_retries: int = 30, timeout: int = 5) -> None: host = self._resolve_docker_host() health_url = f"http://{host}:{self._tool_server_port}/health" @@ -134,7 +128,6 @@ class DockerRuntime(AbstractRuntime): self._tool_server_port = self._find_available_port() self._caido_port = self._find_available_port() - self._browser_cdp_port = self._find_available_port() self._tool_server_token = secrets.token_urlsafe(32) execution_timeout = Config.get("strix_sandbox_execution_timeout") or "120" @@ -147,7 +140,6 @@ class DockerRuntime(AbstractRuntime): ports={ f"{CONTAINER_TOOL_SERVER_PORT}/tcp": self._tool_server_port, f"{CONTAINER_CAIDO_PORT}/tcp": self._caido_port, - f"{CONTAINER_BROWSER_CDP_PORT}/tcp": self._browser_cdp_port, }, cap_add=["NET_ADMIN", "NET_RAW"], shm_size="256m", @@ -158,7 +150,6 @@ class DockerRuntime(AbstractRuntime): "TOOL_SERVER_TOKEN": self._tool_server_token, "STRIX_SANDBOX_EXECUTION_TIMEOUT": str(execution_timeout), "HOST_GATEWAY": HOST_GATEWAY_HOSTNAME, - "BROWSER_CDP_PORT": str(CONTAINER_BROWSER_CDP_PORT), }, extra_hosts={HOST_GATEWAY_HOSTNAME: "host-gateway"}, tty=True, @@ -173,7 +164,6 @@ class DockerRuntime(AbstractRuntime): self._tool_server_port = None self._tool_server_token = None self._caido_port = None - self._browser_cdp_port = None time.sleep(2**attempt) else: return container @@ -196,7 +186,6 @@ class DockerRuntime(AbstractRuntime): self._tool_server_port = None self._tool_server_token = None self._caido_port = None - self._browser_cdp_port = None try: container = self.client.containers.get(container_name) @@ -290,9 +279,6 @@ class DockerRuntime(AbstractRuntime): if self._caido_port is None: raise RuntimeError("Caido port not initialized") - if self._browser_cdp_port is None: - raise RuntimeError("Browser CDP port not initialized") - host = self._resolve_docker_host() api_url = f"http://{host}:{self._tool_server_port}" @@ -304,7 +290,6 @@ class DockerRuntime(AbstractRuntime): "auth_token": token, "tool_server_port": self._tool_server_port, "caido_port": self._caido_port, - "browser_cdp_port": self._browser_cdp_port, "agent_id": agent_id, } @@ -347,7 +332,6 @@ class DockerRuntime(AbstractRuntime): self._tool_server_port = None self._tool_server_token = None self._caido_port = None - self._browser_cdp_port = None except (NotFound, DockerException): pass @@ -358,7 +342,6 @@ class DockerRuntime(AbstractRuntime): self._tool_server_port = None self._tool_server_token = None self._caido_port = None - self._browser_cdp_port = None if container_name is None: return diff --git a/strix/runtime/runtime.py b/strix/runtime/runtime.py index 8319496d..e523d512 100644 --- a/strix/runtime/runtime.py +++ b/strix/runtime/runtime.py @@ -8,7 +8,6 @@ class SandboxInfo(TypedDict): auth_token: str | None tool_server_port: int caido_port: int - browser_cdp_port: int agent_id: str diff --git a/strix/runtime/tool_server.py b/strix/runtime/tool_server.py index 66ed26e4..d9579f8a 100644 --- a/strix/runtime/tool_server.py +++ b/strix/runtime/tool_server.py @@ -7,8 +7,11 @@ import signal import sys from typing import Any +import httpx import uvicorn -from fastapi import Depends, FastAPI, HTTPException, status +import websockets +from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, status +from fastapi.responses import Response from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, ValidationError @@ -27,10 +30,16 @@ parser.add_argument( default=120, help="Hard timeout in seconds for each request execution (default: 120)", ) +parser.add_argument( + "--cdp-upstream", + default="http://127.0.0.1:19222", + help="Chromium CDP upstream address (default: http://127.0.0.1:19222)", +) args = parser.parse_args() EXPECTED_TOKEN = args.token REQUEST_TIMEOUT = args.timeout +CDP_UPSTREAM = args.cdp_upstream.rstrip("/") app = FastAPI() security = HTTPBearer() @@ -57,6 +66,14 @@ def verify_token(credentials: HTTPAuthorizationCredentials) -> str: return credentials.credentials +def verify_ws_token(ws: WebSocket) -> None: + auth = ws.headers.get("Authorization", "") + token_param = ws.query_params.get("token") + if auth == f"Bearer {EXPECTED_TOKEN}" or token_param == EXPECTED_TOKEN: + return + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + class ToolExecutionRequest(BaseModel): agent_id: str tool_name: str @@ -136,19 +153,9 @@ async def register_agent( async def _check_cdp_health() -> dict[str, Any]: - """Probe the container-local Chromium CDP endpoint. - - Returns only a boolean status — never the ``webSocketDebuggerUrl`` - or browser version, because the ``/health`` endpoint is - unauthenticated and those fields would leak session secrets. - """ - cdp_port = os.getenv("CDP_INTERNAL_PORT", "19222") - cdp_url = f"http://127.0.0.1:{cdp_port}/json/version" try: - import httpx - async with httpx.AsyncClient(timeout=3, trust_env=False) as client: - resp = await client.get(cdp_url) + resp = await client.get(f"{CDP_UPSTREAM}/json/version") if resp.status_code == 200: return {"status": "healthy"} return {"status": "unhealthy"} @@ -156,27 +163,6 @@ async def _check_cdp_health() -> dict[str, Any]: return {"status": "unhealthy"} -@app.get("/cdp/version") -async def cdp_version( - credentials: HTTPAuthorizationCredentials = security_dependency, # noqa: ARG001 -) -> dict[str, Any]: - """Return Chromium's ``/json/version`` data via the authenticated tool server. - - This lets the host discover the WebSocket debugger URL (which contains - a random browser GUID) without exposing ``/json/version`` on the - unauthenticated CDP port. - """ - cdp_port = os.getenv("CDP_INTERNAL_PORT", "19222") - cdp_url = f"http://127.0.0.1:{cdp_port}/json/version" - import httpx - - async with httpx.AsyncClient(timeout=5, trust_env=False) as client: - resp = await client.get(cdp_url) - resp.raise_for_status() - data: dict[str, Any] = resp.json() - return data - - @app.get("/health") async def health_check() -> dict[str, Any]: cdp_health = await _check_cdp_health() @@ -191,6 +177,76 @@ async def health_check() -> dict[str, Any]: } +# -- CDP auth proxy ---------------------------------------------------------- +# Proxies HTTP and WebSocket traffic to the container-local Chromium CDP. + + +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}" + + +@app.websocket("/cdp/proxy/{path:path}") +async def cdp_proxy_ws(ws: WebSocket, path: str) -> None: # noqa: ARG001 + verify_ws_token(ws) + url = _cdp_upstream_url(ws.url.path).replace("http", "ws", 1) + await ws.accept() + + async with websockets.connect(url) as upstream: + + async def relay_client_to_upstream() -> None: + async for msg in ws.iter_text(): + await upstream.send(msg) + + async def relay_upstream_to_client() -> None: + async for msg in upstream: + if isinstance(msg, str): + await ws.send_text(msg) + else: + await ws.send_bytes(msg) + + _, pending = await asyncio.wait( + [ + asyncio.create_task(relay_client_to_upstream()), + asyncio.create_task(relay_upstream_to_client()), + ], + return_when=asyncio.FIRST_COMPLETED, + ) + for t in pending: + 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/__init__.py b/strix/tools/__init__.py index 2deceb94..febdc515 100644 --- a/strix/tools/__init__.py +++ b/strix/tools/__init__.py @@ -55,14 +55,13 @@ if not SANDBOX_MODE: if HAS_PERPLEXITY_API: from .web_search import * # noqa: F403 else: + if not DISABLE_BROWSER: + from .browser import * # noqa: F403 from .file_edit import * # noqa: F403 from .proxy import * # noqa: F403 from .python import * # noqa: F403 from .terminal import * # noqa: F403 - if not DISABLE_BROWSER: - from .browser import * # noqa: F403 - __all__ = [ "ImplementedInClientSideOnlyError", "execute_tool", diff --git a/strix/tools/browser/browser_actions.py b/strix/tools/browser/browser_actions.py index 851be35b..b51894b6 100644 --- a/strix/tools/browser/browser_actions.py +++ b/strix/tools/browser/browser_actions.py @@ -1,7 +1,6 @@ import asyncio import json import logging -import os import re from typing import Any, Literal @@ -88,18 +87,11 @@ def _build_llm() -> Any: def _resolve_cdp_url(agent_state: Any) -> tuple[str, str]: info = agent_state.sandbox_info - cdp_port = info.get("browser_cdp_port") - if not cdp_port: - raise ValueError("Missing browser_cdp_port in sandbox_info") + api_url = info.get("api_url") + if not api_url: + raise ValueError("Missing api_url in sandbox_info") - host = "127.0.0.1" - if docker_host := os.getenv("DOCKER_HOST"): - from urllib.parse import urlparse - - if (parsed := urlparse(docker_host)).hostname: - host = parsed.hostname - - return f"http://{host}:{cdp_port}", info.get("auth_token", "") + return f"{api_url}/cdp/proxy", info.get("auth_token", "") async def _execute_task(session: BrowserSession, operation: Any, desc: str) -> dict[str, Any]: @@ -246,7 +238,6 @@ async def _run_browser_tool( async def browser_actions( action: BrowserUseLocalAction, task: str | None = None, - use_local: bool = False, profile_directory: str | None = None, return_fields: list[str] | None = None, *, @@ -259,7 +250,8 @@ async def browser_actions( agent_id = get_current_agent_id() if action == "launch": - if use_local: + has_sandbox = agent_state and getattr(agent_state, "sandbox_info", None) + if not has_sandbox: session = await _launch_local_browser(agent_id, profile_directory) result = { "message": "Local browser ready", diff --git a/strix/tools/browser/browser_actions_schema.xml b/strix/tools/browser/browser_actions_schema.xml index 898ade2a..19e94d7f 100644 --- a/strix/tools/browser/browser_actions_schema.xml +++ b/strix/tools/browser/browser_actions_schema.xml @@ -1,12 +1,9 @@ - Control a browser via natural language or granular commands. Supports two modes: + Control a browser via natural language or granular commands. - **Sandboxed** (default): The browser runs inside the sandbox container (with Caido proxy - intercepting all traffic) and is controlled remotely via Chrome DevTools Protocol (CDP). - **Local** (use_local=true): Uses the system Chrome installation directly. No sandbox - required. Preserves existing login sessions, cookies, and extensions. You may need to - fully close Chrome before launching. Optionally select a Chrome profile. + The browser runs inside the sandbox container (with Caido proxy intercepting all traffic) + and is controlled remotely via Chrome DevTools Protocol (CDP). The browser is PERSISTENT and remains active until explicitly closed, allowing for multi-step workflows. State (cookies, auth, tabs) carries over between calls. @@ -21,7 +18,6 @@ The action to perform: **Lifecycle:** - launch: Start the browser. MUST be called before any other actions. - Pass use_local=true for local Chrome. Optionally pass profile_directory. - close_browser: Shut down the browser session. **Agent mode:** @@ -75,12 +71,8 @@ Required for 'run' action. A natural-language description of what to do in the browser, e.g. "Go to example.com and find the contact email". - - Only used with action='launch'. When true, uses the local system Chrome - installation instead of connecting to the sandbox container via CDP. Default: false. - - Only used with action='launch' and use_local=true. The Chrome profile + Only used with action='launch' outside sandbox mode. The Chrome profile directory to use, e.g. "Default", "Profile 1". If not specified, auto-selected. @@ -261,12 +253,6 @@ launch - # Launch the local system Chrome instead - - launch - true - - # Run a natural-language browser task run diff --git a/strix/tools/browser/browser_manager.py b/strix/tools/browser/browser_manager.py index 54029c36..3983ccc2 100644 --- a/strix/tools/browser/browser_manager.py +++ b/strix/tools/browser/browser_manager.py @@ -232,13 +232,13 @@ def _wait_for_cdp( # Rewrite the WebSocket URL: Chromium reports the container- # internal address (e.g. ws://127.0.0.1:19222/devtools/...) - # but we need it on the Docker-mapped host:port. + # but we need it routed through the tool server's CDP proxy. parsed_cdp = urlparse(cdp_url) parsed_ws = urlparse(raw_ws) - ws_url = raw_ws.replace( - f"{parsed_ws.hostname}:{parsed_ws.port}", - f"{parsed_cdp.hostname}:{parsed_cdp.port}", - ) + ws_url = parsed_ws._replace( + netloc=parsed_cdp.netloc, + path=parsed_cdp.path.rstrip("/") + parsed_ws.path, + ).geturl() # Append auth token so browser-use's WebSocket upgrade # request passes through the CDP auth proxy. diff --git a/strix/tools/executor.py b/strix/tools/executor.py index f44a8724..1c240877 100644 --- a/strix/tools/executor.py +++ b/strix/tools/executor.py @@ -99,18 +99,10 @@ async def _execute_tool_in_sandbox(tool_name: str, agent_state: Any, **kwargs: A async def _execute_tool_locally(tool_name: str, agent_state: Any | None, **kwargs: Any) -> Any: - from strix.tools.context import set_current_agent_id - tool_func = get_tool_by_name(tool_name) if not tool_func: raise ValueError(f"Tool '{tool_name}' not found") - # Propagate agent_id so tools can scope per-agent resources (e.g. browser instances). - # NOTE: This is needed for browser_use tools to work correctly. - agent_id = getattr(agent_state, "agent_id", None) if agent_state else None - if agent_id: - set_current_agent_id(agent_id) - converted_kwargs = convert_arguments(tool_func, kwargs) if needs_agent_state(tool_name):