mirror of
https://github.com/usestrix/strix.git
synced 2026-09-16 23:41:18 +00:00
212 lines
6.7 KiB
Python
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")
|