strix/strix/runtime/tool_server.py
2026-04-01 13:49:30 -07:00

212 lines
6.7 KiB
Python

from __future__ import annotations
import argparse
import asyncio
import os
import signal
import sys
from typing import Any
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ValidationError
SANDBOX_MODE = os.getenv("STRIX_SANDBOX_MODE", "false").lower() == "true"
if not SANDBOX_MODE:
raise RuntimeError("Tool server should only run in sandbox mode (STRIX_SANDBOX_MODE=true)")
parser = argparse.ArgumentParser(description="Start Strix tool server")
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to") # nosec
parser.add_argument("--port", type=int, required=True, help="Port to bind to")
parser.add_argument(
"--timeout",
type=int,
default=120,
help="Hard timeout in seconds for each request execution (default: 120)",
)
args = parser.parse_args()
# Read token from environment to avoid leaking it in /proc/<pid>/cmdline.
EXPECTED_TOKEN = os.environ.get("TOOL_SERVER_TOKEN", "")
if not EXPECTED_TOKEN:
raise RuntimeError("TOOL_SERVER_TOKEN environment variable must be set")
REQUEST_TIMEOUT = args.timeout
app = FastAPI()
security = HTTPBearer()
security_dependency = Depends(security)
agent_tasks: dict[str, asyncio.Task[Any]] = {}
def verify_token(credentials: HTTPAuthorizationCredentials) -> str:
if not credentials or credentials.scheme != "Bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication scheme. Bearer token required.",
headers={"WWW-Authenticate": "Bearer"},
)
if credentials.credentials != EXPECTED_TOKEN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
return credentials.credentials
class ToolExecutionRequest(BaseModel):
agent_id: str
tool_name: str
kwargs: dict[str, Any]
class ToolExecutionResponse(BaseModel):
result: Any | None = None
error: str | None = None
async def _run_tool(agent_id: str, tool_name: str, kwargs: dict[str, Any]) -> Any:
from strix.tools.argument_parser import convert_arguments
from strix.tools.context import set_current_agent_id
from strix.tools.registry import get_tool_by_name
set_current_agent_id(agent_id)
tool_func = get_tool_by_name(tool_name)
if not tool_func:
raise ValueError(f"Tool '{tool_name}' not found")
converted_kwargs = convert_arguments(tool_func, kwargs)
return await asyncio.to_thread(tool_func, **converted_kwargs)
@app.post("/execute", response_model=ToolExecutionResponse)
async def execute_tool(
request: ToolExecutionRequest, credentials: HTTPAuthorizationCredentials = security_dependency
) -> ToolExecutionResponse:
verify_token(credentials)
agent_id = request.agent_id
if agent_id in agent_tasks:
old_task = agent_tasks[agent_id]
if not old_task.done():
old_task.cancel()
task = asyncio.create_task(
asyncio.wait_for(
_run_tool(agent_id, request.tool_name, request.kwargs), timeout=REQUEST_TIMEOUT
)
)
agent_tasks[agent_id] = task
try:
result = await task
return ToolExecutionResponse(result=result)
except asyncio.CancelledError:
return ToolExecutionResponse(error="Cancelled by newer request")
except TimeoutError:
return ToolExecutionResponse(error=f"Tool timed out after {REQUEST_TIMEOUT}s")
except ValidationError as e:
return ToolExecutionResponse(error=f"Invalid arguments: {e}")
except (ValueError, RuntimeError, ImportError) as e:
return ToolExecutionResponse(error=f"Tool execution error: {e}")
except Exception as e: # noqa: BLE001
return ToolExecutionResponse(error=f"Unexpected error: {e}")
finally:
if agent_tasks.get(agent_id) is task:
del agent_tasks[agent_id]
@app.post("/register_agent")
async def register_agent(
agent_id: str, credentials: HTTPAuthorizationCredentials = security_dependency
) -> dict[str, str]:
verify_token(credentials)
return {"status": "registered", "agent_id": agent_id}
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)
if resp.status_code == 200:
return {"status": "healthy"}
return {"status": "unhealthy"}
except Exception: # noqa: BLE001
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()
return {
"status": "healthy",
"sandbox_mode": str(SANDBOX_MODE),
"environment": "sandbox" if SANDBOX_MODE else "main",
"auth_configured": "true" if EXPECTED_TOKEN else "false",
"active_agents": len(agent_tasks),
"agents": list(agent_tasks.keys()),
"chromium_cdp": cdp_health,
}
def signal_handler(_signum: int, _frame: Any) -> None:
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
for task in agent_tasks.values():
task.cancel()
sys.exit(0)
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
if __name__ == "__main__":
uvicorn.run(app, host=args.host, port=args.port, log_level="info")