refactor: SandboxAgent + SDK Shell/Filesystem; agent-browser CLI; nuke FastAPI sidecar

Combined commits 2+3 of the migration plan because the FastAPI sidecar
removal in commit 2 broke ``browser_action`` (which lived in the
sidecar); they have to land together.

Sandbox tool layer (commit 2 piece):
- ``build_strix_agent`` now returns a ``SandboxAgent`` with
  ``capabilities=[Filesystem(), Shell()]``. The SDK runtime binds the
  capabilities to the live sandbox session per-run; agents get
  ``exec_command``, ``write_stdin``, ``apply_patch``, ``view_image``
  function tools auto-merged into their tool list. Plain ``Agent``
  short-circuits capability binding (``agents/sandbox/runtime.py:190``).
- Drop ``Compaction`` from the default capability set — it's
  OpenAI-Responses-API-only and useless for our litellm-routed
  Anthropic setup.
- Delete the entire custom in-container tool layer:
  - ``strix/tools/terminal/`` (5 files, 748 LoC libtmux)
  - ``strix/tools/file_edit/`` (3 files, 276 LoC)
  - ``strix/tools/python/`` (5 files, 459 LoC)
  - ``strix/runtime/tool_server.py`` (163 LoC FastAPI sidecar)
  - ``strix/tools/_sandbox_dispatch.py`` (117 LoC)
  - ``strix/tools/registry.py`` (109 LoC)
  - ``strix/tools/context.py`` (12 LoC)
- Drop the corresponding TUI renderers (``terminal_renderer.py``,
  ``file_edit_renderer.py``, ``python_renderer.py``) and update
  ``interface/tool_components/__init__.py``.

Browser → agent-browser CLI (commit 3 piece):
- Install ``agent-browser@0.26.0`` globally in the Dockerfile right
  after the existing ``npm install -g`` block. Run
  ``agent-browser install --with-deps`` (apt, root) and
  ``agent-browser install`` (Chrome download, pentester) +
  ``agent-browser doctor --offline --quick`` smoke test.
- Drop the explicit Playwright system-deps apt list (replaced by
  ``--with-deps``) and ``RUN .venv/bin/python -m playwright install
  chromium``.
- Vendor ``agent-browser/skill-data/core/SKILL.md`` →
  ``strix/skills/tooling/agent_browser.md`` (476 lines). Adapt
  frontmatter to Strix format; strip the install/Quickstart and the
  ``agent-browser skills get electron|slack|...`` specialized-skills
  block; add the "Caido proxy is wired via env vars; do not pass
  ``--proxy``" note.
- ``_resolve_skills`` now eagerly loads ``tooling/agent_browser`` for
  every agent (matches the previous unconditional ``browser_action``
  in ``_BASE_TOOLS``).
- Delete ``strix/tools/browser/`` (5 files, 1338 LoC) and the
  ``browser_renderer.py`` TUI render.

Sandbox plumbing:
- Drop ``bearer`` token, ``tool_server_host_port`` resolution + bundle
  keys, ``TOOL_SERVER_TOKEN``/``TOOL_SERVER_PORT``/
  ``STRIX_SANDBOX_EXECUTION_TIMEOUT`` from the manifest env in
  ``session_manager.create_or_reuse``. Caido proxy env vars
  (``http_proxy``, ``https_proxy``, ``ALL_PROXY``) stay; manifest
  applies them to every ``docker exec``-spawned process.
- Drop ``sandbox_token`` and ``tool_server_host_port`` params from
  ``make_agent_context`` and the ``create_agent`` graph tool.
- Drop the tool-server health-check from ``entry.py`` (only Caido's
  ``wait_for_tcp_ready`` remains).
- ``docker-entrypoint.sh``: delete the ~30 line
  ``Starting tool server...`` block (sudo + uvicorn launch + curl
  /health poll). Add ``NO_PROXY=localhost,127.0.0.1`` to
  ``/etc/profile.d/proxy.sh`` and ``/etc/environment`` so the
  agent-browser daemon's CDP traffic on localhost isn't routed
  through Caido.

pyproject.toml:
- ``[project.optional-dependencies] sandbox = []`` (every member of
  the previous list — fastapi, uvicorn, ipython, openhands-aci,
  playwright, libtmux — is gone with the sidecar).
- Drop ``numpydoc.*``, ``IPython.*``, ``openhands_aci.*``,
  ``playwright.*``, ``uvicorn.*``, ``pyte.*``, ``libtmux.*`` from
  the missing-imports module list.
- Drop the per-file ruff ignores for the deleted modules.

