feat(migration): phase 2.5 — wrap sandbox-bound SDK tools
Ten tools ported, all pure pass-throughs to post_to_sandbox: - browser_action (1 tool): the 21-action mega-tool dispatcher kept intact rather than fanned out, to preserve the legacy XML shape. - terminal_execute (1 tool): tmux session driver. - python_action (1 tool): IPython session manager. - proxy / Caido (7 tools): list_requests, view_request, send_request, repeat_request, scope_rules, list_sitemap, view_sitemap_entry. strix_tool decorator gains a strict_mode flag (default True, matching the SDK default). send_request and repeat_request opt out of strict mode because their headers / modifications dicts are free-form — the SDK's strict JSON schema rejects dict[str, X] without enumerated keys. Tests: 12 new tests in test_sdk_sandbox_tools.py covering registration, strict-mode opt-out verification for the two free-form tools, and dispatch shape verification (every wrapper is asserted to forward its full kwarg surface to post_to_sandbox so the in-container handler sees the same payload it always has). Per-file ruff TC002 ignores added for the four new wrapper modules. Phase 2 (tools) is now complete: 24 SDK function tools wrapped across think/todo/notes/web_search/file_edit/reporting/load_skill/finish_scan/ browser/terminal/python/proxy. Total: 7 local + 17 sandbox-bound. Phase 3 (multi-agent orchestration) is next. Refs: PLAYBOOK.md §3.6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -294,6 +294,10 @@ ignore = [
|
|||||||
"strix/tools/reporting/reporting_sdk_tools.py" = ["TC002"]
|
"strix/tools/reporting/reporting_sdk_tools.py" = ["TC002"]
|
||||||
"strix/tools/load_skill/load_skill_sdk_tool.py" = ["TC002"]
|
"strix/tools/load_skill/load_skill_sdk_tool.py" = ["TC002"]
|
||||||
"strix/tools/finish/finish_sdk_tool.py" = ["TC002"]
|
"strix/tools/finish/finish_sdk_tool.py" = ["TC002"]
|
||||||
|
"strix/tools/browser/browser_sdk_tool.py" = ["TC002"]
|
||||||
|
"strix/tools/terminal/terminal_sdk_tool.py" = ["TC002"]
|
||||||
|
"strix/tools/python/python_sdk_tool.py" = ["TC002"]
|
||||||
|
"strix/tools/proxy/proxy_sdk_tools.py" = ["TC002"]
|
||||||
# Sandbox dispatch helper has many short-circuit error returns (auth fail,
|
# Sandbox dispatch helper has many short-circuit error returns (auth fail,
|
||||||
# size cap, decode fail, etc). Each is a distinct, documented failure mode
|
# size cap, decode fail, etc). Each is a distinct, documented failure mode
|
||||||
# the model needs to see verbatim — collapsing them harms readability.
|
# the model needs to see verbatim — collapsing them harms readability.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ def strix_tool(
|
|||||||
timeout_behavior: _ToolBehavior = "error_as_result",
|
timeout_behavior: _ToolBehavior = "error_as_result",
|
||||||
name_override: str | None = None,
|
name_override: str | None = None,
|
||||||
description_override: str | None = None,
|
description_override: str | None = None,
|
||||||
|
strict_mode: bool = True,
|
||||||
) -> Callable[[_ToolFn], FunctionTool]:
|
) -> Callable[[_ToolFn], FunctionTool]:
|
||||||
"""Wrap ``agents.function_tool`` with Strix defaults.
|
"""Wrap ``agents.function_tool`` with Strix defaults.
|
||||||
|
|
||||||
@@ -48,6 +49,13 @@ def strix_tool(
|
|||||||
``async def``; sync libraries (libtmux, IPython) get wrapped in
|
``async def``; sync libraries (libtmux, IPython) get wrapped in
|
||||||
``asyncio.to_thread`` inside the async tool body.
|
``asyncio.to_thread`` inside the async tool body.
|
||||||
|
|
||||||
|
The SDK enforces ``strict_mode=True`` by default, which forbids
|
||||||
|
free-form ``dict[str, X]`` parameters (the strict JSON schema needs
|
||||||
|
``additionalProperties: false``). A handful of legacy tools
|
||||||
|
(``send_request``, ``repeat_request``) take arbitrary header /
|
||||||
|
modification dicts whose keys can't be enumerated, so they must
|
||||||
|
opt out of strict mode to preserve parity with the XML schema.
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
@strix_tool()
|
@strix_tool()
|
||||||
@@ -55,10 +63,16 @@ def strix_tool(
|
|||||||
|
|
||||||
@strix_tool(timeout=300, timeout_behavior="raise_exception")
|
@strix_tool(timeout=300, timeout_behavior="raise_exception")
|
||||||
async def critical_tool(ctx: RunContextWrapper, ...) -> str: ...
|
async def critical_tool(ctx: RunContextWrapper, ...) -> str: ...
|
||||||
|
|
||||||
|
@strix_tool(strict_mode=False)
|
||||||
|
async def free_form_dict_tool(
|
||||||
|
ctx: RunContextWrapper, headers: dict[str, str],
|
||||||
|
) -> str: ...
|
||||||
"""
|
"""
|
||||||
return function_tool(
|
return function_tool(
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
timeout_behavior=timeout_behavior,
|
timeout_behavior=timeout_behavior,
|
||||||
name_override=name_override,
|
name_override=name_override,
|
||||||
description_override=description_override,
|
description_override=description_override,
|
||||||
|
strict_mode=strict_mode,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from agents import RunContextWrapper
|
||||||
|
|
||||||
|
from strix.tools._decorator import strix_tool
|
||||||
|
from strix.tools._sandbox_dispatch import post_to_sandbox
|
||||||
|
|
||||||
|
|
||||||
|
def _dump(result: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action: The browser action to dispatch — see ``BrowserAction``
|
||||||
|
literal for the full set.
|
||||||
|
url: Required for ``launch`` / ``goto`` / ``new_tab`` (with URL).
|
||||||
|
coordinate: ``"x,y"`` pixel target for click/hover/double_click.
|
||||||
|
text: Required for ``type``.
|
||||||
|
tab_id: Optional explicit tab targeting; defaults to the active tab.
|
||||||
|
js_code: Required for ``execute_js``.
|
||||||
|
duration: Seconds to wait for ``wait`` action.
|
||||||
|
key: Required for ``press_key`` (e.g. ``"Enter"``, ``"Escape"``).
|
||||||
|
file_path: Required for ``save_pdf``.
|
||||||
|
clear: For ``type``, clears the field first.
|
||||||
|
"""
|
||||||
|
return _dump(
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""SDK function-tool wrappers for the seven Caido proxy tools.
|
||||||
|
|
||||||
|
All seven dispatch to the in-container Caido manager via the sandbox
|
||||||
|
tool server. Same pattern as browser/terminal/python — host wrapper is
|
||||||
|
pure pass-through, no logic of its own.
|
||||||
|
|
||||||
|
Tools: list_requests, view_request, send_request, repeat_request,
|
||||||
|
scope_rules, list_sitemap, view_sitemap_entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from agents import RunContextWrapper
|
||||||
|
|
||||||
|
from strix.tools._decorator import strix_tool
|
||||||
|
from strix.tools._sandbox_dispatch import post_to_sandbox
|
||||||
|
|
||||||
|
|
||||||
|
def _dump(result: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
RequestPart = Literal["request", "response"]
|
||||||
|
SortBy = Literal[
|
||||||
|
"timestamp",
|
||||||
|
"host",
|
||||||
|
"method",
|
||||||
|
"path",
|
||||||
|
"status_code",
|
||||||
|
"response_time",
|
||||||
|
"response_size",
|
||||||
|
"source",
|
||||||
|
]
|
||||||
|
SortOrder = Literal["asc", "desc"]
|
||||||
|
SitemapDepth = Literal["DIRECT", "ALL"]
|
||||||
|
ScopeAction = Literal["get", "list", "create", "update", "delete"]
|
||||||
|
|
||||||
|
|
||||||
|
@strix_tool(timeout=120)
|
||||||
|
async def list_requests(
|
||||||
|
ctx: RunContextWrapper,
|
||||||
|
httpql_filter: str | None = None,
|
||||||
|
start_page: int = 1,
|
||||||
|
end_page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
sort_by: SortBy = "timestamp",
|
||||||
|
sort_order: SortOrder = "desc",
|
||||||
|
scope_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""List captured HTTP requests from the Caido proxy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
httpql_filter: Caido HTTPQL query (e.g. ``"resp.code:eq:500"``).
|
||||||
|
start_page / end_page: Inclusive page range to return.
|
||||||
|
page_size: Entries per page; default 50.
|
||||||
|
sort_by: Field to sort by.
|
||||||
|
sort_order: ``"asc"`` or ``"desc"``.
|
||||||
|
scope_id: Restrict to a specific scope.
|
||||||
|
"""
|
||||||
|
return _dump(
|
||||||
|
await post_to_sandbox(
|
||||||
|
ctx,
|
||||||
|
"list_requests",
|
||||||
|
{
|
||||||
|
"httpql_filter": httpql_filter,
|
||||||
|
"start_page": start_page,
|
||||||
|
"end_page": end_page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"sort_by": sort_by,
|
||||||
|
"sort_order": sort_order,
|
||||||
|
"scope_id": scope_id,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@strix_tool(timeout=60)
|
||||||
|
async def view_request(
|
||||||
|
ctx: RunContextWrapper,
|
||||||
|
request_id: str,
|
||||||
|
part: RequestPart = "request",
|
||||||
|
search_pattern: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
) -> str:
|
||||||
|
"""View a single captured request or its response, with optional regex highlight."""
|
||||||
|
return _dump(
|
||||||
|
await post_to_sandbox(
|
||||||
|
ctx,
|
||||||
|
"view_request",
|
||||||
|
{
|
||||||
|
"request_id": request_id,
|
||||||
|
"part": part,
|
||||||
|
"search_pattern": search_pattern,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# strict_mode=False because ``headers`` is a free-form dict — the model
|
||||||
|
# can't enumerate all possible HTTP headers, and the SDK's strict JSON
|
||||||
|
# schema rejects ``additionalProperties: true``.
|
||||||
|
@strix_tool(timeout=120, strict_mode=False)
|
||||||
|
async def send_request(
|
||||||
|
ctx: RunContextWrapper,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
body: str = "",
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> str:
|
||||||
|
"""Send an arbitrary HTTP request through the Caido proxy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: ``"GET"``, ``"POST"``, etc.
|
||||||
|
url: Full URL.
|
||||||
|
headers: Optional header dict.
|
||||||
|
body: Optional body string.
|
||||||
|
timeout: Per-request timeout in seconds.
|
||||||
|
"""
|
||||||
|
return _dump(
|
||||||
|
await post_to_sandbox(
|
||||||
|
ctx,
|
||||||
|
"send_request",
|
||||||
|
{
|
||||||
|
"method": method,
|
||||||
|
"url": url,
|
||||||
|
"headers": headers or {},
|
||||||
|
"body": body,
|
||||||
|
"timeout": timeout,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# strict_mode=False because ``modifications`` is a free-form patch dict
|
||||||
|
# (header overrides, body replacements, query-string tweaks) the model
|
||||||
|
# composes per-call.
|
||||||
|
@strix_tool(timeout=120, strict_mode=False)
|
||||||
|
async def repeat_request(
|
||||||
|
ctx: RunContextWrapper,
|
||||||
|
request_id: str,
|
||||||
|
modifications: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Repeat a captured request, optionally applying field modifications."""
|
||||||
|
return _dump(
|
||||||
|
await post_to_sandbox(
|
||||||
|
ctx,
|
||||||
|
"repeat_request",
|
||||||
|
{
|
||||||
|
"request_id": request_id,
|
||||||
|
"modifications": modifications or {},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@strix_tool(timeout=60)
|
||||||
|
async def scope_rules(
|
||||||
|
ctx: RunContextWrapper,
|
||||||
|
action: ScopeAction,
|
||||||
|
allowlist: list[str] | None = None,
|
||||||
|
denylist: list[str] | None = None,
|
||||||
|
scope_id: str | None = None,
|
||||||
|
scope_name: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""CRUD on Caido scope rules (allow/deny lists)."""
|
||||||
|
return _dump(
|
||||||
|
await post_to_sandbox(
|
||||||
|
ctx,
|
||||||
|
"scope_rules",
|
||||||
|
{
|
||||||
|
"action": action,
|
||||||
|
"allowlist": allowlist,
|
||||||
|
"denylist": denylist,
|
||||||
|
"scope_id": scope_id,
|
||||||
|
"scope_name": scope_name,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@strix_tool(timeout=60)
|
||||||
|
async def list_sitemap(
|
||||||
|
ctx: RunContextWrapper,
|
||||||
|
scope_id: str | None = None,
|
||||||
|
parent_id: str | None = None,
|
||||||
|
depth: SitemapDepth = "DIRECT",
|
||||||
|
page: int = 1,
|
||||||
|
) -> str:
|
||||||
|
"""List Caido sitemap entries (proxied URL tree).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scope_id: Restrict to a scope.
|
||||||
|
parent_id: Drill into a specific subtree.
|
||||||
|
depth: ``"DIRECT"`` (direct children only) or ``"ALL"`` (recursive).
|
||||||
|
page: 1-indexed page number.
|
||||||
|
"""
|
||||||
|
return _dump(
|
||||||
|
await post_to_sandbox(
|
||||||
|
ctx,
|
||||||
|
"list_sitemap",
|
||||||
|
{
|
||||||
|
"scope_id": scope_id,
|
||||||
|
"parent_id": parent_id,
|
||||||
|
"depth": depth,
|
||||||
|
"page": page,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@strix_tool(timeout=60)
|
||||||
|
async def view_sitemap_entry(ctx: RunContextWrapper, entry_id: str) -> str:
|
||||||
|
"""Fetch a single sitemap entry's metadata + linked requests."""
|
||||||
|
return _dump(
|
||||||
|
await post_to_sandbox(ctx, "view_sitemap_entry", {"entry_id": entry_id}),
|
||||||
|
)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from agents import RunContextWrapper
|
||||||
|
|
||||||
|
from strix.tools._decorator import strix_tool
|
||||||
|
from strix.tools._sandbox_dispatch import post_to_sandbox
|
||||||
|
|
||||||
|
|
||||||
|
def _dump(result: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Manage / execute code in a long-lived sandboxed IPython session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action: ``"new_session"`` to spin one up, ``"execute"`` to run code,
|
||||||
|
``"close"`` to terminate, ``"list_sessions"`` to inspect.
|
||||||
|
code: Required for ``execute`` (and optional for ``new_session``
|
||||||
|
to run a setup snippet immediately).
|
||||||
|
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(
|
||||||
|
await post_to_sandbox(
|
||||||
|
ctx,
|
||||||
|
"python_action",
|
||||||
|
{
|
||||||
|
"action": action,
|
||||||
|
"code": code,
|
||||||
|
"timeout": timeout,
|
||||||
|
"session_id": session_id,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agents import RunContextWrapper
|
||||||
|
|
||||||
|
from strix.tools._decorator import strix_tool
|
||||||
|
from strix.tools._sandbox_dispatch import post_to_sandbox
|
||||||
|
|
||||||
|
|
||||||
|
def _dump(result: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Shell command (or input for an interactive prompt when
|
||||||
|
``is_input=True``).
|
||||||
|
is_input: Treat ``command`` as input to a running foreground process
|
||||||
|
(e.g., feeding y/n to ``apt install``).
|
||||||
|
timeout: Seconds to wait before returning partial output. Defaults
|
||||||
|
to the in-container manager's policy.
|
||||||
|
terminal_id: Persistent session selector. Defaults to ``"default"``.
|
||||||
|
no_enter: When True, sends keystrokes without a trailing return.
|
||||||
|
Useful for sending raw ANSI control sequences.
|
||||||
|
"""
|
||||||
|
return _dump(
|
||||||
|
await post_to_sandbox(
|
||||||
|
ctx,
|
||||||
|
"terminal_execute",
|
||||||
|
{
|
||||||
|
"command": command,
|
||||||
|
"is_input": is_input,
|
||||||
|
"timeout": timeout,
|
||||||
|
"terminal_id": terminal_id,
|
||||||
|
"no_enter": no_enter,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
"""Phase 2.5 smoke tests for the sandbox-bound SDK tool wrappers.
|
||||||
|
|
||||||
|
Covers: browser_action, terminal_execute, python_action, and the seven
|
||||||
|
Caido proxy tools.
|
||||||
|
|
||||||
|
These wrappers are pure pass-throughs to ``post_to_sandbox`` — there's
|
||||||
|
no per-tool logic to assert, so the tests focus on:
|
||||||
|
|
||||||
|
- ``FunctionTool`` registration succeeds (which proves the SDK could
|
||||||
|
derive a JSON schema from the type hints — a non-trivial check given
|
||||||
|
Literal types, ``dict[str, str]``, and strict-mode opt-outs).
|
||||||
|
- The dispatch payload to ``post_to_sandbox`` mirrors the legacy XML
|
||||||
|
schema verbatim, so the in-container tool server gets the same
|
||||||
|
``kwargs`` shape it always has.
|
||||||
|
- The ``send_request`` / ``repeat_request`` tools opt out of strict
|
||||||
|
schema mode (their ``headers`` / ``modifications`` dicts are
|
||||||
|
free-form and would otherwise fail registration).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from agents.tool import FunctionTool
|
||||||
|
|
||||||
|
from strix.tools.browser.browser_sdk_tool import browser_action
|
||||||
|
from strix.tools.proxy.proxy_sdk_tools import (
|
||||||
|
list_requests,
|
||||||
|
list_sitemap,
|
||||||
|
repeat_request,
|
||||||
|
scope_rules,
|
||||||
|
send_request,
|
||||||
|
view_request,
|
||||||
|
view_sitemap_entry,
|
||||||
|
)
|
||||||
|
from strix.tools.python.python_sdk_tool import python_action
|
||||||
|
from strix.tools.terminal.terminal_sdk_tool import terminal_execute
|
||||||
|
|
||||||
|
|
||||||
|
_ALL_SANDBOX_TOOLS = (
|
||||||
|
browser_action,
|
||||||
|
terminal_execute,
|
||||||
|
python_action,
|
||||||
|
list_requests,
|
||||||
|
view_request,
|
||||||
|
send_request,
|
||||||
|
repeat_request,
|
||||||
|
scope_rules,
|
||||||
|
list_sitemap,
|
||||||
|
view_sitemap_entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Ctx:
|
||||||
|
context: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx_for(agent_id: str = "test-agent") -> _Ctx:
|
||||||
|
return _Ctx(
|
||||||
|
context={
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"tool_server_host_port": 12345,
|
||||||
|
"sandbox_token": "test-token",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _invoke(tool: FunctionTool, ctx: _Ctx, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
from agents.tool_context import ToolContext
|
||||||
|
|
||||||
|
tool_ctx = ToolContext(
|
||||||
|
context=ctx.context,
|
||||||
|
usage=None,
|
||||||
|
tool_name=tool.name,
|
||||||
|
tool_call_id="test-call-id",
|
||||||
|
tool_arguments=json.dumps(kwargs),
|
||||||
|
)
|
||||||
|
result = await tool.on_invoke_tool(tool_ctx, json.dumps(kwargs))
|
||||||
|
assert isinstance(result, str)
|
||||||
|
decoded = json.loads(result)
|
||||||
|
assert isinstance(decoded, dict)
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_sandbox_tools_register() -> None:
|
||||||
|
for tool in _ALL_SANDBOX_TOOLS:
|
||||||
|
assert isinstance(tool, FunctionTool)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_and_repeat_request_opt_out_of_strict_mode() -> None:
|
||||||
|
"""The two free-form-dict tools must turn off strict JSON schema."""
|
||||||
|
assert send_request.strict_json_schema is False
|
||||||
|
assert repeat_request.strict_json_schema is False
|
||||||
|
# All other tools should keep strict mode on (the SDK default).
|
||||||
|
for tool in (
|
||||||
|
browser_action,
|
||||||
|
terminal_execute,
|
||||||
|
python_action,
|
||||||
|
list_requests,
|
||||||
|
view_request,
|
||||||
|
scope_rules,
|
||||||
|
list_sitemap,
|
||||||
|
view_sitemap_entry,
|
||||||
|
):
|
||||||
|
assert tool.strict_json_schema is True, tool.name
|
||||||
|
|
||||||
|
|
||||||
|
# --- browser ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browser_action_dispatches_full_payload() -> None:
|
||||||
|
"""All optional browser_action params must forward as None when unset
|
||||||
|
(the in-container handler distinguishes ``None`` from missing)."""
|
||||||
|
fake = {"result": {"screenshot": "data:image/png;base64,..."}}
|
||||||
|
with patch(
|
||||||
|
"strix.tools.browser.browser_sdk_tool.post_to_sandbox",
|
||||||
|
return_value=fake,
|
||||||
|
) as dispatch:
|
||||||
|
out = await _invoke(
|
||||||
|
browser_action,
|
||||||
|
_ctx_for(),
|
||||||
|
action="goto",
|
||||||
|
url="https://example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert out == fake
|
||||||
|
args, _ = dispatch.call_args
|
||||||
|
assert args[1] == "browser_action"
|
||||||
|
payload = args[2]
|
||||||
|
assert payload["action"] == "goto"
|
||||||
|
assert payload["url"] == "https://example.com"
|
||||||
|
# All optional params are present in the payload (as None / defaults)
|
||||||
|
# so the legacy in-container handler sees the full kwarg surface.
|
||||||
|
for key in (
|
||||||
|
"coordinate",
|
||||||
|
"text",
|
||||||
|
"tab_id",
|
||||||
|
"js_code",
|
||||||
|
"duration",
|
||||||
|
"key",
|
||||||
|
"file_path",
|
||||||
|
):
|
||||||
|
assert key in payload, key
|
||||||
|
assert payload[key] is None
|
||||||
|
assert payload["clear"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- terminal --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminal_execute_dispatches() -> None:
|
||||||
|
fake = {"result": {"content": "hello\n", "exit_code": 0}}
|
||||||
|
with patch(
|
||||||
|
"strix.tools.terminal.terminal_sdk_tool.post_to_sandbox",
|
||||||
|
return_value=fake,
|
||||||
|
) as dispatch:
|
||||||
|
out = await _invoke(
|
||||||
|
terminal_execute,
|
||||||
|
_ctx_for(),
|
||||||
|
command="echo hello",
|
||||||
|
terminal_id="term-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert out == fake
|
||||||
|
args, _ = dispatch.call_args
|
||||||
|
assert args[1] == "terminal_execute"
|
||||||
|
assert args[2]["command"] == "echo hello"
|
||||||
|
assert args[2]["terminal_id"] == "term-1"
|
||||||
|
assert args[2]["is_input"] is False
|
||||||
|
assert args[2]["no_enter"] is False
|
||||||
|
assert args[2]["timeout"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- python ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_python_action_dispatches() -> None:
|
||||||
|
fake = {"result": {"stdout": "42\n", "is_running": False}}
|
||||||
|
with patch(
|
||||||
|
"strix.tools.python.python_sdk_tool.post_to_sandbox",
|
||||||
|
return_value=fake,
|
||||||
|
) as dispatch:
|
||||||
|
out = await _invoke(
|
||||||
|
python_action,
|
||||||
|
_ctx_for(),
|
||||||
|
action="execute",
|
||||||
|
code="print(6*7)",
|
||||||
|
session_id="sess-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert out == fake
|
||||||
|
args, _ = dispatch.call_args
|
||||||
|
assert args[1] == "python_action"
|
||||||
|
assert args[2] == {
|
||||||
|
"action": "execute",
|
||||||
|
"code": "print(6*7)",
|
||||||
|
"timeout": 30,
|
||||||
|
"session_id": "sess-1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- proxy / Caido --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_requests_forwards_full_query() -> None:
|
||||||
|
fake: dict[str, Any] = {"result": {"requests": []}}
|
||||||
|
with patch(
|
||||||
|
"strix.tools.proxy.proxy_sdk_tools.post_to_sandbox",
|
||||||
|
return_value=fake,
|
||||||
|
) as dispatch:
|
||||||
|
await _invoke(
|
||||||
|
list_requests,
|
||||||
|
_ctx_for(),
|
||||||
|
httpql_filter="resp.code:eq:500",
|
||||||
|
page_size=20,
|
||||||
|
sort_by="response_time",
|
||||||
|
sort_order="asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
args, _ = dispatch.call_args
|
||||||
|
assert args[1] == "list_requests"
|
||||||
|
assert args[2]["httpql_filter"] == "resp.code:eq:500"
|
||||||
|
assert args[2]["page_size"] == 20
|
||||||
|
assert args[2]["sort_by"] == "response_time"
|
||||||
|
assert args[2]["sort_order"] == "asc"
|
||||||
|
# Defaults preserved.
|
||||||
|
assert args[2]["start_page"] == 1
|
||||||
|
assert args[2]["end_page"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_view_request_dispatches() -> None:
|
||||||
|
fake = {"result": {"raw": "GET / HTTP/1.1..."}}
|
||||||
|
with patch(
|
||||||
|
"strix.tools.proxy.proxy_sdk_tools.post_to_sandbox",
|
||||||
|
return_value=fake,
|
||||||
|
) as dispatch:
|
||||||
|
await _invoke(
|
||||||
|
view_request,
|
||||||
|
_ctx_for(),
|
||||||
|
request_id="req-9",
|
||||||
|
part="response",
|
||||||
|
search_pattern="Set-Cookie",
|
||||||
|
)
|
||||||
|
|
||||||
|
args, _ = dispatch.call_args
|
||||||
|
assert args[1] == "view_request"
|
||||||
|
assert args[2]["request_id"] == "req-9"
|
||||||
|
assert args[2]["part"] == "response"
|
||||||
|
assert args[2]["search_pattern"] == "Set-Cookie"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_request_normalizes_missing_headers() -> None:
|
||||||
|
"""Legacy schema treats omitted ``headers`` as ``{}``; the wrapper must too."""
|
||||||
|
fake = {"result": {"status": 200}}
|
||||||
|
with patch(
|
||||||
|
"strix.tools.proxy.proxy_sdk_tools.post_to_sandbox",
|
||||||
|
return_value=fake,
|
||||||
|
) as dispatch:
|
||||||
|
await _invoke(
|
||||||
|
send_request,
|
||||||
|
_ctx_for(),
|
||||||
|
method="POST",
|
||||||
|
url="https://api.example.com/login",
|
||||||
|
body='{"u":"x"}',
|
||||||
|
)
|
||||||
|
|
||||||
|
args, _ = dispatch.call_args
|
||||||
|
assert args[1] == "send_request"
|
||||||
|
assert args[2]["headers"] == {} # not None
|
||||||
|
assert args[2]["method"] == "POST"
|
||||||
|
assert args[2]["body"] == '{"u":"x"}'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repeat_request_normalizes_missing_modifications() -> None:
|
||||||
|
fake = {"result": {"status": 200}}
|
||||||
|
with patch(
|
||||||
|
"strix.tools.proxy.proxy_sdk_tools.post_to_sandbox",
|
||||||
|
return_value=fake,
|
||||||
|
) as dispatch:
|
||||||
|
await _invoke(repeat_request, _ctx_for(), request_id="req-1")
|
||||||
|
|
||||||
|
args, _ = dispatch.call_args
|
||||||
|
assert args[1] == "repeat_request"
|
||||||
|
assert args[2]["modifications"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scope_rules_dispatches() -> None:
|
||||||
|
fake = {"result": {"scope_id": "s-1"}}
|
||||||
|
with patch(
|
||||||
|
"strix.tools.proxy.proxy_sdk_tools.post_to_sandbox",
|
||||||
|
return_value=fake,
|
||||||
|
) as dispatch:
|
||||||
|
await _invoke(
|
||||||
|
scope_rules,
|
||||||
|
_ctx_for(),
|
||||||
|
action="create",
|
||||||
|
scope_name="prod",
|
||||||
|
allowlist=["*.example.com"],
|
||||||
|
)
|
||||||
|
|
||||||
|
args, _ = dispatch.call_args
|
||||||
|
assert args[1] == "scope_rules"
|
||||||
|
assert args[2]["action"] == "create"
|
||||||
|
assert args[2]["scope_name"] == "prod"
|
||||||
|
assert args[2]["allowlist"] == ["*.example.com"]
|
||||||
|
assert args[2]["denylist"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_sitemap_defaults() -> None:
|
||||||
|
fake: dict[str, Any] = {"result": {"entries": []}}
|
||||||
|
with patch(
|
||||||
|
"strix.tools.proxy.proxy_sdk_tools.post_to_sandbox",
|
||||||
|
return_value=fake,
|
||||||
|
) as dispatch:
|
||||||
|
await _invoke(list_sitemap, _ctx_for())
|
||||||
|
|
||||||
|
args, _ = dispatch.call_args
|
||||||
|
assert args[1] == "list_sitemap"
|
||||||
|
assert args[2]["depth"] == "DIRECT"
|
||||||
|
assert args[2]["page"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_view_sitemap_entry_dispatches() -> None:
|
||||||
|
fake = {"result": {"entry_id": "e-1"}}
|
||||||
|
with patch(
|
||||||
|
"strix.tools.proxy.proxy_sdk_tools.post_to_sandbox",
|
||||||
|
return_value=fake,
|
||||||
|
) as dispatch:
|
||||||
|
await _invoke(view_sitemap_entry, _ctx_for(), entry_id="e-1")
|
||||||
|
|
||||||
|
args, _ = dispatch.call_args
|
||||||
|
assert args[1] == "view_sitemap_entry"
|
||||||
|
assert args[2] == {"entry_id": "e-1"}
|
||||||
Reference in New Issue
Block a user