mirror of
https://github.com/usestrix/strix.git
synced 2026-08-19 01:55:46 +02:00
feat(tui): replace Textual with a Go/Bubble Tea interface (#941)
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -83,3 +84,84 @@ def test_parse_arguments_rejects_resume_with_target_list(
|
||||
cli_main.parse_arguments()
|
||||
|
||||
assert "Cannot combine --resume with --target/--target-list" in capsys.readouterr().err
|
||||
|
||||
|
||||
def _write_run_record(runs_dir: Path, run_name: str, record: dict[str, Any]) -> None:
|
||||
"""Write a resumable run: its record plus the agent snapshot resume needs."""
|
||||
run_dir = runs_dir / run_name
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
|
||||
state_dir = run_dir / ".state"
|
||||
state_dir.mkdir(exist_ok=True)
|
||||
(state_dir / "agents.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
|
||||
def test_resume_restores_a_target_less_workspace_mount(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A run that only mounted a working directory is resumable."""
|
||||
work = tmp_path / "project"
|
||||
work.mkdir()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_run_record(
|
||||
tmp_path / "strix_runs",
|
||||
"pentest_abcd",
|
||||
{
|
||||
"run_name": "pentest_abcd",
|
||||
"targets_info": [],
|
||||
"local_sources": [],
|
||||
"workspace_mount": str(work),
|
||||
"instruction": "audit the auth flow",
|
||||
"scan_mode": "deep",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"])
|
||||
|
||||
args = cli_main.parse_arguments()
|
||||
|
||||
# Still genuinely target-less, and the workspace is mounted again.
|
||||
assert args.targets_info == []
|
||||
assert args.workspace_mount == str(work)
|
||||
assert args.local_sources == [
|
||||
{"source_path": str(work), "workspace_subdir": "project", "protect_metadata": True}
|
||||
]
|
||||
assert args.instruction == "audit the auth flow"
|
||||
|
||||
|
||||
def test_resume_reports_a_missing_workspace_directory(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_run_record(
|
||||
tmp_path / "strix_runs",
|
||||
"pentest_abcd",
|
||||
{
|
||||
"run_name": "pentest_abcd",
|
||||
"targets_info": [],
|
||||
"local_sources": [],
|
||||
"workspace_mount": str(tmp_path / "deleted"),
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"])
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli_main.parse_arguments()
|
||||
|
||||
assert "is missing" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_resume_still_requires_targets_or_a_workspace(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_run_record(
|
||||
tmp_path / "strix_runs",
|
||||
"pentest_abcd",
|
||||
{"run_name": "pentest_abcd", "targets_info": [], "local_sources": []},
|
||||
)
|
||||
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"])
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli_main.parse_arguments()
|
||||
|
||||
assert "has no targets_info" in capsys.readouterr().err
|
||||
|
||||
@@ -58,6 +58,7 @@ def test_post_form_returns_parsed_body() -> None:
|
||||
resp = mock.MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.content = b'{"access_token": "tok"}'
|
||||
resp.__enter__.return_value = resp
|
||||
|
||||
with mock.patch.object(requests, "post", return_value=resp) as post:
|
||||
data = codex._post_form({"grant_type": "refresh_token"})
|
||||
|
||||
@@ -226,8 +226,11 @@ def test_streamed_openrouter_costs_ignores_entries_without_cost() -> None:
|
||||
|
||||
def test_streamed_openrouter_costs_cleared_on_new_run() -> None:
|
||||
streamed_openrouter_costs.remember("gen-stale", {"cost": 0.7})
|
||||
set_global_report_state(ReportState.__new__(ReportState))
|
||||
assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-stale")) is None
|
||||
try:
|
||||
set_global_report_state(ReportState.__new__(ReportState))
|
||||
assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-stale")) is None
|
||||
finally:
|
||||
set_global_report_state(None)
|
||||
|
||||
|
||||
def test_openrouter_stream_handler_records_cost() -> None:
|
||||
|
||||
@@ -0,0 +1,907 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.config.settings import DEFAULT_MAX_TURNS
|
||||
from strix.interface.tui import runtime as go_tui
|
||||
from strix.interface.tui import sidecar
|
||||
from strix.interface.tui.runtime import GoTuiRuntime
|
||||
|
||||
|
||||
def args() -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
needs_setup=True,
|
||||
targets_info=[],
|
||||
instruction=None,
|
||||
scan_mode="deep",
|
||||
max_budget_usd=None,
|
||||
max_turns=DEFAULT_MAX_TURNS,
|
||||
scope_mode="auto",
|
||||
diff_base=None,
|
||||
local_sources=[],
|
||||
diff_scope={"active": False},
|
||||
user_explicit_instruction=None,
|
||||
run_name="test-run",
|
||||
)
|
||||
|
||||
|
||||
def test_binary_command_prefers_packaged_sidecar(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
sidecar = tmp_path / "strix-tui"
|
||||
sidecar.write_text("binary")
|
||||
monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src")
|
||||
monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: sidecar)
|
||||
monkeypatch.setattr(
|
||||
shutil,
|
||||
"which",
|
||||
lambda _name: pytest.fail("PATH lookup should not run"),
|
||||
)
|
||||
|
||||
assert GoTuiRuntime.binary_command() == [str(sidecar)]
|
||||
|
||||
|
||||
def test_binary_command_prefers_current_source_over_packaged_sidecar(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
source = tmp_path / "tui-src"
|
||||
source.mkdir()
|
||||
(source / "go.mod").write_text("module test\n")
|
||||
sidecar = tmp_path / "strix-tui"
|
||||
sidecar.write_text("stale")
|
||||
monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src")
|
||||
monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: sidecar)
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/go" if name == "go" else None)
|
||||
|
||||
assert GoTuiRuntime.binary_command() == ["go", "run", "./cmd/strix-tui"]
|
||||
|
||||
|
||||
def test_binary_command_reports_missing_sidecar(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing")
|
||||
monkeypatch.setattr(shutil, "which", lambda _name: None)
|
||||
monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Bubble Tea TUI binary not found"):
|
||||
GoTuiRuntime.binary_command()
|
||||
|
||||
|
||||
def test_binary_command_ignores_unconstrained_path_sidecar(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing")
|
||||
monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src")
|
||||
monkeypatch.setattr(shutil, "which", lambda _name: "/untrusted/path/strix-tui")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Bubble Tea TUI binary not found"):
|
||||
GoTuiRuntime.binary_command()
|
||||
|
||||
|
||||
def test_child_environment_excludes_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "openai-secret")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "aws-id")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "aws-secret")
|
||||
monkeypatch.setenv("AWS_SESSION_TOKEN", "aws-token")
|
||||
monkeypatch.setenv("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/aws-token")
|
||||
monkeypatch.setenv("VERTEXAI_CREDENTIALS", '{"private_key":"secret"}')
|
||||
monkeypatch.setenv("STRIX_TUI_TOKEN", "stale-transport-token")
|
||||
monkeypatch.setenv("TERM", "xterm-256color")
|
||||
|
||||
env = sidecar.child_environment()
|
||||
|
||||
assert env["TERM"] == "xterm-256color"
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
assert "AWS_ACCESS_KEY_ID" not in env
|
||||
assert "AWS_SECRET_ACCESS_KEY" not in env
|
||||
assert "AWS_SESSION_TOKEN" not in env
|
||||
assert "AWS_WEB_IDENTITY_TOKEN_FILE" not in env
|
||||
assert "VERTEXAI_CREDENTIALS" not in env
|
||||
assert "STRIX_TUI_TOKEN" not in env
|
||||
|
||||
|
||||
def test_accept_authenticated_connection() -> None:
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(1)
|
||||
address = listener.getsockname()
|
||||
|
||||
def connect() -> None:
|
||||
with socket.create_connection(address) as connection:
|
||||
connection.sendall(b"one-use-token")
|
||||
|
||||
thread = threading.Thread(target=connect)
|
||||
thread.start()
|
||||
connection = sidecar._accept_authenticated_connection(listener, "one-use-token")
|
||||
connection.close()
|
||||
listener.close()
|
||||
thread.join()
|
||||
|
||||
|
||||
def test_rejects_invalid_connection_token() -> None:
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(1)
|
||||
address = listener.getsockname()
|
||||
|
||||
def connect() -> None:
|
||||
with socket.create_connection(address) as connection:
|
||||
connection.sendall(b"invalidd-token")
|
||||
|
||||
thread = threading.Thread(target=connect)
|
||||
thread.start()
|
||||
with pytest.raises(PermissionError, match="authentication failed"):
|
||||
sidecar._accept_authenticated_connection(listener, "expected-token")
|
||||
listener.close()
|
||||
thread.join()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_transport_launches_without_inherited_fd() -> None:
|
||||
child = """
|
||||
import os
|
||||
import socket
|
||||
|
||||
host, port = os.environ["STRIX_TUI_ADDR"].rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port))) as connection:
|
||||
connection.sendall(os.environ["STRIX_TUI_TOKEN"].encode("ascii"))
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
env.pop("STRIX_TUI_FD", None)
|
||||
|
||||
process, connection = await sidecar._launch_windows_tui_process(
|
||||
[sys.executable, "-c", child], env, None
|
||||
)
|
||||
connection.close()
|
||||
|
||||
assert await sidecar.wait_process(process) == 0
|
||||
|
||||
|
||||
async def _receive_exactly(connection: socket.socket, size: int) -> bytes:
|
||||
result = b""
|
||||
while len(result) < size:
|
||||
chunk = await asyncio.get_running_loop().sock_recv(connection, size - len(result))
|
||||
if not chunk:
|
||||
raise EOFError
|
||||
result += chunk
|
||||
return result
|
||||
|
||||
|
||||
async def _receive_message(connection: socket.socket) -> dict[str, Any]:
|
||||
size = struct.unpack(">I", await _receive_exactly(connection, 4))[0]
|
||||
message: dict[str, Any] = json.loads(await _receive_exactly(connection, size))
|
||||
return message
|
||||
|
||||
|
||||
async def _send_message(connection: socket.socket, message: dict[str, Any]) -> None:
|
||||
raw = json.dumps(message).encode()
|
||||
await asyncio.get_running_loop().sock_sendall(connection, struct.pack(">I", len(raw)) + raw)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_does_not_initialize_or_scan_before_ready(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime_args = args()
|
||||
runtime_args.needs_setup = False
|
||||
runtime = GoTuiRuntime(runtime_args)
|
||||
backend, child = socket.socketpair()
|
||||
child.setblocking(False) # noqa: FBT003
|
||||
calls: list[str] = []
|
||||
scan_started = asyncio.Event()
|
||||
|
||||
async def launch(
|
||||
_command: list[str], _env: dict[str, str], _cwd: str | None
|
||||
) -> tuple[SimpleNamespace, socket.socket]:
|
||||
return SimpleNamespace(returncode=None), backend
|
||||
|
||||
async def wait_process(_process: object) -> int:
|
||||
await scan_started.wait()
|
||||
return 0
|
||||
|
||||
def init_state() -> None:
|
||||
calls.append("state")
|
||||
|
||||
def start_scan() -> None:
|
||||
calls.append("scan")
|
||||
scan_started.set()
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
calls.append("preflight")
|
||||
|
||||
monkeypatch.setattr(runtime, "binary_command", lambda: ["test-sidecar"])
|
||||
monkeypatch.setattr(go_tui, "launch_tui_process", launch)
|
||||
monkeypatch.setattr(go_tui, "wait_process", wait_process)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(go_tui, "persist_current", lambda: None)
|
||||
monkeypatch.setattr(go_tui, "prepare_run", lambda _args: None)
|
||||
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
|
||||
monkeypatch.setattr(runtime, "init_run_state", init_state)
|
||||
monkeypatch.setattr(runtime, "start_scan", start_scan)
|
||||
|
||||
run_task = asyncio.create_task(runtime.run())
|
||||
try:
|
||||
hello = await _receive_message(child)
|
||||
assert hello["type"] == "hello"
|
||||
assert calls == []
|
||||
await _send_message(
|
||||
child,
|
||||
{
|
||||
"version": 3,
|
||||
"type": "ready",
|
||||
"payload": {
|
||||
"capabilities": [
|
||||
"state-revisions",
|
||||
"collection-deltas",
|
||||
"structured-command-errors",
|
||||
"agents-collection",
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
await asyncio.wait_for(run_task, timeout=2)
|
||||
assert calls == ["preflight", "state", "scan"]
|
||||
finally:
|
||||
child.close()
|
||||
if not run_task.done():
|
||||
run_task.cancel()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_activation_failure_propagates_to_dispatcher(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FailedRuntime:
|
||||
async def run(self) -> None:
|
||||
raise go_tui.GoTuiPreActivationError("protocol mismatch")
|
||||
|
||||
monkeypatch.setattr(go_tui, "GoTuiRuntime", lambda _args: FailedRuntime())
|
||||
|
||||
with pytest.raises(go_tui.GoTuiPreActivationError, match="protocol mismatch"):
|
||||
await go_tui.run_go_tui(args())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_activation_failure_is_surfaced(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class ActivatedRuntime:
|
||||
async def run(self) -> None:
|
||||
raise RuntimeError("sidecar failed after ready")
|
||||
|
||||
monkeypatch.setattr(go_tui, "GoTuiRuntime", lambda _args: ActivatedRuntime())
|
||||
|
||||
with pytest.raises(RuntimeError, match="after ready"):
|
||||
await go_tui.run_go_tui(args())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_preflights_model_before_starting(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime_args = args()
|
||||
runtime_args.instruction = "CLI instruction"
|
||||
runtime = GoTuiRuntime(runtime_args)
|
||||
assert runtime.controller.instruction == "CLI instruction"
|
||||
runtime.controller.targets = ["https://example.com", "/workspace/mounted"]
|
||||
runtime.controller.scan_mode = "quick"
|
||||
runtime.controller.instruction = ""
|
||||
runtime.controller.max_budget_usd = 8.5
|
||||
runtime.controller.max_turns = 321
|
||||
runtime.controller.scope_mode = "diff"
|
||||
runtime.controller.diff_base = "origin/main"
|
||||
calls: list[str] = []
|
||||
|
||||
async def preflight(model: str) -> None:
|
||||
assert model == "openrouter/test-model"
|
||||
calls.append("preflight")
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
|
||||
def build(candidate: argparse.Namespace, **_: object) -> None:
|
||||
calls.append("targets")
|
||||
assert candidate.target == ["https://example.com", "/workspace/mounted"]
|
||||
candidate.targets_info = [
|
||||
{
|
||||
"type": "web",
|
||||
"details": {"target_url": "https://example.com"},
|
||||
"original": "https://example.com",
|
||||
},
|
||||
{
|
||||
"type": "local_code",
|
||||
"details": {"target_path": "/workspace/mounted"},
|
||||
"original": "/workspace/mounted",
|
||||
},
|
||||
]
|
||||
|
||||
def prepare(candidate: argparse.Namespace) -> None:
|
||||
calls.append("prepare")
|
||||
assert candidate.max_budget_usd == 8.5
|
||||
assert candidate.max_turns == 321
|
||||
assert candidate.scope_mode == "diff"
|
||||
assert candidate.diff_base == "origin/main"
|
||||
|
||||
monkeypatch.setattr(go_tui, "build_targets_info", build)
|
||||
monkeypatch.setattr(go_tui, "prepare_run", prepare)
|
||||
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry"))
|
||||
monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state"))
|
||||
monkeypatch.setattr(runtime, "start_scan", lambda: calls.append("scan"))
|
||||
|
||||
await runtime.start_from_setup()
|
||||
|
||||
assert calls == ["preflight", "targets", "prepare", "telemetry", "state", "scan"]
|
||||
assert runtime.args.scan_mode == "quick"
|
||||
assert runtime.args.instruction == ""
|
||||
assert runtime.args.max_budget_usd == 8.5
|
||||
assert runtime.args.max_turns == 321
|
||||
assert runtime.args.scope_mode == "diff"
|
||||
assert runtime.args.diff_base == "origin/main"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_optimistic_setup_skips_model_preflight(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime = GoTuiRuntime(args())
|
||||
runtime.controller.targets = [str(Path.cwd())]
|
||||
calls: list[str] = []
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
calls.append("preflight")
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(go_tui, "build_targets_info", lambda _args, **_kw: calls.append("targets"))
|
||||
monkeypatch.setattr(go_tui, "prepare_run", lambda _args: calls.append("prepare"))
|
||||
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry"))
|
||||
monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state"))
|
||||
monkeypatch.setattr(runtime, "start_scan", lambda: calls.append("scan"))
|
||||
|
||||
await runtime.start_from_setup(verify=False)
|
||||
|
||||
# No preflight: the scan launches straight through and any model error
|
||||
# surfaces once the agent runs.
|
||||
assert "preflight" not in calls
|
||||
assert calls == ["targets", "prepare", "telemetry", "state", "scan"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirmed_target_less_launch_mounts_workspace_without_targets(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The working directory reaches the run as a workspace, never as a target."""
|
||||
runtime = GoTuiRuntime(args())
|
||||
runtime.controller.workspace_mount = str(Path.home())
|
||||
prepared: list[argparse.Namespace] = []
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"build_targets_info",
|
||||
lambda _args, **_kw: pytest.fail("a target-less launch must not build targets"),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "prepare_run", prepared.append)
|
||||
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
|
||||
monkeypatch.setattr(runtime, "init_run_state", lambda: None)
|
||||
monkeypatch.setattr(runtime, "start_scan", lambda: None)
|
||||
|
||||
await runtime.start_from_setup(verify=False)
|
||||
|
||||
assert prepared[0].workspace_mount == str(Path.home())
|
||||
assert prepared[0].targets_info == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_preserves_prepared_cli_targets(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime_args = args()
|
||||
runtime_args.target = ["https://example.com"]
|
||||
runtime_args.target_list = []
|
||||
runtime_args.targets_info = [
|
||||
{
|
||||
"type": "web",
|
||||
"details": {"url": "https://example.com"},
|
||||
"original": "https://example.com",
|
||||
}
|
||||
]
|
||||
runtime = GoTuiRuntime(runtime_args)
|
||||
calls: list[str] = []
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
calls.append("preflight")
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"build_targets_info",
|
||||
lambda _args, **_kw: pytest.fail("prepared targets should not be rebuilt"),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "prepare_run", lambda _args: calls.append("prepare"))
|
||||
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry"))
|
||||
monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state"))
|
||||
monkeypatch.setattr(runtime, "start_scan", lambda: calls.append("scan"))
|
||||
|
||||
await runtime.start_from_setup()
|
||||
|
||||
assert runtime.controller.targets == ["https://example.com"]
|
||||
assert runtime.args.targets_info[0]["type"] == "web"
|
||||
assert calls == ["preflight", "prepare", "telemetry", "state", "scan"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_target_change_preserves_local_targets(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime_args = args()
|
||||
runtime_args.target = []
|
||||
runtime_args.target_list = ["targets.txt"]
|
||||
runtime_args.targets_info = [
|
||||
{
|
||||
"type": "local_code",
|
||||
"details": {"target_path": "/workspace/source"},
|
||||
"original": "/workspace/source",
|
||||
}
|
||||
]
|
||||
runtime = GoTuiRuntime(runtime_args)
|
||||
runtime.controller.targets.append("https://example.com")
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
return None
|
||||
|
||||
def build(target_args: argparse.Namespace, **_: object) -> None:
|
||||
assert target_args.target == ["/workspace/source", "https://example.com"]
|
||||
target_args.targets_info = [
|
||||
{
|
||||
"type": "web",
|
||||
"details": {"url": "https://example.com"},
|
||||
"original": "https://example.com",
|
||||
},
|
||||
{
|
||||
"type": "local_code",
|
||||
"details": {"target_path": "/workspace/source"},
|
||||
"original": "/workspace/source",
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(go_tui, "build_targets_info", build)
|
||||
monkeypatch.setattr(go_tui, "prepare_run", lambda _args: None)
|
||||
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
|
||||
monkeypatch.setattr(runtime, "init_run_state", lambda: None)
|
||||
monkeypatch.setattr(runtime, "start_scan", lambda: None)
|
||||
|
||||
await runtime.start_from_setup()
|
||||
|
||||
assert runtime.args.target_list == []
|
||||
assert runtime.args.targets_info[0]["type"] == "web"
|
||||
assert runtime.args.targets_info[1]["type"] == "local_code"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_same_basename_uses_combined_workspace_names_on_retry(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
existing_repo = "https://example.com/first/app.git"
|
||||
added_repo = "https://example.com/second/app.git"
|
||||
runtime_args = args()
|
||||
runtime_args.target = []
|
||||
runtime_args.target_list = ["targets.txt"]
|
||||
runtime_args.targets_info = [
|
||||
{
|
||||
"type": "repository",
|
||||
"details": {
|
||||
"target_repo": existing_repo,
|
||||
"workspace_subdir": "app",
|
||||
"cloned_repo_path": "/clones/app",
|
||||
},
|
||||
"original": existing_repo,
|
||||
}
|
||||
]
|
||||
runtime = GoTuiRuntime(runtime_args)
|
||||
runtime.controller.targets.append(added_repo)
|
||||
prepare_attempts = 0
|
||||
started: list[str] = []
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
return None
|
||||
|
||||
def build(target_args: argparse.Namespace, **_: object) -> None:
|
||||
assert target_args.target == [existing_repo, added_repo]
|
||||
target_args.targets_info = [
|
||||
{
|
||||
"type": "repository",
|
||||
"details": {
|
||||
"target_repo": existing_repo,
|
||||
"workspace_subdir": "app",
|
||||
},
|
||||
"original": existing_repo,
|
||||
},
|
||||
{
|
||||
"type": "repository",
|
||||
"details": {
|
||||
"target_repo": added_repo,
|
||||
"workspace_subdir": "app-2",
|
||||
},
|
||||
"original": added_repo,
|
||||
},
|
||||
]
|
||||
|
||||
def prepare(candidate: argparse.Namespace) -> None:
|
||||
nonlocal prepare_attempts
|
||||
prepare_attempts += 1
|
||||
assert [target["details"]["workspace_subdir"] for target in candidate.targets_info] == [
|
||||
"app",
|
||||
"app-2",
|
||||
]
|
||||
if prepare_attempts == 1:
|
||||
candidate.targets_info[0]["details"]["target_repo"] = "/mutated"
|
||||
candidate.targets_info[1]["details"]["workspace_subdir"] = "mutated"
|
||||
raise ValueError("retry setup")
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(go_tui, "build_targets_info", build)
|
||||
monkeypatch.setattr(go_tui, "prepare_run", prepare)
|
||||
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
|
||||
monkeypatch.setattr(runtime, "init_run_state", lambda: started.append("state"))
|
||||
monkeypatch.setattr(runtime, "start_scan", lambda: started.append("scan"))
|
||||
|
||||
with pytest.raises(ValueError, match="retry setup"):
|
||||
await runtime.start_from_setup()
|
||||
|
||||
assert runtime.args.targets_info[0]["details"] == {
|
||||
"target_repo": existing_repo,
|
||||
"workspace_subdir": "app",
|
||||
"cloned_repo_path": "/clones/app",
|
||||
}
|
||||
assert started == []
|
||||
|
||||
await runtime.start_from_setup()
|
||||
|
||||
assert prepare_attempts == 2
|
||||
assert runtime.args.target_list == []
|
||||
assert [target["details"]["workspace_subdir"] for target in runtime.args.targets_info] == [
|
||||
"app",
|
||||
"app-2",
|
||||
]
|
||||
assert runtime.args.targets_info[0]["details"]["target_repo"] == existing_repo
|
||||
assert started == ["state", "scan"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_target_rebuild_restores_all_target_fields_on_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime_args = args()
|
||||
runtime_args.target = None
|
||||
runtime_args.target_list = ["targets.txt"]
|
||||
runtime_args.targets_info = [
|
||||
{
|
||||
"type": "local_code",
|
||||
"details": {"target_path": "/workspace/source"},
|
||||
"original": "/workspace/source",
|
||||
}
|
||||
]
|
||||
original_targets_info = json.loads(json.dumps(runtime_args.targets_info))
|
||||
runtime = GoTuiRuntime(runtime_args)
|
||||
runtime.controller.targets.append("https://example.com")
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
return None
|
||||
|
||||
def fail_rebuild(target_args: argparse.Namespace, **_: object) -> None:
|
||||
target_args.target = ["mutated"]
|
||||
target_args.target_list = ["mutated.txt"]
|
||||
target_args.targets_info = [{"original": "partial"}]
|
||||
raise ValueError("bad target")
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(go_tui, "build_targets_info", fail_rebuild)
|
||||
|
||||
with pytest.raises(ValueError, match="bad target"):
|
||||
await runtime.start_from_setup()
|
||||
|
||||
assert runtime.args.target is None
|
||||
assert runtime.args.target_list == ["targets.txt"]
|
||||
assert runtime.args.targets_info == original_targets_info
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_rebuild_canonicalizes_relative_local_target(
|
||||
tmp_path: Any,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runtime_args = args()
|
||||
runtime_args.target = []
|
||||
runtime_args.target_list = []
|
||||
runtime = GoTuiRuntime(runtime_args)
|
||||
runtime.controller.targets = ["source"]
|
||||
prepared = False
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
return None
|
||||
|
||||
def prepare(candidate: argparse.Namespace) -> None:
|
||||
nonlocal prepared
|
||||
prepared = True
|
||||
assert len(candidate.targets_info) == 1
|
||||
assert candidate.targets_info[0]["details"]["target_path"] == str(source.resolve())
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(go_tui, "prepare_run", prepare)
|
||||
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
|
||||
monkeypatch.setattr(runtime, "init_run_state", lambda: None)
|
||||
monkeypatch.setattr(runtime, "start_scan", lambda: None)
|
||||
|
||||
await runtime.start_from_setup()
|
||||
|
||||
assert prepared is True
|
||||
assert runtime.args.targets_info[0]["original"] == str(source.resolve())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_prepare_system_exit_is_recoverable_and_transactional(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime_args = args()
|
||||
runtime_args.instruction = "CLI instruction"
|
||||
runtime_args.scan_mode = "deep"
|
||||
runtime_args.target = ["https://example.com"]
|
||||
runtime_args.target_list = []
|
||||
runtime_args.targets_info = [
|
||||
{
|
||||
"type": "web",
|
||||
"details": {"url": "https://example.com"},
|
||||
"original": "https://example.com",
|
||||
}
|
||||
]
|
||||
original_args = json.loads(json.dumps(vars(runtime_args)))
|
||||
runtime = GoTuiRuntime(runtime_args)
|
||||
runtime.controller.scan_mode = "quick"
|
||||
runtime.controller.instruction = ""
|
||||
telemetry_started = False
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
return None
|
||||
|
||||
def fail_prepare(candidate: argparse.Namespace) -> None:
|
||||
assert candidate is not runtime.args
|
||||
candidate.run_name = "mutated-run"
|
||||
candidate.targets_info[0]["details"]["url"] = "https://mutated.example"
|
||||
raise ValueError("invalid diff scope")
|
||||
|
||||
def telemetry(_candidate: argparse.Namespace) -> None:
|
||||
nonlocal telemetry_started
|
||||
telemetry_started = True
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(go_tui, "prepare_run", fail_prepare)
|
||||
monkeypatch.setattr(go_tui, "telemetry_start", telemetry)
|
||||
|
||||
with pytest.raises(ValueError, match="invalid diff scope"):
|
||||
await runtime.start_from_setup()
|
||||
|
||||
assert vars(runtime.args) == original_args
|
||||
assert telemetry_started is False
|
||||
assert runtime.scan_task is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_passes_max_turns_and_budget(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
runtime_args = args()
|
||||
runtime_args.max_turns = 37
|
||||
runtime_args.max_budget_usd = 4.25
|
||||
runtime = GoTuiRuntime(runtime_args)
|
||||
runtime.scan_config = {"run_name": "test-run"}
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def run_scan(**kwargs: Any) -> None:
|
||||
captured.update(kwargs)
|
||||
coordinator = kwargs["coordinator"]
|
||||
await coordinator.register("root", "Root", parent_id=None)
|
||||
await coordinator.set_status("root", "stopped")
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(runtime=SimpleNamespace(image="test-image")),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "run_strix_scan", run_scan)
|
||||
|
||||
await runtime._run_scan()
|
||||
|
||||
assert captured["max_turns"] == 37
|
||||
assert captured["max_budget_usd"] == 4.25
|
||||
assert runtime.controller.scan_state == "stopped"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_preflight_failure_does_not_start_scan(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime = GoTuiRuntime(args())
|
||||
runtime.controller.targets = ["https://example.com"]
|
||||
started = False
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
raise ValueError("401 Unauthorized")
|
||||
|
||||
def mark_started(*_args: Any) -> None:
|
||||
nonlocal started
|
||||
started = True
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(go_tui, "build_targets_info", mark_started)
|
||||
monkeypatch.setattr(runtime, "init_run_state", mark_started)
|
||||
monkeypatch.setattr(runtime, "start_scan", mark_started)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Model connection failed: 401 Unauthorized"):
|
||||
await runtime.start_from_setup()
|
||||
|
||||
assert started is False
|
||||
assert runtime.scan_task is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_state_sync_uses_latest_graph_snapshot_shape() -> None:
|
||||
runtime = GoTuiRuntime(args())
|
||||
await runtime.coordinator.register("root", "Strix", parent_id=None)
|
||||
await runtime.coordinator.register("child", "Recon", parent_id="root")
|
||||
await runtime.coordinator.set_status("child", "failed", error="provider rejected request")
|
||||
|
||||
await runtime._sync_agent_state()
|
||||
|
||||
assert runtime.live_view.agents["root"]["name"] == "Strix"
|
||||
child = runtime.live_view.agents["child"]
|
||||
assert child["name"] == "Recon"
|
||||
assert child["parent_id"] == "root"
|
||||
assert child["status"] == "failed"
|
||||
assert child["error_message"] == "provider rejected request"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_state_sync_projects_completed_report() -> None:
|
||||
runtime = GoTuiRuntime(args())
|
||||
runtime.report_state = cast("Any", SimpleNamespace(run_record={"status": "completed"}))
|
||||
await runtime.coordinator.register("root", "Strix", parent_id=None)
|
||||
await runtime.coordinator.set_status("root", "completed")
|
||||
|
||||
await runtime._sync_agent_state()
|
||||
|
||||
assert runtime.controller.scan_state == "completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_state_sync_does_not_mask_root_failure_with_completed_report() -> None:
|
||||
runtime = GoTuiRuntime(args())
|
||||
runtime.report_state = cast("Any", SimpleNamespace(run_record={"status": "completed"}))
|
||||
await runtime.coordinator.register("root", "Strix", parent_id=None)
|
||||
await runtime.coordinator.set_status("root", "failed", error="finalization failed")
|
||||
|
||||
await runtime._sync_agent_state()
|
||||
|
||||
assert runtime.controller.scan_state == "failed"
|
||||
assert runtime.controller.error == "finalization failed"
|
||||
|
||||
|
||||
def _direct_launch_args() -> argparse.Namespace:
|
||||
launch_args = args()
|
||||
launch_args.needs_setup = False
|
||||
return launch_args
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_and_start_reports_ordinary_connection_failures(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime = GoTuiRuntime(_direct_launch_args())
|
||||
started: list[str] = []
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
raise TimeoutError("connection timed out")
|
||||
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(runtime, "start_scan", lambda: started.append("scan"))
|
||||
|
||||
await runtime.prepare_and_start()
|
||||
|
||||
assert started == []
|
||||
assert runtime.controller.setup_mode is False
|
||||
assert runtime.controller.scan_state == "failed"
|
||||
assert "connection timed out" in (runtime.controller.error or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_and_start_runs_the_scan_after_preparation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime = GoTuiRuntime(_direct_launch_args())
|
||||
order: list[str] = []
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
order.append("preflight")
|
||||
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(go_tui, "persist_current", lambda: order.append("persist"))
|
||||
monkeypatch.setattr(go_tui, "prepare_run", lambda _args: order.append("prepare"))
|
||||
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: order.append("telemetry"))
|
||||
monkeypatch.setattr(runtime, "init_run_state", lambda: order.append("state"))
|
||||
monkeypatch.setattr(runtime, "start_scan", lambda: order.append("scan"))
|
||||
|
||||
await runtime.prepare_and_start()
|
||||
|
||||
assert order == ["preflight", "persist", "prepare", "telemetry", "state", "scan"]
|
||||
assert runtime.controller.scan_state == "running"
|
||||
+34
-1
@@ -8,7 +8,12 @@ from typing import Any
|
||||
import litellm
|
||||
import pytest
|
||||
|
||||
from strix.core.inputs import build_root_task, child_initial_input, make_model_settings
|
||||
from strix.core.inputs import (
|
||||
build_root_task,
|
||||
build_scope_context,
|
||||
child_initial_input,
|
||||
make_model_settings,
|
||||
)
|
||||
|
||||
|
||||
def _child_kwargs(parent_history: list[Any]) -> dict[str, Any]:
|
||||
@@ -202,6 +207,34 @@ def test_build_root_task_web_application_with_instructions() -> None:
|
||||
assert "Special instructions: Focus on auth." in task
|
||||
|
||||
|
||||
def test_build_root_task_workspace_mount_is_not_a_target() -> None:
|
||||
"""A target-less run gets a working directory, not an assessment scope."""
|
||||
config = {
|
||||
"targets": [],
|
||||
"user_instructions": "Find IDOR in the checkout flow.",
|
||||
"workspace_mount": "/Users/me/code/api",
|
||||
"workspace_subdir": "api",
|
||||
}
|
||||
task = build_root_task(config)
|
||||
|
||||
assert "Working Directory:" in task
|
||||
assert "/workspace/api" in task
|
||||
assert "No scan target was set" in task
|
||||
assert "Special instructions: Find IDOR in the checkout flow." in task
|
||||
# It must not be presented as an asset to test.
|
||||
for label in ("Local Codebases:", "Repositories:", "URLs:", "IP Addresses:"):
|
||||
assert label not in task
|
||||
|
||||
|
||||
def test_build_scope_context_authorizes_nothing_without_targets() -> None:
|
||||
"""A mounted workspace grants no authorized scope."""
|
||||
scope = build_scope_context(
|
||||
{"targets": [], "workspace_mount": "/Users/me/code/api", "workspace_subdir": "api"}
|
||||
)
|
||||
|
||||
assert scope["authorized_targets"] == []
|
||||
|
||||
|
||||
def test_build_root_task_diff_scope() -> None:
|
||||
config = {
|
||||
"targets": [],
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.interface.scan_setup import attach_workspace_mount
|
||||
from strix.interface.utils import (
|
||||
check_mountable_dir,
|
||||
collect_local_sources,
|
||||
@@ -14,6 +16,7 @@ from strix.interface.utils import (
|
||||
infer_target_type,
|
||||
read_target_list_file,
|
||||
)
|
||||
from strix.runtime.session_manager import build_bind_mounts
|
||||
|
||||
|
||||
def _local_target(target_path: str) -> dict[str, Any]:
|
||||
@@ -66,6 +69,55 @@ def test_check_mountable_dir_rejects_home(tmp_path: Path, monkeypatch: pytest.Mo
|
||||
check_mountable_dir(home)
|
||||
|
||||
|
||||
def test_infer_target_type_guards_sensitive_dirs_by_default(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: home))
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to mount"):
|
||||
infer_target_type(str(home))
|
||||
|
||||
|
||||
def test_workspace_mount_is_mounted_without_becoming_a_target(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A workspace mount reaches the sandbox but carries no target semantics.
|
||||
|
||||
It is the directory the agent works in, so it is exempt from the guard that
|
||||
refuses home directories for scan targets, and it never enters targets_info.
|
||||
"""
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: home))
|
||||
args = argparse.Namespace(targets_info=[], local_sources=[], workspace_mount=str(home))
|
||||
|
||||
attach_workspace_mount(args)
|
||||
|
||||
assert args.targets_info == []
|
||||
assert args.local_sources == [
|
||||
{
|
||||
"source_path": str(home),
|
||||
"workspace_subdir": args.workspace_subdir,
|
||||
"protect_metadata": True,
|
||||
}
|
||||
]
|
||||
# It is a real bind mount, so the sandbox exposes it under /workspace.
|
||||
assert build_bind_mounts(args.local_sources)[0]["target"] == (
|
||||
f"/workspace/{args.workspace_subdir}"
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_mount_absent_leaves_local_sources_alone() -> None:
|
||||
args = argparse.Namespace(targets_info=[], local_sources=[], workspace_mount=None)
|
||||
|
||||
attach_workspace_mount(args)
|
||||
|
||||
assert args.local_sources == []
|
||||
|
||||
|
||||
def test_check_mountable_dir_rejects_system_root() -> None:
|
||||
etc = Path("/etc")
|
||||
if not etc.is_dir():
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_wheel_build_requires_go(tmp_path: Path) -> None:
|
||||
uv = shutil.which("uv")
|
||||
if uv is None:
|
||||
pytest.skip("uv is required for the packaging smoke test")
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = str(tmp_path / "path-without-go")
|
||||
result = subprocess.run( # noqa: S603
|
||||
[uv, "build", "--wheel", "--out-dir", str(tmp_path / "dist")],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Go 1.24 or newer is required" in result.stdout + result.stderr
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Tests for the proxy tool TUI renderers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from strix.interface.tui.renderers.proxy_renderer import ViewRequestRenderer
|
||||
|
||||
|
||||
def _plain(static: object) -> str:
|
||||
content = static.content # type: ignore[attr-defined]
|
||||
return content.plain if isinstance(content, Text) else str(content)
|
||||
|
||||
|
||||
def _render(content: str, *, has_more: bool) -> str:
|
||||
tool_data = {
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"content": content,
|
||||
"has_more": has_more,
|
||||
"page": 1,
|
||||
"total_lines": len(content.split("\n")),
|
||||
},
|
||||
}
|
||||
return _plain(ViewRequestRenderer.render(tool_data))
|
||||
|
||||
|
||||
_MARKER = "... more content available"
|
||||
|
||||
|
||||
def test_more_content_hint_shown_when_over_fifteen_lines() -> None:
|
||||
content = "\n".join(f"line{i}" for i in range(30))
|
||||
|
||||
assert _MARKER in _render(content, has_more=False)
|
||||
|
||||
|
||||
def test_no_more_content_hint_within_fifteen_lines() -> None:
|
||||
content = "\n".join(f"line{i}" for i in range(5))
|
||||
|
||||
assert _MARKER not in _render(content, has_more=False)
|
||||
|
||||
|
||||
def test_more_content_hint_shown_when_has_more_flag_set() -> None:
|
||||
content = "\n".join(f"line{i}" for i in range(3))
|
||||
|
||||
assert _MARKER in _render(content, has_more=True)
|
||||
@@ -0,0 +1,430 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.config import apply_config_override, loader
|
||||
from strix.config.settings import DEFAULT_MAX_TURNS
|
||||
from strix.interface.tui.backend.controller import TuiController
|
||||
|
||||
|
||||
def args() -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
needs_setup=True,
|
||||
targets_info=[],
|
||||
instruction=None,
|
||||
scan_mode="deep",
|
||||
max_budget_usd=None,
|
||||
max_turns=DEFAULT_MAX_TURNS,
|
||||
scope_mode="auto",
|
||||
diff_base=None,
|
||||
local_sources=[],
|
||||
diff_scope={"active": False},
|
||||
user_explicit_instruction=None,
|
||||
run_name=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_config(tmp_path: Path) -> None:
|
||||
for key in (
|
||||
"STRIX_LLM",
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"LLM_API_KEY",
|
||||
"LLM_API_BASE",
|
||||
"AZURE_API_KEY",
|
||||
"AZURE_API_BASE",
|
||||
"AZURE_API_VERSION",
|
||||
):
|
||||
os.environ.pop(key, None)
|
||||
apply_config_override(tmp_path / "config.json")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_state_is_serializable() -> None:
|
||||
controller = TuiController(args())
|
||||
await controller.handle("setup.add_target", {"target": "https://example.com"})
|
||||
await controller.handle("setup.set_instruction", {"instruction": "focus on auth"})
|
||||
snapshot = controller.snapshot()
|
||||
assert snapshot["targets"] == ["https://example.com"]
|
||||
assert snapshot["instruction"] == "focus on auth"
|
||||
assert snapshot["scan_state"] == "setup"
|
||||
assert snapshot["scan_mode"] == "deep"
|
||||
assert snapshot["max_budget_usd"] is None
|
||||
assert snapshot["max_turns"] == 500
|
||||
assert snapshot["scope_mode"] == "auto"
|
||||
assert snapshot["diff_base"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_instruction_starts_from_cli_and_can_be_cleared() -> None:
|
||||
setup_args = args()
|
||||
setup_args.instruction = " CLI instruction "
|
||||
controller = TuiController(setup_args)
|
||||
|
||||
assert controller.snapshot()["instruction"] == "CLI instruction"
|
||||
|
||||
result = await controller.handle("setup.set_instruction", {"instruction": ""})
|
||||
|
||||
assert result == {"instruction": ""}
|
||||
assert controller.snapshot()["instruction"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_controls_reject_changes_after_start() -> None:
|
||||
controller = TuiController(args())
|
||||
controller.setup_mode = False
|
||||
controller.scan_started = True
|
||||
|
||||
with pytest.raises(RuntimeError, match="can no longer be changed"):
|
||||
await controller.handle("setup.add_target", {"target": "https://example.com"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_large_target_list_reports_truncated_snapshot_count() -> None:
|
||||
controller = TuiController(args())
|
||||
|
||||
for index in range(20):
|
||||
await controller.handle("setup.add_target", {"target": f"https://target-{index}.example"})
|
||||
added = await controller.handle("setup.add_target", {"target": "https://last.example"})
|
||||
snapshot = controller.snapshot()
|
||||
|
||||
assert added == {"target": "https://last.example", "total": 21}
|
||||
assert snapshot["target_count"] == 21
|
||||
# The snapshot only carries a bounded prefix of the list.
|
||||
assert len(snapshot["targets"]) == 16
|
||||
|
||||
|
||||
def test_state_populates_model_warning_for_non_frontier_model() -> None:
|
||||
os.environ["STRIX_LLM"] = "openai/gpt-3.5-turbo"
|
||||
loader._cached = None
|
||||
|
||||
warning = TuiController(args()).snapshot()["model_warning"]
|
||||
|
||||
assert "openai/gpt-3.5-turbo" in warning
|
||||
assert "not a recommended frontier model" in warning
|
||||
|
||||
|
||||
def test_setup_restores_prepared_cli_targets() -> None:
|
||||
setup_args = args()
|
||||
setup_args.targets_info = [
|
||||
{"type": "web", "details": {}, "original": "https://example.com"},
|
||||
{"type": "local_code", "details": {}, "original": "/workspace/source"},
|
||||
]
|
||||
|
||||
controller = TuiController(setup_args)
|
||||
|
||||
assert controller.snapshot()["targets"] == ["https://example.com", "/workspace/source"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_validates_model_before_callback() -> None:
|
||||
started = False
|
||||
|
||||
async def start(_verify: bool = True) -> None:
|
||||
nonlocal started
|
||||
started = True
|
||||
|
||||
controller = TuiController(args(), on_start=start)
|
||||
await controller.handle("setup.add_target", {"target": "https://example.com"})
|
||||
with pytest.raises(ValueError, match="No model configured"):
|
||||
await controller.handle("setup.start", {})
|
||||
assert started is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_launches_with_a_configured_model() -> None:
|
||||
started = False
|
||||
|
||||
async def start(_verify: bool = True) -> None:
|
||||
nonlocal started
|
||||
started = True
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start)
|
||||
await controller.handle("setup.add_target", {"target": "https://example.com"})
|
||||
|
||||
result = await controller.handle("setup.start", {})
|
||||
|
||||
assert result == {"started": True}
|
||||
assert started is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_without_target_requires_mount_consent() -> None:
|
||||
started = False
|
||||
|
||||
async def start(_verify: bool = True) -> None:
|
||||
nonlocal started
|
||||
started = True
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start)
|
||||
|
||||
# Mounting the working directory is never silent.
|
||||
with pytest.raises(ValueError, match="No target set"):
|
||||
await controller.handle("setup.start", {"verify": False})
|
||||
assert started is False
|
||||
assert controller.targets == []
|
||||
assert controller.workspace_mount is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_target_less_start_enters_live_view_and_waits_for_the_mount() -> None:
|
||||
"""Nothing is prepared until the live-view confirmation is answered."""
|
||||
started = False
|
||||
|
||||
async def start(_verify: bool = True) -> None:
|
||||
nonlocal started
|
||||
started = True
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start)
|
||||
|
||||
result = await controller.handle("setup.start", {"verify": False, "mount_working_dir": True})
|
||||
|
||||
assert result == {"started": True}
|
||||
# The live view is up so the prompt can be shown there, but the scan has not
|
||||
# been prepared and nothing is mounted yet.
|
||||
assert started is False
|
||||
assert controller.setup_mode is False
|
||||
assert controller.scan_state == "preparing"
|
||||
assert controller.pending_workspace_mount == str(Path.cwd())
|
||||
assert controller.workspace_mount is None
|
||||
assert controller.snapshot()["pending_mount"] == str(Path.cwd())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirming_the_mount_starts_the_scan_without_a_target() -> None:
|
||||
started = False
|
||||
seen_verify: bool | None = None
|
||||
|
||||
async def start(verify: bool = True) -> None:
|
||||
nonlocal started, seen_verify
|
||||
started = True
|
||||
seen_verify = verify
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start)
|
||||
await controller.handle("setup.start", {"verify": False, "mount_working_dir": True})
|
||||
|
||||
result = await controller.handle("setup.confirm_mount", {"approved": True})
|
||||
|
||||
assert result == {"approved": True}
|
||||
assert started is True
|
||||
# Launched optimistically, and mounted as a workspace: the scan genuinely
|
||||
# has no target, so the instruction is the only source of truth.
|
||||
assert seen_verify is False
|
||||
assert controller.workspace_mount == str(Path.cwd())
|
||||
assert controller.targets == []
|
||||
assert controller.scan_state == "running"
|
||||
assert controller.snapshot()["pending_mount"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_declining_the_mount_returns_to_the_start_screen() -> None:
|
||||
started = False
|
||||
|
||||
async def start(_verify: bool = True) -> None:
|
||||
nonlocal started
|
||||
started = True
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start)
|
||||
await controller.handle("setup.start", {"verify": False, "mount_working_dir": True})
|
||||
|
||||
result = await controller.handle("setup.confirm_mount", {"approved": False})
|
||||
|
||||
assert result == {"approved": False}
|
||||
# Nothing was prepared, so the session goes back to the start screen and can
|
||||
# be launched again.
|
||||
assert started is False
|
||||
assert controller.workspace_mount is None
|
||||
assert controller.pending_workspace_mount is None
|
||||
assert controller.setup_mode is True
|
||||
assert controller.scan_started is False
|
||||
assert controller.scan_state == "setup"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_mount_requires_a_pending_request() -> None:
|
||||
controller = TuiController(args())
|
||||
|
||||
with pytest.raises(RuntimeError, match="No mount confirmation is pending"):
|
||||
await controller.handle("setup.confirm_mount", {"approved": True})
|
||||
|
||||
|
||||
def test_snapshot_exposes_working_directory() -> None:
|
||||
controller = TuiController(args())
|
||||
|
||||
assert controller.snapshot()["working_dir"] == str(Path.cwd())
|
||||
assert controller.snapshot()["pending_mount"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_forwards_verify_flag_by_default() -> None:
|
||||
seen_verify: bool | None = None
|
||||
|
||||
async def start(verify: bool = True) -> None:
|
||||
nonlocal seen_verify
|
||||
seen_verify = verify
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start)
|
||||
await controller.handle("setup.add_target", {"target": "https://example.com"})
|
||||
|
||||
# A named target keeps the upfront model check.
|
||||
await controller.handle("setup.start", {})
|
||||
|
||||
assert seen_verify is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_rejects_concurrent_and_repeated_submissions() -> None:
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def start(_verify: bool = True) -> None:
|
||||
entered.set()
|
||||
await release.wait()
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start)
|
||||
await controller.handle("setup.add_target", {"target": "https://example.com"})
|
||||
|
||||
first_start = asyncio.create_task(controller.handle("setup.start", {}))
|
||||
await entered.wait()
|
||||
with pytest.raises(RuntimeError, match="already starting or running"):
|
||||
await controller.handle("setup.start", {})
|
||||
release.set()
|
||||
await first_start
|
||||
with pytest.raises(RuntimeError, match="already starting or running"):
|
||||
await controller.handle("setup.start", {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ["completed", "failed", "crashed", "stopped"])
|
||||
async def test_stop_rejects_terminal_agents(status: str) -> None:
|
||||
class Coordinator:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def cancel_descendants_graceful(self, agent_id: str) -> bool:
|
||||
self.calls.append(agent_id)
|
||||
return True
|
||||
|
||||
coordinator = Coordinator()
|
||||
controller = TuiController(args(), coordinator=coordinator)
|
||||
controller.set_runtime(scan_loop=asyncio.get_running_loop())
|
||||
controller.live_view.upsert_agent("agent-1", name="Agent", status=status)
|
||||
|
||||
with pytest.raises(RuntimeError, match=f"cannot be stopped while {status}"):
|
||||
await controller.handle("agent.stop", {"agent_id": "agent-1"})
|
||||
|
||||
assert coordinator.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ["running", "waiting", "budget_paused"])
|
||||
async def test_stop_allows_active_agents(status: str) -> None:
|
||||
class Coordinator:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def cancel_descendants_graceful(self, agent_id: str) -> bool:
|
||||
self.calls.append(agent_id)
|
||||
return True
|
||||
|
||||
coordinator = Coordinator()
|
||||
controller = TuiController(args(), coordinator=coordinator)
|
||||
controller.set_runtime(scan_loop=asyncio.get_running_loop())
|
||||
controller.live_view.upsert_agent("agent-1", name="Agent", status=status)
|
||||
|
||||
result = await controller.handle("agent.stop", {"agent_id": "agent-1"})
|
||||
|
||||
assert result == {"stopped": True}
|
||||
assert coordinator.calls == ["agent-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_handles_coordinator_rejection_after_stale_active_projection() -> None:
|
||||
class Coordinator:
|
||||
async def cancel_descendants_graceful(self, _agent_id: str) -> bool:
|
||||
return False
|
||||
|
||||
controller = TuiController(args(), coordinator=Coordinator())
|
||||
controller.set_runtime(scan_loop=asyncio.get_running_loop())
|
||||
controller.live_view.upsert_agent("agent-1", name="Agent", status="running")
|
||||
|
||||
with pytest.raises(RuntimeError, match="no longer active"):
|
||||
await controller.handle("agent.stop", {"agent_id": "agent-1"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_command_is_rejected() -> None:
|
||||
controller = TuiController(args())
|
||||
with pytest.raises(ValueError, match="Unknown command"):
|
||||
await controller.handle("nope", {})
|
||||
|
||||
|
||||
def test_messages_are_sanitized_and_agents_are_collection_only() -> None:
|
||||
controller = TuiController(args())
|
||||
controller.add_message("replace\x1b]52;c;Y2xpcA==\x07 key\x85")
|
||||
for index in range(40):
|
||||
controller.live_view.upsert_agent(f"agent-{index}", name=f"Agent {index}")
|
||||
|
||||
snapshot = controller.snapshot()
|
||||
|
||||
assert "agents" not in snapshot
|
||||
assert [message["text"] for message in snapshot["messages"]] == ["replace key"]
|
||||
assert len(controller.collection("agents")) == 40
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_viewer_is_reopened_and_closed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
opened: list[str] = []
|
||||
|
||||
class ViewerServer:
|
||||
shutdown_called = False
|
||||
close_called = False
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.shutdown_called = True
|
||||
|
||||
def server_close(self) -> None:
|
||||
self.close_called = True
|
||||
|
||||
controller = TuiController(args())
|
||||
controller.viewer_status = "running"
|
||||
controller.viewer_url = "http://127.0.0.1:1234/?token=test"
|
||||
server = ViewerServer()
|
||||
controller._viewer_httpd = server
|
||||
monkeypatch.setattr("strix.interface.tui.backend.controller.webbrowser.open", opened.append)
|
||||
|
||||
result = await controller.handle("viewer.open", {})
|
||||
controller.close_viewer()
|
||||
|
||||
assert result == {"status": "running", "url": controller.viewer_url}
|
||||
assert opened == [controller.viewer_url]
|
||||
assert server.shutdown_called is True
|
||||
assert server.close_called is True
|
||||
@@ -0,0 +1,560 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from agents.tool import ToolOutputImage
|
||||
|
||||
from strix.config.settings import DEFAULT_MAX_TURNS
|
||||
from strix.interface.tui.backend.controller import TuiController
|
||||
from strix.interface.tui.backend.projection import terminal_projection
|
||||
from strix.interface.tui.backend.protocol import (
|
||||
MAX_COMMAND_BYTES,
|
||||
PROTOCOL_CAPABILITIES,
|
||||
PROTOCOL_VERSION,
|
||||
ProtocolHandshakeError,
|
||||
envelope,
|
||||
)
|
||||
from strix.interface.tui.backend.server import TuiBackendServer
|
||||
from strix.interface.tui.live_view import TuiLiveView
|
||||
|
||||
|
||||
def args() -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
needs_setup=True,
|
||||
targets_info=[],
|
||||
instruction=None,
|
||||
scan_mode="deep",
|
||||
max_budget_usd=None,
|
||||
max_turns=DEFAULT_MAX_TURNS,
|
||||
scope_mode="auto",
|
||||
diff_base=None,
|
||||
local_sources=[],
|
||||
diff_scope={"active": False},
|
||||
user_explicit_instruction=None,
|
||||
run_name=None,
|
||||
)
|
||||
|
||||
|
||||
async def send_message(connection: socket.socket, message: dict[str, object]) -> None:
|
||||
raw = json.dumps(message).encode()
|
||||
await asyncio.get_running_loop().sock_sendall(connection, struct.pack(">I", len(raw)) + raw)
|
||||
|
||||
|
||||
async def receive_exactly(connection: socket.socket, size: int) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
while size:
|
||||
chunk = await asyncio.get_running_loop().sock_recv(connection, size)
|
||||
if not chunk:
|
||||
raise EOFError
|
||||
chunks.append(chunk)
|
||||
size -= len(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
async def receive_frame(connection: socket.socket) -> tuple[int, dict[str, Any]]:
|
||||
size = struct.unpack(">I", await receive_exactly(connection, 4))[0]
|
||||
value = json.loads(await receive_exactly(connection, size))
|
||||
assert isinstance(value, dict)
|
||||
return size, value
|
||||
|
||||
|
||||
async def receive_message(connection: socket.socket) -> dict[str, Any]:
|
||||
return (await receive_frame(connection))[1]
|
||||
|
||||
|
||||
async def start_server(
|
||||
server: TuiBackendServer, backend: socket.socket, child: socket.socket
|
||||
) -> dict[str, Any]:
|
||||
start_task = asyncio.create_task(server.start(backend))
|
||||
hello = await receive_message(child)
|
||||
await send_message(
|
||||
child,
|
||||
{
|
||||
"version": PROTOCOL_VERSION,
|
||||
"type": "ready",
|
||||
"payload": {"capabilities": list(PROTOCOL_CAPABILITIES)},
|
||||
},
|
||||
)
|
||||
await asyncio.wait_for(start_task, timeout=1)
|
||||
return hello
|
||||
|
||||
|
||||
async def receive_until(
|
||||
connection: socket.socket,
|
||||
message_type: str,
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
for _ in range(100):
|
||||
message = await asyncio.wait_for(receive_message(connection), timeout=2)
|
||||
if message.get("type") != message_type:
|
||||
continue
|
||||
if request_id is not None and message.get("request_id") != request_id:
|
||||
continue
|
||||
return message
|
||||
raise AssertionError(f"did not receive {message_type}")
|
||||
|
||||
|
||||
async def receive_initial_state(connection: socket.socket) -> None:
|
||||
state_received = False
|
||||
complete: set[str] = set()
|
||||
while not state_received or complete != {"agents", "events", "vulnerabilities"}:
|
||||
message = await asyncio.wait_for(receive_message(connection), timeout=2)
|
||||
if message["type"] == "state":
|
||||
state_received = True
|
||||
elif message["type"] == "collection_bootstrap":
|
||||
payload = message["payload"]
|
||||
if payload["done"]:
|
||||
complete.add(payload["collection"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_requires_ready_before_state_or_commands() -> None:
|
||||
backend, child = socket.socketpair()
|
||||
child.setblocking(False) # noqa: FBT003
|
||||
server = TuiBackendServer(TuiController(args()))
|
||||
start_task = asyncio.create_task(server.start(backend))
|
||||
try:
|
||||
hello = await receive_message(child)
|
||||
assert hello == {
|
||||
"version": 3,
|
||||
"type": "hello",
|
||||
"payload": {"capabilities": list(PROTOCOL_CAPABILITIES)},
|
||||
}
|
||||
with pytest.raises(TimeoutError):
|
||||
await asyncio.wait_for(receive_message(child), timeout=0.1)
|
||||
assert not start_task.done()
|
||||
|
||||
await send_message(
|
||||
child,
|
||||
{
|
||||
"version": 3,
|
||||
"type": "ready",
|
||||
"payload": {"capabilities": list(PROTOCOL_CAPABILITIES)},
|
||||
},
|
||||
)
|
||||
await asyncio.wait_for(start_task, timeout=1)
|
||||
assert server.activated is True
|
||||
assert (await receive_until(child, "state"))["payload"]["revision"] == 1
|
||||
finally:
|
||||
child.close()
|
||||
start_task.cancel()
|
||||
await server.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("version", "capabilities"),
|
||||
[
|
||||
(2, list(PROTOCOL_CAPABILITIES)),
|
||||
(3, ["state-revisions"]),
|
||||
],
|
||||
)
|
||||
async def test_server_rejects_handshake_mismatch(version: int, capabilities: list[str]) -> None:
|
||||
backend, child = socket.socketpair()
|
||||
child.setblocking(False) # noqa: FBT003
|
||||
server = TuiBackendServer(TuiController(args()))
|
||||
start_task = asyncio.create_task(server.start(backend))
|
||||
try:
|
||||
await receive_message(child)
|
||||
await send_message(
|
||||
child,
|
||||
{"version": version, "type": "ready", "payload": {"capabilities": capabilities}},
|
||||
)
|
||||
with pytest.raises(ProtocolHandshakeError, match="mismatch"):
|
||||
await asyncio.wait_for(start_task, timeout=1)
|
||||
assert server.activated is False
|
||||
finally:
|
||||
child.close()
|
||||
await server.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_command_round_trip_over_inherited_socket() -> None:
|
||||
backend, child = socket.socketpair()
|
||||
child.setblocking(False) # noqa: FBT003
|
||||
server = TuiBackendServer(TuiController(args()))
|
||||
await start_server(server, backend, child)
|
||||
try:
|
||||
await send_message(
|
||||
child,
|
||||
{
|
||||
"version": 3,
|
||||
"type": "setup.add_target",
|
||||
"request_id": "test-1",
|
||||
"payload": {"target": "example.com"},
|
||||
},
|
||||
)
|
||||
result = await receive_until(child, "command_result", request_id="test-1")
|
||||
assert result["payload"]["ok"] is True
|
||||
assert result["payload"]["command"] == "setup.add_target"
|
||||
state = await receive_until(child, "state")
|
||||
assert state["payload"]["revision"] >= 1
|
||||
assert state["payload"]["state"]["targets"] == ["example.com"]
|
||||
finally:
|
||||
child.close()
|
||||
await server.close()
|
||||
|
||||
|
||||
def test_unicode_heavy_setup_state_stays_within_control_frame_limit() -> None:
|
||||
controller = TuiController(args())
|
||||
controller.instruction = "🔒" * 10_000
|
||||
controller.targets = [f"https://例え.{index}/" + "界" * 500 for index in range(20)]
|
||||
controller.error = "失" * 10_000
|
||||
controller.messages = [
|
||||
{"id": str(index), "text": "警" * 10_000, "level": "warning"} for index in range(10)
|
||||
]
|
||||
controller.report_state = cast(
|
||||
"Any",
|
||||
SimpleNamespace(
|
||||
caido_url="https://例え.example/" + "道" * 10_000,
|
||||
get_total_llm_usage=lambda: {f"model-{index}": "費" * 10_000 for index in range(20)},
|
||||
),
|
||||
)
|
||||
server = TuiBackendServer(controller)
|
||||
|
||||
snapshot = controller.snapshot()
|
||||
encoded = server._encode(envelope("state", {"revision": 1, "state": snapshot}))
|
||||
|
||||
assert len(encoded) <= MAX_COMMAND_BYTES
|
||||
assert "🔒".encode() in encoded
|
||||
assert snapshot["projection_truncated"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persistence_error_does_not_kill_command_reader(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
backend, child = socket.socketpair()
|
||||
child.setblocking(False) # noqa: FBT003
|
||||
controller = TuiController(args())
|
||||
calls = 0
|
||||
|
||||
async def handle(command: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise OSError("disk is read-only")
|
||||
return {"command": command, "payload": payload}
|
||||
|
||||
monkeypatch.setattr(controller, "handle", handle)
|
||||
server = TuiBackendServer(controller)
|
||||
await start_server(server, backend, child)
|
||||
try:
|
||||
for request_id in ("persist-1", "persist-2"):
|
||||
await send_message(
|
||||
child,
|
||||
{
|
||||
"version": 3,
|
||||
"type": "setup.select_model",
|
||||
"request_id": request_id,
|
||||
"payload": {"provider": "openai", "model": "openai/gpt-5"},
|
||||
},
|
||||
)
|
||||
result = await receive_until(child, "command_result", request_id=request_id)
|
||||
if request_id == "persist-1":
|
||||
assert result["payload"]["error"] == {
|
||||
"code": "persistence_error",
|
||||
"message": "disk is read-only",
|
||||
"retryable": True,
|
||||
}
|
||||
else:
|
||||
assert result["payload"]["ok"] is True
|
||||
assert server._reader_task is not None and not server._reader_task.done()
|
||||
finally:
|
||||
child.close()
|
||||
await server.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_version_error_is_correlated_and_next_command_succeeds() -> None:
|
||||
backend, child = socket.socketpair()
|
||||
child.setblocking(False) # noqa: FBT003
|
||||
server = TuiBackendServer(TuiController(args()))
|
||||
await start_server(server, backend, child)
|
||||
try:
|
||||
await send_message(
|
||||
child,
|
||||
{
|
||||
"version": 2,
|
||||
"type": "setup.add_target",
|
||||
"request_id": "bad-version",
|
||||
"payload": {"target": "ignored.example"},
|
||||
},
|
||||
)
|
||||
rejected = await receive_until(child, "command_result", request_id="bad-version")
|
||||
assert rejected["payload"]["error"]["code"] == "invalid_request"
|
||||
|
||||
await send_message(
|
||||
child,
|
||||
{
|
||||
"version": 3,
|
||||
"type": "setup.add_target",
|
||||
"request_id": "after-error",
|
||||
"payload": {"target": "example.com"},
|
||||
},
|
||||
)
|
||||
accepted = await receive_until(child, "command_result", request_id="after-error")
|
||||
assert accepted["payload"]["ok"] is True
|
||||
finally:
|
||||
child.close()
|
||||
await server.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_bootstrap_is_chunked_deltas_are_incremental_and_idle_is_silent() -> None:
|
||||
backend, child = socket.socketpair()
|
||||
child.setblocking(False) # noqa: FBT003
|
||||
controller = TuiController(args())
|
||||
report_state = SimpleNamespace(
|
||||
vulnerability_reports=[],
|
||||
caido_url=None,
|
||||
get_total_llm_usage=dict,
|
||||
)
|
||||
controller.report_state = cast("Any", report_state)
|
||||
content = "x" * (64 * 1024)
|
||||
for index in range(80):
|
||||
controller.live_view.record_user_message(f"agent-{index}", content)
|
||||
server = TuiBackendServer(controller)
|
||||
await start_server(server, backend, child)
|
||||
try:
|
||||
event_frames = 0
|
||||
event_count = 0
|
||||
complete: set[str] = set()
|
||||
state_received = False
|
||||
while not (complete == {"agents", "events", "vulnerabilities"} and state_received):
|
||||
size, message = await asyncio.wait_for(receive_frame(child), timeout=5)
|
||||
if message["type"] == "state":
|
||||
state_received = True
|
||||
if message["type"] != "collection_bootstrap":
|
||||
continue
|
||||
payload = message["payload"]
|
||||
if payload["collection"] == "events":
|
||||
event_frames += 1
|
||||
event_count += len(payload["items"])
|
||||
assert size <= 4 * 1024 * 1024
|
||||
if payload["done"]:
|
||||
complete.add(payload["collection"])
|
||||
assert event_frames >= 2
|
||||
assert event_count == 80
|
||||
|
||||
server.notify_changed()
|
||||
with pytest.raises(TimeoutError):
|
||||
await asyncio.wait_for(receive_message(child), timeout=0.2)
|
||||
|
||||
controller.live_view.record_user_message("agent-new", "delta")
|
||||
controller.notify_changed()
|
||||
delta = await receive_until(child, "collection_delta")
|
||||
assert delta["payload"]["collection"] == "events"
|
||||
assert delta["payload"]["base_revision"] == 1
|
||||
assert len(delta["payload"]["operations"]) == 1
|
||||
|
||||
report_state.vulnerability_reports.append(
|
||||
{"id": "vuln-0001", "title": "Incremental finding", "severity": "high"}
|
||||
)
|
||||
controller.notify_changed()
|
||||
finding_delta = await receive_until(child, "collection_delta")
|
||||
assert finding_delta["payload"]["collection"] == "vulnerabilities"
|
||||
assert len(finding_delta["payload"]["operations"]) == 1
|
||||
finally:
|
||||
child.close()
|
||||
await server.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agents_collection_has_no_state_cap_and_sends_delete_and_resync() -> None:
|
||||
backend, child = socket.socketpair()
|
||||
child.setblocking(False) # noqa: FBT003
|
||||
controller = TuiController(args())
|
||||
for index in range(40):
|
||||
controller.live_view.upsert_agent(
|
||||
f"agent-{index}",
|
||||
name=f"Agent {index}",
|
||||
status="running",
|
||||
)
|
||||
server = TuiBackendServer(controller)
|
||||
await start_server(server, backend, child)
|
||||
try:
|
||||
agents: list[dict[str, Any]] = []
|
||||
complete: set[str] = set()
|
||||
state: dict[str, Any] | None = None
|
||||
while state is None or complete != {"agents", "events", "vulnerabilities"}:
|
||||
message = await asyncio.wait_for(receive_message(child), timeout=2)
|
||||
if message["type"] == "state":
|
||||
state = message["payload"]["state"]
|
||||
elif message["type"] == "collection_bootstrap":
|
||||
payload = message["payload"]
|
||||
if payload["collection"] == "agents":
|
||||
agents.extend(payload["items"])
|
||||
if payload["done"]:
|
||||
complete.add(payload["collection"])
|
||||
|
||||
assert "agents" not in state
|
||||
assert len(agents) == 40
|
||||
|
||||
controller.live_view.agents.pop("agent-7")
|
||||
controller.notify_changed()
|
||||
delta = await receive_until(child, "collection_delta")
|
||||
assert delta["payload"]["collection"] == "agents"
|
||||
assert delta["payload"]["operations"] == [{"op": "delete", "id": "agent-7"}]
|
||||
|
||||
await send_message(
|
||||
child,
|
||||
{
|
||||
"version": 3,
|
||||
"type": "collection.resync",
|
||||
"request_id": "resync-agents",
|
||||
"payload": {"collection": "agents"},
|
||||
},
|
||||
)
|
||||
result = await receive_until(child, "command_result", request_id="resync-agents")
|
||||
assert result["payload"]["ok"] is True
|
||||
bootstrap = await receive_until(child, "collection_bootstrap")
|
||||
assert bootstrap["payload"]["collection"] == "agents"
|
||||
assert bootstrap["payload"]["revision"] == 3
|
||||
assert len(bootstrap["payload"]["items"]) == 39
|
||||
finally:
|
||||
child.close()
|
||||
await server.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bootstrap_larger_than_64_mib_has_no_total_message_ceiling(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
server = TuiBackendServer(TuiController(args()))
|
||||
shared_projection = "x" * (1024 * 1024)
|
||||
items = [{"id": f"event-{index}", "content": shared_projection} for index in range(65)]
|
||||
frames: list[dict[str, Any]] = []
|
||||
encoded_sizes: list[int] = []
|
||||
|
||||
async def capture(message: dict[str, Any]) -> None:
|
||||
encoded_sizes.append(len(server._encode(message)))
|
||||
frames.append(message)
|
||||
|
||||
monkeypatch.setattr(server, "_send", capture)
|
||||
|
||||
await server._send_collection_frames(
|
||||
"collection_bootstrap",
|
||||
{"collection": "events", "revision": 1},
|
||||
"items",
|
||||
items,
|
||||
)
|
||||
|
||||
assert sum(len(item["content"]) for item in items) > 64 * 1024 * 1024
|
||||
assert len(frames) > 16
|
||||
assert max(encoded_sizes) <= 4 * 1024 * 1024
|
||||
assert frames[0]["payload"]["cursor"] == 0
|
||||
assert frames[-1]["payload"]["next_cursor"] == len(items)
|
||||
assert frames[-1]["payload"]["done"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oversized_terminal_projection_is_truncated_without_mutating_history() -> None:
|
||||
controller = TuiController(args())
|
||||
durable = "x" * (2 * 1024 * 1024)
|
||||
controller.live_view.record_user_message("agent", durable)
|
||||
|
||||
projected = controller.collection("events")
|
||||
|
||||
assert len(projected[0]["data"]["content"]) < len(durable)
|
||||
assert controller.live_view.events[0]["data"]["content"] == durable
|
||||
|
||||
|
||||
def test_terminal_projection_strips_ansi_osc_and_c1_controls() -> None:
|
||||
controller = TuiController(args())
|
||||
hostile = "safe\x1b[31mred\x1b[0m\x1b]52;c;Y2xpcGJvYXJk\x07\x85tail"
|
||||
controller.live_view.record_user_message("agent", hostile)
|
||||
|
||||
projected = controller.collection_snapshot("events")[1][0]["data"]["content"]
|
||||
|
||||
assert projected == "saferedtail"
|
||||
assert "\x1b" not in projected
|
||||
|
||||
hostile_mapping = {"header\x1b]52;c;Y2xpcA==\x07": "value"}
|
||||
assert list(terminal_projection(hostile_mapping)) == ["header"]
|
||||
assert list(TuiBackendServer._sanitize_wire_value(hostile_mapping)) == ["header"]
|
||||
|
||||
|
||||
def test_terminal_event_history_is_bounded_without_changing_durable_sessions() -> None:
|
||||
controller = TuiController(args())
|
||||
for index in range(10_050):
|
||||
controller.live_view.record_user_message("agent", f"message-{index}")
|
||||
|
||||
_cursor, projected = controller.collection_snapshot("events")
|
||||
|
||||
assert len(controller.live_view.events) == 10_000
|
||||
assert len(projected) == 5_000
|
||||
assert projected[0]["data"]["content"] == "message-5050"
|
||||
assert projected[-1]["data"]["content"] == "message-10049"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oversized_command_frame_is_rejected_before_payload_read() -> None:
|
||||
backend, child = socket.socketpair()
|
||||
child.setblocking(False) # noqa: FBT003
|
||||
server = TuiBackendServer(TuiController(args()))
|
||||
await start_server(server, backend, child)
|
||||
try:
|
||||
await asyncio.get_running_loop().sock_sendall(
|
||||
child, struct.pack(">I", MAX_COMMAND_BYTES + 1)
|
||||
)
|
||||
assert server._reader_task is not None
|
||||
await asyncio.wait_for(server._reader_task, timeout=1)
|
||||
assert server._socket is None
|
||||
finally:
|
||||
child.close()
|
||||
await server.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_stops_when_peer_closes() -> None:
|
||||
backend, child = socket.socketpair()
|
||||
child.setblocking(False) # noqa: FBT003
|
||||
server = TuiBackendServer(TuiController(args()))
|
||||
await start_server(server, backend, child)
|
||||
child.close()
|
||||
try:
|
||||
assert server._reader_task is not None
|
||||
await asyncio.wait_for(server._reader_task, timeout=1)
|
||||
finally:
|
||||
await server.close()
|
||||
|
||||
|
||||
def test_image_data_uri_survives_terminal_projection() -> None:
|
||||
uri = "data:image/png;base64," + "A" * 100_000
|
||||
assert terminal_projection(uri) == uri
|
||||
assert terminal_projection({"type": "image", "image_url": uri})["image_url"] == uri
|
||||
|
||||
oversized = "data:image/png;base64," + "A" * (3 * 1024 * 1024)
|
||||
assert terminal_projection(oversized) == "[image omitted from terminal projection]"
|
||||
|
||||
|
||||
def test_view_image_tool_output_is_normalized_to_image_dict() -> None:
|
||||
uri = "data:image/png;base64," + "B" * 4000
|
||||
view = TuiLiveView()
|
||||
view._record_tool_output_data(
|
||||
"agent",
|
||||
{
|
||||
"call_id": "c1",
|
||||
"tool_name": "view_image",
|
||||
"output": ToolOutputImage(type="image", image_url=uri),
|
||||
},
|
||||
)
|
||||
view._record_tool_output_data(
|
||||
"agent",
|
||||
{
|
||||
"call_id": "c2",
|
||||
"tool_name": "view_image",
|
||||
"output": [{"type": "input_image", "image_url": uri}],
|
||||
},
|
||||
)
|
||||
for event in view.events:
|
||||
assert event["data"]["result"] == {"type": "image", "image_url": uri}
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Guard against the Python and Go protocol constants drifting apart.
|
||||
|
||||
The wire protocol is declared twice — ``strix/interface/tui/backend/protocol.py``
|
||||
for the backend and ``strix/interface/tui/internal/protocol/protocol.go`` for the
|
||||
sidecar. This test parses the Go source shipped in the tree and checks the two
|
||||
declarations agree, so a version or capability change in one language cannot
|
||||
land silently without the other.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from strix.interface.tui.backend.protocol import PROTOCOL_CAPABILITIES, PROTOCOL_VERSION
|
||||
|
||||
|
||||
GO_PROTOCOL_SOURCE = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "strix"
|
||||
/ "interface"
|
||||
/ "tui"
|
||||
/ "internal"
|
||||
/ "protocol"
|
||||
/ "protocol.go"
|
||||
)
|
||||
|
||||
|
||||
def test_go_protocol_source_is_present() -> None:
|
||||
assert GO_PROTOCOL_SOURCE.is_file()
|
||||
|
||||
|
||||
def test_protocol_version_matches_go() -> None:
|
||||
source = GO_PROTOCOL_SOURCE.read_text(encoding="utf-8")
|
||||
match = re.search(r"^const Version = (\d+)$", source, flags=re.MULTILINE)
|
||||
assert match is not None, "const Version not found in protocol.go"
|
||||
assert int(match.group(1)) == PROTOCOL_VERSION
|
||||
|
||||
|
||||
def test_protocol_capabilities_match_go() -> None:
|
||||
source = GO_PROTOCOL_SOURCE.read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
r"^var Capabilities = \[\]string\{\n(?P<body>(?:\t\"[^\"]+\",\n)+)\}",
|
||||
source,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
assert match is not None, "var Capabilities not found in protocol.go"
|
||||
go_capabilities = re.findall(r"\"([^\"]+)\"", match.group("body"))
|
||||
assert tuple(go_capabilities) == PROTOCOL_CAPABILITIES
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Resumed history must attribute only typed messages to the user.
|
||||
|
||||
Guidance the system feeds an agent is injected as a user turn, so replayed
|
||||
history cannot tell it apart from a typed message by role alone. A live run only
|
||||
shows what the user actually typed; resuming has to match that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.core.paths import runtime_state_dir
|
||||
from strix.interface.tui.backend.live_view import TuiLiveView as GoTuiLiveView
|
||||
from strix.interface.tui.live_view import TuiLiveView, _is_internal_agent_turn
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write_run(run_dir: Path, items: list[dict[str, Any]], agent_id: str = "root") -> None:
|
||||
"""Persist an agent snapshot plus a session history for hydration to read."""
|
||||
state_dir = runtime_state_dir(run_dir)
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
(state_dir / "agents.json").write_text(
|
||||
json.dumps({"statuses": {agent_id: "running"}, "names": {agent_id: "recon"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
connection = sqlite3.connect(state_dir / "agents.db")
|
||||
try:
|
||||
connection.execute(
|
||||
"create table agent_messages (id integer primary key, session_id text, "
|
||||
"message_data text, created_at text)"
|
||||
)
|
||||
for index, item in enumerate(items, start=1):
|
||||
connection.execute(
|
||||
"insert into agent_messages (id, session_id, message_data, created_at) "
|
||||
"values (?, ?, ?, ?)",
|
||||
(index, agent_id, json.dumps(item), f"2026-01-01T00:00:{index:02d}+00:00"),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def _user_messages(view: TuiLiveView) -> list[str]:
|
||||
return [
|
||||
str(event["data"]["content"])
|
||||
for event in view.events
|
||||
if event.get("type") == "chat" and event["data"].get("role") == "user"
|
||||
]
|
||||
|
||||
|
||||
def test_resume_hides_system_guidance_injected_as_user_turns(tmp_path: Path) -> None:
|
||||
run_dir = tmp_path / "run"
|
||||
_write_run(
|
||||
run_dir,
|
||||
[
|
||||
# The task the agent was launched with, not a typed message.
|
||||
{"role": "user", "content": "\n\nURLs: - https://example.com"},
|
||||
{"role": "assistant", "content": "starting"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[Message from system (system) | type=auto_resume | priority=normal]\n"
|
||||
"Waiting timeout reached.",
|
||||
},
|
||||
{"role": "user", "content": "[NOTICE] Turn budget: 350/500 used (70%)."},
|
||||
# A stall notice reaches the parent through the coordinator, so it
|
||||
# arrives wrapped rather than as a bare "[Agent stalled]".
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[Message from recon (a1) | type=stalled | priority=high]\n"
|
||||
"[Agent stalled] recon (a1) kept ending turns",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Your previous message ended a turn without a tool call. "
|
||||
"Plain text never ends execution.",
|
||||
},
|
||||
{"role": "assistant", "content": "continuing"},
|
||||
],
|
||||
)
|
||||
view = TuiLiveView()
|
||||
|
||||
view.hydrate_from_run_dir(run_dir)
|
||||
|
||||
assert _user_messages(view) == []
|
||||
# The agent's own side of the conversation is untouched.
|
||||
assert [
|
||||
str(event["data"]["content"])
|
||||
for event in view.events
|
||||
if event.get("type") == "chat" and event["data"].get("role") == "assistant"
|
||||
] == ["starting", "continuing"]
|
||||
|
||||
|
||||
def test_resume_keeps_messages_the_user_actually_typed(tmp_path: Path) -> None:
|
||||
run_dir = tmp_path / "run"
|
||||
_write_run(
|
||||
run_dir,
|
||||
[
|
||||
{"role": "user", "content": "\n\nURLs: - https://example.com"},
|
||||
{"role": "assistant", "content": "starting"},
|
||||
{"role": "user", "content": "check the coupon endpoint next"},
|
||||
{"role": "assistant", "content": "on it"},
|
||||
{"role": "user", "content": "[NOTICE] Turn budget: 350/500 used (70%)."},
|
||||
{"role": "user", "content": "stop testing the admin panel"},
|
||||
],
|
||||
)
|
||||
view = TuiLiveView()
|
||||
|
||||
view.hydrate_from_run_dir(run_dir)
|
||||
|
||||
assert _user_messages(view) == [
|
||||
"check the coupon endpoint next",
|
||||
"stop testing the admin panel",
|
||||
]
|
||||
|
||||
|
||||
def test_resume_treats_each_agents_first_user_turn_as_its_task(tmp_path: Path) -> None:
|
||||
"""Subagents get their task the same way, so it is skipped per agent."""
|
||||
run_dir = tmp_path / "run"
|
||||
state_dir = runtime_state_dir(run_dir)
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
(state_dir / "agents.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"statuses": {"root": "running", "child": "running"},
|
||||
"names": {"root": "root", "child": "recon"},
|
||||
"parent_of": {"child": "root"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
connection = sqlite3.connect(state_dir / "agents.db")
|
||||
try:
|
||||
connection.execute(
|
||||
"create table agent_messages (id integer primary key, session_id text, "
|
||||
"message_data text, created_at text)"
|
||||
)
|
||||
rows = [
|
||||
("root", {"role": "user", "content": "\n\nURLs: - https://example.com"}),
|
||||
("child", {"role": "user", "content": "Audit the login flow."}),
|
||||
("child", {"role": "user", "content": "also try the password reset"}),
|
||||
]
|
||||
for index, (session_id, item) in enumerate(rows, start=1):
|
||||
connection.execute(
|
||||
"insert into agent_messages (id, session_id, message_data, created_at) "
|
||||
"values (?, ?, ?, ?)",
|
||||
(index, session_id, json.dumps(item), f"2026-01-01T00:00:{index:02d}+00:00"),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
view = TuiLiveView()
|
||||
|
||||
view.hydrate_from_run_dir(run_dir)
|
||||
|
||||
# Both tasks are skipped; only the follow-up typed at the child remains.
|
||||
assert _user_messages(view) == ["also try the password reset"]
|
||||
|
||||
|
||||
def test_internal_turn_classifier_matches_every_injected_form() -> None:
|
||||
for content in (
|
||||
# Coordinator deliveries, which wrap the stall, terminal and budget notices.
|
||||
"[Message from recon (a1) | type=information | priority=normal]\nfound it",
|
||||
"[Message from recon (a1) | type=stalled | priority=high]\n[Agent stalled] recon (a1)",
|
||||
"[Message from system (system) | type=budget_extended | priority=normal]\n"
|
||||
"[Budget] extended",
|
||||
# Budget warnings, the only notices injected without a wrapper.
|
||||
"[NOTICE] Turn budget: 350/500 used (70%).",
|
||||
"[URGENT] Scan cost budget: $9.50/$10.00 spent (95%).",
|
||||
"[CRITICAL] Turn budget: 480/500 used (96%).",
|
||||
"== Inherited context from parent (background only) ==",
|
||||
"Your previous message ended a turn without a tool call.",
|
||||
"Your previous response ended the autonomous Strix run without a lifecycle tool call.",
|
||||
):
|
||||
assert _is_internal_agent_turn(content), content
|
||||
|
||||
|
||||
def test_internal_turn_classifier_keeps_bracketed_user_text() -> None:
|
||||
"""A leading bracket is not enough: typed text often starts with one."""
|
||||
for content in (
|
||||
'[{"id": 1, "role": "admin"}, {"id": 2}]',
|
||||
"[link](https://example.com) check this endpoint",
|
||||
"[URGENT] stop testing the admin panel",
|
||||
"[2026-01-01 12:00:03] ERROR auth failed - look into this",
|
||||
"[note] creds are admin:hunter2",
|
||||
"[Agent] can you check this?",
|
||||
"[]",
|
||||
"check the coupon endpoint next",
|
||||
"Use creds admin:hunter2 for the login form",
|
||||
"stop",
|
||||
):
|
||||
assert not _is_internal_agent_turn(content), content
|
||||
|
||||
|
||||
@pytest.mark.parametrize("view_class", [TuiLiveView, GoTuiLiveView])
|
||||
def test_user_instruction_opens_the_transcript_when_the_root_agent_appears(
|
||||
view_class: type[TuiLiveView],
|
||||
) -> None:
|
||||
"""A live scan has no root agent yet, so the message waits for it.
|
||||
|
||||
Exercised against the projection the Go TUI actually uses as well as the
|
||||
base one: that subclass overrides upsert_agent without calling back, so a
|
||||
hook placed there would silently never run.
|
||||
"""
|
||||
view = view_class()
|
||||
|
||||
view.set_user_instruction("find IDOR in the checkout flow")
|
||||
assert _user_messages(view) == []
|
||||
|
||||
view.upsert_agent("ab12", name="Strix", parent_id=None, status="running")
|
||||
assert view.flush_user_instruction() is True
|
||||
assert _user_messages(view) == ["find IDOR in the checkout flow"]
|
||||
|
||||
# Repeated agent syncs and subagents must not repeat it.
|
||||
view.upsert_agent("cd34", name="recon", parent_id="ab12", status="running")
|
||||
view.upsert_agent("ab12", status="running")
|
||||
assert view.flush_user_instruction() is False
|
||||
assert _user_messages(view) == ["find IDOR in the checkout flow"]
|
||||
|
||||
|
||||
def test_blank_user_instruction_adds_nothing() -> None:
|
||||
view = TuiLiveView()
|
||||
|
||||
view.set_user_instruction(" ")
|
||||
view.set_user_instruction(None)
|
||||
view.upsert_agent("ab12", name="Strix", parent_id=None, status="running")
|
||||
|
||||
assert _user_messages(view) == []
|
||||
|
||||
|
||||
def test_replayed_run_opens_with_the_users_instruction(tmp_path: Path) -> None:
|
||||
"""It comes from the run record and sorts ahead of replayed history."""
|
||||
run_dir = tmp_path / "run"
|
||||
_write_run(
|
||||
run_dir,
|
||||
[
|
||||
{"role": "user", "content": "\n\nURLs: - https://example.com"},
|
||||
{"role": "assistant", "content": "starting"},
|
||||
{"role": "user", "content": "also check coupons"},
|
||||
],
|
||||
)
|
||||
(run_dir / "run.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"start_time": "2026-01-01T00:00:00+00:00",
|
||||
# instruction carries the diff-scope preamble; only the user's own
|
||||
# text belongs in the transcript.
|
||||
"instruction": "[diff-scope preamble]\n\naudit the auth flow",
|
||||
"user_instruction": "audit the auth flow",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
view = TuiLiveView()
|
||||
|
||||
view.hydrate_from_run_dir(run_dir)
|
||||
|
||||
assert _user_messages(view) == ["audit the auth flow", "also check coupons"]
|
||||
first = view.events[0]
|
||||
assert first["data"]["content"] == "audit the auth flow"
|
||||
# Stamped with the run's start, so ordering by timestamp keeps it first.
|
||||
assert first["timestamp"] == "2026-01-01T00:00:00+00:00"
|
||||
|
||||
|
||||
def test_replayed_run_without_an_instruction_is_unchanged(tmp_path: Path) -> None:
|
||||
run_dir = tmp_path / "run"
|
||||
_write_run(run_dir, [{"role": "assistant", "content": "starting"}])
|
||||
(run_dir / "run.json").write_text(
|
||||
json.dumps({"start_time": "2026-01-01T00:00:00+00:00"}), encoding="utf-8"
|
||||
)
|
||||
view = TuiLiveView()
|
||||
|
||||
view.hydrate_from_run_dir(run_dir)
|
||||
|
||||
assert _user_messages(view) == []
|
||||
@@ -0,0 +1,53 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import urllib3.response
|
||||
|
||||
from strix.telemetry import logging as tlog
|
||||
from strix.telemetry.logging import _is_urllib3_closed_file_noise
|
||||
|
||||
|
||||
class _Args:
|
||||
def __init__(self, exc_value: BaseException | None, obj: object) -> None:
|
||||
self.exc_type = type(exc_value) if exc_value is not None else None
|
||||
self.exc_value = exc_value
|
||||
self.exc_traceback = None
|
||||
self.err_msg = None
|
||||
self.object = obj
|
||||
|
||||
|
||||
def _urllib3_response() -> urllib3.response.HTTPResponse:
|
||||
return urllib3.response.HTTPResponse(body=b"")
|
||||
|
||||
|
||||
def test_filters_urllib3_closed_file_noise() -> None:
|
||||
args = _Args(ValueError("I/O operation on closed file."), _urllib3_response())
|
||||
assert _is_urllib3_closed_file_noise(args) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_passes_through_other_unraisables() -> None:
|
||||
assert not _is_urllib3_closed_file_noise(
|
||||
_Args(ValueError("I/O operation on closed file."), object()) # type: ignore[arg-type]
|
||||
)
|
||||
assert not _is_urllib3_closed_file_noise(
|
||||
_Args(RuntimeError("boom"), _urllib3_response()) # type: ignore[arg-type]
|
||||
)
|
||||
assert not _is_urllib3_closed_file_noise(
|
||||
_Args(ValueError("something else"), _urllib3_response()) # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_installed_hook_filters_and_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[object] = []
|
||||
monkeypatch.setattr(sys, "unraisablehook", calls.append)
|
||||
monkeypatch.setattr(tlog, "_unraisable_hook_installed", False)
|
||||
tlog._silence_urllib3_finalizer_noise()
|
||||
hook = sys.unraisablehook
|
||||
assert hook is not calls.append
|
||||
|
||||
hook(_Args(ValueError("I/O operation on closed file."), _urllib3_response())) # type: ignore[arg-type]
|
||||
assert calls == []
|
||||
|
||||
other = _Args(RuntimeError("boom"), object())
|
||||
hook(other) # type: ignore[arg-type]
|
||||
assert calls == [other]
|
||||
Reference in New Issue
Block a user