6e5d96af34
Phase 2.1 — sandbox dispatch helper:
- strix/tools/_sandbox_dispatch.py: post_to_sandbox() centralizes the
host->container HTTP wire format. Connect=10s, read=150s timeouts mirror
legacy executor.py. 50 MB response cap (C18) prevents OOM from a runaway
tool. All errors surface as {"error": str} so the model can recover
instead of the run dying.
Phase 2.2 — C6 lock-protected JSONL writes:
- strix/tools/notes/notes_actions.py: notes.jsonl appends are now wrapped
in _notes_lock so concurrent agents can't interleave half-written lines.
Regression test in test_notes_jsonl_concurrency.py verifies 1000 parallel
writes produce exactly 1000 valid JSON lines.
Phase 2.3 — thin-slice SDK wrappers (think + todo + notes):
- strix/tools/_legacy_adapter.py: LegacyAgentStateAdapter shim — exposes
just enough surface (.agent_id) for legacy tools that close over
agent_state, sourced from ctx.context['agent_id'].
- strix/tools/thinking/thinking_sdk_tools.py: 1 tool (think).
- strix/tools/todo/todo_sdk_tools.py: 6 tools (create/list/update/done/
pending/delete) with bulk-form preserved.
- strix/tools/notes/notes_sdk_tools.py: 5 tools (create/list/get/update/
delete) with asyncio.to_thread around the lock-protected file I/O.
Tests: 22 new tests pass (10 sandbox dispatch + 2 concurrency + 10 SDK
local). Full suite still green.
Per-file ruff ignores added for SDK wrapper files: TC002 (RunContextWrapper
must be runtime-importable because the SDK calls get_type_hints() to
derive the JSON schema) and PLR0911 (sandbox dispatch's 10 short-circuit
returns are intentional, each a distinct documented failure mode).
Refs: PLAYBOOK.md §3.4, AUDIT_R3.md C6/C18.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
207 lines
6.3 KiB
Python
207 lines
6.3 KiB
Python
"""Phase 2.1 smoke tests for the sandbox dispatch helper."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from strix.tools._sandbox_dispatch import post_to_sandbox
|
|
|
|
|
|
@dataclass
|
|
class _Ctx:
|
|
"""Stand-in for ``RunContextWrapper``. Only ``.context`` is touched."""
|
|
|
|
context: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
def _ok_ctx(**overrides: Any) -> _Ctx:
|
|
base: dict[str, Any] = {
|
|
"tool_server_host_port": 48081,
|
|
"sandbox_token": "test-bearer",
|
|
"agent_id": "agent-1",
|
|
}
|
|
base.update(overrides)
|
|
return _Ctx(context=base)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_context_returns_error() -> None:
|
|
"""If ``ctx.context`` isn't a dict, return error — never raise."""
|
|
ctx = _Ctx(context="not a dict")
|
|
result = await post_to_sandbox(ctx, "browser_action", {"action": "launch"})
|
|
assert "error" in result
|
|
assert "context" in result["error"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_port_or_token_returns_error() -> None:
|
|
ctx = _Ctx(context={"sandbox_token": "tok"}) # no port
|
|
result = await post_to_sandbox(ctx, "x", {})
|
|
assert "error" in result
|
|
assert "tool server port" in result["error"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_successful_response_returned_as_dict(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
async def fake_post(
|
|
self: httpx.AsyncClient,
|
|
url: str,
|
|
*,
|
|
json: dict[str, Any] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
) -> httpx.Response:
|
|
captured["url"] = url
|
|
captured["json"] = json
|
|
captured["headers"] = headers
|
|
return httpx.Response(
|
|
status_code=200,
|
|
json={"result": "ok"},
|
|
request=httpx.Request("POST", url),
|
|
)
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
|
|
result = await post_to_sandbox(_ok_ctx(), "terminal_execute", {"command": "ls"})
|
|
assert result == {"result": "ok"}
|
|
assert captured["url"] == "http://127.0.0.1:48081/execute"
|
|
assert captured["json"] == {
|
|
"agent_id": "agent-1",
|
|
"tool_name": "terminal_execute",
|
|
"kwargs": {"command": "ls"},
|
|
}
|
|
assert captured["headers"]["Authorization"] == "Bearer test-bearer"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_401_returns_auth_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
async def fake_post(
|
|
self: httpx.AsyncClient,
|
|
url: str,
|
|
**_: Any,
|
|
) -> httpx.Response:
|
|
return httpx.Response(
|
|
status_code=401,
|
|
text="forbidden",
|
|
request=httpx.Request("POST", url),
|
|
)
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
result = await post_to_sandbox(_ok_ctx(), "x", {})
|
|
assert "error" in result
|
|
assert "authorization" in result["error"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_5xx_returns_http_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
async def fake_post(
|
|
self: httpx.AsyncClient,
|
|
url: str,
|
|
**_: Any,
|
|
) -> httpx.Response:
|
|
return httpx.Response(
|
|
status_code=503,
|
|
text="server fell over",
|
|
request=httpx.Request("POST", url),
|
|
)
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
result = await post_to_sandbox(_ok_ctx(), "browser_action", {})
|
|
assert "error" in result
|
|
assert "503" in result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_returns_timeout_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
async def fake_post(*_args: Any, **_kwargs: Any) -> httpx.Response:
|
|
raise httpx.ReadTimeout("read timeout")
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
result = await post_to_sandbox(_ok_ctx(), "python_action", {})
|
|
assert "error" in result
|
|
assert "timed out" in result["error"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connection_error_returns_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
async def fake_post(*_args: Any, **_kwargs: Any) -> httpx.Response:
|
|
raise httpx.ConnectError("refused")
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
result = await post_to_sandbox(_ok_ctx(), "x", {})
|
|
assert "error" in result
|
|
assert "connection" in result["error"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_response_too_large_capped(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""C18 (AUDIT_R3): response > 50MB returns error, doesn't OOM."""
|
|
|
|
async def fake_post(
|
|
self: httpx.AsyncClient,
|
|
url: str,
|
|
**_: Any,
|
|
) -> httpx.Response:
|
|
# Construct a response with a >50MB body. Use a string trick —
|
|
# httpx.Response stores .content directly; we make it look huge.
|
|
big = b"x" * (51 * 1024 * 1024)
|
|
return httpx.Response(
|
|
status_code=200,
|
|
content=big,
|
|
request=httpx.Request("POST", url),
|
|
)
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
result = await post_to_sandbox(_ok_ctx(), "browser_action", {})
|
|
assert "error" in result
|
|
assert "too large" in result["error"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_json_response_returns_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_post(
|
|
self: httpx.AsyncClient,
|
|
url: str,
|
|
**_: Any,
|
|
) -> httpx.Response:
|
|
return httpx.Response(
|
|
status_code=200,
|
|
text="hello not json",
|
|
request=httpx.Request("POST", url),
|
|
)
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
result = await post_to_sandbox(_ok_ctx(), "x", {})
|
|
assert "error" in result
|
|
assert "non-json" in result["error"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_object_json_returns_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_post(
|
|
self: httpx.AsyncClient,
|
|
url: str,
|
|
**_: Any,
|
|
) -> httpx.Response:
|
|
return httpx.Response(
|
|
status_code=200,
|
|
json=["a", "list", "not", "an", "object"],
|
|
request=httpx.Request("POST", url),
|
|
)
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
result = await post_to_sandbox(_ok_ctx(), "x", {})
|
|
assert "error" in result
|
|
assert "non-object" in result["error"].lower()
|