streamline into tool-server

This commit is contained in:
STJ 2026-03-12 19:44:44 -07:00
parent 3718943134
commit 9836e3fc84
14 changed files with 118 additions and 230 deletions

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -141,6 +141,7 @@ module = [
"browser_use.*",
"cdp_use.*",
"aiohttp.*",
"websockets.*",
]
ignore_missing_imports = true

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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",

View file

@ -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",

View file

@ -1,12 +1,9 @@
<tools>
<tool name="browser_actions">
<description>Control a browser via natural language or granular commands. Supports two modes:
<description>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 @@
<description>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 @@
<description>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".</description>
</parameter>
<parameter name="use_local" type="boolean" required="false">
<description>Only used with action='launch'. When true, uses the local system Chrome
installation instead of connecting to the sandbox container via CDP. Default: false.</description>
</parameter>
<parameter name="profile_directory" type="string" required="false">
<description>Only used with action='launch' and use_local=true. The Chrome profile
<description>Only used with action='launch' outside sandbox mode. The Chrome profile
directory to use, e.g. "Default", "Profile 1". If not specified, auto-selected.</description>
</parameter>
<parameter name="query" type="string" required="false">
@ -261,12 +253,6 @@
<parameter=action>launch</parameter>
</function>
# Launch the local system Chrome instead
<function=browser_actions>
<parameter=action>launch</parameter>
<parameter=use_local>true</parameter>
</function>
# Run a natural-language browser task
<function=browser_actions>
<parameter=action>run</parameter>

View file

@ -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.

View file

@ -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):