From 044e4e82ae447e0ab1afec3b3281654bb28e71f4 Mon Sep 17 00:00:00 2001 From: 0xallam Date: Sat, 25 Apr 2026 00:26:30 -0700 Subject: [PATCH] =?UTF-8?q?feat(migration):=20phase=202.5=20=E2=80=94=20wr?= =?UTF-8?q?ap=20sandbox-bound=20SDK=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pyproject.toml | 4 + strix/tools/_decorator.py | 14 + strix/tools/browser/browser_sdk_tool.py | 102 +++++++ strix/tools/proxy/proxy_sdk_tools.py | 223 ++++++++++++++ strix/tools/python/python_sdk_tool.py | 56 ++++ strix/tools/terminal/terminal_sdk_tool.py | 57 ++++ tests/tools/test_sdk_sandbox_tools.py | 348 ++++++++++++++++++++++ 7 files changed, 804 insertions(+) create mode 100644 strix/tools/browser/browser_sdk_tool.py create mode 100644 strix/tools/proxy/proxy_sdk_tools.py create mode 100644 strix/tools/python/python_sdk_tool.py create mode 100644 strix/tools/terminal/terminal_sdk_tool.py create mode 100644 tests/tools/test_sdk_sandbox_tools.py diff --git a/pyproject.toml b/pyproject.toml index 46eabde..cc961a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -294,6 +294,10 @@ ignore = [ "strix/tools/reporting/reporting_sdk_tools.py" = ["TC002"] "strix/tools/load_skill/load_skill_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, # size cap, decode fail, etc). Each is a distinct, documented failure mode # the model needs to see verbatim — collapsing them harms readability. diff --git a/strix/tools/_decorator.py b/strix/tools/_decorator.py index 92accf2..c52a7ae 100644 --- a/strix/tools/_decorator.py +++ b/strix/tools/_decorator.py @@ -40,6 +40,7 @@ def strix_tool( timeout_behavior: _ToolBehavior = "error_as_result", name_override: str | None = None, description_override: str | None = None, + strict_mode: bool = True, ) -> Callable[[_ToolFn], FunctionTool]: """Wrap ``agents.function_tool`` with Strix defaults. @@ -48,6 +49,13 @@ def strix_tool( ``async def``; sync libraries (libtmux, IPython) get wrapped in ``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:: @strix_tool() @@ -55,10 +63,16 @@ def strix_tool( @strix_tool(timeout=300, timeout_behavior="raise_exception") 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( timeout=timeout, timeout_behavior=timeout_behavior, name_override=name_override, description_override=description_override, + strict_mode=strict_mode, ) diff --git a/strix/tools/browser/browser_sdk_tool.py b/strix/tools/browser/browser_sdk_tool.py new file mode 100644 index 0000000..56abac6 --- /dev/null +++ b/strix/tools/browser/browser_sdk_tool.py @@ -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, + }, + ), + ) diff --git a/strix/tools/proxy/proxy_sdk_tools.py b/strix/tools/proxy/proxy_sdk_tools.py new file mode 100644 index 0000000..ac6c04c --- /dev/null +++ b/strix/tools/proxy/proxy_sdk_tools.py @@ -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}), + ) diff --git a/strix/tools/python/python_sdk_tool.py b/strix/tools/python/python_sdk_tool.py new file mode 100644 index 0000000..4d494f0 --- /dev/null +++ b/strix/tools/python/python_sdk_tool.py @@ -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, + }, + ), + ) diff --git a/strix/tools/terminal/terminal_sdk_tool.py b/strix/tools/terminal/terminal_sdk_tool.py new file mode 100644 index 0000000..8940905 --- /dev/null +++ b/strix/tools/terminal/terminal_sdk_tool.py @@ -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, + }, + ), + ) diff --git a/tests/tools/test_sdk_sandbox_tools.py b/tests/tools/test_sdk_sandbox_tools.py new file mode 100644 index 0000000..9c9c1d7 --- /dev/null +++ b/tests/tools/test_sdk_sandbox_tools.py @@ -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"}