Net delta: −5512 LoC. ruff drops to 3 errors (was 21 baseline). mypy
falls to 69 errors over 3 files (was 84 over 8 — the drop comes from
deleting the modules with the worst untyped-import problems).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-25 14:33:38 -07:00
parent 5449af2456
commit 2c2ab13c8f
40 changed files with 527 additions and 5572 deletions
+4 -20
View File
@@ -1,33 +1,17 @@
"""Tool package.
Importing every sub-package triggers the ``@register_tool``
decorations that populate ``strix.tools.registry.tools``. The
in-container FastAPI tool server (:mod:`strix.runtime.tool_server`)
dispatches against that registry.
Host-side SDK function tools live in ``<family>/tool[s].py`` and are
imported directly by :mod:`strix.agents.factory` — they don't flow
through this registry.
imported directly by :mod:`strix.agents.factory`. The sandbox-bound
shell + filesystem tools are emitted by the SDK's ``Shell`` and
``Filesystem`` capabilities and bound to the live sandbox session
per-run.
"""
from .agents_graph import * # noqa: F403
from .browser import * # noqa: F403
from .file_edit import * # noqa: F403
from .finish import * # noqa: F403
from .notes import * # noqa: F403
from .proxy import * # noqa: F403
from .python import * # noqa: F403
from .registry import get_tool_by_name, get_tool_names, register_tool, tools
from .reporting import * # noqa: F403
from .terminal import * # noqa: F403
from .thinking import * # noqa: F403
from .todo import * # noqa: F403
from .web_search import * # noqa: F403
__all__ = [
"get_tool_by_name",
"get_tool_names",
"register_tool",
"tools",
]
-117
View File
@@ -1,117 +0,0 @@
"""``post_to_sandbox`` — host-to-container HTTP transport for sandbox tools.
Every Strix tool that runs inside the Kali container (browser,
terminal, python, file_edit, the seven Caido tools) has the same wire
shape: POST JSON to ``http://localhost:{tool_server_host_port}/execute``
with a Bearer token and ``{"agent_id", "tool_name", "kwargs"}`` body.
The helper centralizes timeouts (``connect=10s`` / ``read=150s``), a
50 MB response-size cap so a runaway tool can't OOM the host, and
predictable error-string shaping so transport failures don't tear
down the run.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
import httpx
if TYPE_CHECKING:
from agents import RunContextWrapper
logger = logging.getLogger(__name__)
_SANDBOX_TIMEOUT = httpx.Timeout(connect=10.0, read=150.0, write=150.0, pool=150.0)
# Cap so a runaway tool body never blows up the host heap.
_MAX_RESPONSE_BYTES = 50 * 1024 * 1024 # 50 MB
def _ctx_dict(ctx: RunContextWrapper) -> dict[str, Any] | None:
"""Return ``ctx.context`` if it's a dict, else ``None``.
Strix's runtime always passes a dict (``make_agent_context``); other
callers might not. Be defensive so a sandbox tool never raises just
because the context shape is wrong.
"""
inner = getattr(ctx, "context", None)
return inner if isinstance(inner, dict) else None
async def post_to_sandbox(
ctx: RunContextWrapper,
tool_name: str,
kwargs: dict[str, Any],
) -> dict[str, Any]:
"""POST a tool invocation to the in-container FastAPI tool server.
Returns:
On success: ``{"result": <whatever the tool returned>}``.
On any failure: ``{"error": "<human-readable error string>"}``.
Never raises. Tool authors call this and pass the return value
straight to the model (or extract ``result`` for further shaping).
"""
inner = _ctx_dict(ctx)
if inner is None:
return {"error": "Sandbox not initialized: context is missing or not a dict."}
port = inner.get("tool_server_host_port")
token = inner.get("sandbox_token")
agent_id = inner.get("agent_id", "unknown")
if not port or not token:
return {"error": "Sandbox not initialized: tool server port or token missing."}
url = f"http://127.0.0.1:{port}/execute"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
body = {"agent_id": agent_id, "tool_name": tool_name, "kwargs": kwargs}
try:
async with httpx.AsyncClient(timeout=_SANDBOX_TIMEOUT) as client:
response = await client.post(url, json=body, headers=headers)
except httpx.TimeoutException:
return {
"error": (f"Sandbox tool '{tool_name}' timed out after {_SANDBOX_TIMEOUT.read}s."),
}
except httpx.RequestError as e:
# ConnectError, ReadError, NetworkError, etc.
return {"error": f"Sandbox connection failed: {e!s}"[:300]}
if response.status_code == 401:
return {"error": "Sandbox authorization failed (Bearer token invalid)."}
if response.status_code >= 400:
return {
"error": (
f"Sandbox tool '{tool_name}' failed with HTTP "
f"{response.status_code}: {response.text[:300]}"
),
}
# Cap response size before parsing so a 1 GB rogue payload never lands
# in our heap. Most legitimate tool responses are well under 100 KB.
raw = response.content
if len(raw) > _MAX_RESPONSE_BYTES:
return {
"error": (f"Sandbox response too large ({len(raw)} bytes; max {_MAX_RESPONSE_BYTES})."),
}
try:
data: Any = response.json()
except ValueError:
return {
"error": (f"Sandbox tool '{tool_name}' returned non-JSON: {response.text[:200]}"),
}
if not isinstance(data, dict):
return {"error": f"Sandbox tool '{tool_name}' returned non-object JSON."}
return data
-2
View File
@@ -403,8 +403,6 @@ async def create_agent(
bus=bus,
sandbox_session=inner.get("sandbox_session"),
sandbox_client=inner.get("sandbox_client"),
sandbox_token=inner.get("sandbox_token"),
tool_server_host_port=inner.get("tool_server_host_port"),
caido_host_port=inner.get("caido_host_port"),
caido_client=inner.get("caido_client"),
agent_id=child_id,
-4
View File
@@ -1,4 +0,0 @@
from .browser_actions import browser_action
__all__ = ["browser_action"]
-240
View File
@@ -1,240 +0,0 @@
from typing import TYPE_CHECKING, Any, Literal, NoReturn
from strix.tools.registry import register_tool
if TYPE_CHECKING:
from .tab_manager import BrowserTabManager
BrowserAction = Literal[
"launch",
"goto",
"click",
"type",
"scroll_down",
"scroll_up",
"back",
"forward",
"new_tab",
"switch_tab",
"close_tab",
"wait",
"execute_js",
"double_click",
"hover",
"press_key",
"save_pdf",
"get_console_logs",
"view_source",
"close",
"list_tabs",
]
def _validate_url(action_name: str, url: str | None) -> None:
if not url:
raise ValueError(f"url parameter is required for {action_name} action")
def _validate_coordinate(action_name: str, coordinate: str | None) -> None:
if not coordinate:
raise ValueError(f"coordinate parameter is required for {action_name} action")
def _validate_text(action_name: str, text: str | None) -> None:
if not text:
raise ValueError(f"text parameter is required for {action_name} action")
def _validate_tab_id(action_name: str, tab_id: str | None) -> None:
if not tab_id:
raise ValueError(f"tab_id parameter is required for {action_name} action")
def _validate_js_code(action_name: str, js_code: str | None) -> None:
if not js_code:
raise ValueError(f"js_code parameter is required for {action_name} action")
def _validate_duration(action_name: str, duration: float | None) -> None:
if duration is None:
raise ValueError(f"duration parameter is required for {action_name} action")
def _validate_key(action_name: str, key: str | None) -> None:
if not key:
raise ValueError(f"key parameter is required for {action_name} action")
def _validate_file_path(action_name: str, file_path: str | None) -> None:
if not file_path:
raise ValueError(f"file_path parameter is required for {action_name} action")
def _handle_navigation_actions(
manager: "BrowserTabManager",
action: str,
url: str | None = None,
tab_id: str | None = None,
) -> dict[str, Any]:
if action == "launch":
return manager.launch_browser(url)
if action == "goto":
_validate_url(action, url)
assert url is not None
return manager.goto_url(url, tab_id)
if action == "back":
return manager.back(tab_id)
if action == "forward":
return manager.forward(tab_id)
raise ValueError(f"Unknown navigation action: {action}")
def _handle_interaction_actions(
manager: "BrowserTabManager",
action: str,
coordinate: str | None = None,
text: str | None = None,
key: str | None = None,
tab_id: str | None = None,
) -> dict[str, Any]:
if action in {"click", "double_click", "hover"}:
_validate_coordinate(action, coordinate)
assert coordinate is not None
action_map = {
"click": manager.click,
"double_click": manager.double_click,
"hover": manager.hover,
}
return action_map[action](coordinate, tab_id)
if action in {"scroll_down", "scroll_up"}:
direction = "down" if action == "scroll_down" else "up"
return manager.scroll(direction, tab_id)
if action == "type":
_validate_text(action, text)
assert text is not None
return manager.type_text(text, tab_id)
if action == "press_key":
_validate_key(action, key)
assert key is not None
return manager.press_key(key, tab_id)
raise ValueError(f"Unknown interaction action: {action}")
def _raise_unknown_action(action: str) -> NoReturn:
raise ValueError(f"Unknown action: {action}")
def _handle_tab_actions(
manager: "BrowserTabManager",
action: str,
url: str | None = None,
tab_id: str | None = None,
) -> dict[str, Any]:
if action == "new_tab":
return manager.new_tab(url)
if action == "switch_tab":
_validate_tab_id(action, tab_id)
assert tab_id is not None
return manager.switch_tab(tab_id)
if action == "close_tab":
_validate_tab_id(action, tab_id)
assert tab_id is not None
return manager.close_tab(tab_id)
if action == "list_tabs":
return manager.list_tabs()
raise ValueError(f"Unknown tab action: {action}")
def _handle_utility_actions(
manager: "BrowserTabManager",
action: str,
duration: float | None = None,
js_code: str | None = None,
file_path: str | None = None,
tab_id: str | None = None,
clear: bool = False,
) -> dict[str, Any]:
if action == "wait":
_validate_duration(action, duration)
assert duration is not None
return manager.wait_browser(duration, tab_id)
if action == "execute_js":
_validate_js_code(action, js_code)
assert js_code is not None
return manager.execute_js(js_code, tab_id)
if action == "save_pdf":
_validate_file_path(action, file_path)
assert file_path is not None
return manager.save_pdf(file_path, tab_id)
if action == "get_console_logs":
return manager.get_console_logs(tab_id, clear)
if action == "view_source":
return manager.view_source(tab_id)
if action == "close":
return manager.close_browser()
raise ValueError(f"Unknown utility action: {action}")
@register_tool(requires_browser_mode=True)
def browser_action(
action: BrowserAction,
url: str | None = None,
coordinate: str | None = None,
text: str | None = None,
tab_id: str | None = None,
js_code: str | None = None,
duration: float | None = None,
key: str | None = None,
file_path: str | None = None,
clear: bool = False,
) -> dict[str, Any]:
from .tab_manager import get_browser_tab_manager
manager = get_browser_tab_manager()
try:
navigation_actions = {"launch", "goto", "back", "forward"}
interaction_actions = {
"click",
"type",
"double_click",
"hover",
"press_key",
"scroll_down",
"scroll_up",
}
tab_actions = {"new_tab", "switch_tab", "close_tab", "list_tabs"}
utility_actions = {
"wait",
"execute_js",
"save_pdf",
"get_console_logs",
"view_source",
"close",
}
if action in navigation_actions:
return _handle_navigation_actions(manager, action, url, tab_id)
if action in interaction_actions:
return _handle_interaction_actions(manager, action, coordinate, text, key, tab_id)
if action in tab_actions:
return _handle_tab_actions(manager, action, url, tab_id)
if action in utility_actions:
return _handle_utility_actions(
manager, action, duration, js_code, file_path, tab_id, clear
)
_raise_unknown_action(action)
except (ValueError, RuntimeError) as e:
return {
"error": str(e),
"tab_id": tab_id,
"screenshot": "",
"is_running": False,
}
-581
View File
@@ -1,581 +0,0 @@
import asyncio
import base64
import contextlib
import logging
import threading
from pathlib import Path
from typing import Any, cast
from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright
logger = logging.getLogger(__name__)
MAX_PAGE_SOURCE_LENGTH = 20_000
MAX_CONSOLE_LOG_LENGTH = 30_000
MAX_INDIVIDUAL_LOG_LENGTH = 1_000
MAX_CONSOLE_LOGS_COUNT = 200
MAX_JS_RESULT_LENGTH = 5_000
class _BrowserState:
"""Singleton state for the shared browser instance."""
lock = threading.Lock()
event_loop: asyncio.AbstractEventLoop | None = None
event_loop_thread: threading.Thread | None = None
playwright: Playwright | None = None
browser: Browser | None = None
_state = _BrowserState()
def _ensure_event_loop() -> None:
if _state.event_loop is not None:
return
def run_loop() -> None:
_state.event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(_state.event_loop)
_state.event_loop.run_forever()
_state.event_loop_thread = threading.Thread(target=run_loop, daemon=True)
_state.event_loop_thread.start()
while _state.event_loop is None:
threading.Event().wait(0.01)
async def _create_browser() -> Browser:
if _state.browser is not None and _state.browser.is_connected():
return _state.browser
if _state.browser is not None:
with contextlib.suppress(Exception):
await _state.browser.close()
_state.browser = None
if _state.playwright is not None:
with contextlib.suppress(Exception):
await _state.playwright.stop()
_state.playwright = None
_state.playwright = await async_playwright().start()
_state.browser = await _state.playwright.chromium.launch(
headless=True,
args=[
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-web-security",
],
)
return _state.browser
def _get_browser() -> tuple[asyncio.AbstractEventLoop, Browser]:
with _state.lock:
_ensure_event_loop()
assert _state.event_loop is not None
if _state.browser is None or not _state.browser.is_connected():
future = asyncio.run_coroutine_threadsafe(_create_browser(), _state.event_loop)
future.result(timeout=30)
assert _state.browser is not None
return _state.event_loop, _state.browser
class BrowserInstance:
def __init__(self) -> None:
self.is_running = True
self._execution_lock = threading.Lock()
self._loop: asyncio.AbstractEventLoop | None = None
self._browser: Browser | None = None
self.context: BrowserContext | None = None
self.pages: dict[str, Page] = {}
self.current_page_id: str | None = None
self._next_tab_id = 1
self.console_logs: dict[str, list[dict[str, Any]]] = {}
def _run_async(self, coro: Any) -> dict[str, Any]:
if not self._loop or not self.is_running:
raise RuntimeError("Browser instance is not running")
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
return cast("dict[str, Any]", future.result(timeout=30)) # 30 second timeout
async def _setup_console_logging(self, page: Page, tab_id: str) -> None:
self.console_logs[tab_id] = []
def handle_console(msg: Any) -> None:
text = msg.text
if len(text) > MAX_INDIVIDUAL_LOG_LENGTH:
text = text[:MAX_INDIVIDUAL_LOG_LENGTH] + "... [TRUNCATED]"
log_entry = {
"type": msg.type,
"text": text,
"location": msg.location,
"timestamp": asyncio.get_event_loop().time(),
}
self.console_logs[tab_id].append(log_entry)
if len(self.console_logs[tab_id]) > MAX_CONSOLE_LOGS_COUNT:
self.console_logs[tab_id] = self.console_logs[tab_id][-MAX_CONSOLE_LOGS_COUNT:]
page.on("console", handle_console)
async def _create_context(self, url: str | None = None) -> dict[str, Any]:
assert self._browser is not None
self.context = await self._browser.new_context(
viewport={"width": 1280, "height": 720},
user_agent=(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
),
)
page = await self.context.new_page()
tab_id = f"tab_{self._next_tab_id}"
self._next_tab_id += 1
self.pages[tab_id] = page
self.current_page_id = tab_id
await self._setup_console_logging(page, tab_id)
if url:
await page.goto(url, wait_until="domcontentloaded")
return await self._get_page_state(tab_id)
async def _get_page_state(self, tab_id: str | None = None) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
page = self.pages[tab_id]
await asyncio.sleep(2)
screenshot_bytes = await page.screenshot(type="png", full_page=False)
screenshot_b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
url = page.url
title = await page.title()
viewport = page.viewport_size
all_tabs = {}
for tid, tab_page in self.pages.items():
all_tabs[tid] = {
"url": tab_page.url,
"title": await tab_page.title() if not tab_page.is_closed() else "Closed",
}
return {
"screenshot": screenshot_b64,
"url": url,
"title": title,
"viewport": viewport,
"tab_id": tab_id,
"all_tabs": all_tabs,
}
def launch(self, url: str | None = None) -> dict[str, Any]:
with self._execution_lock:
if self.context is not None:
raise ValueError("Browser is already launched")
self._loop, self._browser = _get_browser()
return self._run_async(self._create_context(url))
def goto(self, url: str, tab_id: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._goto(url, tab_id))
async def _goto(self, url: str, tab_id: str | None = None) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
page = self.pages[tab_id]
await page.goto(url, wait_until="domcontentloaded")
return await self._get_page_state(tab_id)
def click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._click(coordinate, tab_id))
async def _click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
try:
x, y = map(int, coordinate.split(","))
except ValueError as e:
raise ValueError(f"Invalid coordinate format: {coordinate}. Use 'x,y'") from e
page = self.pages[tab_id]
await page.mouse.click(x, y)
return await self._get_page_state(tab_id)
def type_text(self, text: str, tab_id: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._type_text(text, tab_id))
async def _type_text(self, text: str, tab_id: str | None = None) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
page = self.pages[tab_id]
await page.keyboard.type(text)
return await self._get_page_state(tab_id)
def scroll(self, direction: str, tab_id: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._scroll(direction, tab_id))
async def _scroll(self, direction: str, tab_id: str | None = None) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
page = self.pages[tab_id]
if direction == "down":
await page.keyboard.press("PageDown")
elif direction == "up":
await page.keyboard.press("PageUp")
else:
raise ValueError(f"Invalid scroll direction: {direction}")
return await self._get_page_state(tab_id)
def back(self, tab_id: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._back(tab_id))
async def _back(self, tab_id: str | None = None) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
page = self.pages[tab_id]
await page.go_back(wait_until="domcontentloaded")
return await self._get_page_state(tab_id)
def forward(self, tab_id: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._forward(tab_id))
async def _forward(self, tab_id: str | None = None) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
page = self.pages[tab_id]
await page.go_forward(wait_until="domcontentloaded")
return await self._get_page_state(tab_id)
def new_tab(self, url: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._new_tab(url))
async def _new_tab(self, url: str | None = None) -> dict[str, Any]:
if not self.context:
raise ValueError("Browser not launched")
page = await self.context.new_page()
tab_id = f"tab_{self._next_tab_id}"
self._next_tab_id += 1
self.pages[tab_id] = page
self.current_page_id = tab_id
await self._setup_console_logging(page, tab_id)
if url:
await page.goto(url, wait_until="domcontentloaded")
return await self._get_page_state(tab_id)
def switch_tab(self, tab_id: str) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._switch_tab(tab_id))
async def _switch_tab(self, tab_id: str) -> dict[str, Any]:
if tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
self.current_page_id = tab_id
return await self._get_page_state(tab_id)
def close_tab(self, tab_id: str) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._close_tab(tab_id))
async def _close_tab(self, tab_id: str) -> dict[str, Any]:
if tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
if len(self.pages) == 1:
raise ValueError("Cannot close the last tab")
page = self.pages.pop(tab_id)
await page.close()
if tab_id in self.console_logs:
del self.console_logs[tab_id]
if self.current_page_id == tab_id:
self.current_page_id = next(iter(self.pages.keys()))
return await self._get_page_state(self.current_page_id)
def wait(self, duration: float, tab_id: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._wait(duration, tab_id))
async def _wait(self, duration: float, tab_id: str | None = None) -> dict[str, Any]:
await asyncio.sleep(duration)
return await self._get_page_state(tab_id)
def execute_js(self, js_code: str, tab_id: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._execute_js(js_code, tab_id))
async def _execute_js(self, js_code: str, tab_id: str | None = None) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
page = self.pages[tab_id]
try:
result = await page.evaluate(js_code)
except Exception as e: # noqa: BLE001
result = {
"error": True,
"error_type": type(e).__name__,
"error_message": str(e),
}
result_str = str(result)
if len(result_str) > MAX_JS_RESULT_LENGTH:
result = result_str[:MAX_JS_RESULT_LENGTH] + "... [JS result truncated at 5k chars]"
state = await self._get_page_state(tab_id)
state["js_result"] = result
return state
def get_console_logs(self, tab_id: str | None = None, clear: bool = False) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._get_console_logs(tab_id, clear))
async def _get_console_logs(
self, tab_id: str | None = None, clear: bool = False
) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
logs = self.console_logs.get(tab_id, [])
total_length = sum(len(str(log)) for log in logs)
if total_length > MAX_CONSOLE_LOG_LENGTH:
truncated_logs: list[dict[str, Any]] = []
current_length = 0
for log in reversed(logs):
log_length = len(str(log))
if current_length + log_length <= MAX_CONSOLE_LOG_LENGTH:
truncated_logs.insert(0, log)
current_length += log_length
else:
break
if len(truncated_logs) < len(logs):
truncation_notice = {
"type": "info",
"text": (
f"[TRUNCATED: {len(logs) - len(truncated_logs)} older logs "
f"removed to stay within {MAX_CONSOLE_LOG_LENGTH} character limit]"
),
"location": {},
"timestamp": 0,
}
truncated_logs.insert(0, truncation_notice)
logs = truncated_logs
if clear:
self.console_logs[tab_id] = []
state = await self._get_page_state(tab_id)
state["console_logs"] = logs
return state
def view_source(self, tab_id: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._view_source(tab_id))
async def _view_source(self, tab_id: str | None = None) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
page = self.pages[tab_id]
source = await page.content()
original_length = len(source)
if original_length > MAX_PAGE_SOURCE_LENGTH:
truncation_message = (
f"\n\n<!-- [TRUNCATED: {original_length - MAX_PAGE_SOURCE_LENGTH} "
"characters removed] -->\n\n"
)
available_space = MAX_PAGE_SOURCE_LENGTH - len(truncation_message)
truncate_point = available_space // 2
source = source[:truncate_point] + truncation_message + source[-truncate_point:]
state = await self._get_page_state(tab_id)
state["page_source"] = source
return state
def double_click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._double_click(coordinate, tab_id))
async def _double_click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
try:
x, y = map(int, coordinate.split(","))
except ValueError as e:
raise ValueError(f"Invalid coordinate format: {coordinate}. Use 'x,y'") from e
page = self.pages[tab_id]
await page.mouse.dblclick(x, y)
return await self._get_page_state(tab_id)
def hover(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._hover(coordinate, tab_id))
async def _hover(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
try:
x, y = map(int, coordinate.split(","))
except ValueError as e:
raise ValueError(f"Invalid coordinate format: {coordinate}. Use 'x,y'") from e
page = self.pages[tab_id]
await page.mouse.move(x, y)
return await self._get_page_state(tab_id)
def press_key(self, key: str, tab_id: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._press_key(key, tab_id))
async def _press_key(self, key: str, tab_id: str | None = None) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
page = self.pages[tab_id]
await page.keyboard.press(key)
return await self._get_page_state(tab_id)
def save_pdf(self, file_path: str, tab_id: str | None = None) -> dict[str, Any]:
with self._execution_lock:
return self._run_async(self._save_pdf(file_path, tab_id))
async def _save_pdf(self, file_path: str, tab_id: str | None = None) -> dict[str, Any]:
if not tab_id:
tab_id = self.current_page_id
if not tab_id or tab_id not in self.pages:
raise ValueError(f"Tab '{tab_id}' not found")
if not Path(file_path).is_absolute():
file_path = str(Path("/workspace") / file_path)
page = self.pages[tab_id]
await page.pdf(path=file_path)
state = await self._get_page_state(tab_id)
state["pdf_saved"] = file_path
return state
def close(self) -> None:
with self._execution_lock:
self.is_running = False
if self._loop and self.context:
future = asyncio.run_coroutine_threadsafe(self._close_context(), self._loop)
with contextlib.suppress(Exception):
future.result(timeout=5)
self.pages.clear()
self.console_logs.clear()
self.current_page_id = None
self.context = None
async def _close_context(self) -> None:
try:
if self.context:
await self.context.close()
except (OSError, RuntimeError) as e:
logger.warning(f"Error closing context: {e}")
def is_alive(self) -> bool:
return (
self.is_running
and self.context is not None
and self._browser is not None
and self._browser.is_connected()
)
-361
View File
@@ -1,361 +0,0 @@
import atexit
import contextlib
import threading
from typing import Any
from strix.tools.context import get_current_agent_id
from .browser_instance import BrowserInstance
class BrowserTabManager:
def __init__(self) -> None:
self._browsers_by_agent: dict[str, BrowserInstance] = {}
self._lock = threading.Lock()
self._register_cleanup_handlers()
def _get_agent_browser(self) -> BrowserInstance | None:
agent_id = get_current_agent_id()
with self._lock:
return self._browsers_by_agent.get(agent_id)
def _set_agent_browser(self, browser: BrowserInstance | None) -> None:
agent_id = get_current_agent_id()
with self._lock:
if browser is None:
self._browsers_by_agent.pop(agent_id, None)
else:
self._browsers_by_agent[agent_id] = browser
def launch_browser(self, url: str | None = None) -> dict[str, Any]:
with self._lock:
agent_id = get_current_agent_id()
if agent_id in self._browsers_by_agent:
raise ValueError("Browser is already launched")
try:
browser = BrowserInstance()
result = browser.launch(url)
self._browsers_by_agent[agent_id] = browser
result["message"] = "Browser launched successfully"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to launch browser: {e}") from e
else:
return result
def goto_url(self, url: str, tab_id: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.goto(url, tab_id)
result["message"] = f"Navigated to {url}"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to navigate to URL: {e}") from e
else:
return result
def click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.click(coordinate, tab_id)
result["message"] = f"Clicked at {coordinate}"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to click: {e}") from e
else:
return result
def type_text(self, text: str, tab_id: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.type_text(text, tab_id)
result["message"] = f"Typed text: {text[:50]}{'...' if len(text) > 50 else ''}"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to type text: {e}") from e
else:
return result
def scroll(self, direction: str, tab_id: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.scroll(direction, tab_id)
result["message"] = f"Scrolled {direction}"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to scroll: {e}") from e
else:
return result
def back(self, tab_id: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.back(tab_id)
result["message"] = "Navigated back"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to go back: {e}") from e
else:
return result
def forward(self, tab_id: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.forward(tab_id)
result["message"] = "Navigated forward"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to go forward: {e}") from e
else:
return result
def new_tab(self, url: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.new_tab(url)
result["message"] = f"Created new tab {result.get('tab_id', '')}"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to create new tab: {e}") from e
else:
return result
def switch_tab(self, tab_id: str) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.switch_tab(tab_id)
result["message"] = f"Switched to tab {tab_id}"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to switch tab: {e}") from e
else:
return result
def close_tab(self, tab_id: str) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.close_tab(tab_id)
result["message"] = f"Closed tab {tab_id}"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to close tab: {e}") from e
else:
return result
def wait_browser(self, duration: float, tab_id: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.wait(duration, tab_id)
result["message"] = f"Waited {duration}s"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to wait: {e}") from e
else:
return result
def execute_js(self, js_code: str, tab_id: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.execute_js(js_code, tab_id)
result["message"] = "JavaScript executed successfully"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to execute JavaScript: {e}") from e
else:
return result
def double_click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.double_click(coordinate, tab_id)
result["message"] = f"Double clicked at {coordinate}"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to double click: {e}") from e
else:
return result
def hover(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.hover(coordinate, tab_id)
result["message"] = f"Hovered at {coordinate}"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to hover: {e}") from e
else:
return result
def press_key(self, key: str, tab_id: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.press_key(key, tab_id)
result["message"] = f"Pressed key {key}"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to press key: {e}") from e
else:
return result
def save_pdf(self, file_path: str, tab_id: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.save_pdf(file_path, tab_id)
result["message"] = f"Page saved as PDF: {file_path}"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to save PDF: {e}") from e
else:
return result
def get_console_logs(self, tab_id: str | None = None, clear: bool = False) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.get_console_logs(tab_id, clear)
action_text = "cleared and retrieved" if clear else "retrieved"
logs = result.get("console_logs", [])
truncated = any(log.get("text", "").startswith("[TRUNCATED:") for log in logs)
truncated_text = " (truncated)" if truncated else ""
result["message"] = (
f"Console logs {action_text} for tab "
f"{result.get('tab_id', 'current')}{truncated_text}"
)
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to get console logs: {e}") from e
else:
return result
def view_source(self, tab_id: str | None = None) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
raise ValueError("Browser not launched")
try:
result = browser.view_source(tab_id)
result["message"] = "Page source retrieved"
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to get page source: {e}") from e
else:
return result
def list_tabs(self) -> dict[str, Any]:
browser = self._get_agent_browser()
if browser is None:
return {"tabs": {}, "total_count": 0, "current_tab": None}
try:
tab_info = {}
for tid, tab_page in browser.pages.items():
try:
tab_info[tid] = {
"url": tab_page.url,
"title": "Unknown" if tab_page.is_closed() else "Active",
"is_current": tid == browser.current_page_id,
}
except (AttributeError, RuntimeError):
tab_info[tid] = {
"url": "Unknown",
"title": "Closed",
"is_current": False,
}
return {
"tabs": tab_info,
"total_count": len(tab_info),
"current_tab": browser.current_page_id,
}
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to list tabs: {e}") from e
def close_browser(self) -> dict[str, Any]:
agent_id = get_current_agent_id()
with self._lock:
browser = self._browsers_by_agent.pop(agent_id, None)
if browser is None:
raise ValueError("Browser not launched")
try:
browser.close()
except (OSError, ValueError, RuntimeError) as e:
raise RuntimeError(f"Failed to close browser: {e}") from e
else:
return {
"message": "Browser closed successfully",
"screenshot": "",
"is_running": False,
}
def cleanup_agent(self, agent_id: str) -> None:
with self._lock:
browser = self._browsers_by_agent.pop(agent_id, None)
if browser:
with contextlib.suppress(Exception):
browser.close()
def cleanup_dead_browser(self) -> None:
with self._lock:
dead_agents = []
for agent_id, browser in self._browsers_by_agent.items():
if not browser.is_alive():
dead_agents.append(agent_id)
for agent_id in dead_agents:
browser = self._browsers_by_agent.pop(agent_id)
with contextlib.suppress(Exception):
browser.close()
def close_all(self) -> None:
with self._lock:
browsers = list(self._browsers_by_agent.values())
self._browsers_by_agent.clear()
for browser in browsers:
with contextlib.suppress(Exception):
browser.close()
def _register_cleanup_handlers(self) -> None:
atexit.register(self.close_all)
_browser_tab_manager = BrowserTabManager()
def get_browser_tab_manager() -> BrowserTabManager:
return _browser_tab_manager
-152
View File
@@ -1,152 +0,0 @@
"""SDK function-tool wrapper for the legacy ``browser_action`` tool.
The browser is fully sandbox-bound — the legacy implementation runs
inside the container against a Playwright instance the tool server
manages. We delegate every action verbatim to ``post_to_sandbox``.
The legacy ``browser_action`` is a single mega-tool dispatching 21
discrete actions (launch, goto, click, scroll_*, new_tab, etc.). We
preserve that shape for parity rather than fanning out into 21
separate tools — that would balloon the system prompt and surprise
the model.
"""
from __future__ import annotations
from typing import Literal
from agents import RunContextWrapper
from strix.tools._decorator import dump_tool_result, strix_tool
from strix.tools._sandbox_dispatch import post_to_sandbox
BrowserAction = Literal[
"launch",
"goto",
"click",
"type",
"scroll_down",
"scroll_up",
"back",
"forward",
"new_tab",
"switch_tab",
"close_tab",
"wait",
"execute_js",
"double_click",
"hover",
"press_key",
"save_pdf",
"get_console_logs",
"view_source",
"close",
"list_tabs",
]
# Browser actions can take time (page loads, navigation timeouts), so
# match the sandbox dispatch read budget rather than capping shorter.
@strix_tool(timeout=180)
async def browser_action(
ctx: RunContextWrapper,
action: BrowserAction,
url: str | None = None,
coordinate: str | None = None,
text: str | None = None,
tab_id: str | None = None,
js_code: str | None = None,
duration: float | None = None,
key: str | None = None,
file_path: str | None = None,
clear: bool = False,
) -> str:
"""Drive the sandboxed Playwright browser (Chromium, headless).
The browser is **persistent** — state survives across calls and tabs
until you ``close``. Browser interaction must start with ``launch``
and end with ``close``. Multiple tabs are supported; the first tab
after ``launch`` is ``"tab_1"`` and new tabs are numbered
sequentially.
**Click coordinates** — derive them from the most recent screenshot.
Target the *center* of the element, not the edge. After clicking,
verify success against the next screenshot. Bad coordinates are the
most common reason clicks silently fail.
**JavaScript execution** (``execute_js``):
- Code runs in the page context with full DOM access.
- The **last evaluated expression is auto-returned** — do not use
``return`` (it breaks evaluation).
- For an object literal as the final expression, wrap in parentheses:
``({title: document.title, url: location.href})``.
- ``await`` is supported: ``await fetch(location.href).then(r => r.status)``.
- Variables from your tool context are NOT available — pass data
via the URL or DOM if you need to thread it through.
- The ``js_code`` parameter is executed as-is; no escaping needed,
single- or multi-line both work.
**Form filling** — click the field first, then ``type`` the text.
**Tabs** — actions affect the currently active tab unless ``tab_id``
is set. Always keep at least one tab open. Close tabs you don't need
with ``close_tab``, and ``close`` the browser when you're fully done.
**Concurrency** — the browser session can run alongside terminal /
python tool calls in subsequent turns; nothing in the browser is
serialized against other tools.
Special keys for ``press_key``: single chars ``a``-``z`` / ``0``-``9``,
``Enter`` / ``Escape`` / ``Tab`` / ``Space`` / ``ArrowLeft`` /
``ArrowRight`` / ``ArrowUp`` / ``ArrowDown``, modifiers ``Shift`` /
``Control`` / ``Alt`` / ``Meta``, function keys ``F1``-``F12``.
Returns: a JSON dict with ``screenshot`` (base64 PNG), ``url``,
``title``, ``viewport``, ``tab_id``, ``all_tabs``. Per-action extras:
``js_result`` for ``execute_js``, ``pdf_saved`` for ``save_pdf``,
``console_logs`` (≤50 KB / ≤200 most recent) for ``get_console_logs``,
``page_source`` (truncated to 100 KB) for ``view_source``.
Args:
action: One of: ``launch``, ``goto``, ``click``, ``type``,
``scroll_down``, ``scroll_up``, ``back``, ``forward``,
``new_tab``, ``switch_tab``, ``close_tab``, ``list_tabs``,
``wait``, ``execute_js``, ``double_click``, ``hover``,
``press_key``, ``save_pdf``, ``get_console_logs``,
``view_source``, ``close``.
url: Required for ``launch`` / ``goto``; optional for
``new_tab``. Must include the protocol (e.g.
``https://...``, ``file://...``).
coordinate: ``"x,y"`` pixel target for ``click`` / ``double_click``
/ ``hover``. Format example: ``"432,321"``. Must be within
viewport.
text: Required for ``type``.
tab_id: Required for ``switch_tab`` / ``close_tab``; optional
elsewhere to target a specific tab.
js_code: Required for ``execute_js``.
duration: Seconds for ``wait`` (fractional OK, e.g. ``0.5``).
key: Required for ``press_key``.
file_path: Required for ``save_pdf``.
clear: For ``get_console_logs``, clear logs after retrieval
(default False).
"""
return dump_tool_result(
await post_to_sandbox(
ctx,
"browser_action",
{
"action": action,
"url": url,
"coordinate": coordinate,
"text": text,
"tab_id": tab_id,
"js_code": js_code,
"duration": duration,
"key": key,
"file_path": file_path,
"clear": clear,
},
),
)
-12
View File
@@ -1,12 +0,0 @@
from contextvars import ContextVar
current_agent_id: ContextVar[str] = ContextVar("current_agent_id", default="default")
def get_current_agent_id() -> str:
return current_agent_id.get()
def set_current_agent_id(agent_id: str) -> None:
current_agent_id.set(agent_id)
-4
View File
@@ -1,4 +0,0 @@
from .file_edit_actions import list_files, search_files, str_replace_editor
__all__ = ["list_files", "search_files", "str_replace_editor"]
-144
View File
@@ -1,144 +0,0 @@
import json
import re
from pathlib import Path
from typing import Any, cast
from strix.tools.registry import register_tool
def _parse_file_editor_output(output: str) -> dict[str, Any]:
try:
pattern = r"<oh_aci_output_[^>]+>\n(.*?)\n</oh_aci_output_[^>]+>"
match = re.search(pattern, output, re.DOTALL)
if match:
json_str = match.group(1)
data = json.loads(json_str)
return cast("dict[str, Any]", data)
return {"output": output, "error": None}
except (json.JSONDecodeError, AttributeError):
return {"output": output, "error": None}
@register_tool
def str_replace_editor(
command: str,
path: str,
file_text: str | None = None,
view_range: list[int] | None = None,
old_str: str | None = None,
new_str: str | None = None,
insert_line: int | None = None,
) -> dict[str, Any]:
from openhands_aci import file_editor
try:
path_obj = Path(path)
if not path_obj.is_absolute():
path = str(Path("/workspace") / path_obj)
result = file_editor(
command=command,
path=path,
file_text=file_text,
view_range=view_range,
old_str=old_str,
new_str=new_str,
insert_line=insert_line,
)
parsed = _parse_file_editor_output(result)
if parsed.get("error"):
return {"error": parsed["error"]}
return {"content": parsed.get("output", result)}
except (OSError, ValueError) as e:
return {"error": f"Error in {command} operation: {e!s}"}
@register_tool
def list_files(
path: str,
recursive: bool = False,
) -> dict[str, Any]:
from openhands_aci.utils.shell import run_shell_cmd
try:
path_obj = Path(path)
if not path_obj.is_absolute():
path = str(Path("/workspace") / path_obj)
path_obj = Path(path)
if not path_obj.exists():
return {"error": f"Directory not found: {path}"}
if not path_obj.is_dir():
return {"error": f"Path is not a directory: {path}"}
cmd = f"find '{path}' -type f -o -type d | head -500" if recursive else f"ls -1a '{path}'"
exit_code, stdout, stderr = run_shell_cmd(cmd)
if exit_code != 0:
return {"error": f"Error listing directory: {stderr}"}
items = stdout.strip().split("\n") if stdout.strip() else []
files = []
dirs = []
for item in items:
item_path = item if recursive else str(Path(path) / item)
item_path_obj = Path(item_path)
if item_path_obj.is_file():
files.append(item)
elif item_path_obj.is_dir():
dirs.append(item)
return {
"files": sorted(files),
"directories": sorted(dirs),
"total_files": len(files),
"total_dirs": len(dirs),
"path": path,
"recursive": recursive,
}
except (OSError, ValueError) as e:
return {"error": f"Error listing directory: {e!s}"}
@register_tool
def search_files(
path: str,
regex: str,
file_pattern: str = "*",
) -> dict[str, Any]:
from openhands_aci.utils.shell import run_shell_cmd
try:
path_obj = Path(path)
if not path_obj.is_absolute():
path = str(Path("/workspace") / path_obj)
if not Path(path).exists():
return {"error": f"Directory not found: {path}"}
escaped_regex = regex.replace("'", "'\"'\"'")
cmd = f"rg --line-number --glob '{file_pattern}' '{escaped_regex}' '{path}'"
exit_code, stdout, stderr = run_shell_cmd(cmd)
if exit_code not in {0, 1}:
return {"error": f"Error searching files: {stderr}"}
return {"output": stdout if stdout else "No matches found"}
except (OSError, ValueError) as e:
return {"error": f"Error searching files: {e!s}"}
# ruff: noqa: TRY300
-128
View File
@@ -1,128 +0,0 @@
"""SDK function-tool wrappers for the legacy ``file_edit`` tools.
These three tools (``str_replace_editor``, ``list_files``, ``search_files``)
operate on files inside the sandbox container's ``/workspace`` filesystem.
The legacy harness marks them ``sandbox_execution=True`` (default) so the
executor POSTs them to the in-container tool server.
The host-side SDK wrappers therefore delegate to ``post_to_sandbox`` —
the legacy implementations live in the container image and we don't
import them on the host (they pull in ``openhands_aci``, which is a
sandbox-only dependency).
"""
from __future__ import annotations
from agents import RunContextWrapper
from strix.tools._decorator import dump_tool_result, strix_tool
from strix.tools._sandbox_dispatch import post_to_sandbox
@strix_tool(timeout=180)
async def str_replace_editor(
ctx: RunContextWrapper,
command: str,
path: str,
file_text: str | None = None,
view_range: list[int] | None = None,
old_str: str | None = None,
new_str: str | None = None,
insert_line: int | None = None,
) -> str:
"""View, create, or edit a file in the sandbox filesystem.
Commands:
- ``view`` — show file contents. Optionally restrict to a line range
via ``view_range`` (1-indexed; ``[start, -1]`` for "from start to
end of file").
- ``create`` — write a new file with ``file_text``. Use this for
exploit scripts, PoCs, helper modules, etc.
- ``str_replace`` — find ``old_str`` in the file and replace with
``new_str``. ``old_str`` must be unique in the file; include
enough surrounding context to make it so.
- ``insert`` — insert ``new_str`` after line ``insert_line``.
- ``undo_edit`` — revert the most recent edit to ``path``.
Multi-line ``new_str`` / ``old_str`` / ``file_text`` use real
newlines, not literal ``\\n``.
Args:
command: ``view`` / ``create`` / ``str_replace`` / ``insert`` /
``undo_edit``.
path: File path. Relative paths anchor at ``/workspace``.
file_text: Required for ``create``.
view_range: Optional ``[start, end]`` (1-indexed) for ``view``.
old_str: Required for ``str_replace`` — must be unique in file.
new_str: Required for ``str_replace`` and ``insert``.
insert_line: Required for ``insert``; new content goes AFTER
this line.
"""
return dump_tool_result(
await post_to_sandbox(
ctx,
"str_replace_editor",
{
"command": command,
"path": path,
"file_text": file_text,
"view_range": view_range,
"old_str": old_str,
"new_str": new_str,
"insert_line": insert_line,
},
),
)
@strix_tool(timeout=120)
async def list_files(
ctx: RunContextWrapper,
path: str,
recursive: bool = False,
) -> str:
"""List files and directories under a sandbox path.
Output is sorted alphabetically and capped at 500 entries to avoid
flooding the model with huge directory trees.
Args:
path: Directory path; relative paths anchor at ``/workspace``.
recursive: When True, walks subdirectories.
"""
return dump_tool_result(
await post_to_sandbox(
ctx,
"list_files",
{"path": path, "recursive": recursive},
),
)
@strix_tool(timeout=120)
async def search_files(
ctx: RunContextWrapper,
path: str,
regex: str,
file_pattern: str = "*",
) -> str:
"""Recursively regex-search files in the sandbox using ripgrep.
Fast — uses ``rg`` under the hood. Walks subdirectories. Use this
for code-pattern hunts (``def\\s+authenticate``, ``API_KEY``,
secrets, etc.) when you don't already know the file.
Args:
path: Root path to search. Relative paths anchor at ``/workspace``.
regex: Pattern to match (PCRE-style; passed straight to ``rg``).
file_pattern: Glob filter (e.g. ``"*.py"``, ``"*.{js,ts}"``).
Defaults to all files.
"""
return dump_tool_result(
await post_to_sandbox(
ctx,
"search_files",
{"path": path, "regex": regex, "file_pattern": file_pattern},
),
)
+2 -2
View File
@@ -383,8 +383,8 @@ async def repeat_request(
The standard pentesting workflow with this tool:
1. ``browser_action`` (or live target traffic) → request gets
captured by Caido.
1. ``agent-browser`` (via ``exec_command``) or live target traffic
→ request gets captured by Caido.
2. ``list_requests`` → find the request ID you want to manipulate.
3. ``repeat_request`` → send a modified version (auth-bypass test,
payload injection, parameter tampering).
-4
View File
@@ -1,4 +0,0 @@
from .python_actions import python_action
__all__ = ["python_action"]
-47
View File
@@ -1,47 +0,0 @@
from typing import Any, Literal
from strix.tools.registry import register_tool
PythonAction = Literal["new_session", "execute", "close", "list_sessions"]
@register_tool
def python_action(
action: PythonAction,
code: str | None = None,
timeout: int = 30,
session_id: str | None = None,
) -> dict[str, Any]:
from .python_manager import get_python_session_manager
def _validate_code(action_name: str, code: str | None) -> None:
if not code:
raise ValueError(f"code parameter is required for {action_name} action")
def _validate_action(action_name: str) -> None:
raise ValueError(f"Unknown action: {action_name}")
manager = get_python_session_manager()
try:
match action:
case "new_session":
return manager.create_session(session_id, code, timeout)
case "execute":
_validate_code(action, code)
assert code is not None
return manager.execute_code(session_id, code, timeout)
case "close":
return manager.close_session(session_id)
case "list_sessions":
return manager.list_sessions()
case _:
_validate_action(action) # type: ignore[unreachable]
except (ValueError, RuntimeError) as e:
return {"stderr": str(e), "session_id": session_id, "stdout": "", "is_running": False}
-153
View File
@@ -1,153 +0,0 @@
import io
import sys
import threading
from typing import Any
from IPython.core.interactiveshell import InteractiveShell
MAX_STDOUT_LENGTH = 10_000
MAX_STDERR_LENGTH = 5_000
class PythonInstance:
def __init__(self, session_id: str) -> None:
self.session_id = session_id
self.is_running = True
self._execution_lock = threading.Lock()
import os
os.chdir("/workspace")
self.shell = InteractiveShell()
self.shell.init_completer()
self.shell.init_history()
self.shell.init_logger()
def _validate_session(self) -> dict[str, Any] | None:
if not self.is_running:
return {
"session_id": self.session_id,
"stdout": "",
"stderr": "Session is not running",
"result": None,
}
return None
def _truncate_output(self, content: str, max_length: int, suffix: str) -> str:
if len(content) > max_length:
return content[:max_length] + suffix
return content
def _format_execution_result(
self, execution_result: Any, stdout_content: str, stderr_content: str
) -> dict[str, Any]:
stdout = self._truncate_output(
stdout_content, MAX_STDOUT_LENGTH, "... [stdout truncated at 10k chars]"
)
if execution_result.result is not None:
if stdout and not stdout.endswith("\n"):
stdout += "\n"
result_repr = repr(execution_result.result)
result_repr = self._truncate_output(
result_repr, MAX_STDOUT_LENGTH, "... [result truncated at 10k chars]"
)
stdout += result_repr
stdout = self._truncate_output(
stdout, MAX_STDOUT_LENGTH, "... [output truncated at 10k chars]"
)
stderr_content = stderr_content if stderr_content else ""
stderr_content = self._truncate_output(
stderr_content, MAX_STDERR_LENGTH, "... [stderr truncated at 5k chars]"
)
if (
execution_result.error_before_exec or execution_result.error_in_exec
) and not stderr_content:
stderr_content = "Execution error occurred"
return {
"session_id": self.session_id,
"stdout": stdout,
"stderr": stderr_content,
"result": repr(execution_result.result)
if execution_result.result is not None
else None,
}
def _handle_execution_error(self, error: BaseException) -> dict[str, Any]:
error_msg = str(error)
error_msg = self._truncate_output(
error_msg, MAX_STDERR_LENGTH, "... [error truncated at 5k chars]"
)
return {
"session_id": self.session_id,
"stdout": "",
"stderr": error_msg,
"result": None,
}
def execute_code(self, code: str, timeout: int = 30) -> dict[str, Any]:
session_error = self._validate_session()
if session_error:
return session_error
with self._execution_lock:
result_container: dict[str, Any] = {}
stdout_capture = io.StringIO()
stderr_capture = io.StringIO()
cancelled = threading.Event()
old_stdout, old_stderr = sys.stdout, sys.stderr
def _run_code() -> None:
try:
sys.stdout = stdout_capture
sys.stderr = stderr_capture
execution_result = self.shell.run_cell(code, silent=False, store_history=True)
result_container["execution_result"] = execution_result
result_container["stdout"] = stdout_capture.getvalue()
result_container["stderr"] = stderr_capture.getvalue()
except (KeyboardInterrupt, SystemExit) as e:
result_container["error"] = e
except Exception as e: # noqa: BLE001
result_container["error"] = e
finally:
if not cancelled.is_set():
sys.stdout = old_stdout
sys.stderr = old_stderr
exec_thread = threading.Thread(target=_run_code, daemon=True)
exec_thread.start()
exec_thread.join(timeout=timeout)
if exec_thread.is_alive():
cancelled.set()
sys.stdout, sys.stderr = old_stdout, old_stderr
return self._handle_execution_error(
TimeoutError(f"Code execution timed out after {timeout} seconds")
)
if "error" in result_container:
return self._handle_execution_error(result_container["error"])
if "execution_result" in result_container:
return self._format_execution_result(
result_container["execution_result"],
result_container.get("stdout", ""),
result_container.get("stderr", ""),
)
return self._handle_execution_error(RuntimeError("Unknown execution error"))
def close(self) -> None:
self.is_running = False
self.shell.reset(new_session=False)
def is_alive(self) -> bool:
return self.is_running
-143
View File
@@ -1,143 +0,0 @@
import atexit
import contextlib
import threading
from typing import Any
from strix.tools.context import get_current_agent_id
from .python_instance import PythonInstance
class PythonSessionManager:
def __init__(self) -> None:
self._sessions_by_agent: dict[str, dict[str, PythonInstance]] = {}
self._lock = threading.Lock()
self.default_session_id = "default"
self._register_cleanup_handlers()
def _get_agent_sessions(self) -> dict[str, PythonInstance]:
agent_id = get_current_agent_id()
with self._lock:
if agent_id not in self._sessions_by_agent:
self._sessions_by_agent[agent_id] = {}
return self._sessions_by_agent[agent_id]
def create_session(
self, session_id: str | None = None, initial_code: str | None = None, timeout: int = 30
) -> dict[str, Any]:
if session_id is None:
session_id = self.default_session_id
sessions = self._get_agent_sessions()
with self._lock:
if session_id in sessions:
raise ValueError(f"Python session '{session_id}' already exists")
session = PythonInstance(session_id)
sessions[session_id] = session
if initial_code:
result = session.execute_code(initial_code, timeout)
result["message"] = (
f"Python session '{session_id}' created successfully with initial code"
)
else:
result = {
"session_id": session_id,
"message": f"Python session '{session_id}' created successfully",
}
return result
def execute_code(
self, session_id: str | None = None, code: str | None = None, timeout: int = 30
) -> dict[str, Any]:
if session_id is None:
session_id = self.default_session_id
if not code:
raise ValueError("No code provided for execution")
sessions = self._get_agent_sessions()
with self._lock:
if session_id not in sessions:
raise ValueError(f"Python session '{session_id}' not found")
session = sessions[session_id]
result = session.execute_code(code, timeout)
result["message"] = f"Code executed in session '{session_id}'"
return result
def close_session(self, session_id: str | None = None) -> dict[str, Any]:
if session_id is None:
session_id = self.default_session_id
sessions = self._get_agent_sessions()
with self._lock:
if session_id not in sessions:
raise ValueError(f"Python session '{session_id}' not found")
session = sessions.pop(session_id)
session.close()
return {
"session_id": session_id,
"message": f"Python session '{session_id}' closed successfully",
"is_running": False,
}
def list_sessions(self) -> dict[str, Any]:
sessions = self._get_agent_sessions()
with self._lock:
session_info = {}
for sid, session in sessions.items():
session_info[sid] = {
"is_running": session.is_running,
"is_alive": session.is_alive(),
}
return {"sessions": session_info, "total_count": len(session_info)}
def cleanup_agent(self, agent_id: str) -> None:
with self._lock:
sessions = self._sessions_by_agent.pop(agent_id, {})
for session in sessions.values():
with contextlib.suppress(Exception):
session.close()
def cleanup_dead_sessions(self) -> None:
with self._lock:
for sessions in self._sessions_by_agent.values():
dead_sessions = []
for sid, session in sessions.items():
if not session.is_alive():
dead_sessions.append(sid)
for sid in dead_sessions:
session = sessions.pop(sid)
with contextlib.suppress(Exception):
session.close()
def close_all_sessions(self) -> None:
with self._lock:
all_sessions: list[PythonInstance] = []
for sessions in self._sessions_by_agent.values():
all_sessions.extend(sessions.values())
self._sessions_by_agent.clear()
for session in all_sessions:
with contextlib.suppress(Exception):
session.close()
def _register_cleanup_handlers(self) -> None:
atexit.register(self.close_all_sessions)
_python_session_manager = PythonSessionManager()
def get_python_session_manager() -> PythonSessionManager:
return _python_session_manager
-91
View File
@@ -1,91 +0,0 @@
"""SDK function-tool wrapper for the legacy ``python_action`` tool.
Sandbox-bound. The in-container manager keeps long-lived IPython
sessions keyed by ``session_id`` so the model can build up state
across multiple ``execute`` calls. Pure pass-through wrapper.
"""
from __future__ import annotations
from typing import Literal
from agents import RunContextWrapper
from strix.tools._decorator import dump_tool_result, strix_tool
from strix.tools._sandbox_dispatch import post_to_sandbox
PythonAction = Literal["new_session", "execute", "close", "list_sessions"]
@strix_tool(timeout=180)
async def python_action(
ctx: RunContextWrapper,
action: PythonAction,
code: str | None = None,
timeout: int = 30,
session_id: str | None = None,
) -> str:
"""Run Python code in a long-lived IPython session — preferred for any
Python work (payloads, exploit scripts, HTTP automation, log analysis,
crypto, data processing).
Pick this over ``terminal_execute`` whenever the work is Python.
Don't wrap Python in bash heredocs, ``python -c`` one-liners, or
interactive REPL sessions in the terminal the structured,
persistent, debuggable execution lives here.
Sessions are **persistent** variables, imports, and function
definitions survive between ``execute`` calls within the same
``session_id``. Each session has its own isolated namespace; multiple
sessions can run concurrently. Sessions stay alive until explicitly
``close``-d.
Caido proxy helpers are pre-imported into every session, so you can
correlate captured HTTP requests with custom analysis without any
setup: ``list_requests`` / ``view_request`` / ``send_request`` /
``repeat_request`` / ``scope_rules`` / ``list_sitemap`` /
``view_sitemap_entry`` are all available as bare names.
For large payload sprays / fuzzing loops, encapsulate the entire
loop inside a single ``python_action`` ``execute`` call (e.g.,
asyncio + aiohttp). Don't issue one tool call per payload — that
burns turns and is dramatically slower.
Code execution notes:
- Both expressions and statements are supported. Expressions auto-
return their result; ``print`` output is captured to stdout.
- IPython magics work: ``%pip install ...``, ``%time``, ``%whos``,
``%%writefile``, etc.
- Use real newlines in multi-line ``code``, not literal ``\\n``.
Workflow:
1. ``new_session`` (always first per ``session_id``) optionally
pass ``code`` for an initial setup snippet (imports, helpers).
2. ``execute`` run code. Variables persist across calls.
3. ``close`` terminate the session and free memory.
4. ``list_sessions`` inspect what's currently alive.
Args:
action: ``"new_session"`` / ``"execute"`` / ``"close"`` /
``"list_sessions"``.
code: Required for ``execute``; optional initial code for
``new_session``.
timeout: Per-call execution budget in seconds. Default 30.
session_id: Required for ``execute`` / ``close``. Optional for
``new_session`` (auto-generated when omitted).
"""
return dump_tool_result(
await post_to_sandbox(
ctx,
"python_action",
{
"action": action,
"code": code,
"timeout": timeout,
"session_id": session_id,
},
),
)
-109
View File
@@ -1,109 +0,0 @@
"""Minimal in-container tool registry.
Used inside the sandbox container by ``strix.runtime.tool_server`` to
look up ``@register_tool``-decorated functions by name. Sandbox-bound
tools (browser, terminal, python, file_edit, proxy) live as
``*_actions.py`` modules with this decoration; the host POSTs to
:func:`tool_server.execute_tool` which dispatches via
:func:`get_tool_by_name`.
Host-side SDK function tools are wired through
:mod:`strix.agents.factory` and don't touch this registry.
"""
import logging
import os
from collections.abc import Callable
from functools import wraps
from typing import Any
logger = logging.getLogger(__name__)
tools: list[dict[str, Any]] = []
_tools_by_name: dict[str, Callable[..., Any]] = {}
def _is_sandbox_mode() -> bool:
return os.getenv("STRIX_SANDBOX_MODE", "false").lower() == "true"
def _is_browser_disabled() -> bool:
return os.getenv("STRIX_DISABLE_BROWSER", "").lower() == "true"
def _has_perplexity_api() -> bool:
return bool(os.getenv("PERPLEXITY_API_KEY"))
def _should_register_tool(
*,
sandbox_execution: bool,
requires_browser_mode: bool,
requires_web_search_mode: bool,
) -> bool:
"""In-container side only registers sandbox-execution tools."""
sandbox_mode = _is_sandbox_mode()
if sandbox_mode and not sandbox_execution:
return False
if requires_browser_mode and _is_browser_disabled():
return False
return not (requires_web_search_mode and not _has_perplexity_api())
def register_tool(
func: Callable[..., Any] | None = None,
*,
sandbox_execution: bool = True,
requires_browser_mode: bool = False,
requires_web_search_mode: bool = False,
) -> Callable[..., Any]:
"""Register a tool function for in-container dispatch.
Decorations are conditional on the env (``STRIX_SANDBOX_MODE``,
``STRIX_DISABLE_BROWSER``, ``PERPLEXITY_API_KEY``) so the host
side, which imports these modules but doesn't run sandbox-bound
tools locally, doesn't accumulate dead registrations.
"""
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
if not _should_register_tool(
sandbox_execution=sandbox_execution,
requires_browser_mode=requires_browser_mode,
requires_web_search_mode=requires_web_search_mode,
):
return f
tools.append(
{
"name": f.__name__,
"function": f,
"sandbox_execution": sandbox_execution,
},
)
_tools_by_name[f.__name__] = f
@wraps(f)
def wrapper(*args: Any, **kwargs: Any) -> Any:
return f(*args, **kwargs)
return wrapper
if func is None:
return decorator
return decorator(func)
def get_tool_by_name(name: str) -> Callable[..., Any] | None:
return _tools_by_name.get(name)
def get_tool_names() -> list[str]:
return list(_tools_by_name.keys())
def clear_registry() -> None:
tools.clear()
_tools_by_name.clear()
-4
View File
@@ -1,4 +0,0 @@
from .terminal_actions import terminal_execute
__all__ = ["terminal_execute"]
-35
View File
@@ -1,35 +0,0 @@
from typing import Any
from strix.tools.registry import register_tool
@register_tool
def terminal_execute(
command: str,
is_input: bool = False,
timeout: float | None = None,
terminal_id: str | None = None,
no_enter: bool = False,
) -> dict[str, Any]:
from .terminal_manager import get_terminal_manager
manager = get_terminal_manager()
try:
return manager.execute_command(
command=command,
is_input=is_input,
timeout=timeout,
terminal_id=terminal_id,
no_enter=no_enter,
)
except (ValueError, RuntimeError) as e:
return {
"error": str(e),
"command": command,
"terminal_id": terminal_id or "default",
"content": "",
"status": "error",
"exit_code": None,
"working_dir": None,
}
-162
View File
@@ -1,162 +0,0 @@
import atexit
import contextlib
import threading
from typing import Any
from strix.tools.context import get_current_agent_id
from .terminal_session import TerminalSession
class TerminalManager:
def __init__(self) -> None:
self._sessions_by_agent: dict[str, dict[str, TerminalSession]] = {}
self._lock = threading.Lock()
self.default_terminal_id = "default"
self.default_timeout = 30.0
self._register_cleanup_handlers()
def _get_agent_sessions(self) -> dict[str, TerminalSession]:
agent_id = get_current_agent_id()
with self._lock:
if agent_id not in self._sessions_by_agent:
self._sessions_by_agent[agent_id] = {}
return self._sessions_by_agent[agent_id]
def execute_command(
self,
command: str,
is_input: bool = False,
timeout: float | None = None,
terminal_id: str | None = None,
no_enter: bool = False,
) -> dict[str, Any]:
if terminal_id is None:
terminal_id = self.default_terminal_id
session = self._get_or_create_session(terminal_id)
try:
result = session.execute(command, is_input, timeout or self.default_timeout, no_enter)
return {
"content": result["content"],
"command": command,
"terminal_id": terminal_id,
"status": result["status"],
"exit_code": result.get("exit_code"),
"working_dir": result.get("working_dir"),
}
except RuntimeError as e:
return {
"error": str(e),
"command": command,
"terminal_id": terminal_id,
"content": "",
"status": "error",
"exit_code": None,
"working_dir": None,
}
except OSError as e:
return {
"error": f"System error: {e}",
"command": command,
"terminal_id": terminal_id,
"content": "",
"status": "error",
"exit_code": None,
"working_dir": None,
}
def _get_or_create_session(self, terminal_id: str) -> TerminalSession:
sessions = self._get_agent_sessions()
with self._lock:
if terminal_id not in sessions:
sessions[terminal_id] = TerminalSession(terminal_id)
return sessions[terminal_id]
def close_session(self, terminal_id: str | None = None) -> dict[str, Any]:
if terminal_id is None:
terminal_id = self.default_terminal_id
sessions = self._get_agent_sessions()
with self._lock:
if terminal_id not in sessions:
return {
"terminal_id": terminal_id,
"message": f"Terminal '{terminal_id}' not found",
"status": "not_found",
}
session = sessions.pop(terminal_id)
try:
session.close()
except (RuntimeError, OSError) as e:
return {
"terminal_id": terminal_id,
"error": f"Failed to close terminal '{terminal_id}': {e}",
"status": "error",
}
else:
return {
"terminal_id": terminal_id,
"message": f"Terminal '{terminal_id}' closed successfully",
"status": "closed",
}
def list_sessions(self) -> dict[str, Any]:
sessions = self._get_agent_sessions()
with self._lock:
session_info: dict[str, dict[str, Any]] = {}
for tid, session in sessions.items():
session_info[tid] = {
"is_running": session.is_running(),
"working_dir": session.get_working_dir(),
}
return {"sessions": session_info, "total_count": len(session_info)}
def cleanup_agent(self, agent_id: str) -> None:
with self._lock:
sessions = self._sessions_by_agent.pop(agent_id, {})
for session in sessions.values():
with contextlib.suppress(Exception):
session.close()
def cleanup_dead_sessions(self) -> None:
with self._lock:
for sessions in self._sessions_by_agent.values():
dead_sessions: list[str] = []
for tid, session in sessions.items():
if not session.is_running():
dead_sessions.append(tid)
for tid in dead_sessions:
session = sessions.pop(tid)
with contextlib.suppress(Exception):
session.close()
def close_all_sessions(self) -> None:
with self._lock:
all_sessions: list[TerminalSession] = []
for sessions in self._sessions_by_agent.values():
all_sessions.extend(sessions.values())
self._sessions_by_agent.clear()
for session in all_sessions:
with contextlib.suppress(Exception):
session.close()
def _register_cleanup_handlers(self) -> None:
atexit.register(self.close_all_sessions)
_terminal_manager = TerminalManager()
def get_terminal_manager() -> TerminalManager:
return _terminal_manager
-447
View File
@@ -1,447 +0,0 @@
import logging
import re
import time
import uuid
from enum import Enum
from pathlib import Path
from typing import Any
import libtmux
logger = logging.getLogger(__name__)
class BashCommandStatus(Enum):
CONTINUE = "continue"
COMPLETED = "completed"
NO_CHANGE_TIMEOUT = "no_change_timeout"
HARD_TIMEOUT = "hard_timeout"
def _remove_command_prefix(command_output: str, command: str) -> str:
return command_output.lstrip().removeprefix(command.lstrip()).lstrip()
class TerminalSession:
POLL_INTERVAL = 0.5
HISTORY_LIMIT = 10_000
PS1_END = "]$ "
def __init__(self, session_id: str, work_dir: str = "/workspace") -> None:
self.session_id = session_id
self.work_dir = str(Path(work_dir).resolve())
self._closed = False
self._cwd = self.work_dir
self.server: libtmux.Server | None = None
self.session: libtmux.Session | None = None
self.window: libtmux.Window | None = None
self.pane: libtmux.Pane | None = None
self.prev_status: BashCommandStatus | None = None
self.prev_output: str = ""
self._initialized = False
self.initialize()
@property
def PS1(self) -> str: # noqa: N802
return r"[STRIX_$?]$ "
@property
def PS1_PATTERN(self) -> str: # noqa: N802
return r"\[STRIX_(\d+)\]"
def initialize(self) -> None:
self.server = libtmux.Server()
session_name = f"strix-{self.session_id}-{uuid.uuid4()}"
self.session = self.server.new_session(
session_name=session_name,
start_directory=self.work_dir,
kill_session=True,
x=120,
y=30,
)
self.session.set_option("history-limit", str(self.HISTORY_LIMIT))
self.session.history_limit = self.HISTORY_LIMIT
_initial_window = self.session.active_window
self.window = self.session.new_window(
window_name="bash",
window_shell="/bin/bash",
start_directory=self.work_dir,
)
self.pane = self.window.active_pane
_initial_window.kill()
self.pane.send_keys(f'export PROMPT_COMMAND=\'export PS1="{self.PS1}"\'; export PS2=""')
time.sleep(0.1)
self._clear_screen()
self.prev_status = None
self.prev_output = ""
self._closed = False
self._cwd = str(Path(self.work_dir).resolve())
self._initialized = True
assert self.server is not None
assert self.session is not None
assert self.window is not None
assert self.pane is not None
def _get_pane_content(self) -> str:
if not self.pane:
raise RuntimeError("Terminal session not properly initialized")
return "\n".join(
line.rstrip() for line in self.pane.cmd("capture-pane", "-J", "-pS", "-").stdout
)
def _clear_screen(self) -> None:
if not self.pane:
raise RuntimeError("Terminal session not properly initialized")
self.pane.send_keys("C-l", enter=False)
time.sleep(0.1)
self.pane.cmd("clear-history")
def _is_control_key(self, command: str) -> bool:
return (
(command.startswith("C-") and len(command) >= 3)
or (command.startswith("^") and len(command) >= 2)
or (command.startswith("S-") and len(command) >= 3)
or (command.startswith("M-") and len(command) >= 3)
)
def _is_function_key(self, command: str) -> bool:
if not command.startswith("F") or len(command) > 3:
return False
try:
num_part = command[1:]
return num_part.isdigit() and 1 <= int(num_part) <= 12
except (ValueError, IndexError):
return False
def _is_navigation_or_special_key(self, command: str) -> bool:
navigation_keys = {"Up", "Down", "Left", "Right", "Home", "End"}
special_keys = {"BSpace", "BTab", "DC", "Enter", "Escape", "IC", "Space", "Tab"}
page_keys = {"NPage", "PageDown", "PgDn", "PPage", "PageUp", "PgUp"}
return command in navigation_keys or command in special_keys or command in page_keys
def _is_complex_modifier_key(self, command: str) -> bool:
return "-" in command and any(
command.startswith(prefix)
for prefix in ["C-S-", "C-M-", "S-M-", "M-S-", "M-C-", "S-C-"]
)
def _is_special_key(self, command: str) -> bool:
_command = command.strip()
if not _command:
return False
return (
self._is_control_key(_command)
or self._is_function_key(_command)
or self._is_navigation_or_special_key(_command)
or self._is_complex_modifier_key(_command)
)
def _matches_ps1_metadata(self, content: str) -> list[re.Match[str]]:
return list(re.finditer(self.PS1_PATTERN + r"\]\$ ", content))
def _get_command_output(
self,
command: str,
raw_command_output: str,
continue_prefix: str = "",
) -> str:
if self.prev_output:
command_output = raw_command_output.removeprefix(self.prev_output)
if continue_prefix:
command_output = continue_prefix + command_output
else:
command_output = raw_command_output
self.prev_output = raw_command_output
command_output = _remove_command_prefix(command_output, command)
return command_output.rstrip()
def _combine_outputs_between_matches(
self,
pane_content: str,
ps1_matches: list[re.Match[str]],
get_content_before_last_match: bool = False,
) -> str:
if len(ps1_matches) == 1:
if get_content_before_last_match:
return pane_content[: ps1_matches[0].start()]
return pane_content[ps1_matches[0].end() + 1 :]
if len(ps1_matches) == 0:
return pane_content
combined_output = ""
for i in range(len(ps1_matches) - 1):
output_segment = pane_content[ps1_matches[i].end() + 1 : ps1_matches[i + 1].start()]
combined_output += output_segment + "\n"
combined_output += pane_content[ps1_matches[-1].end() + 1 :]
return combined_output
def _extract_exit_code_from_matches(self, ps1_matches: list[re.Match[str]]) -> int | None:
if not ps1_matches:
return None
last_match = ps1_matches[-1]
try:
return int(last_match.group(1))
except (ValueError, IndexError):
return None
def _handle_empty_command(
self,
cur_pane_output: str,
ps1_matches: list[re.Match[str]],
is_command_running: bool,
timeout: float,
) -> dict[str, Any]:
if not is_command_running:
raw_command_output = self._combine_outputs_between_matches(cur_pane_output, ps1_matches)
command_output = self._get_command_output("", raw_command_output)
return {
"content": command_output,
"status": "completed",
"exit_code": 0,
"working_dir": self._cwd,
}
start_time = time.time()
last_pane_output = cur_pane_output
while True:
cur_pane_output = self._get_pane_content()
ps1_matches = self._matches_ps1_metadata(cur_pane_output)
if cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0:
exit_code = self._extract_exit_code_from_matches(ps1_matches)
raw_command_output = self._combine_outputs_between_matches(
cur_pane_output, ps1_matches
)
command_output = self._get_command_output("", raw_command_output)
self.prev_status = BashCommandStatus.COMPLETED
self.prev_output = ""
self._ready_for_next_command()
return {
"content": command_output,
"status": "completed",
"exit_code": exit_code or 0,
"working_dir": self._cwd,
}
elapsed_time = time.time() - start_time
if elapsed_time >= timeout:
raw_command_output = self._combine_outputs_between_matches(
cur_pane_output, ps1_matches
)
command_output = self._get_command_output("", raw_command_output)
return {
"content": command_output
+ f"\n[Command still running after {timeout}s - showing output so far]",
"status": "running",
"exit_code": None,
"working_dir": self._cwd,
}
if cur_pane_output != last_pane_output:
last_pane_output = cur_pane_output
time.sleep(self.POLL_INTERVAL)
def _handle_input_command(
self, command: str, no_enter: bool, is_command_running: bool
) -> dict[str, Any]:
if not is_command_running:
return {
"content": "No command is currently running. Cannot send input.",
"status": "error",
"exit_code": None,
"working_dir": self._cwd,
}
if not self.pane:
raise RuntimeError("Terminal session not properly initialized")
is_special_key = self._is_special_key(command)
should_add_enter = not is_special_key and not no_enter
self.pane.send_keys(command, enter=should_add_enter)
time.sleep(2)
cur_pane_output = self._get_pane_content()
ps1_matches = self._matches_ps1_metadata(cur_pane_output)
raw_command_output = self._combine_outputs_between_matches(cur_pane_output, ps1_matches)
command_output = self._get_command_output(command, raw_command_output)
is_still_running = not (
cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0
)
if is_still_running:
return {
"content": command_output,
"status": "running",
"exit_code": None,
"working_dir": self._cwd,
}
exit_code = self._extract_exit_code_from_matches(ps1_matches)
self.prev_status = BashCommandStatus.COMPLETED
self.prev_output = ""
self._ready_for_next_command()
return {
"content": command_output,
"status": "completed",
"exit_code": exit_code or 0,
"working_dir": self._cwd,
}
def _execute_new_command(self, command: str, no_enter: bool, timeout: float) -> dict[str, Any]:
if not self.pane:
raise RuntimeError("Terminal session not properly initialized")
initial_pane_output = self._get_pane_content()
initial_ps1_matches = self._matches_ps1_metadata(initial_pane_output)
initial_ps1_count = len(initial_ps1_matches)
start_time = time.time()
last_pane_output = initial_pane_output
is_special_key = self._is_special_key(command)
should_add_enter = not is_special_key and not no_enter
self.pane.send_keys(command, enter=should_add_enter)
while True:
cur_pane_output = self._get_pane_content()
ps1_matches = self._matches_ps1_metadata(cur_pane_output)
current_ps1_count = len(ps1_matches)
if cur_pane_output != last_pane_output:
last_pane_output = cur_pane_output
if current_ps1_count > initial_ps1_count or cur_pane_output.rstrip().endswith(
self.PS1_END.rstrip()
):
exit_code = self._extract_exit_code_from_matches(ps1_matches)
get_content_before_last_match = bool(len(ps1_matches) == 1)
raw_command_output = self._combine_outputs_between_matches(
cur_pane_output,
ps1_matches,
get_content_before_last_match=get_content_before_last_match,
)
command_output = self._get_command_output(command, raw_command_output)
self.prev_status = BashCommandStatus.COMPLETED
self.prev_output = ""
self._ready_for_next_command()
return {
"content": command_output,
"status": "completed",
"exit_code": exit_code or 0,
"working_dir": self._cwd,
}
elapsed_time = time.time() - start_time
if elapsed_time >= timeout:
raw_command_output = self._combine_outputs_between_matches(
cur_pane_output, ps1_matches
)
command_output = self._get_command_output(
command,
raw_command_output,
continue_prefix="[Below is the output of the previous command.]\n",
)
self.prev_status = BashCommandStatus.CONTINUE
timeout_msg = (
f"\n[Command still running after {timeout}s - showing output so far. "
"Use C-c to interrupt if needed.]"
)
return {
"content": command_output + timeout_msg,
"status": "running",
"exit_code": None,
"working_dir": self._cwd,
}
time.sleep(self.POLL_INTERVAL)
def execute(
self, command: str, is_input: bool = False, timeout: float = 10.0, no_enter: bool = False
) -> dict[str, Any]:
if not self._initialized:
raise RuntimeError("Bash session is not initialized")
cur_pane_output = self._get_pane_content()
ps1_matches = self._matches_ps1_metadata(cur_pane_output)
is_command_running = not (
cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0
)
if command.strip() == "":
return self._handle_empty_command(
cur_pane_output, ps1_matches, is_command_running, timeout
)
is_special_key = self._is_special_key(command)
if is_input:
return self._handle_input_command(command, no_enter, is_command_running)
if is_special_key and is_command_running:
return self._handle_input_command(command, no_enter, is_command_running)
if is_command_running:
return {
"content": (
"A command is already running. Use is_input=true to send input to it, "
"or interrupt it first (e.g., with C-c)."
),
"status": "error",
"exit_code": None,
"working_dir": self._cwd,
}
return self._execute_new_command(command, no_enter, timeout)
def _ready_for_next_command(self) -> None:
self._clear_screen()
def is_running(self) -> bool:
if self._closed or not self.session:
return False
try:
return self.session.id in [s.id for s in self.server.sessions] if self.server else False
except (AttributeError, OSError) as e:
logger.debug("Error checking if session is running: %s", e)
return False
def get_working_dir(self) -> str:
return self._cwd
def close(self) -> None:
if self._closed:
return
if self.session:
try:
self.session.kill()
except (AttributeError, OSError) as e:
logger.debug("Error closing terminal session: %s", e)
self._closed = True
self.server = None
self.session = None
self.window = None
self.pane = None
-100
View File
@@ -1,100 +0,0 @@
"""SDK function-tool wrapper for the legacy ``terminal_execute`` tool.
The terminal lives in the sandbox container each persistent tmux
session is keyed by ``terminal_id`` on the in-container manager. The
host-side wrapper is a thin pass-through.
"""
from __future__ import annotations
from agents import RunContextWrapper
from strix.tools._decorator import dump_tool_result, strix_tool
from strix.tools._sandbox_dispatch import post_to_sandbox
@strix_tool(timeout=180)
async def terminal_execute(
ctx: RunContextWrapper,
command: str,
is_input: bool = False,
timeout: float | None = None,
terminal_id: str | None = None,
no_enter: bool = False,
) -> str:
"""Run a shell command in the sandboxed Kali tmux session.
The session is **persistent** environment variables, current
directory, and running processes carry across calls keyed by
``terminal_id`` (default: ``"default"``). Use distinct ids to run
multiple concurrent sessions.
When to use this vs ``python_action``:
- Shell work: CLI tools (nmap, sqlmap, ffuf, nuclei), package
managers, file/system commands, services, process control. Use
``terminal_execute``.
- Python code, data processing, HTTP automation, iterative scripting:
use ``python_action`` instead it's more structured and easier to
debug. Don't run embedded Python via ``python -c`` or heredocs
here.
Avoid long pipelines and complex bash one-liners; prefer multiple
simple calls for clarity and debugging. For multi-step shell work,
separate tool calls beat ``&& ; |``-chained commands.
Long-running commands:
- Commands are **never** killed automatically they keep running
after the timeout fires.
- ``timeout`` (max 60s, capped) only controls how long to wait for
output before returning. On timeout the call returns
``status="running"``; on completion ``status="completed"``.
- For daemons / very long jobs, append ``&`` to background.
- Use an **empty command** to poll for new output from a running
process (the call waits ``timeout`` seconds collecting output).
- Use ``C-c`` / ``C-d`` / ``C-z`` to interrupt special keys work
automatically without setting ``is_input``.
Interactive processes:
- ``is_input=True`` sends the command as input to a running foreground
process (REPL prompts, ``apt install`` y/n, etc.).
- ``no_enter=True`` sends keystrokes without a trailing newline
useful for vim navigation (``gg``, ``5j``, ``i``), passwords, or
multi-step keybindings.
Special key support (tmux key names): ``C-c``, ``C-d``, ``Up``,
``Down``, ``F1``-``F12``, ``Enter``, ``Escape``, ``Tab``, ``Space``,
``BSpace``, ``M-f`` (alt), ``S-Tab`` (shift), and combinations like
``C-S-key``. Note: ``BSpace`` not ``Backspace``, ``Escape`` not
``Esc``.
Working directory is tracked across calls and returned in the
response. Large outputs are auto-truncated.
Args:
command: Shell command, special key (``C-c``), or empty string
to poll a running process.
is_input: Treat ``command`` as input to a running foreground
process. Special keys auto-detect; you only need this for
regular text input.
timeout: Seconds to wait before returning partial output. Capped
at 60s. Defaults to 30s.
terminal_id: Persistent session selector. Use distinct ids for
concurrent sessions.
no_enter: When True, sends keystrokes without a trailing return.
"""
return dump_tool_result(
await post_to_sandbox(
ctx,
"terminal_execute",
{
"command": command,
"is_input": is_input,
"timeout": timeout,
"terminal_id": terminal_id,
"no_enter": no_enter,
},
),
)