strix/tests/runtime/test_tool_server_timeout.py
d 🔹 995416edda fix(runtime): grow sandbox wait_for when tool kwarg requests longer timeout
Tool requests carrying an explicit `timeout` kwarg (terminal_execute,
python_action, etc.) were capped at the sandbox hard timeout (default
120s) regardless of the tool-level value, producing a "Tool timed out
after 120s" error on legitimate long-running scans.

- Resolve the wait_for envelope per-request: if the tool kwarg sets a
  timeout larger than the default cap, grow the envelope to
  `tool_timeout + 30s` so the tool's own timer runs to completion
  plus a small buffer for sandbox overhead. Otherwise keep the
  sandbox default as the hard cap.
- Make the timeout error actionable — mention
  STRIX_SANDBOX_EXECUTION_TIMEOUT and the per-call timeout kwarg so
  users can self-discover the knob.

Closes #436
2026-04-24 06:38:13 +08:00

120 lines
4.8 KiB
Python

"""Unit + functional tests for request-timeout resolution in the sandbox tool server.
Regression coverage for #436 — ``terminal_execute`` timing out at the sandbox
hard cap even when the caller passed a longer explicit ``timeout`` kwarg.
"""
from __future__ import annotations
import importlib.util
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from fastapi.security import HTTPAuthorizationCredentials
from strix.tools import registry
if TYPE_CHECKING:
from types import ModuleType
TOOL_SERVER_PATH = Path(__file__).resolve().parents[2] / "strix" / "runtime" / "tool_server.py"
@pytest.fixture
def tool_server(monkeypatch: pytest.MonkeyPatch) -> ModuleType:
"""Load ``strix.runtime.tool_server`` under its import-time guards.
The module requires ``STRIX_SANDBOX_MODE=true`` and reads ``sys.argv`` at
import time. We load it under a unique module name via ``importlib.util``
and register it with ``monkeypatch.setitem`` so the entry is torn down
between tests — keeping sibling tests clean.
"""
monkeypatch.setenv("STRIX_SANDBOX_MODE", "true")
monkeypatch.setattr(
sys,
"argv",
["tool_server", "--token", "test-token", "--port", "0"],
)
spec = importlib.util.spec_from_file_location(
"strix.runtime._tool_server_under_test", TOOL_SERVER_PATH
)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, spec.name, module)
spec.loader.exec_module(module)
return module
class TestResolveRequestTimeout:
def test_no_timeout_kwarg_uses_default(self, tool_server: ModuleType) -> None:
assert tool_server._resolve_request_timeout(120, {}) == 120
assert tool_server._resolve_request_timeout(120, {"command": "ls"}) == 120
def test_small_tool_timeout_keeps_default_hard_cap(self, tool_server: ModuleType) -> None:
# Inner tool asks for 10s; sandbox default (120) still dominates.
assert tool_server._resolve_request_timeout(120, {"timeout": 10}) == 120
def test_large_tool_timeout_grows_envelope_by_buffer(self, tool_server: ModuleType) -> None:
# The actual #436 case: agent passes a 600s timeout, sandbox should grow
# the wait_for envelope to 600s + buffer instead of killing at 120.
buffer = tool_server._TOOL_TIMEOUT_BUFFER_SECONDS
assert tool_server._resolve_request_timeout(120, {"timeout": 600}) == 600 + buffer
def test_float_timeout_is_accepted(self, tool_server: ModuleType) -> None:
buffer = tool_server._TOOL_TIMEOUT_BUFFER_SECONDS
assert tool_server._resolve_request_timeout(120, {"timeout": 300.0}) == 300 + buffer
def test_zero_or_negative_timeout_falls_back_to_default(self, tool_server: ModuleType) -> None:
assert tool_server._resolve_request_timeout(120, {"timeout": 0}) == 120
assert tool_server._resolve_request_timeout(120, {"timeout": -1}) == 120
def test_bool_is_not_treated_as_timeout(self, tool_server: ModuleType) -> None:
# ``bool`` is a subclass of ``int``; guard against ``True`` being read
# as ``1 second`` (or ``False`` disabling the envelope).
assert tool_server._resolve_request_timeout(120, {"timeout": True}) == 120
assert tool_server._resolve_request_timeout(120, {"timeout": False}) == 120
def test_non_numeric_timeout_is_ignored(self, tool_server: ModuleType) -> None:
assert tool_server._resolve_request_timeout(120, {"timeout": None}) == 120
assert tool_server._resolve_request_timeout(120, {"timeout": "forever"}) == 120
assert tool_server._resolve_request_timeout(120, {"timeout": ["a"]}) == 120
async def test_timeout_error_message_is_actionable(
tool_server: ModuleType, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When a tool exceeds the effective envelope, the response error mentions
``STRIX_SANDBOX_EXECUTION_TIMEOUT`` so issue-reporters like #436 can
self-discover the configuration knob."""
# Shrink the envelope to keep the test fast.
monkeypatch.setattr(tool_server, "REQUEST_TIMEOUT", 1)
def _slow_tool(**_kwargs: Any) -> dict[str, Any]:
time.sleep(3)
return {"ok": True}
_slow_tool.__name__ = "__slow_test_tool__"
monkeypatch.setitem(registry._tools_by_name, "__slow_test_tool__", _slow_tool)
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials="test-token")
request = tool_server.ToolExecutionRequest(
agent_id="agent-x",
tool_name="__slow_test_tool__",
kwargs={},
)
response = await tool_server.execute_tool(request, creds)
assert response.result is None
assert response.error is not None
assert "timed out after 1s" in response.error
assert "STRIX_SANDBOX_EXECUTION_TIMEOUT" in response.error