chore: nuke tests/ and the entire test toolchain
The test suite was carrying migration scars and a long tail of low-density assertions over SDK-derived behavior. Drop it wholesale. - Delete ``tests/`` (42 files, ~4900 LoC). - Drop ``pytest`` / ``pytest-asyncio`` / ``pytest-cov`` / ``pytest-mock`` from the dev dependency group; ``uv sync`` uninstalls the matching wheels. - Strip the pytest + coverage config blocks, the ``flake8-pytest-style`` ruff selector, the ``tests/**`` per-file ignores, the ``[tool.mypy.overrides] tests.*`` block, and the ``"tests"`` entry from bandit's ``exclude_dirs``. - Drop the ``test`` / ``test-cov`` Makefile targets; ``dev`` no longer depends on tests. - Strip the ``# Testing`` block from ``.gitignore`` (``.coverage``, ``.pytest_cache/``, ``htmlcov/``, ``coverage.xml``, ``nosetests.xml``, ``.tox/``, ``.hypothesis/``). ruff (27) and mypy (82) baselines unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
-12
@@ -39,18 +39,6 @@ pip-delete-this-directory.txt
|
|||||||
.pydevproject
|
.pydevproject
|
||||||
.settings/
|
.settings/
|
||||||
|
|
||||||
# Testing
|
|
||||||
.tox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
htmlcov/
|
|
||||||
|
|
||||||
# FastAPI
|
# FastAPI
|
||||||
.env.local
|
.env.local
|
||||||
.env.development.local
|
.env.development.local
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help install dev-install format lint type-check test test-cov clean pre-commit setup-dev
|
.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Available commands:"
|
@echo "Available commands:"
|
||||||
@@ -13,10 +13,6 @@ help:
|
|||||||
@echo " security - Run security checks with bandit"
|
@echo " security - Run security checks with bandit"
|
||||||
@echo " check-all - Run all code quality checks"
|
@echo " check-all - Run all code quality checks"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Testing:"
|
|
||||||
@echo " test - Run tests with pytest"
|
|
||||||
@echo " test-cov - Run tests with coverage reporting"
|
|
||||||
@echo ""
|
|
||||||
@echo "Development:"
|
@echo "Development:"
|
||||||
@echo " pre-commit - Run pre-commit hooks on all files"
|
@echo " pre-commit - Run pre-commit hooks on all files"
|
||||||
@echo " clean - Clean up cache files and artifacts"
|
@echo " clean - Clean up cache files and artifacts"
|
||||||
@@ -59,17 +55,6 @@ security:
|
|||||||
check-all: format lint type-check security
|
check-all: format lint type-check security
|
||||||
@echo "✅ All code quality checks passed!"
|
@echo "✅ All code quality checks passed!"
|
||||||
|
|
||||||
test:
|
|
||||||
@echo "🧪 Running tests..."
|
|
||||||
uv run pytest -v
|
|
||||||
@echo "✅ Tests complete!"
|
|
||||||
|
|
||||||
test-cov:
|
|
||||||
@echo "🧪 Running tests with coverage..."
|
|
||||||
uv run pytest -v --cov=strix --cov-report=term-missing --cov-report=html
|
|
||||||
@echo "✅ Tests with coverage complete!"
|
|
||||||
@echo "📊 Coverage report generated in htmlcov/"
|
|
||||||
|
|
||||||
pre-commit:
|
pre-commit:
|
||||||
@echo "🔧 Running pre-commit hooks..."
|
@echo "🔧 Running pre-commit hooks..."
|
||||||
uv run pre-commit run --all-files
|
uv run pre-commit run --all-files
|
||||||
@@ -78,13 +63,10 @@ pre-commit:
|
|||||||
clean:
|
clean:
|
||||||
@echo "🧹 Cleaning up cache files..."
|
@echo "🧹 Cleaning up cache files..."
|
||||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||||
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
|
|
||||||
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
|
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||||
find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
|
find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||||
find . -type d -name "htmlcov" -exec rm -rf {} + 2>/dev/null || true
|
|
||||||
find . -name "*.pyc" -delete 2>/dev/null || true
|
find . -name "*.pyc" -delete 2>/dev/null || true
|
||||||
find . -name ".coverage" -delete 2>/dev/null || true
|
|
||||||
@echo "✅ Cleanup complete!"
|
@echo "✅ Cleanup complete!"
|
||||||
|
|
||||||
dev: format lint type-check test
|
dev: format lint type-check
|
||||||
@echo "✅ Development cycle complete!"
|
@echo "✅ Development cycle complete!"
|
||||||
|
|||||||
+1
-78
@@ -71,10 +71,6 @@ dev = [
|
|||||||
"pyright>=1.1.401",
|
"pyright>=1.1.401",
|
||||||
"pylint>=3.3.7",
|
"pylint>=3.3.7",
|
||||||
"bandit>=1.8.3",
|
"bandit>=1.8.3",
|
||||||
"pytest>=8.4.0",
|
|
||||||
"pytest-asyncio>=1.0.0",
|
|
||||||
"pytest-cov>=6.1.1",
|
|
||||||
"pytest-mock>=3.14.1",
|
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.2.0",
|
||||||
"black>=25.1.0",
|
"black>=25.1.0",
|
||||||
"isort>=6.0.1",
|
"isort>=6.0.1",
|
||||||
@@ -131,7 +127,6 @@ module = [
|
|||||||
"textual.*",
|
"textual.*",
|
||||||
"pyte.*",
|
"pyte.*",
|
||||||
"libtmux.*",
|
"libtmux.*",
|
||||||
"pytest.*",
|
|
||||||
"cvss.*",
|
"cvss.*",
|
||||||
"opentelemetry.*",
|
"opentelemetry.*",
|
||||||
"scrubadub.*",
|
"scrubadub.*",
|
||||||
@@ -140,23 +135,6 @@ module = [
|
|||||||
]
|
]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
# Relax strict rules for test files (pytest decorators are not fully typed)
|
|
||||||
[[tool.mypy.overrides]]
|
|
||||||
module = ["tests.*"]
|
|
||||||
disallow_untyped_decorators = false
|
|
||||||
disallow_untyped_defs = false
|
|
||||||
# Test fixtures often build raw dicts that match SDK TypedDict unions
|
|
||||||
# structurally; type:ignore-per-line would be very noisy.
|
|
||||||
disable_error_code = [
|
|
||||||
"typeddict-item", # TypedDict key access on union variants
|
|
||||||
"typeddict-unknown-key",
|
|
||||||
"type-arg", # generic params on dict/MagicMock in test helpers
|
|
||||||
"no-untyped-call", # SDK has untyped __init__ in some places
|
|
||||||
"no-any-return", # test helpers often return Any from typed call_args
|
|
||||||
"arg-type", # fakes pass mocks where SDK wants strict types
|
|
||||||
"attr-defined", # fake objects monkey-patch attrs
|
|
||||||
]
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Ruff Configuration (Fast Python Linter & Formatter)
|
# Ruff Configuration (Fast Python Linter & Formatter)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -167,7 +145,6 @@ line-length = 100
|
|||||||
extend-exclude = [
|
extend-exclude = [
|
||||||
".git",
|
".git",
|
||||||
".mypy_cache",
|
".mypy_cache",
|
||||||
".pytest_cache",
|
|
||||||
".ruff_cache",
|
".ruff_cache",
|
||||||
"__pycache__",
|
"__pycache__",
|
||||||
"build",
|
"build",
|
||||||
@@ -203,7 +180,6 @@ select = [
|
|||||||
"PIE", # flake8-pie
|
"PIE", # flake8-pie
|
||||||
"T20", # flake8-print
|
"T20", # flake8-print
|
||||||
"PYI", # flake8-pyi
|
"PYI", # flake8-pyi
|
||||||
"PT", # flake8-pytest-style
|
|
||||||
"Q", # flake8-quotes
|
"Q", # flake8-quotes
|
||||||
"RSE", # flake8-raise
|
"RSE", # flake8-raise
|
||||||
"RET", # flake8-return
|
"RET", # flake8-return
|
||||||
@@ -246,17 +222,6 @@ ignore = [
|
|||||||
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
|
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
|
||||||
"strix/tools/finish/tool.py" = ["PLC0415", "TC002"]
|
"strix/tools/finish/tool.py" = ["PLC0415", "TC002"]
|
||||||
"strix/tools/reporting/tool.py" = ["PLC0415", "TC002"]
|
"strix/tools/reporting/tool.py" = ["PLC0415", "TC002"]
|
||||||
"tests/**/*.py" = [
|
|
||||||
"S105", # Possible hardcoded password (string literal)
|
|
||||||
"S106", # Possible hardcoded password (function call)
|
|
||||||
"S108", # Possible insecure usage of temporary file/directory
|
|
||||||
"ARG001", # Unused function argument
|
|
||||||
"ARG002", # Unused method argument (test helpers / fakes mirror SDK signatures)
|
|
||||||
"PLR2004", # Magic value used in comparison
|
|
||||||
"PT018", # Multi-part assertions are a pytest style preference
|
|
||||||
"TC002", # Type-only third-party import (tests import for runtime instantiation)
|
|
||||||
"TC003", # Type-only stdlib import
|
|
||||||
]
|
|
||||||
"strix/tools/**/*.py" = [
|
"strix/tools/**/*.py" = [
|
||||||
"ARG001", # Unused function argument (tools may have unused args for interface consistency)
|
"ARG001", # Unused function argument (tools may have unused args for interface consistency)
|
||||||
]
|
]
|
||||||
@@ -409,53 +374,11 @@ ensure_newline_before_comments = true
|
|||||||
known_first_party = ["strix"]
|
known_first_party = ["strix"]
|
||||||
known_third_party = ["fastapi", "pydantic", "litellm"]
|
known_third_party = ["fastapi", "pydantic", "litellm"]
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Pytest Configuration
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
minversion = "6.0"
|
|
||||||
addopts = [
|
|
||||||
"--strict-markers",
|
|
||||||
"--strict-config",
|
|
||||||
"--cov=strix",
|
|
||||||
"--cov-report=term-missing",
|
|
||||||
"--cov-report=html",
|
|
||||||
"--cov-report=xml",
|
|
||||||
]
|
|
||||||
testpaths = ["tests"]
|
|
||||||
python_files = ["test_*.py", "*_test.py"]
|
|
||||||
python_functions = ["test_*"]
|
|
||||||
python_classes = ["Test*"]
|
|
||||||
asyncio_mode = "auto"
|
|
||||||
|
|
||||||
[tool.coverage.run]
|
|
||||||
source = ["strix"]
|
|
||||||
omit = [
|
|
||||||
"*/tests/*",
|
|
||||||
"*/migrations/*",
|
|
||||||
"*/__pycache__/*"
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.coverage.report]
|
|
||||||
exclude_lines = [
|
|
||||||
"pragma: no cover",
|
|
||||||
"def __repr__",
|
|
||||||
"if self.debug:",
|
|
||||||
"if settings.DEBUG",
|
|
||||||
"raise AssertionError",
|
|
||||||
"raise NotImplementedError",
|
|
||||||
"if 0:",
|
|
||||||
"if __name__ == .__main__.:",
|
|
||||||
"class .*\\bProtocol\\):",
|
|
||||||
"@(abc\\.)?abstractmethod",
|
|
||||||
]
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Bandit Configuration (Security Linting)
|
# Bandit Configuration (Security Linting)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
[tool.bandit]
|
[tool.bandit]
|
||||||
exclude_dirs = ["tests", "docs", "build", "dist"]
|
exclude_dirs = ["docs", "build", "dist"]
|
||||||
skips = ["B101", "B601", "B404", "B603", "B607"] # Skip assert, shell injection, subprocess import and partial path checks
|
skips = ["B101", "B601", "B404", "B603", "B607"] # Skip assert, shell injection, subprocess import and partial path checks
|
||||||
severity = "medium"
|
severity = "medium"
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# Strix Test Suite
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for strix.agents module."""
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
"""Phase 5 tests for the SDK agent factory + prompt renderer.
|
|
||||||
|
|
||||||
These two modules are the keystone wiring between Phases 2-4 and an
|
|
||||||
actual ``Runner.run`` invocation. The tests verify:
|
|
||||||
|
|
||||||
- The prompt renderer reuses the existing Jinja template (parity with
|
|
||||||
legacy LLM._load_system_prompt) and degrades gracefully when the
|
|
||||||
template isn't available.
|
|
||||||
- ``build_strix_agent(is_root=True)`` carries ``finish_scan`` and
|
|
||||||
stops on it; child agents carry ``agent_finish`` and stop on it.
|
|
||||||
- ``make_child_factory`` snapshots scan-level config into a closure
|
|
||||||
so each spawned child inherits the right scan_mode / is_whitebox /
|
|
||||||
prompt context without create_agent having to re-derive it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from agents import Agent
|
|
||||||
from agents.tool import FunctionTool
|
|
||||||
|
|
||||||
from strix.agents.factory import build_strix_agent, make_child_factory
|
|
||||||
from strix.agents.prompt import _resolve_skills, render_system_prompt
|
|
||||||
|
|
||||||
|
|
||||||
# --- prompt renderer ----------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_skills_deduplicates_and_orders() -> None:
|
|
||||||
out = _resolve_skills(
|
|
||||||
requested=["recon", "xss", "recon"],
|
|
||||||
scan_mode="deep",
|
|
||||||
is_whitebox=False,
|
|
||||||
)
|
|
||||||
assert out == ["recon", "xss", "scan_modes/deep"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_skills_adds_whitebox_pair() -> None:
|
|
||||||
out = _resolve_skills(requested=None, scan_mode="fast", is_whitebox=True)
|
|
||||||
# The whitebox pair sits at the tail; scan_modes goes in the middle
|
|
||||||
# because callers can append more skills after it via the requested arg.
|
|
||||||
assert out == [
|
|
||||||
"scan_modes/fast",
|
|
||||||
"coordination/source_aware_whitebox",
|
|
||||||
"custom/source_aware_sast",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_system_prompt_returns_string() -> None:
|
|
||||||
"""Smoke: the StrixAgent template is on disk and renders to non-empty."""
|
|
||||||
out = render_system_prompt(skills=[], scan_mode="deep")
|
|
||||||
assert isinstance(out, str)
|
|
||||||
# The first line of the template starts with 'You are Strix'.
|
|
||||||
assert out.startswith("You are Strix")
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_system_prompt_swallows_template_errors() -> None:
|
|
||||||
"""If the template path can't be resolved, return an empty string
|
|
||||||
(not raise) — agent construction must never blow up on prompt load."""
|
|
||||||
with patch(
|
|
||||||
"strix.agents.prompt.get_strix_resource_path",
|
|
||||||
side_effect=RuntimeError("missing"),
|
|
||||||
):
|
|
||||||
out = render_system_prompt(skills=[])
|
|
||||||
assert out == ""
|
|
||||||
|
|
||||||
|
|
||||||
# --- factory: shape + tools --------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_root_agent_carries_finish_scan_and_stops_there() -> None:
|
|
||||||
agent = build_strix_agent(name="strix", is_root=True)
|
|
||||||
assert isinstance(agent, Agent)
|
|
||||||
tool_names = {t.name for t in agent.tools if isinstance(t, FunctionTool)}
|
|
||||||
assert "finish_scan" in tool_names
|
|
||||||
assert "agent_finish" not in tool_names
|
|
||||||
behavior = agent.tool_use_behavior
|
|
||||||
# StopAtTools is a TypedDict at runtime → behavior is a dict.
|
|
||||||
assert isinstance(behavior, dict)
|
|
||||||
assert behavior["stop_at_tool_names"] == ["finish_scan"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_child_agent_carries_agent_finish_and_stops_there() -> None:
|
|
||||||
agent = build_strix_agent(name="recon-bot", is_root=False)
|
|
||||||
tool_names = {t.name for t in agent.tools if isinstance(t, FunctionTool)}
|
|
||||||
assert "agent_finish" in tool_names
|
|
||||||
assert "finish_scan" not in tool_names
|
|
||||||
behavior = agent.tool_use_behavior
|
|
||||||
assert isinstance(behavior, dict)
|
|
||||||
assert behavior["stop_at_tool_names"] == ["agent_finish"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_root_and_child_share_base_tool_set() -> None:
|
|
||||||
"""The base tool set (think/todo/notes/file_edit/web_search/etc) is
|
|
||||||
identical between root and child — only the terminator differs."""
|
|
||||||
root = build_strix_agent(is_root=True)
|
|
||||||
child = build_strix_agent(is_root=False)
|
|
||||||
root_names = {t.name for t in root.tools if isinstance(t, FunctionTool)}
|
|
||||||
child_names = {t.name for t in child.tools if isinstance(t, FunctionTool)}
|
|
||||||
# Drop the terminators and compare.
|
|
||||||
assert root_names - {"finish_scan"} == child_names - {"agent_finish"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_includes_graph_and_sandbox_tools() -> None:
|
|
||||||
"""The graph + sandbox tool families are required for parity with
|
|
||||||
legacy. Spot-check the ones most likely to be forgotten in a refactor."""
|
|
||||||
agent = build_strix_agent(is_root=True)
|
|
||||||
names = {t.name for t in agent.tools if isinstance(t, FunctionTool)}
|
|
||||||
expected = {
|
|
||||||
"think",
|
|
||||||
"create_todo",
|
|
||||||
"create_note",
|
|
||||||
"web_search",
|
|
||||||
"str_replace_editor",
|
|
||||||
"create_vulnerability_report",
|
|
||||||
"browser_action",
|
|
||||||
"terminal_execute",
|
|
||||||
"python_action",
|
|
||||||
"view_agent_graph",
|
|
||||||
"agent_status",
|
|
||||||
"send_message_to_agent",
|
|
||||||
"wait_for_message",
|
|
||||||
"create_agent",
|
|
||||||
}
|
|
||||||
missing = expected - names
|
|
||||||
assert not missing, f"missing tools: {missing}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_does_not_include_caido_tools() -> None:
|
|
||||||
"""Caido tools come from CaidoCapability.tools(); the agent doesn't
|
|
||||||
declare them directly to avoid double-registration when the SDK
|
|
||||||
runtime merges capability tools."""
|
|
||||||
agent = build_strix_agent(is_root=True)
|
|
||||||
names = {t.name for t in agent.tools if isinstance(t, FunctionTool)}
|
|
||||||
caido = {
|
|
||||||
"list_requests",
|
|
||||||
"view_request",
|
|
||||||
"send_request",
|
|
||||||
"repeat_request",
|
|
||||||
"scope_rules",
|
|
||||||
"list_sitemap",
|
|
||||||
"view_sitemap_entry",
|
|
||||||
}
|
|
||||||
overlap = names & caido
|
|
||||||
assert overlap == set(), f"unexpected Caido tools in agent.tools: {overlap}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_uses_run_config_model() -> None:
|
|
||||||
"""``model=None`` so the RunConfig drives the model alias through
|
|
||||||
MultiProvider rather than an SDK default like gpt-4.1."""
|
|
||||||
agent = build_strix_agent(is_root=True)
|
|
||||||
assert agent.model is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_instructions_contain_rendered_prompt() -> None:
|
|
||||||
"""The factory must wire the rendered prompt into ``instructions``."""
|
|
||||||
agent = build_strix_agent(is_root=True, scan_mode="deep")
|
|
||||||
assert isinstance(agent.instructions, str)
|
|
||||||
assert agent.instructions.startswith("You are Strix")
|
|
||||||
|
|
||||||
|
|
||||||
# --- child factory ------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_child_factory_returns_callable_that_builds_child() -> None:
|
|
||||||
factory = make_child_factory(scan_mode="deep", is_whitebox=False)
|
|
||||||
assert callable(factory)
|
|
||||||
child = factory(name="sub-1", skills=["recon"])
|
|
||||||
assert isinstance(child, Agent)
|
|
||||||
assert child.name == "sub-1"
|
|
||||||
behavior = child.tool_use_behavior
|
|
||||||
assert isinstance(behavior, dict)
|
|
||||||
assert behavior["stop_at_tool_names"] == ["agent_finish"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_child_factory_passes_scan_level_config() -> None:
|
|
||||||
"""Verify scan_mode + is_whitebox flow into the rendered prompt
|
|
||||||
via the closure rather than the create_agent call site."""
|
|
||||||
captured: dict[str, Any] = {}
|
|
||||||
|
|
||||||
def fake_render(**kwargs: Any) -> str:
|
|
||||||
captured.update(kwargs)
|
|
||||||
return "stub-prompt"
|
|
||||||
|
|
||||||
factory = make_child_factory(
|
|
||||||
scan_mode="fast",
|
|
||||||
is_whitebox=True,
|
|
||||||
interactive=True,
|
|
||||||
system_prompt_context={"scope_source": "test"},
|
|
||||||
)
|
|
||||||
with patch("strix.agents.factory.render_system_prompt", side_effect=fake_render):
|
|
||||||
factory(name="child", skills=["xss"])
|
|
||||||
|
|
||||||
assert captured["scan_mode"] == "fast"
|
|
||||||
assert captured["is_whitebox"] is True
|
|
||||||
assert captured["interactive"] is True
|
|
||||||
assert captured["system_prompt_context"] == {"scope_source": "test"}
|
|
||||||
assert captured["skills"] == ["xss"]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for strix.config module."""
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
from strix.config.config import Config, apply_saved_config
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_config_override_clears_default_only_vars(monkeypatch, tmp_path) -> None:
|
|
||||||
from strix.interface.main import apply_config_override
|
|
||||||
|
|
||||||
default_cfg = tmp_path / "cli-config.json"
|
|
||||||
default_cfg.write_text(
|
|
||||||
json.dumps({"env": {"LLM_API_BASE": "https://default.api", "STRIX_LLM": "default-model"}}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
custom_cfg = tmp_path / "custom.json"
|
|
||||||
custom_cfg.write_text(json.dumps({"env": {"STRIX_LLM": "custom-model"}}), encoding="utf-8")
|
|
||||||
|
|
||||||
monkeypatch.setattr(Config, "_config_file_override", None)
|
|
||||||
monkeypatch.setattr(Config, "_applied_from_default", {})
|
|
||||||
monkeypatch.setattr(Config, "config_dir", classmethod(lambda cls: tmp_path))
|
|
||||||
for var_name in Config._llm_env_vars():
|
|
||||||
monkeypatch.delenv(var_name, raising=False)
|
|
||||||
|
|
||||||
apply_saved_config()
|
|
||||||
|
|
||||||
assert os.environ.get("LLM_API_BASE") == "https://default.api"
|
|
||||||
|
|
||||||
apply_config_override(str(custom_cfg))
|
|
||||||
|
|
||||||
assert os.environ.get("STRIX_LLM") == "custom-model"
|
|
||||||
assert "LLM_API_BASE" not in os.environ
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
from strix.config.config import Config
|
|
||||||
|
|
||||||
|
|
||||||
def test_traceloop_vars_are_tracked() -> None:
|
|
||||||
tracked = Config.tracked_vars()
|
|
||||||
|
|
||||||
assert "STRIX_OTEL_TELEMETRY" in tracked
|
|
||||||
assert "STRIX_POSTHOG_TELEMETRY" in tracked
|
|
||||||
assert "TRACELOOP_BASE_URL" in tracked
|
|
||||||
assert "TRACELOOP_API_KEY" in tracked
|
|
||||||
assert "TRACELOOP_HEADERS" in tracked
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_saved_uses_saved_traceloop_vars(monkeypatch, tmp_path) -> None:
|
|
||||||
config_path = tmp_path / "cli-config.json"
|
|
||||||
config_path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"env": {
|
|
||||||
"TRACELOOP_BASE_URL": "https://otel.example.com",
|
|
||||||
"TRACELOOP_API_KEY": "api-key",
|
|
||||||
"TRACELOOP_HEADERS": "x-test=value",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(Config, "_config_file_override", config_path)
|
|
||||||
monkeypatch.delenv("TRACELOOP_BASE_URL", raising=False)
|
|
||||||
monkeypatch.delenv("TRACELOOP_API_KEY", raising=False)
|
|
||||||
monkeypatch.delenv("TRACELOOP_HEADERS", raising=False)
|
|
||||||
|
|
||||||
applied = Config.apply_saved()
|
|
||||||
|
|
||||||
assert applied["TRACELOOP_BASE_URL"] == "https://otel.example.com"
|
|
||||||
assert applied["TRACELOOP_API_KEY"] == "api-key"
|
|
||||||
assert applied["TRACELOOP_HEADERS"] == "x-test=value"
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_saved_respects_existing_env_traceloop_vars(monkeypatch, tmp_path) -> None:
|
|
||||||
config_path = tmp_path / "cli-config.json"
|
|
||||||
config_path.write_text(
|
|
||||||
json.dumps({"env": {"TRACELOOP_BASE_URL": "https://otel.example.com"}}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(Config, "_config_file_override", config_path)
|
|
||||||
monkeypatch.setenv("TRACELOOP_BASE_URL", "https://env.example.com")
|
|
||||||
|
|
||||||
applied = Config.apply_saved(force=False)
|
|
||||||
|
|
||||||
assert "TRACELOOP_BASE_URL" not in applied
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Pytest configuration and shared fixtures for Strix tests."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for strix.interface module."""
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import importlib.util
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def _load_utils_module():
|
|
||||||
module_path = Path(__file__).resolve().parents[2] / "strix" / "interface" / "utils.py"
|
|
||||||
spec = importlib.util.spec_from_file_location("strix_interface_utils_test", module_path)
|
|
||||||
if spec is None or spec.loader is None:
|
|
||||||
raise RuntimeError("Failed to load strix.interface.utils for tests")
|
|
||||||
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
utils = _load_utils_module()
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_name_status_uses_rename_destination_path() -> None:
|
|
||||||
raw = (
|
|
||||||
b"R100\x00old/path.py\x00new/path.py\x00"
|
|
||||||
b"R75\x00legacy/module.py\x00modern/module.py\x00"
|
|
||||||
b"M\x00src/app.py\x00"
|
|
||||||
b"A\x00src/new_file.py\x00"
|
|
||||||
b"D\x00src/deleted.py\x00"
|
|
||||||
)
|
|
||||||
|
|
||||||
entries = utils._parse_name_status_z(raw)
|
|
||||||
classified = utils._classify_diff_entries(entries)
|
|
||||||
|
|
||||||
assert "new/path.py" in classified["analyzable_files"]
|
|
||||||
assert "old/path.py" not in classified["analyzable_files"]
|
|
||||||
assert "modern/module.py" in classified["analyzable_files"]
|
|
||||||
assert classified["renamed_files"][0]["old_path"] == "old/path.py"
|
|
||||||
assert classified["renamed_files"][0]["new_path"] == "new/path.py"
|
|
||||||
assert "src/deleted.py" in classified["deleted_files"]
|
|
||||||
assert "src/deleted.py" not in classified["analyzable_files"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_diff_scope_instruction_includes_added_modified_and_deleted_guidance() -> None:
|
|
||||||
scope = utils.RepoDiffScope(
|
|
||||||
source_path="/tmp/repo",
|
|
||||||
workspace_subdir="repo",
|
|
||||||
base_ref="refs/remotes/origin/main",
|
|
||||||
merge_base="abc123",
|
|
||||||
added_files=["src/added.py"],
|
|
||||||
modified_files=["src/changed.py"],
|
|
||||||
renamed_files=[{"old_path": "src/old.py", "new_path": "src/new.py", "similarity": 90}],
|
|
||||||
deleted_files=["src/deleted.py"],
|
|
||||||
analyzable_files=["src/added.py", "src/changed.py", "src/new.py"],
|
|
||||||
)
|
|
||||||
|
|
||||||
instruction = utils.build_diff_scope_instruction([scope])
|
|
||||||
|
|
||||||
assert "For Added files, review the entire file content." in instruction
|
|
||||||
assert "For Modified files, focus primarily on the changed areas." in instruction
|
|
||||||
assert "Note: These files were deleted" in instruction
|
|
||||||
assert "src/deleted.py" in instruction
|
|
||||||
assert "src/old.py -> src/new.py" in instruction
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_base_ref_prefers_github_base_ref(monkeypatch) -> None:
|
|
||||||
calls: list[str] = []
|
|
||||||
|
|
||||||
def fake_ref_exists(_repo_path: Path, ref: str) -> bool:
|
|
||||||
calls.append(ref)
|
|
||||||
return ref == "refs/remotes/origin/release-2026"
|
|
||||||
|
|
||||||
monkeypatch.setattr(utils, "_git_ref_exists", fake_ref_exists)
|
|
||||||
monkeypatch.setattr(utils, "_extract_github_base_sha", lambda _env: None)
|
|
||||||
monkeypatch.setattr(utils, "_resolve_origin_head_ref", lambda _repo_path: None)
|
|
||||||
|
|
||||||
base_ref = utils._resolve_base_ref(
|
|
||||||
Path("/tmp/repo"),
|
|
||||||
diff_base=None,
|
|
||||||
env={"GITHUB_BASE_REF": "release-2026"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert base_ref == "refs/remotes/origin/release-2026"
|
|
||||||
assert calls[0] == "refs/remotes/origin/release-2026"
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_base_ref_falls_back_to_remote_main(monkeypatch) -> None:
|
|
||||||
calls: list[str] = []
|
|
||||||
|
|
||||||
def fake_ref_exists(_repo_path: Path, ref: str) -> bool:
|
|
||||||
calls.append(ref)
|
|
||||||
return ref == "refs/remotes/origin/main"
|
|
||||||
|
|
||||||
monkeypatch.setattr(utils, "_git_ref_exists", fake_ref_exists)
|
|
||||||
monkeypatch.setattr(utils, "_extract_github_base_sha", lambda _env: None)
|
|
||||||
monkeypatch.setattr(utils, "_resolve_origin_head_ref", lambda _repo_path: None)
|
|
||||||
|
|
||||||
base_ref = utils._resolve_base_ref(Path("/tmp/repo"), diff_base=None, env={})
|
|
||||||
|
|
||||||
assert base_ref == "refs/remotes/origin/main"
|
|
||||||
assert "refs/remotes/origin/main" in calls
|
|
||||||
assert "origin/main" not in calls
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_diff_scope_context_auto_degrades_when_repo_scope_resolution_fails(
|
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
source = {"source_path": "/tmp/repo", "workspace_subdir": "repo"}
|
|
||||||
|
|
||||||
monkeypatch.setattr(utils, "_should_activate_auto_scope", lambda *_args, **_kwargs: True)
|
|
||||||
monkeypatch.setattr(utils, "_is_git_repo", lambda _repo_path: True)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
utils,
|
|
||||||
"_resolve_repo_diff_scope",
|
|
||||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("shallow history")),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = utils.resolve_diff_scope_context(
|
|
||||||
local_sources=[source],
|
|
||||||
scope_mode="auto",
|
|
||||||
diff_base=None,
|
|
||||||
non_interactive=True,
|
|
||||||
env={},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.active is False
|
|
||||||
assert result.mode == "auto"
|
|
||||||
assert result.metadata["active"] is False
|
|
||||||
assert result.metadata["mode"] == "auto"
|
|
||||||
assert "skipped_diff_scope_sources" in result.metadata
|
|
||||||
assert result.metadata["skipped_diff_scope_sources"] == [
|
|
||||||
"/tmp/repo (diff-scope skipped: shallow history)"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_diff_scope_context_diff_mode_still_raises_on_repo_scope_resolution_failure(
|
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
source = {"source_path": "/tmp/repo", "workspace_subdir": "repo"}
|
|
||||||
|
|
||||||
monkeypatch.setattr(utils, "_is_git_repo", lambda _repo_path: True)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
utils,
|
|
||||||
"_resolve_repo_diff_scope",
|
|
||||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("shallow history")),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="shallow history"):
|
|
||||||
utils.resolve_diff_scope_context(
|
|
||||||
local_sources=[source],
|
|
||||||
scope_mode="diff",
|
|
||||||
diff_base=None,
|
|
||||||
non_interactive=True,
|
|
||||||
env={},
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for strix.llm module."""
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
"""Smoke tests for AnthropicCachingLitellmModel."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from strix.llm.anthropic_cache_wrapper import AnthropicCachingLitellmModel
|
|
||||||
|
|
||||||
|
|
||||||
def _make(model: str) -> AnthropicCachingLitellmModel:
|
|
||||||
# ``LitellmModel.__init__`` only validates that model is a string; we
|
|
||||||
# don't need a real API key for in-memory ``_patch`` testing.
|
|
||||||
return AnthropicCachingLitellmModel(model=model, api_key="test-key")
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_anthropic_detects_anthropic_prefix() -> None:
|
|
||||||
m = _make("anthropic/claude-3-5-sonnet")
|
|
||||||
assert m._is_anthropic() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_anthropic_detects_claude_substring() -> None:
|
|
||||||
m = _make("openrouter/anthropic-claude-haiku")
|
|
||||||
assert m._is_anthropic() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_anthropic_false_for_openai() -> None:
|
|
||||||
m = _make("openai/gpt-4o")
|
|
||||||
assert m._is_anthropic() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_anthropic_false_for_gemini() -> None:
|
|
||||||
m = _make("gemini/gemini-1.5-pro")
|
|
||||||
assert m._is_anthropic() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_patch_anthropic_adds_cache_control_to_system() -> None:
|
|
||||||
m = _make("anthropic/claude-3-5-sonnet")
|
|
||||||
items: list = [
|
|
||||||
{"role": "system", "content": "You are a helpful agent."},
|
|
||||||
{"role": "user", "content": "hi"},
|
|
||||||
]
|
|
||||||
out = m._patch(items)
|
|
||||||
assert out[0]["role"] == "system"
|
|
||||||
content = out[0]["content"]
|
|
||||||
assert isinstance(content, list)
|
|
||||||
assert content[0]["type"] == "text"
|
|
||||||
assert content[0]["text"] == "You are a helpful agent."
|
|
||||||
assert content[0]["cache_control"] == {"type": "ephemeral"}
|
|
||||||
# Second item passes through unchanged.
|
|
||||||
assert out[1] == {"role": "user", "content": "hi"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_patch_non_anthropic_passes_through() -> None:
|
|
||||||
m = _make("openai/gpt-4o")
|
|
||||||
items: list = [
|
|
||||||
{"role": "system", "content": "You are a helpful agent."},
|
|
||||||
{"role": "user", "content": "hi"},
|
|
||||||
]
|
|
||||||
assert m._patch(items) is items # exact same list reference, no copy
|
|
||||||
|
|
||||||
|
|
||||||
def test_patch_skips_non_string_system_content() -> None:
|
|
||||||
"""If system content is already structured (e.g., previously patched),
|
|
||||||
don't re-wrap — pass through unchanged."""
|
|
||||||
m = _make("anthropic/claude-3-5-sonnet")
|
|
||||||
items: list = [
|
|
||||||
{"role": "system", "content": [{"type": "text", "text": "x"}]},
|
|
||||||
{"role": "user", "content": "hi"},
|
|
||||||
]
|
|
||||||
out = m._patch(items)
|
|
||||||
assert out[0]["content"] == [{"type": "text", "text": "x"}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_patch_handles_empty_list() -> None:
|
|
||||||
m = _make("anthropic/claude-3-5-sonnet")
|
|
||||||
assert m._patch([]) == []
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""Smoke tests for the multi-provider setup."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from agents.exceptions import UserError
|
|
||||||
|
|
||||||
from strix.llm.anthropic_cache_wrapper import AnthropicCachingLitellmModel
|
|
||||||
from strix.llm.multi_provider_setup import (
|
|
||||||
_AnthropicCachingProvider,
|
|
||||||
build_multi_provider,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_anthropic_provider_wraps_in_caching_model() -> None:
|
|
||||||
provider = _AnthropicCachingProvider()
|
|
||||||
model = provider.get_model("claude-sonnet-4-6")
|
|
||||||
assert isinstance(model, AnthropicCachingLitellmModel)
|
|
||||||
# The provider re-prefixes with ``anthropic/`` so litellm routes correctly.
|
|
||||||
assert model.model == "anthropic/claude-sonnet-4-6"
|
|
||||||
assert model._is_anthropic() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_anthropic_provider_preserves_existing_anthropic_prefix() -> None:
|
|
||||||
"""If the alias already carries ``anthropic/``, don't double-prefix."""
|
|
||||||
provider = _AnthropicCachingProvider()
|
|
||||||
model = provider.get_model("anthropic/claude-3-5-sonnet-20241022")
|
|
||||||
assert model.model == "anthropic/claude-3-5-sonnet-20241022"
|
|
||||||
|
|
||||||
|
|
||||||
def test_anthropic_provider_empty_name_raises() -> None:
|
|
||||||
provider = _AnthropicCachingProvider()
|
|
||||||
with pytest.raises(UserError, match="non-empty"):
|
|
||||||
provider.get_model(None)
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_multi_provider_routes_anthropic_through_caching_wrapper() -> None:
|
|
||||||
"""The configured MultiProvider should hit our caching wrapper for the
|
|
||||||
``anthropic/`` prefix."""
|
|
||||||
mp = build_multi_provider()
|
|
||||||
model = mp.get_model("anthropic/claude-sonnet-4-6")
|
|
||||||
assert isinstance(model, AnthropicCachingLitellmModel)
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
"""Phase 0 smoke tests for AgentMessageBus."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from strix.orchestration.bus import AgentMessageBus
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def bus() -> AgentMessageBus:
|
|
||||||
return AgentMessageBus()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_register_records_agent(bus: AgentMessageBus) -> None:
|
|
||||||
await bus.register("a1", "alpha", parent_id=None)
|
|
||||||
assert bus.statuses["a1"] == "running"
|
|
||||||
assert bus.parent_of["a1"] is None
|
|
||||||
assert bus.names["a1"] == "alpha"
|
|
||||||
assert bus.inboxes["a1"] == []
|
|
||||||
assert bus.stats_live["a1"]["calls"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_and_drain_fifo(bus: AgentMessageBus) -> None:
|
|
||||||
await bus.register("a1", "alpha", parent_id=None)
|
|
||||||
await bus.send("a1", {"from": "b", "content": "first"})
|
|
||||||
await bus.send("a1", {"from": "c", "content": "second"})
|
|
||||||
drained = await bus.drain("a1")
|
|
||||||
assert [m["content"] for m in drained] == ["first", "second"]
|
|
||||||
assert await bus.drain("a1") == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_to_unknown_agent_is_dropped(bus: AgentMessageBus) -> None:
|
|
||||||
await bus.send("ghost", {"from": "user", "content": "x"})
|
|
||||||
assert "ghost" not in bus.inboxes
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_finalize_clears_inbox_parent_name(bus: AgentMessageBus) -> None:
|
|
||||||
"""C13 (AUDIT_R3): finalize cleans up routing state to avoid orphan messages."""
|
|
||||||
await bus.register("a1", "alpha", parent_id=None)
|
|
||||||
await bus.register("a2", "beta", parent_id="a1")
|
|
||||||
await bus.send("a1", {"from": "a2", "content": "hi"})
|
|
||||||
await bus.finalize("a1", "completed")
|
|
||||||
# Inbox / parent / name removed so siblings can't accidentally re-fill.
|
|
||||||
assert "a1" not in bus.inboxes
|
|
||||||
assert "a1" not in bus.parent_of
|
|
||||||
assert "a1" not in bus.names
|
|
||||||
# Status remains for diagnostics.
|
|
||||||
assert bus.statuses["a1"] == "completed"
|
|
||||||
# Messages sent to a finalized agent are dropped silently.
|
|
||||||
await bus.send("a1", {"from": "a2", "content": "ignored"})
|
|
||||||
assert "a1" not in bus.inboxes
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_record_usage_aggregates(bus: AgentMessageBus) -> None:
|
|
||||||
await bus.register("a1", "alpha", parent_id=None)
|
|
||||||
|
|
||||||
class _Details:
|
|
||||||
cached_tokens = 10
|
|
||||||
|
|
||||||
class _Usage:
|
|
||||||
input_tokens = 100
|
|
||||||
output_tokens = 50
|
|
||||||
input_tokens_details = _Details()
|
|
||||||
|
|
||||||
await bus.record_usage("a1", _Usage())
|
|
||||||
await bus.record_usage("a1", _Usage())
|
|
||||||
stats = bus.stats_live["a1"]
|
|
||||||
assert stats["in"] == 200
|
|
||||||
assert stats["out"] == 100
|
|
||||||
assert stats["cached"] == 20
|
|
||||||
assert stats["calls"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_record_usage_handles_none(bus: AgentMessageBus) -> None:
|
|
||||||
await bus.register("a1", "alpha", parent_id=None)
|
|
||||||
await bus.record_usage("a1", None)
|
|
||||||
assert bus.stats_live["a1"]["calls"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_total_stats_snapshot(bus: AgentMessageBus) -> None:
|
|
||||||
"""C12 (AUDIT_R2): total_stats acquires the lock for a consistent snapshot."""
|
|
||||||
await bus.register("a1", "alpha", parent_id=None)
|
|
||||||
await bus.register("a2", "beta", parent_id="a1")
|
|
||||||
|
|
||||||
class _Details:
|
|
||||||
cached_tokens = 5
|
|
||||||
|
|
||||||
class _Usage:
|
|
||||||
input_tokens = 10
|
|
||||||
output_tokens = 20
|
|
||||||
input_tokens_details = _Details()
|
|
||||||
|
|
||||||
await bus.record_usage("a1", _Usage())
|
|
||||||
await bus.record_usage("a2", _Usage())
|
|
||||||
await bus.finalize("a2", "completed")
|
|
||||||
|
|
||||||
totals = await bus.total_stats()
|
|
||||||
assert totals["in"] == 20
|
|
||||||
assert totals["out"] == 40
|
|
||||||
assert totals["cached"] == 10
|
|
||||||
assert totals["calls"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_concurrent_send_drain_no_lost_messages() -> None:
|
|
||||||
bus = AgentMessageBus()
|
|
||||||
await bus.register("a1", "alpha", parent_id=None)
|
|
||||||
|
|
||||||
async def producer(start: int, count: int) -> None:
|
|
||||||
for i in range(count):
|
|
||||||
await bus.send("a1", {"from": "p", "content": str(start + i)})
|
|
||||||
|
|
||||||
# 50 producers x 20 messages = 1000 messages; drain in 1 reader.
|
|
||||||
producers = [asyncio.create_task(producer(i * 20, 20)) for i in range(50)]
|
|
||||||
await asyncio.gather(*producers)
|
|
||||||
drained = await bus.drain("a1")
|
|
||||||
assert len(drained) == 1000
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cancel_descendants_cancels_whole_tree() -> None:
|
|
||||||
"""C9 (AUDIT_R2): cancel_descendants cancels every transitive child."""
|
|
||||||
bus = AgentMessageBus()
|
|
||||||
await bus.register("root", "root", parent_id=None)
|
|
||||||
await bus.register("child1", "c1", parent_id="root")
|
|
||||||
await bus.register("grandchild1", "g1", parent_id="child1")
|
|
||||||
await bus.register("child2", "c2", parent_id="root")
|
|
||||||
|
|
||||||
pending = asyncio.get_event_loop().create_future()
|
|
||||||
|
|
||||||
async def fake_run() -> None:
|
|
||||||
await pending # block until cancelled
|
|
||||||
|
|
||||||
for aid in ("root", "child1", "grandchild1", "child2"):
|
|
||||||
bus.tasks[aid] = asyncio.create_task(fake_run())
|
|
||||||
|
|
||||||
await bus.cancel_descendants("root")
|
|
||||||
|
|
||||||
for aid in ("root", "child1", "grandchild1", "child2"):
|
|
||||||
assert bus.tasks[aid].cancelled() or bus.tasks[aid].done()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cancel_descendants_triggers_leaves_before_root() -> None:
|
|
||||||
"""C9: explicit ordering check — leaves' .cancel() called before root's."""
|
|
||||||
bus = AgentMessageBus()
|
|
||||||
await bus.register("root", "root", parent_id=None)
|
|
||||||
await bus.register("child1", "c1", parent_id="root")
|
|
||||||
await bus.register("grandchild1", "g1", parent_id="child1")
|
|
||||||
|
|
||||||
cancel_call_order: list[str] = []
|
|
||||||
pending = asyncio.get_event_loop().create_future()
|
|
||||||
|
|
||||||
class _RecordingTask:
|
|
||||||
"""Wrap a real Task; record the moment .cancel() is invoked."""
|
|
||||||
|
|
||||||
def __init__(self, name: str, task: asyncio.Task) -> None:
|
|
||||||
self._name = name
|
|
||||||
self._task = task
|
|
||||||
|
|
||||||
def done(self) -> bool:
|
|
||||||
return self._task.done()
|
|
||||||
|
|
||||||
def cancelled(self) -> bool:
|
|
||||||
return self._task.cancelled()
|
|
||||||
|
|
||||||
def cancel(self, *args: object, **kwargs: object) -> bool:
|
|
||||||
cancel_call_order.append(self._name)
|
|
||||||
return self._task.cancel()
|
|
||||||
|
|
||||||
def __await__(self):
|
|
||||||
return self._task.__await__()
|
|
||||||
|
|
||||||
async def fake_run() -> None:
|
|
||||||
await pending
|
|
||||||
|
|
||||||
for aid in ("root", "child1", "grandchild1"):
|
|
||||||
real = asyncio.create_task(fake_run())
|
|
||||||
bus.tasks[aid] = _RecordingTask(aid, real) # type: ignore[assignment]
|
|
||||||
|
|
||||||
await bus.cancel_descendants("root")
|
|
||||||
|
|
||||||
# grandchild and child must have .cancel() called before root.
|
|
||||||
assert cancel_call_order.index("grandchild1") < cancel_call_order.index("root")
|
|
||||||
assert cancel_call_order.index("child1") < cancel_call_order.index("root")
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"""Phase 0 smoke tests for inject_messages_filter."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from agents.run_config import CallModelData, ModelInputData
|
|
||||||
|
|
||||||
from strix.orchestration.bus import AgentMessageBus
|
|
||||||
from strix.orchestration.filter import inject_messages_filter
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _FakeAgent:
|
|
||||||
name: str = "agent"
|
|
||||||
|
|
||||||
|
|
||||||
def _call_data(
|
|
||||||
context: Any,
|
|
||||||
items: list[Any],
|
|
||||||
instructions: str | None = "system",
|
|
||||||
) -> CallModelData[Any]:
|
|
||||||
return CallModelData(
|
|
||||||
model_data=ModelInputData(input=items, instructions=instructions),
|
|
||||||
agent=_FakeAgent(),
|
|
||||||
context=context,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_empty_inbox_passes_through() -> None:
|
|
||||||
bus = AgentMessageBus()
|
|
||||||
await bus.register("a1", "alpha", parent_id=None)
|
|
||||||
data = _call_data({"bus": bus, "agent_id": "a1"}, [{"role": "user", "content": "x"}])
|
|
||||||
|
|
||||||
out = await inject_messages_filter(data)
|
|
||||||
|
|
||||||
assert out.input == [{"role": "user", "content": "x"}]
|
|
||||||
assert out.instructions == "system"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pending_messages_appended_in_order() -> None:
|
|
||||||
bus = AgentMessageBus()
|
|
||||||
await bus.register("a1", "alpha", parent_id=None)
|
|
||||||
await bus.send("a1", {"from": "b", "content": "hello", "type": "info", "priority": "normal"})
|
|
||||||
await bus.send("a1", {"from": "c", "content": "second", "type": "info", "priority": "high"})
|
|
||||||
data = _call_data({"bus": bus, "agent_id": "a1"}, [{"role": "user", "content": "task"}])
|
|
||||||
|
|
||||||
out = await inject_messages_filter(data)
|
|
||||||
|
|
||||||
assert len(out.input) == 3
|
|
||||||
assert out.input[0] == {"role": "user", "content": "task"}
|
|
||||||
assert "Message from agent b" in out.input[1]["content"]
|
|
||||||
assert "hello" in out.input[1]["content"]
|
|
||||||
assert "second" in out.input[2]["content"]
|
|
||||||
assert "priority=high" in out.input[2]["content"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_user_sender_skips_envelope() -> None:
|
|
||||||
bus = AgentMessageBus()
|
|
||||||
await bus.register("a1", "alpha", parent_id=None)
|
|
||||||
await bus.send("a1", {"from": "user", "content": "follow-up question"})
|
|
||||||
data = _call_data({"bus": bus, "agent_id": "a1"}, [])
|
|
||||||
|
|
||||||
out = await inject_messages_filter(data)
|
|
||||||
|
|
||||||
assert out.input == [{"role": "user", "content": "follow-up question"}]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_no_bus_in_context_passes_through() -> None:
|
|
||||||
data = _call_data({"agent_id": "a1"}, [{"role": "user", "content": "x"}])
|
|
||||||
out = await inject_messages_filter(data)
|
|
||||||
assert out.input == [{"role": "user", "content": "x"}]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_filter_exception_returns_unmodified() -> None:
|
|
||||||
"""C14 (AUDIT_R3): filter exception is caught; original data returned."""
|
|
||||||
|
|
||||||
class _BrokenBus:
|
|
||||||
async def drain(self, _: str) -> list[dict[str, Any]]:
|
|
||||||
raise RuntimeError("simulated bug")
|
|
||||||
|
|
||||||
data = _call_data(
|
|
||||||
{"bus": _BrokenBus(), "agent_id": "a1"},
|
|
||||||
[{"role": "user", "content": "still works"}],
|
|
||||||
)
|
|
||||||
|
|
||||||
out = await inject_messages_filter(data)
|
|
||||||
assert out.input == [{"role": "user", "content": "still works"}]
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
"""Phase 0 smoke tests for StrixOrchestrationHooks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from strix.orchestration.bus import AgentMessageBus
|
|
||||||
from strix.orchestration.hooks import StrixOrchestrationHooks
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _Ctx:
|
|
||||||
"""Minimal stand-in for RunContextWrapper / AgentHookContext.
|
|
||||||
|
|
||||||
Only ``.context`` is exercised by the hooks under test; SDK's real wrappers
|
|
||||||
expose much more, but the hooks treat ``.context`` as the dict we put in.
|
|
||||||
"""
|
|
||||||
|
|
||||||
context: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _Tool:
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeTracer:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.starts: list[tuple[str, str]] = []
|
|
||||||
self.ends: list[tuple[str, str, Any]] = []
|
|
||||||
|
|
||||||
def log_tool_start(self, agent_id: str, tool_name: str) -> None:
|
|
||||||
self.starts.append((agent_id, tool_name))
|
|
||||||
|
|
||||||
def log_tool_end(self, agent_id: str, tool_name: str, result: Any) -> None:
|
|
||||||
self.ends.append((agent_id, tool_name, result))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_llm_start_injects_85_percent_warning() -> None:
|
|
||||||
hooks = StrixOrchestrationHooks()
|
|
||||||
items: list[Any] = []
|
|
||||||
ctx = _Ctx(context={"max_turns": 100, "turn_count": 85})
|
|
||||||
await hooks.on_llm_start(ctx, agent=None, system_prompt="x", input_items=items)
|
|
||||||
assert len(items) == 1
|
|
||||||
assert "85%" in items[0]["content"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_llm_start_injects_n_minus_3_warning() -> None:
|
|
||||||
hooks = StrixOrchestrationHooks()
|
|
||||||
items: list[Any] = []
|
|
||||||
ctx = _Ctx(context={"max_turns": 100, "turn_count": 97})
|
|
||||||
await hooks.on_llm_start(ctx, agent=None, system_prompt="x", input_items=items)
|
|
||||||
assert len(items) == 1
|
|
||||||
assert "3 iterations left" in items[0]["content"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_llm_start_no_warning_at_other_turns() -> None:
|
|
||||||
hooks = StrixOrchestrationHooks()
|
|
||||||
items: list[Any] = []
|
|
||||||
ctx = _Ctx(context={"max_turns": 100, "turn_count": 50})
|
|
||||||
await hooks.on_llm_start(ctx, agent=None, system_prompt="x", input_items=items)
|
|
||||||
assert items == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_llm_end_records_usage_and_increments_turn() -> None:
|
|
||||||
hooks = StrixOrchestrationHooks()
|
|
||||||
bus = AgentMessageBus()
|
|
||||||
await bus.register("a1", "alpha", parent_id=None)
|
|
||||||
ctx = _Ctx(context={"bus": bus, "agent_id": "a1", "turn_count": 0})
|
|
||||||
|
|
||||||
class _Details:
|
|
||||||
cached_tokens = 5
|
|
||||||
|
|
||||||
class _Usage:
|
|
||||||
input_tokens = 10
|
|
||||||
output_tokens = 20
|
|
||||||
input_tokens_details = _Details()
|
|
||||||
|
|
||||||
class _Resp:
|
|
||||||
usage = _Usage()
|
|
||||||
|
|
||||||
await hooks.on_llm_end(ctx, agent=None, response=_Resp())
|
|
||||||
assert ctx.context["turn_count"] == 1
|
|
||||||
assert bus.stats_live["a1"]["in"] == 10
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_agent_end_detects_crash() -> None:
|
|
||||||
"""on_agent_end without agent_finish_called posts crash message to parent."""
|
|
||||||
hooks = StrixOrchestrationHooks()
|
|
||||||
bus = AgentMessageBus()
|
|
||||||
await bus.register("root", "root", parent_id=None)
|
|
||||||
await bus.register("child", "specialist", parent_id="root")
|
|
||||||
ctx = _Ctx(context={"bus": bus, "agent_id": "child"})
|
|
||||||
|
|
||||||
await hooks.on_agent_end(ctx, agent=None, output=None) # crashed (output=None)
|
|
||||||
|
|
||||||
drained = await bus.drain("root")
|
|
||||||
assert len(drained) == 1
|
|
||||||
assert "[Agent crash]" in drained[0]["content"]
|
|
||||||
assert "child" in drained[0]["content"]
|
|
||||||
assert drained[0]["type"] == "crash"
|
|
||||||
assert bus.statuses["child"] == "crashed"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_agent_end_no_crash_when_finish_called() -> None:
|
|
||||||
hooks = StrixOrchestrationHooks()
|
|
||||||
bus = AgentMessageBus()
|
|
||||||
await bus.register("root", "root", parent_id=None)
|
|
||||||
await bus.register("child", "specialist", parent_id="root")
|
|
||||||
ctx = _Ctx(
|
|
||||||
context={
|
|
||||||
"bus": bus,
|
|
||||||
"agent_id": "child",
|
|
||||||
"agent_finish_called": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
await hooks.on_agent_end(ctx, agent=None, output="done")
|
|
||||||
|
|
||||||
assert await bus.drain("root") == []
|
|
||||||
assert bus.statuses["child"] == "completed"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_tool_end_marks_finish_called() -> None:
|
|
||||||
"""When agent_finish or finish_scan returns, mark context flag for crash detection."""
|
|
||||||
hooks = StrixOrchestrationHooks()
|
|
||||||
ctx = _Ctx(context={"agent_id": "a1"})
|
|
||||||
await hooks.on_tool_end(ctx, agent=None, tool=_Tool("agent_finish"), result="ok")
|
|
||||||
assert ctx.context["agent_finish_called"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_tool_end_other_tool_does_not_set_flag() -> None:
|
|
||||||
hooks = StrixOrchestrationHooks()
|
|
||||||
ctx = _Ctx(context={"agent_id": "a1"})
|
|
||||||
await hooks.on_tool_end(ctx, agent=None, tool=_Tool("terminal_execute"), result="x")
|
|
||||||
assert ctx.context.get("agent_finish_called") is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_tool_start_logs_to_tracer() -> None:
|
|
||||||
hooks = StrixOrchestrationHooks()
|
|
||||||
tracer = _FakeTracer()
|
|
||||||
ctx = _Ctx(context={"tracer": tracer, "agent_id": "a1"})
|
|
||||||
await hooks.on_tool_start(ctx, agent=None, tool=_Tool("browser_action"))
|
|
||||||
assert tracer.starts == [("a1", "browser_action")]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_hook_exception_does_not_propagate() -> None:
|
|
||||||
"""C15 (AUDIT_R3): a bug in the hook body must never tear down the run."""
|
|
||||||
hooks = StrixOrchestrationHooks()
|
|
||||||
|
|
||||||
class _BrokenBus:
|
|
||||||
async def record_usage(self, *_: Any, **__: Any) -> None:
|
|
||||||
raise RuntimeError("simulated")
|
|
||||||
|
|
||||||
ctx = _Ctx(context={"bus": _BrokenBus(), "agent_id": "a1"})
|
|
||||||
|
|
||||||
class _Resp:
|
|
||||||
usage = None
|
|
||||||
|
|
||||||
# Should not raise.
|
|
||||||
await hooks.on_llm_end(ctx, agent=None, response=_Resp())
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for strix.runtime module."""
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"""Phase 0 smoke tests for StrixDockerSandboxClient.
|
|
||||||
|
|
||||||
These tests do NOT require Docker. They mock the Docker SDK client (passed
|
|
||||||
to the constructor) and verify our subclass injects the right kwargs into
|
|
||||||
``containers.create``. Live container tests are part of Phase 0 manual
|
|
||||||
smoke (TESTING_STRATEGY.md §9).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import docker.errors # type: ignore[import-untyped,unused-ignore]
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from strix.runtime.strix_docker_client import StrixDockerSandboxClient
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_docker() -> MagicMock:
|
|
||||||
"""Stand-in for the Docker SDK client passed to the subclass."""
|
|
||||||
fake = MagicMock()
|
|
||||||
fake.images.get.return_value = MagicMock() # image_exists -> True
|
|
||||||
fake.images.pull = MagicMock()
|
|
||||||
fake.containers.create.return_value = MagicMock(name="container")
|
|
||||||
return fake
|
|
||||||
|
|
||||||
|
|
||||||
def _create_kwargs(fake: MagicMock) -> dict[str, Any]:
|
|
||||||
result: dict[str, Any] = fake.containers.create.call_args.kwargs
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def test_subclass_injects_net_admin_and_net_raw(fake_docker: MagicMock) -> None:
|
|
||||||
client = StrixDockerSandboxClient(docker_client=fake_docker)
|
|
||||||
asyncio.run(
|
|
||||||
client._create_container("strix-image:latest", manifest=None, exposed_ports=()),
|
|
||||||
)
|
|
||||||
kwargs = _create_kwargs(fake_docker)
|
|
||||||
assert "NET_ADMIN" in kwargs["cap_add"]
|
|
||||||
assert "NET_RAW" in kwargs["cap_add"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_subclass_injects_host_gateway(fake_docker: MagicMock) -> None:
|
|
||||||
client = StrixDockerSandboxClient(docker_client=fake_docker)
|
|
||||||
asyncio.run(
|
|
||||||
client._create_container("strix-image:latest", manifest=None, exposed_ports=()),
|
|
||||||
)
|
|
||||||
kwargs = _create_kwargs(fake_docker)
|
|
||||||
assert kwargs["extra_hosts"]["host.docker.internal"] == "host-gateway"
|
|
||||||
|
|
||||||
|
|
||||||
def test_subclass_preserves_image_and_command(fake_docker: MagicMock) -> None:
|
|
||||||
client = StrixDockerSandboxClient(docker_client=fake_docker)
|
|
||||||
asyncio.run(
|
|
||||||
client._create_container("custom:tag", manifest=None, exposed_ports=()),
|
|
||||||
)
|
|
||||||
kwargs = _create_kwargs(fake_docker)
|
|
||||||
assert kwargs["image"] == "custom:tag"
|
|
||||||
assert kwargs["entrypoint"] == ["tail"]
|
|
||||||
assert kwargs["command"] == ["-f", "/dev/null"]
|
|
||||||
assert kwargs["detach"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_subclass_emits_ports_dict_for_exposed_ports(fake_docker: MagicMock) -> None:
|
|
||||||
client = StrixDockerSandboxClient(docker_client=fake_docker)
|
|
||||||
asyncio.run(
|
|
||||||
client._create_container(
|
|
||||||
"strix-image:latest",
|
|
||||||
manifest=None,
|
|
||||||
exposed_ports=(48081, 48080),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
kwargs = _create_kwargs(fake_docker)
|
|
||||||
assert "48081/tcp" in kwargs["ports"]
|
|
||||||
assert "48080/tcp" in kwargs["ports"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_caps_appended_not_duplicated(fake_docker: MagicMock) -> None:
|
|
||||||
"""Idempotent injection: calling twice doesn't add duplicate caps."""
|
|
||||||
client = StrixDockerSandboxClient(docker_client=fake_docker)
|
|
||||||
asyncio.run(
|
|
||||||
client._create_container("img:latest", manifest=None, exposed_ports=()),
|
|
||||||
)
|
|
||||||
kwargs = _create_kwargs(fake_docker)
|
|
||||||
assert kwargs["cap_add"].count("NET_ADMIN") == 1
|
|
||||||
assert kwargs["cap_add"].count("NET_RAW") == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_pulls_image_when_missing(fake_docker: MagicMock) -> None:
|
|
||||||
"""If image_exists returns False on first check, pull is invoked."""
|
|
||||||
# First call raises ImageNotFound, second succeeds.
|
|
||||||
fake_docker.images.get.side_effect = [
|
|
||||||
docker.errors.ImageNotFound("not found"),
|
|
||||||
MagicMock(),
|
|
||||||
]
|
|
||||||
client = StrixDockerSandboxClient(docker_client=fake_docker)
|
|
||||||
asyncio.run(
|
|
||||||
client._create_container("registry.io/strix:tag", manifest=None, exposed_ports=()),
|
|
||||||
)
|
|
||||||
fake_docker.images.pull.assert_called_once()
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
"""Phase 4 tests for CaidoCapability.
|
|
||||||
|
|
||||||
The capability has three observable behaviors that need parity with the
|
|
||||||
PLAYBOOK contract:
|
|
||||||
|
|
||||||
1. ``process_manifest`` injects http_proxy / https_proxy / ALL_PROXY env
|
|
||||||
vars into the manifest's ``Environment.value`` dict.
|
|
||||||
2. ``tools()`` returns the seven Caido SDK function tools we wrapped in
|
|
||||||
Phase 2.5 — same instances, in the documented order.
|
|
||||||
3. ``bind`` schedules an aggregated healthcheck task; the orchestration
|
|
||||||
hook later awaits it on first agent start.
|
|
||||||
|
|
||||||
The healthcheck task itself is exercised by the healthcheck unit tests;
|
|
||||||
here we only verify the wiring (task created, name set, points at the
|
|
||||||
right ports).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from agents.sandbox.entries import LocalDir
|
|
||||||
from agents.sandbox.manifest import Environment, Manifest
|
|
||||||
|
|
||||||
from strix.sandbox.caido_capability import CaidoCapability
|
|
||||||
|
|
||||||
|
|
||||||
def test_capability_type_and_default_state() -> None:
|
|
||||||
cap = CaidoCapability()
|
|
||||||
assert cap.type == "caido"
|
|
||||||
assert cap._healthcheck_task is None
|
|
||||||
assert cap._tool_server_host_port is None
|
|
||||||
assert cap._caido_host_port is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_manifest_injects_proxy_env_vars(tmp_path: object) -> None:
|
|
||||||
"""Existing env vars must be preserved; proxy keys are added."""
|
|
||||||
cap = CaidoCapability()
|
|
||||||
manifest = Manifest(
|
|
||||||
environment=Environment(
|
|
||||||
value={"PYTHONUNBUFFERED": "1", "TOOL_SERVER_TOKEN": "abc"},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
out = cap.process_manifest(manifest)
|
|
||||||
env = out.environment.value
|
|
||||||
# Pre-existing entries preserved.
|
|
||||||
assert env["PYTHONUNBUFFERED"] == "1"
|
|
||||||
assert env["TOOL_SERVER_TOKEN"] == "abc"
|
|
||||||
# Proxy entries injected, all pointing at the in-container Caido port.
|
|
||||||
assert env["http_proxy"] == "http://127.0.0.1:48080"
|
|
||||||
assert env["https_proxy"] == "http://127.0.0.1:48080"
|
|
||||||
assert env["ALL_PROXY"] == "http://127.0.0.1:48080"
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_manifest_handles_missing_environment() -> None:
|
|
||||||
"""A manifest without env entries should still get the proxy block."""
|
|
||||||
cap = CaidoCapability()
|
|
||||||
# ``LocalDir`` requires a real path on disk; use a temp one to satisfy
|
|
||||||
# the validator without actually mounting anything.
|
|
||||||
manifest = Manifest(entries={"src": LocalDir(src="/tmp")})
|
|
||||||
out = cap.process_manifest(manifest)
|
|
||||||
env = out.environment.value
|
|
||||||
assert env["http_proxy"] == "http://127.0.0.1:48080"
|
|
||||||
|
|
||||||
|
|
||||||
def test_tools_returns_seven_caido_tools_in_order() -> None:
|
|
||||||
cap = CaidoCapability()
|
|
||||||
names = [t.name for t in cap.tools()]
|
|
||||||
assert names == [
|
|
||||||
"list_requests",
|
|
||||||
"view_request",
|
|
||||||
"send_request",
|
|
||||||
"repeat_request",
|
|
||||||
"scope_rules",
|
|
||||||
"list_sitemap",
|
|
||||||
"view_sitemap_entry",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_tools_returns_a_fresh_list_per_call() -> None:
|
|
||||||
"""SDK convention — caller may mutate the returned list."""
|
|
||||||
cap = CaidoCapability()
|
|
||||||
a = cap.tools()
|
|
||||||
b = cap.tools()
|
|
||||||
assert a == b
|
|
||||||
assert a is not b
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_instructions_mentions_caido_and_tools() -> None:
|
|
||||||
cap = CaidoCapability()
|
|
||||||
out = await cap.instructions(Manifest())
|
|
||||||
assert out is not None
|
|
||||||
assert "<caido_proxy>" in out
|
|
||||||
# Every tool name appears verbatim so the model knows what's available.
|
|
||||||
for name in (
|
|
||||||
"list_requests",
|
|
||||||
"view_request",
|
|
||||||
"send_request",
|
|
||||||
"repeat_request",
|
|
||||||
"scope_rules",
|
|
||||||
"list_sitemap",
|
|
||||||
"view_sitemap_entry",
|
|
||||||
):
|
|
||||||
assert name in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_configure_host_ports_stores_both() -> None:
|
|
||||||
cap = CaidoCapability()
|
|
||||||
cap.configure_host_ports(tool_server_host_port=12345, caido_host_port=12346)
|
|
||||||
assert cap._tool_server_host_port == 12345
|
|
||||||
assert cap._caido_host_port == 12346
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bind_without_configured_ports_skips_healthcheck() -> None:
|
|
||||||
"""If the session manager forgets to configure ports, bind shouldn't
|
|
||||||
schedule a probe against ``None`` — it should warn and no-op.
|
|
||||||
"""
|
|
||||||
cap = CaidoCapability()
|
|
||||||
fake_session = MagicMock()
|
|
||||||
cap.bind(fake_session)
|
|
||||||
assert cap._healthcheck_task is None
|
|
||||||
assert cap.session is fake_session
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bind_schedules_healthcheck_task_when_ports_configured() -> None:
|
|
||||||
"""The hook chain (StrixOrchestrationHooks.on_agent_start) awaits this
|
|
||||||
task — it must exist as an asyncio.Task with a useful name.
|
|
||||||
"""
|
|
||||||
cap = CaidoCapability()
|
|
||||||
cap.configure_host_ports(tool_server_host_port=54321, caido_host_port=54322)
|
|
||||||
fake_session = MagicMock()
|
|
||||||
|
|
||||||
# Patch the actual probes so we don't try to connect for real.
|
|
||||||
async def _fake_probe(*args: object, **kwargs: object) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"strix.sandbox.caido_capability.wait_for_http_ready",
|
|
||||||
side_effect=_fake_probe,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"strix.sandbox.caido_capability.wait_for_tcp_ready",
|
|
||||||
side_effect=_fake_probe,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
cap.bind(fake_session)
|
|
||||||
assert cap._healthcheck_task is not None
|
|
||||||
assert isinstance(cap._healthcheck_task, asyncio.Task)
|
|
||||||
assert "caido-healthcheck-54321" in cap._healthcheck_task.get_name()
|
|
||||||
await cap._healthcheck_task # must complete without error
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
"""Phase 4 tests for the sandbox port readiness probes.
|
|
||||||
|
|
||||||
The two helpers (``wait_for_http_ready`` and ``wait_for_tcp_ready``)
|
|
||||||
gate session bring-up, so a regression here would mean every fresh
|
|
||||||
scan hits a connection-refused on its first tool call. Tests cover:
|
|
||||||
|
|
||||||
- Happy path returns when the probe succeeds.
|
|
||||||
- Polling continues across transient failures.
|
|
||||||
- Timeout raises ``SandboxNotReadyError`` with a useful last-error.
|
|
||||||
- Real ``asyncio.open_connection`` against a local listener verifies
|
|
||||||
the TCP probe end-to-end (no mocking — the helper is small enough
|
|
||||||
that a real socket is the cheaper test).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from strix.sandbox.healthcheck import (
|
|
||||||
SandboxNotReadyError,
|
|
||||||
wait_for_http_ready,
|
|
||||||
wait_for_tcp_ready,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- HTTP probe ----------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wait_for_http_ready_returns_immediately_on_2xx() -> None:
|
|
||||||
response = MagicMock(spec=httpx.Response)
|
|
||||||
response.status_code = 200
|
|
||||||
client = AsyncMock()
|
|
||||||
client.get = AsyncMock(return_value=response)
|
|
||||||
client.__aenter__ = AsyncMock(return_value=client)
|
|
||||||
client.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
with patch("strix.sandbox.healthcheck.httpx.AsyncClient", return_value=client):
|
|
||||||
await wait_for_http_ready("http://localhost:9999/health", timeout=1)
|
|
||||||
|
|
||||||
assert client.get.await_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wait_for_http_ready_polls_through_connect_errors() -> None:
|
|
||||||
"""Two connect errors followed by a 200 — the helper should keep going."""
|
|
||||||
response_ok = MagicMock(spec=httpx.Response)
|
|
||||||
response_ok.status_code = 200
|
|
||||||
|
|
||||||
side_effects: list[Any] = [
|
|
||||||
httpx.ConnectError("conn refused"),
|
|
||||||
httpx.ConnectError("conn refused"),
|
|
||||||
response_ok,
|
|
||||||
]
|
|
||||||
|
|
||||||
client = AsyncMock()
|
|
||||||
client.get = AsyncMock(side_effect=side_effects)
|
|
||||||
client.__aenter__ = AsyncMock(return_value=client)
|
|
||||||
client.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
with patch("strix.sandbox.healthcheck.httpx.AsyncClient", return_value=client):
|
|
||||||
await wait_for_http_ready(
|
|
||||||
"http://localhost:9999/health",
|
|
||||||
timeout=5,
|
|
||||||
poll_interval=0.01,
|
|
||||||
)
|
|
||||||
assert client.get.await_count == 3
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wait_for_http_ready_raises_after_timeout() -> None:
|
|
||||||
client = AsyncMock()
|
|
||||||
client.get = AsyncMock(side_effect=httpx.ConnectError("nope"))
|
|
||||||
client.__aenter__ = AsyncMock(return_value=client)
|
|
||||||
client.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"strix.sandbox.healthcheck.httpx.AsyncClient",
|
|
||||||
return_value=client,
|
|
||||||
),
|
|
||||||
pytest.raises(SandboxNotReadyError) as exc_info,
|
|
||||||
):
|
|
||||||
await wait_for_http_ready(
|
|
||||||
"http://localhost:9999/health",
|
|
||||||
timeout=0.3,
|
|
||||||
poll_interval=0.05,
|
|
||||||
)
|
|
||||||
|
|
||||||
err = str(exc_info.value)
|
|
||||||
assert "http://localhost:9999/health" in err
|
|
||||||
assert "ConnectError" in err
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wait_for_http_ready_treats_5xx_as_not_ready() -> None:
|
|
||||||
response_500 = MagicMock(spec=httpx.Response)
|
|
||||||
response_500.status_code = 500
|
|
||||||
response_ok = MagicMock(spec=httpx.Response)
|
|
||||||
response_ok.status_code = 200
|
|
||||||
|
|
||||||
client = AsyncMock()
|
|
||||||
client.get = AsyncMock(side_effect=[response_500, response_ok])
|
|
||||||
client.__aenter__ = AsyncMock(return_value=client)
|
|
||||||
client.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
with patch("strix.sandbox.healthcheck.httpx.AsyncClient", return_value=client):
|
|
||||||
await wait_for_http_ready(
|
|
||||||
"http://localhost:9999/health",
|
|
||||||
timeout=2,
|
|
||||||
poll_interval=0.01,
|
|
||||||
)
|
|
||||||
assert client.get.await_count == 2
|
|
||||||
|
|
||||||
|
|
||||||
# --- TCP probe -----------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wait_for_tcp_ready_against_real_listener() -> None:
|
|
||||||
"""Spin up a local TCP echo server and verify the probe connects."""
|
|
||||||
|
|
||||||
async def _server_handler(
|
|
||||||
reader: asyncio.StreamReader,
|
|
||||||
writer: asyncio.StreamWriter,
|
|
||||||
) -> None:
|
|
||||||
# Drain any bytes the test sends, then close.
|
|
||||||
await reader.read(0)
|
|
||||||
writer.close()
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
await writer.wait_closed()
|
|
||||||
|
|
||||||
server = await asyncio.start_server(_server_handler, "127.0.0.1", 0)
|
|
||||||
port = server.sockets[0].getsockname()[1]
|
|
||||||
try:
|
|
||||||
await wait_for_tcp_ready("127.0.0.1", port, timeout=2, poll_interval=0.05)
|
|
||||||
finally:
|
|
||||||
server.close()
|
|
||||||
await server.wait_closed()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wait_for_tcp_ready_raises_when_port_closed() -> None:
|
|
||||||
async def _no_handler(
|
|
||||||
_reader: asyncio.StreamReader,
|
|
||||||
_writer: asyncio.StreamWriter,
|
|
||||||
) -> None:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Bind and immediately close to claim a definitely-unused port number.
|
|
||||||
server = await asyncio.start_server(_no_handler, "127.0.0.1", 0)
|
|
||||||
closed_port = server.sockets[0].getsockname()[1]
|
|
||||||
server.close()
|
|
||||||
await server.wait_closed()
|
|
||||||
|
|
||||||
with pytest.raises(SandboxNotReadyError) as exc_info:
|
|
||||||
await wait_for_tcp_ready(
|
|
||||||
"127.0.0.1",
|
|
||||||
closed_port,
|
|
||||||
timeout=0.3,
|
|
||||||
poll_interval=0.05,
|
|
||||||
)
|
|
||||||
|
|
||||||
err = str(exc_info.value)
|
|
||||||
assert f"127.0.0.1:{closed_port}" in err
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
"""Phase 4 tests for the per-scan sandbox session manager.
|
|
||||||
|
|
||||||
We don't spin up real Docker here — the ``StrixDockerSandboxClient`` is
|
|
||||||
patched and we assert on the manifest / options / bundle shape. Goals:
|
|
||||||
|
|
||||||
- Cache hit: a second ``create_or_reuse(scan_id, ...)`` returns the same
|
|
||||||
bundle without calling client.create twice.
|
|
||||||
- Manifest carries the right env vars (TOOL_SERVER_TOKEN, container ports,
|
|
||||||
STRIX_SANDBOX_EXECUTION_TIMEOUT, PYTHONUNBUFFERED).
|
|
||||||
- The Docker client options request both container ports be exposed.
|
|
||||||
- Capability is configured with the resolved host ports *before* bind,
|
|
||||||
so its healthcheck task probes the right ones.
|
|
||||||
- Bundle is cached and surfaces in ``cached_scan_ids``.
|
|
||||||
- ``cleanup`` cancels the healthcheck task and calls ``client.delete``;
|
|
||||||
errors during delete are swallowed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from strix.sandbox import session_manager
|
|
||||||
from strix.sandbox.caido_capability import CaidoCapability
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _isolate_cache() -> Iterator[None]:
|
|
||||||
session_manager._reset_cache_for_tests()
|
|
||||||
yield
|
|
||||||
session_manager._reset_cache_for_tests()
|
|
||||||
|
|
||||||
|
|
||||||
def _noop_bind(_self: Any, _session: Any) -> None:
|
|
||||||
"""Stand-in for CaidoCapability.bind that skips the healthcheck task."""
|
|
||||||
|
|
||||||
|
|
||||||
def _make_endpoint(port: int) -> Any:
|
|
||||||
ep = MagicMock()
|
|
||||||
ep.port = port
|
|
||||||
ep.host = "127.0.0.1"
|
|
||||||
ep.tls = False
|
|
||||||
return ep
|
|
||||||
|
|
||||||
|
|
||||||
def _make_client_and_session(
|
|
||||||
*,
|
|
||||||
tool_port: int = 12001,
|
|
||||||
caido_port: int = 12002,
|
|
||||||
) -> tuple[Any, Any]:
|
|
||||||
"""Build a fake DockerSandboxClient and session pair."""
|
|
||||||
session = MagicMock()
|
|
||||||
session._resolve_exposed_port = AsyncMock(
|
|
||||||
side_effect=lambda port: _make_endpoint(
|
|
||||||
tool_port if port == 48081 else caido_port,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
client = MagicMock()
|
|
||||||
client.create = AsyncMock(return_value=session)
|
|
||||||
client.delete = AsyncMock()
|
|
||||||
return client, session
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_or_reuse_creates_new_session(tmp_path: Any) -> None:
|
|
||||||
client, session = _make_client_and_session()
|
|
||||||
# Patch the capability's bind to a no-op so we don't spin up the
|
|
||||||
# healthcheck task in unit tests.
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"strix.sandbox.session_manager.StrixDockerSandboxClient",
|
|
||||||
return_value=client,
|
|
||||||
),
|
|
||||||
patch.object(CaidoCapability, "bind", _noop_bind),
|
|
||||||
):
|
|
||||||
bundle = await session_manager.create_or_reuse(
|
|
||||||
"scan-1",
|
|
||||||
image="strix-sandbox:test",
|
|
||||||
sources_path=tmp_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Bundle shape.
|
|
||||||
assert bundle["client"] is client
|
|
||||||
assert bundle["session"] is session
|
|
||||||
assert bundle["tool_server_host_port"] == 12001
|
|
||||||
assert bundle["caido_host_port"] == 12002
|
|
||||||
assert isinstance(bundle["bearer"], str) and len(bundle["bearer"]) >= 32
|
|
||||||
assert isinstance(bundle["capability"], CaidoCapability)
|
|
||||||
# Capability got the resolved host ports BEFORE bind would have run.
|
|
||||||
assert bundle["capability"]._tool_server_host_port == 12001
|
|
||||||
assert bundle["capability"]._caido_host_port == 12002
|
|
||||||
|
|
||||||
# client.create called exactly once with manifest + exposed ports.
|
|
||||||
assert client.create.await_count == 1
|
|
||||||
options = client.create.await_args.kwargs["options"]
|
|
||||||
assert options.image == "strix-sandbox:test"
|
|
||||||
assert set(options.exposed_ports) == {48080, 48081}
|
|
||||||
|
|
||||||
manifest = client.create.await_args.kwargs["manifest"]
|
|
||||||
env = manifest.environment.value
|
|
||||||
assert env["TOOL_SERVER_TOKEN"] == bundle["bearer"]
|
|
||||||
assert env["TOOL_SERVER_PORT"] == "48081"
|
|
||||||
assert env["CAIDO_PORT"] == "48080"
|
|
||||||
assert env["STRIX_SANDBOX_EXECUTION_TIMEOUT"] == "120"
|
|
||||||
assert env["PYTHONUNBUFFERED"] == "1"
|
|
||||||
assert env["HOST_GATEWAY"] == "host.docker.internal"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_or_reuse_returns_cached_bundle(tmp_path: Any) -> None:
|
|
||||||
client, _ = _make_client_and_session()
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"strix.sandbox.session_manager.StrixDockerSandboxClient",
|
|
||||||
return_value=client,
|
|
||||||
),
|
|
||||||
patch.object(CaidoCapability, "bind", _noop_bind),
|
|
||||||
):
|
|
||||||
first = await session_manager.create_or_reuse(
|
|
||||||
"scan-X",
|
|
||||||
image="i",
|
|
||||||
sources_path=tmp_path,
|
|
||||||
)
|
|
||||||
second = await session_manager.create_or_reuse(
|
|
||||||
"scan-X",
|
|
||||||
image="i",
|
|
||||||
sources_path=tmp_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert first is second
|
|
||||||
assert client.create.await_count == 1
|
|
||||||
assert "scan-X" in session_manager.cached_scan_ids()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_or_reuse_passes_custom_execution_timeout(tmp_path: Any) -> None:
|
|
||||||
client, _ = _make_client_and_session()
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"strix.sandbox.session_manager.StrixDockerSandboxClient",
|
|
||||||
return_value=client,
|
|
||||||
),
|
|
||||||
patch.object(CaidoCapability, "bind", _noop_bind),
|
|
||||||
):
|
|
||||||
await session_manager.create_or_reuse(
|
|
||||||
"scan-2",
|
|
||||||
image="i",
|
|
||||||
sources_path=tmp_path,
|
|
||||||
execution_timeout=300,
|
|
||||||
)
|
|
||||||
|
|
||||||
manifest = client.create.await_args.kwargs["manifest"]
|
|
||||||
assert manifest.environment.value["STRIX_SANDBOX_EXECUTION_TIMEOUT"] == "300"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cleanup_calls_delete_and_drops_cache(tmp_path: Any) -> None:
|
|
||||||
client, session = _make_client_and_session()
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"strix.sandbox.session_manager.StrixDockerSandboxClient",
|
|
||||||
return_value=client,
|
|
||||||
),
|
|
||||||
patch.object(CaidoCapability, "bind", _noop_bind),
|
|
||||||
):
|
|
||||||
await session_manager.create_or_reuse(
|
|
||||||
"scan-3",
|
|
||||||
image="i",
|
|
||||||
sources_path=tmp_path,
|
|
||||||
)
|
|
||||||
assert "scan-3" in session_manager.cached_scan_ids()
|
|
||||||
await session_manager.cleanup("scan-3")
|
|
||||||
|
|
||||||
client.delete.assert_awaited_once_with(session)
|
|
||||||
assert "scan-3" not in session_manager.cached_scan_ids()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cleanup_swallows_delete_errors(tmp_path: Any) -> None:
|
|
||||||
"""A flaky Docker daemon shouldn't prevent cache eviction."""
|
|
||||||
client, _ = _make_client_and_session()
|
|
||||||
client.delete = AsyncMock(side_effect=RuntimeError("docker daemon went away"))
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"strix.sandbox.session_manager.StrixDockerSandboxClient",
|
|
||||||
return_value=client,
|
|
||||||
),
|
|
||||||
patch.object(CaidoCapability, "bind", _noop_bind),
|
|
||||||
):
|
|
||||||
await session_manager.create_or_reuse(
|
|
||||||
"scan-4",
|
|
||||||
image="i",
|
|
||||||
sources_path=tmp_path,
|
|
||||||
)
|
|
||||||
await session_manager.cleanup("scan-4") # must not raise
|
|
||||||
|
|
||||||
assert "scan-4" not in session_manager.cached_scan_ids()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cleanup_unknown_scan_is_noop() -> None:
|
|
||||||
"""No cached entry → cleanup is a quiet no-op."""
|
|
||||||
await session_manager.cleanup("never-existed") # must not raise
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Tests for skill-related runtime behavior.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for strix.telemetry module."""
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
from strix.telemetry.flags import is_otel_enabled, is_posthog_enabled
|
|
||||||
|
|
||||||
|
|
||||||
def test_flags_fallback_to_strix_telemetry(monkeypatch) -> None:
|
|
||||||
monkeypatch.delenv("STRIX_OTEL_TELEMETRY", raising=False)
|
|
||||||
monkeypatch.delenv("STRIX_POSTHOG_TELEMETRY", raising=False)
|
|
||||||
monkeypatch.setenv("STRIX_TELEMETRY", "0")
|
|
||||||
|
|
||||||
assert is_otel_enabled() is False
|
|
||||||
assert is_posthog_enabled() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_otel_flag_overrides_global_telemetry(monkeypatch) -> None:
|
|
||||||
monkeypatch.setenv("STRIX_TELEMETRY", "0")
|
|
||||||
monkeypatch.setenv("STRIX_OTEL_TELEMETRY", "1")
|
|
||||||
monkeypatch.delenv("STRIX_POSTHOG_TELEMETRY", raising=False)
|
|
||||||
|
|
||||||
assert is_otel_enabled() is True
|
|
||||||
assert is_posthog_enabled() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_posthog_flag_overrides_global_telemetry(monkeypatch) -> None:
|
|
||||||
monkeypatch.setenv("STRIX_TELEMETRY", "0")
|
|
||||||
monkeypatch.setenv("STRIX_POSTHOG_TELEMETRY", "1")
|
|
||||||
monkeypatch.delenv("STRIX_OTEL_TELEMETRY", raising=False)
|
|
||||||
|
|
||||||
assert is_otel_enabled() is False
|
|
||||||
assert is_posthog_enabled() is True
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
"""Phase 1 smoke tests for StrixTracingProcessor."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from strix.telemetry.strix_processor import StrixTracingProcessor
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _FakeSpanData:
|
|
||||||
"""Minimal stand-in for an SDK SpanData class."""
|
|
||||||
|
|
||||||
name: str = "FunctionSpanData" # used by class .__name__
|
|
||||||
payload: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def export(self) -> dict[str, Any]:
|
|
||||||
return dict(self.payload)
|
|
||||||
|
|
||||||
|
|
||||||
# Concrete SpanData subclasses so the processor's ``_span_kind`` heuristic
|
|
||||||
# (drop ``SpanData`` suffix, lowercase) produces stable event_types.
|
|
||||||
class FunctionSpanData(_FakeSpanData):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class GenerationSpanData(_FakeSpanData):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class AgentSpanData(_FakeSpanData):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _FakeSpan:
|
|
||||||
span_id: str
|
|
||||||
trace_id: str
|
|
||||||
span_data: _FakeSpanData
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _FakeTrace:
|
|
||||||
trace_id: str
|
|
||||||
name: str = "test-workflow"
|
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def export(self) -> dict[str, Any]:
|
|
||||||
return {"name": self.name, "metadata": self.metadata}
|
|
||||||
|
|
||||||
|
|
||||||
def _read_events(path: Path) -> list[dict[str, Any]]:
|
|
||||||
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def run_dir(tmp_path: Path) -> Path:
|
|
||||||
return tmp_path / "strix_runs" / "test-run"
|
|
||||||
|
|
||||||
|
|
||||||
def test_constructor_creates_run_dir(run_dir: Path) -> None:
|
|
||||||
StrixTracingProcessor(run_dir=run_dir)
|
|
||||||
assert run_dir.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_on_trace_start_writes_run_started(run_dir: Path) -> None:
|
|
||||||
p = StrixTracingProcessor(run_dir=run_dir)
|
|
||||||
p.on_trace_start(_FakeTrace(trace_id="t-1", metadata={"scan_id": "abc"}))
|
|
||||||
|
|
||||||
events = _read_events(p.events_path)
|
|
||||||
assert events == [
|
|
||||||
{
|
|
||||||
"event_type": "run.started",
|
|
||||||
"trace_id": "t-1",
|
|
||||||
"metadata": {"name": "test-workflow", "metadata": {"scan_id": "abc"}},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_on_trace_end_writes_run_completed(run_dir: Path) -> None:
|
|
||||||
p = StrixTracingProcessor(run_dir=run_dir)
|
|
||||||
p.on_trace_end(_FakeTrace(trace_id="t-1"))
|
|
||||||
|
|
||||||
events = _read_events(p.events_path)
|
|
||||||
assert events == [{"event_type": "run.completed", "trace_id": "t-1"}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_span_start_and_end_emit_typed_events(run_dir: Path) -> None:
|
|
||||||
"""``GenerationSpanData`` → ``generation.started`` / ``generation.completed``."""
|
|
||||||
p = StrixTracingProcessor(run_dir=run_dir)
|
|
||||||
span = _FakeSpan(
|
|
||||||
span_id="s-1",
|
|
||||||
trace_id="t-1",
|
|
||||||
span_data=GenerationSpanData(payload={"model": "gpt-foo"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
p.on_span_start(span)
|
|
||||||
p.on_span_end(span)
|
|
||||||
|
|
||||||
events = _read_events(p.events_path)
|
|
||||||
assert [e["event_type"] for e in events] == [
|
|
||||||
"generation.started",
|
|
||||||
"generation.completed",
|
|
||||||
]
|
|
||||||
assert events[0]["span_id"] == "s-1"
|
|
||||||
assert events[0]["data"] == {"model": "gpt-foo"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_concurrent_writes_yield_valid_jsonl(run_dir: Path) -> None:
|
|
||||||
"""C7 (AUDIT_R2): per-path lock prevents JSONL corruption under contention."""
|
|
||||||
p = StrixTracingProcessor(run_dir=run_dir)
|
|
||||||
|
|
||||||
def writer(idx: int) -> None:
|
|
||||||
for i in range(50):
|
|
||||||
p._emit({"event_type": "synthetic", "writer": idx, "i": i})
|
|
||||||
|
|
||||||
threads = [threading.Thread(target=writer, args=(i,)) for i in range(10)]
|
|
||||||
for t in threads:
|
|
||||||
t.start()
|
|
||||||
for t in threads:
|
|
||||||
t.join()
|
|
||||||
|
|
||||||
# 10 threads x 50 events = 500 lines; all valid JSON.
|
|
||||||
lines = p.events_path.read_text().splitlines()
|
|
||||||
assert len(lines) == 500
|
|
||||||
for line in lines:
|
|
||||||
json.loads(line) # raises on corrupt line
|
|
||||||
|
|
||||||
|
|
||||||
def test_emit_swallows_oserror_and_logs(
|
|
||||||
run_dir: Path,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""C16 (AUDIT_R3): a write failure must NOT propagate."""
|
|
||||||
p = StrixTracingProcessor(run_dir=run_dir)
|
|
||||||
# Make events_path point to a directory so open(..., "a") raises.
|
|
||||||
p.events_path = run_dir
|
|
||||||
|
|
||||||
with caplog.at_level("ERROR", logger="strix.telemetry.strix_processor"):
|
|
||||||
p._emit({"event_type": "boom"})
|
|
||||||
|
|
||||||
assert any("Failed to append" in rec.message for rec in caplog.records)
|
|
||||||
|
|
||||||
|
|
||||||
def test_span_export_failure_does_not_propagate(run_dir: Path) -> None:
|
|
||||||
"""If span_data.export raises, we still emit an event with data=None."""
|
|
||||||
p = StrixTracingProcessor(run_dir=run_dir)
|
|
||||||
|
|
||||||
class _BoomSpanData:
|
|
||||||
def export(self) -> dict[str, Any]:
|
|
||||||
raise RuntimeError("nope")
|
|
||||||
|
|
||||||
# Reuse the lowercase rule: class name has no "SpanData" suffix → "boomspandata"
|
|
||||||
# would not be ideal; use a properly-named subclass.
|
|
||||||
class FunctionSpanDataBroken(_BoomSpanData):
|
|
||||||
pass
|
|
||||||
|
|
||||||
span = _FakeSpan(span_id="s-1", trace_id="t-1", span_data=FunctionSpanDataBroken())
|
|
||||||
p.on_span_end(span)
|
|
||||||
|
|
||||||
events = _read_events(p.events_path)
|
|
||||||
assert len(events) == 1
|
|
||||||
assert events[0]["data"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_pii_scrubbed_via_sanitizer(run_dir: Path) -> None:
|
|
||||||
"""Sanitizer is invoked on every emit before write."""
|
|
||||||
seen: list[Any] = []
|
|
||||||
|
|
||||||
class _StubSanitizer:
|
|
||||||
def sanitize(self, data: Any, key_hint: str | None = None) -> Any:
|
|
||||||
seen.append(data)
|
|
||||||
# Replace any "secret" string with [REDACTED].
|
|
||||||
if isinstance(data, dict):
|
|
||||||
clean = {k: "[REDACTED]" if k == "api_key" else v for k, v in data.items()}
|
|
||||||
if "metadata" in clean and isinstance(clean["metadata"], dict):
|
|
||||||
md = dict(clean["metadata"])
|
|
||||||
md.pop("api_key", None)
|
|
||||||
clean["metadata"] = md
|
|
||||||
return clean
|
|
||||||
return data
|
|
||||||
|
|
||||||
p = StrixTracingProcessor(run_dir=run_dir, sanitizer=_StubSanitizer())
|
|
||||||
p._emit({"event_type": "test", "api_key": "sk-very-secret"})
|
|
||||||
|
|
||||||
events = _read_events(p.events_path)
|
|
||||||
assert events[0]["api_key"] == "[REDACTED]"
|
|
||||||
assert seen and seen[0]["api_key"] == "sk-very-secret"
|
|
||||||
|
|
||||||
|
|
||||||
def test_force_flush_and_shutdown_are_noops(run_dir: Path) -> None:
|
|
||||||
p = StrixTracingProcessor(run_dir=run_dir)
|
|
||||||
# Should not raise.
|
|
||||||
p.force_flush()
|
|
||||||
p.shutdown()
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
import json
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, ClassVar
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExportResult
|
|
||||||
|
|
||||||
from strix.telemetry import tracer as tracer_module
|
|
||||||
from strix.telemetry import utils as telemetry_utils
|
|
||||||
from strix.telemetry.tracer import Tracer, set_global_tracer
|
|
||||||
|
|
||||||
|
|
||||||
def _load_events(events_path: Path) -> list[dict[str, Any]]:
|
|
||||||
lines = events_path.read_text(encoding="utf-8").splitlines()
|
|
||||||
return [json.loads(line) for line in lines if line]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _reset_tracer_globals(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setattr(tracer_module, "_global_tracer", None)
|
|
||||||
monkeypatch.setattr(tracer_module, "_OTEL_BOOTSTRAPPED", False)
|
|
||||||
monkeypatch.setattr(tracer_module, "_OTEL_REMOTE_ENABLED", False)
|
|
||||||
telemetry_utils.reset_events_write_locks()
|
|
||||||
monkeypatch.delenv("STRIX_TELEMETRY", raising=False)
|
|
||||||
monkeypatch.delenv("STRIX_OTEL_TELEMETRY", raising=False)
|
|
||||||
monkeypatch.delenv("STRIX_POSTHOG_TELEMETRY", raising=False)
|
|
||||||
monkeypatch.delenv("TRACELOOP_BASE_URL", raising=False)
|
|
||||||
monkeypatch.delenv("TRACELOOP_API_KEY", raising=False)
|
|
||||||
monkeypatch.delenv("TRACELOOP_HEADERS", raising=False)
|
|
||||||
|
|
||||||
|
|
||||||
def test_tracer_local_mode_writes_jsonl_with_correlation(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
|
|
||||||
tracer = Tracer("local-observability")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
tracer.set_scan_config({"targets": ["https://example.com"], "user_instructions": "focus auth"})
|
|
||||||
tracer.log_chat_message("starting scan", "user", "agent-1")
|
|
||||||
tracer.log_chat_message("scanning login form", "assistant", "agent-1")
|
|
||||||
|
|
||||||
events_path = tmp_path / "strix_runs" / "local-observability" / "events.jsonl"
|
|
||||||
assert events_path.exists()
|
|
||||||
|
|
||||||
events = _load_events(events_path)
|
|
||||||
assert any(event["event_type"] == "chat.message" for event in events)
|
|
||||||
assert any(event["event_type"] == "run.configured" for event in events)
|
|
||||||
|
|
||||||
for event in events:
|
|
||||||
assert event["run_id"] == "local-observability"
|
|
||||||
assert event["trace_id"]
|
|
||||||
assert event["span_id"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_tracer_redacts_sensitive_payloads(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
|
|
||||||
tracer = Tracer("redaction-run")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
tracer.log_chat_message(
|
|
||||||
"request failed with token sk-secret-token-value",
|
|
||||||
"assistant",
|
|
||||||
"agent-1",
|
|
||||||
metadata={
|
|
||||||
"api_key": "sk-secret-token-value",
|
|
||||||
"authorization": "Bearer super-secret-token",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
events_path = tmp_path / "strix_runs" / "redaction-run" / "events.jsonl"
|
|
||||||
events = _load_events(events_path)
|
|
||||||
serialized = json.dumps(events)
|
|
||||||
|
|
||||||
assert "sk-secret-token-value" not in serialized
|
|
||||||
assert "super-secret-token" not in serialized
|
|
||||||
assert "[REDACTED]" in serialized
|
|
||||||
|
|
||||||
|
|
||||||
def test_tracer_remote_mode_configures_traceloop_export(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
|
|
||||||
class FakeTraceloop:
|
|
||||||
init_calls: ClassVar[list[dict[str, Any]]] = []
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def init(**kwargs: Any) -> None:
|
|
||||||
FakeTraceloop.init_calls.append(kwargs)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def set_association_properties(properties: dict[str, Any]) -> None: # noqa: ARG004
|
|
||||||
return None
|
|
||||||
|
|
||||||
monkeypatch.setattr(tracer_module, "Traceloop", FakeTraceloop)
|
|
||||||
monkeypatch.setenv("TRACELOOP_BASE_URL", "https://otel.example.com")
|
|
||||||
monkeypatch.setenv("TRACELOOP_API_KEY", "test-api-key")
|
|
||||||
monkeypatch.setenv("TRACELOOP_HEADERS", '{"x-custom":"header"}')
|
|
||||||
|
|
||||||
tracer = Tracer("remote-observability")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
tracer.log_chat_message("hello", "user", "agent-1")
|
|
||||||
|
|
||||||
assert tracer._remote_export_enabled is True
|
|
||||||
assert FakeTraceloop.init_calls
|
|
||||||
init_kwargs = FakeTraceloop.init_calls[-1]
|
|
||||||
assert init_kwargs["api_endpoint"] == "https://otel.example.com"
|
|
||||||
assert init_kwargs["api_key"] == "test-api-key"
|
|
||||||
assert init_kwargs["headers"] == {"x-custom": "header"}
|
|
||||||
assert isinstance(init_kwargs["processor"], SimpleSpanProcessor)
|
|
||||||
assert "strix.run_id" not in init_kwargs["resource_attributes"]
|
|
||||||
assert "strix.run_name" not in init_kwargs["resource_attributes"]
|
|
||||||
|
|
||||||
events_path = tmp_path / "strix_runs" / "remote-observability" / "events.jsonl"
|
|
||||||
events = _load_events(events_path)
|
|
||||||
run_started = next(event for event in events if event["event_type"] == "run.started")
|
|
||||||
assert run_started["payload"]["remote_export_enabled"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_tracer_local_mode_avoids_traceloop_remote_endpoint(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
|
|
||||||
class FakeTraceloop:
|
|
||||||
init_calls: ClassVar[list[dict[str, Any]]] = []
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def init(**kwargs: Any) -> None:
|
|
||||||
FakeTraceloop.init_calls.append(kwargs)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def set_association_properties(properties: dict[str, Any]) -> None: # noqa: ARG004
|
|
||||||
return None
|
|
||||||
|
|
||||||
monkeypatch.setattr(tracer_module, "Traceloop", FakeTraceloop)
|
|
||||||
|
|
||||||
tracer = Tracer("local-traceloop")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
tracer.log_chat_message("hello", "user", "agent-1")
|
|
||||||
|
|
||||||
assert FakeTraceloop.init_calls
|
|
||||||
init_kwargs = FakeTraceloop.init_calls[-1]
|
|
||||||
assert "api_endpoint" not in init_kwargs
|
|
||||||
assert "api_key" not in init_kwargs
|
|
||||||
assert "headers" not in init_kwargs
|
|
||||||
assert isinstance(init_kwargs["processor"], SimpleSpanProcessor)
|
|
||||||
assert tracer._remote_export_enabled is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_otlp_fallback_includes_auth_and_custom_headers(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
monkeypatch.setattr(tracer_module, "Traceloop", None)
|
|
||||||
monkeypatch.setenv("TRACELOOP_BASE_URL", "https://otel.example.com")
|
|
||||||
monkeypatch.setenv("TRACELOOP_API_KEY", "test-api-key")
|
|
||||||
monkeypatch.setenv("TRACELOOP_HEADERS", '{"x-custom":"header"}')
|
|
||||||
|
|
||||||
captured: dict[str, Any] = {}
|
|
||||||
|
|
||||||
class FakeOTLPSpanExporter:
|
|
||||||
def __init__(self, endpoint: str, headers: dict[str, str] | None = None, **kwargs: Any):
|
|
||||||
captured["endpoint"] = endpoint
|
|
||||||
captured["headers"] = headers or {}
|
|
||||||
captured["kwargs"] = kwargs
|
|
||||||
|
|
||||||
def export(self, spans: Any) -> SpanExportResult:
|
|
||||||
return SpanExportResult.SUCCESS
|
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
fake_module = types.ModuleType("opentelemetry.exporter.otlp.proto.http.trace_exporter")
|
|
||||||
fake_module.OTLPSpanExporter = FakeOTLPSpanExporter
|
|
||||||
monkeypatch.setitem(
|
|
||||||
sys.modules,
|
|
||||||
"opentelemetry.exporter.otlp.proto.http.trace_exporter",
|
|
||||||
fake_module,
|
|
||||||
)
|
|
||||||
|
|
||||||
tracer = Tracer("otlp-fallback")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
|
|
||||||
assert tracer._remote_export_enabled is True
|
|
||||||
assert captured["endpoint"] == "https://otel.example.com/v1/traces"
|
|
||||||
assert captured["headers"]["Authorization"] == "Bearer test-api-key"
|
|
||||||
assert captured["headers"]["x-custom"] == "header"
|
|
||||||
|
|
||||||
|
|
||||||
def test_traceloop_init_failure_does_not_mark_bootstrapped_on_provider_failure(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
|
|
||||||
class FakeTraceloop:
|
|
||||||
@staticmethod
|
|
||||||
def init(**kwargs: Any) -> None: # noqa: ARG004
|
|
||||||
raise RuntimeError("traceloop init failed")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def set_association_properties(properties: dict[str, Any]) -> None: # noqa: ARG004
|
|
||||||
return None
|
|
||||||
|
|
||||||
monkeypatch.setattr(tracer_module, "Traceloop", FakeTraceloop)
|
|
||||||
|
|
||||||
def _raise_provider_error(provider: Any) -> None:
|
|
||||||
raise RuntimeError("provider setup failed")
|
|
||||||
|
|
||||||
monkeypatch.setattr(tracer_module.trace, "set_tracer_provider", _raise_provider_error)
|
|
||||||
|
|
||||||
tracer = Tracer("bootstrap-failure")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
|
|
||||||
assert tracer_module._OTEL_BOOTSTRAPPED is False
|
|
||||||
assert tracer._remote_export_enabled is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_completed_event_emitted_once(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
|
|
||||||
tracer = Tracer("single-complete")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
tracer.save_run_data(mark_complete=True)
|
|
||||||
tracer.save_run_data(mark_complete=True)
|
|
||||||
|
|
||||||
events_path = tmp_path / "strix_runs" / "single-complete" / "events.jsonl"
|
|
||||||
events = _load_events(events_path)
|
|
||||||
run_completed = [event for event in events if event["event_type"] == "run.completed"]
|
|
||||||
assert len(run_completed) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_events_with_agent_id_include_agent_name(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
|
|
||||||
tracer = Tracer("agent-name-enrichment")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
# _enrich_actor pulls names from the tracer's agents dict; populate it
|
|
||||||
# the same way the orchestration path will once wired (placeholder
|
|
||||||
# until live wiring lands).
|
|
||||||
tracer.agents["agent-1"] = {"name": "Root Agent"}
|
|
||||||
tracer.log_chat_message("hello", "assistant", "agent-1")
|
|
||||||
|
|
||||||
events_path = tmp_path / "strix_runs" / "agent-name-enrichment" / "events.jsonl"
|
|
||||||
events = _load_events(events_path)
|
|
||||||
chat_event = next(event for event in events if event["event_type"] == "chat.message")
|
|
||||||
|
|
||||||
assert chat_event["actor"]["agent_id"] == "agent-1"
|
|
||||||
assert chat_event["actor"]["agent_name"] == "Root Agent"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_total_llm_stats_aggregates_recordings(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
tracer = Tracer("cost-rollup")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
|
|
||||||
tracer.record_llm_usage(
|
|
||||||
input_tokens=1_000,
|
|
||||||
output_tokens=250,
|
|
||||||
cached_tokens=100,
|
|
||||||
cost=0.12345,
|
|
||||||
requests=2,
|
|
||||||
)
|
|
||||||
tracer.record_llm_usage(
|
|
||||||
input_tokens=2_000,
|
|
||||||
output_tokens=500,
|
|
||||||
cached_tokens=400,
|
|
||||||
cost=0.54321,
|
|
||||||
requests=3,
|
|
||||||
)
|
|
||||||
|
|
||||||
stats = tracer.get_total_llm_stats()
|
|
||||||
|
|
||||||
assert stats["total"] == {
|
|
||||||
"input_tokens": 3_000,
|
|
||||||
"output_tokens": 750,
|
|
||||||
"cached_tokens": 500,
|
|
||||||
"cost": 0.6667,
|
|
||||||
"requests": 5,
|
|
||||||
}
|
|
||||||
assert stats["total_tokens"] == 3_750
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_metadata_is_only_on_run_lifecycle_events(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
|
|
||||||
tracer = Tracer("metadata-scope")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
tracer.log_chat_message("hello", "assistant", "agent-1")
|
|
||||||
tracer.save_run_data(mark_complete=True)
|
|
||||||
|
|
||||||
events_path = tmp_path / "strix_runs" / "metadata-scope" / "events.jsonl"
|
|
||||||
events = _load_events(events_path)
|
|
||||||
|
|
||||||
run_started = next(event for event in events if event["event_type"] == "run.started")
|
|
||||||
run_completed = next(event for event in events if event["event_type"] == "run.completed")
|
|
||||||
chat_event = next(event for event in events if event["event_type"] == "chat.message")
|
|
||||||
|
|
||||||
assert "run_metadata" in run_started
|
|
||||||
assert "run_metadata" in run_completed
|
|
||||||
assert "run_metadata" not in chat_event
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_run_name_resets_cached_paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
|
|
||||||
tracer = Tracer()
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
old_events_path = tracer.events_file_path
|
|
||||||
|
|
||||||
tracer.set_run_name("renamed-run")
|
|
||||||
tracer.log_chat_message("hello", "assistant", "agent-1")
|
|
||||||
|
|
||||||
new_events_path = tracer.events_file_path
|
|
||||||
assert new_events_path != old_events_path
|
|
||||||
assert new_events_path == tmp_path / "strix_runs" / "renamed-run" / "events.jsonl"
|
|
||||||
|
|
||||||
events = _load_events(new_events_path)
|
|
||||||
assert any(event["event_type"] == "run.started" for event in events)
|
|
||||||
assert any(event["event_type"] == "chat.message" for event in events)
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_run_name_resets_run_completed_flag(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
|
|
||||||
tracer = Tracer()
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
|
|
||||||
tracer.save_run_data(mark_complete=True)
|
|
||||||
tracer.set_run_name("renamed-complete")
|
|
||||||
tracer.save_run_data(mark_complete=True)
|
|
||||||
|
|
||||||
events_path = tmp_path / "strix_runs" / "renamed-complete" / "events.jsonl"
|
|
||||||
events = _load_events(events_path)
|
|
||||||
run_completed = [event for event in events if event["event_type"] == "run.completed"]
|
|
||||||
|
|
||||||
assert any(event["event_type"] == "run.started" for event in events)
|
|
||||||
assert len(run_completed) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_run_name_updates_traceloop_association_properties(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
|
|
||||||
class FakeTraceloop:
|
|
||||||
associations: ClassVar[list[dict[str, Any]]] = []
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def init(**kwargs: Any) -> None: # noqa: ARG004
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def set_association_properties(properties: dict[str, Any]) -> None:
|
|
||||||
FakeTraceloop.associations.append(properties)
|
|
||||||
|
|
||||||
monkeypatch.setattr(tracer_module, "Traceloop", FakeTraceloop)
|
|
||||||
|
|
||||||
tracer = Tracer()
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
tracer.set_run_name("renamed-run")
|
|
||||||
|
|
||||||
assert FakeTraceloop.associations
|
|
||||||
assert FakeTraceloop.associations[-1]["run_id"] == "renamed-run"
|
|
||||||
assert FakeTraceloop.associations[-1]["run_name"] == "renamed-run"
|
|
||||||
|
|
||||||
|
|
||||||
def test_events_write_locks_are_scoped_by_events_file(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
monkeypatch.setenv("STRIX_TELEMETRY", "0")
|
|
||||||
|
|
||||||
tracer_one = Tracer("lock-run-a")
|
|
||||||
tracer_two = Tracer("lock-run-b")
|
|
||||||
|
|
||||||
lock_a_from_one = tracer_one._get_events_write_lock(tracer_one.events_file_path)
|
|
||||||
lock_a_from_two = tracer_two._get_events_write_lock(tracer_one.events_file_path)
|
|
||||||
lock_b = tracer_two._get_events_write_lock(tracer_two.events_file_path)
|
|
||||||
|
|
||||||
assert lock_a_from_one is lock_a_from_two
|
|
||||||
assert lock_a_from_one is not lock_b
|
|
||||||
|
|
||||||
|
|
||||||
def test_tracer_skips_jsonl_when_telemetry_disabled(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
monkeypatch.setenv("STRIX_TELEMETRY", "0")
|
|
||||||
|
|
||||||
tracer = Tracer("telemetry-disabled")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
tracer.log_chat_message("hello", "assistant", "agent-1")
|
|
||||||
tracer.save_run_data(mark_complete=True)
|
|
||||||
|
|
||||||
events_path = tmp_path / "strix_runs" / "telemetry-disabled" / "events.jsonl"
|
|
||||||
assert not events_path.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_tracer_otel_flag_overrides_global_telemetry(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
monkeypatch.setenv("STRIX_TELEMETRY", "0")
|
|
||||||
monkeypatch.setenv("STRIX_OTEL_TELEMETRY", "1")
|
|
||||||
|
|
||||||
tracer = Tracer("otel-enabled")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
tracer.log_chat_message("hello", "assistant", "agent-1")
|
|
||||||
tracer.save_run_data(mark_complete=True)
|
|
||||||
|
|
||||||
events_path = tmp_path / "strix_runs" / "otel-enabled" / "events.jsonl"
|
|
||||||
assert events_path.exists()
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
from strix.telemetry.utils import prune_otel_span_attributes
|
|
||||||
|
|
||||||
|
|
||||||
def test_prune_otel_span_attributes_drops_high_volume_prompt_content() -> None:
|
|
||||||
attributes = {
|
|
||||||
"gen_ai.operation.name": "openai.chat",
|
|
||||||
"gen_ai.request.model": "gpt-5.2",
|
|
||||||
"gen_ai.prompt.0.role": "system",
|
|
||||||
"gen_ai.prompt.0.content": "a" * 20_000,
|
|
||||||
"gen_ai.completion.0.content": "b" * 10_000,
|
|
||||||
"llm.input_messages.0.content": "c" * 5_000,
|
|
||||||
"llm.output_messages.0.content": "d" * 5_000,
|
|
||||||
"llm.input": "x" * 3_000,
|
|
||||||
"llm.output": "y" * 3_000,
|
|
||||||
}
|
|
||||||
|
|
||||||
pruned = prune_otel_span_attributes(attributes)
|
|
||||||
|
|
||||||
assert "gen_ai.prompt.0.content" not in pruned
|
|
||||||
assert "gen_ai.completion.0.content" not in pruned
|
|
||||||
assert "llm.input_messages.0.content" not in pruned
|
|
||||||
assert "llm.output_messages.0.content" not in pruned
|
|
||||||
assert "llm.input" not in pruned
|
|
||||||
assert "llm.output" not in pruned
|
|
||||||
assert pruned["gen_ai.operation.name"] == "openai.chat"
|
|
||||||
assert pruned["gen_ai.prompt.0.role"] == "system"
|
|
||||||
assert pruned["strix.filtered_attributes_count"] == 6
|
|
||||||
|
|
||||||
|
|
||||||
def test_prune_otel_span_attributes_keeps_metadata_when_nothing_is_dropped() -> None:
|
|
||||||
attributes = {
|
|
||||||
"gen_ai.operation.name": "openai.chat",
|
|
||||||
"gen_ai.request.model": "gpt-5.2",
|
|
||||||
"gen_ai.prompt.0.role": "user",
|
|
||||||
}
|
|
||||||
|
|
||||||
pruned = prune_otel_span_attributes(attributes)
|
|
||||||
|
|
||||||
assert pruned == attributes
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
"""Phase 5 tests for the top-level SDK scan entry point.
|
|
||||||
|
|
||||||
We never spin up a real Docker container or hit a real LLM here. The
|
|
||||||
tests patch ``session_manager.create_or_reuse``, ``Runner.run``, and
|
|
||||||
the agent factory so we can verify the wiring shape:
|
|
||||||
|
|
||||||
- The bus is registered with a root agent before Runner.run.
|
|
||||||
- The context dict carries every field downstream code (tools, hooks,
|
|
||||||
filter) reads.
|
|
||||||
- The session manager's bundle flows through to the context (host
|
|
||||||
ports, bearer, sandbox session/client).
|
|
||||||
- ``cleanup_on_exit=True`` always cleans up, even when Runner.run
|
|
||||||
raises.
|
|
||||||
- ``cleanup_on_exit=False`` preserves the cached session.
|
|
||||||
- Cancellation propagates: if Runner.run raises, descendants are
|
|
||||||
cancelled before re-raising.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from strix.entry import _build_root_task, _build_scope_context, run_strix_scan
|
|
||||||
from strix.orchestration.bus import AgentMessageBus
|
|
||||||
|
|
||||||
|
|
||||||
# --- helpers ------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _bundle_for_test() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"client": MagicMock(name="docker_client"),
|
|
||||||
"session": MagicMock(name="sandbox_session"),
|
|
||||||
"capability": MagicMock(),
|
|
||||||
"tool_server_host_port": 12001,
|
|
||||||
"caido_host_port": 12002,
|
|
||||||
"bearer": "test-bearer-token-1234567890",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_config(**overrides: Any) -> dict[str, Any]:
|
|
||||||
base: dict[str, Any] = {
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"type": "web_application",
|
|
||||||
"details": {"target_url": "https://example.com"},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"user_instructions": "find xss",
|
|
||||||
"scan_mode": "deep",
|
|
||||||
"is_whitebox": False,
|
|
||||||
}
|
|
||||||
base.update(overrides)
|
|
||||||
return base
|
|
||||||
|
|
||||||
|
|
||||||
# --- task / scope builders ---------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_root_task_groups_targets_and_appends_instructions() -> None:
|
|
||||||
config = _scan_config(
|
|
||||||
targets=[
|
|
||||||
{
|
|
||||||
"type": "repository",
|
|
||||||
"details": {
|
|
||||||
"target_repo": "https://github.com/x/y",
|
|
||||||
"cloned_repo_path": "/tmp/y",
|
|
||||||
"workspace_subdir": "y",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "ip_address",
|
|
||||||
"details": {"target_ip": "10.0.0.1"},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
user_instructions="report only critical issues",
|
|
||||||
)
|
|
||||||
task = _build_root_task(config)
|
|
||||||
assert "Repositories:" in task
|
|
||||||
assert "https://github.com/x/y (available at: /workspace/y)" in task
|
|
||||||
assert "IP Addresses:" in task
|
|
||||||
assert "10.0.0.1" in task
|
|
||||||
assert "Special instructions: report only critical issues" in task
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_root_task_renders_diff_scope_block() -> None:
|
|
||||||
config = _scan_config(
|
|
||||||
diff_scope={
|
|
||||||
"active": True,
|
|
||||||
"repos": [
|
|
||||||
{
|
|
||||||
"workspace_subdir": "service-x",
|
|
||||||
"analyzable_files_count": 7,
|
|
||||||
"deleted_files_count": 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
task = _build_root_task(config)
|
|
||||||
assert "Scope Constraints:" in task
|
|
||||||
assert "service-x: 7 changed file(s)" in task
|
|
||||||
assert "service-x: 2 deleted file(s)" in task
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_scope_context_marks_authorization_source() -> None:
|
|
||||||
config = _scan_config(
|
|
||||||
targets=[
|
|
||||||
{
|
|
||||||
"type": "web_application",
|
|
||||||
"details": {"target_url": "https://target.test"},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
ctx = _build_scope_context(config)
|
|
||||||
assert ctx["scope_source"] == "system_scan_config"
|
|
||||||
assert ctx["authorization_source"] == "strix_platform_verified_targets"
|
|
||||||
assert ctx["user_instructions_do_not_expand_scope"] is True
|
|
||||||
assert ctx["authorized_targets"] == [
|
|
||||||
{"type": "web_application", "value": "https://target.test", "workspace_path": ""},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# --- run_strix_scan wiring ---------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_strix_scan_wires_context_and_calls_runner(tmp_path: Path) -> None:
|
|
||||||
"""End-to-end (mocked) — assert every downstream consumer of context
|
|
||||||
sees the bundle's bearer + host ports."""
|
|
||||||
bundle = _bundle_for_test()
|
|
||||||
captured_context: dict[str, Any] = {}
|
|
||||||
|
|
||||||
async def fake_runner_run(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
captured_context.update(kwargs.get("context", {}))
|
|
||||||
return MagicMock(name="run_result")
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"strix.entry.session_manager.create_or_reuse",
|
|
||||||
new=AsyncMock(return_value=bundle),
|
|
||||||
) as create_mock,
|
|
||||||
patch(
|
|
||||||
"strix.entry.session_manager.cleanup",
|
|
||||||
new=AsyncMock(),
|
|
||||||
) as cleanup_mock,
|
|
||||||
patch("strix.entry.Runner.run", side_effect=fake_runner_run) as runner_mock,
|
|
||||||
# Stub the factory to avoid rendering the 158k-char prompt for
|
|
||||||
# every test (it's covered by sdk_prompt tests).
|
|
||||||
patch(
|
|
||||||
"strix.entry.build_strix_agent",
|
|
||||||
return_value=MagicMock(name="root_agent"),
|
|
||||||
) as factory_mock,
|
|
||||||
):
|
|
||||||
await run_strix_scan(
|
|
||||||
scan_config=_scan_config(),
|
|
||||||
scan_id="scan-test",
|
|
||||||
image="strix-sandbox:test",
|
|
||||||
sources_path=tmp_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Session manager calls.
|
|
||||||
create_mock.assert_awaited_once()
|
|
||||||
create_args = create_mock.await_args
|
|
||||||
assert create_args is not None
|
|
||||||
assert create_args.args == ("scan-test",)
|
|
||||||
assert create_args.kwargs["image"] == "strix-sandbox:test"
|
|
||||||
assert create_args.kwargs["sources_path"] == tmp_path
|
|
||||||
cleanup_mock.assert_awaited_once_with("scan-test")
|
|
||||||
|
|
||||||
# Factory called with is_root=True.
|
|
||||||
factory_mock.assert_called_once()
|
|
||||||
assert factory_mock.call_args.kwargs["is_root"] is True
|
|
||||||
|
|
||||||
# Runner.run called once with the root agent.
|
|
||||||
assert runner_mock.call_count == 1
|
|
||||||
|
|
||||||
# Context shape passed into Runner.run.
|
|
||||||
assert captured_context["sandbox_session"] is bundle["session"]
|
|
||||||
assert captured_context["sandbox_client"] is bundle["client"]
|
|
||||||
assert captured_context["sandbox_token"] == bundle["bearer"]
|
|
||||||
assert captured_context["tool_server_host_port"] == bundle["tool_server_host_port"]
|
|
||||||
assert captured_context["caido_host_port"] == bundle["caido_host_port"]
|
|
||||||
# Bus is registered and root agent_id is populated.
|
|
||||||
bus = captured_context["bus"]
|
|
||||||
assert isinstance(bus, AgentMessageBus)
|
|
||||||
assert captured_context["agent_id"] in bus.statuses
|
|
||||||
assert bus.parent_of[captured_context["agent_id"]] is None
|
|
||||||
# Child factory wired through so create_agent works.
|
|
||||||
assert callable(captured_context["agent_factory"])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_strix_scan_cleans_up_on_runner_failure(tmp_path: Path) -> None:
|
|
||||||
"""If Runner.run raises, cleanup must still fire (the finally branch)."""
|
|
||||||
bundle = _bundle_for_test()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"strix.entry.session_manager.create_or_reuse",
|
|
||||||
new=AsyncMock(return_value=bundle),
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"strix.entry.session_manager.cleanup",
|
|
||||||
new=AsyncMock(),
|
|
||||||
) as cleanup_mock,
|
|
||||||
patch(
|
|
||||||
"strix.entry.Runner.run",
|
|
||||||
side_effect=RuntimeError("simulated LLM blow-up"),
|
|
||||||
),
|
|
||||||
patch("strix.entry.build_strix_agent", return_value=MagicMock()),
|
|
||||||
pytest.raises(RuntimeError, match="simulated LLM"),
|
|
||||||
):
|
|
||||||
await run_strix_scan(
|
|
||||||
scan_config=_scan_config(),
|
|
||||||
scan_id="scan-fail",
|
|
||||||
image="i",
|
|
||||||
sources_path=tmp_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
cleanup_mock.assert_awaited_once_with("scan-fail")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_strix_scan_skips_cleanup_when_disabled(tmp_path: Path) -> None:
|
|
||||||
bundle = _bundle_for_test()
|
|
||||||
|
|
||||||
async def fake_runner_run(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
return MagicMock()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"strix.entry.session_manager.create_or_reuse",
|
|
||||||
new=AsyncMock(return_value=bundle),
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"strix.entry.session_manager.cleanup",
|
|
||||||
new=AsyncMock(),
|
|
||||||
) as cleanup_mock,
|
|
||||||
patch("strix.entry.Runner.run", side_effect=fake_runner_run),
|
|
||||||
patch("strix.entry.build_strix_agent", return_value=MagicMock()),
|
|
||||||
):
|
|
||||||
await run_strix_scan(
|
|
||||||
scan_config=_scan_config(),
|
|
||||||
scan_id="scan-keep",
|
|
||||||
image="i",
|
|
||||||
sources_path=tmp_path,
|
|
||||||
cleanup_on_exit=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
cleanup_mock.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_strix_scan_auto_generates_scan_id(tmp_path: Path) -> None:
|
|
||||||
"""A caller without a stable id should still get a valid scan_id
|
|
||||||
flowing into create_or_reuse."""
|
|
||||||
bundle = _bundle_for_test()
|
|
||||||
captured_scan_id: list[str] = []
|
|
||||||
|
|
||||||
async def fake_create(scan_id: str, **_kwargs: Any) -> Any:
|
|
||||||
captured_scan_id.append(scan_id)
|
|
||||||
return bundle
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"strix.entry.session_manager.create_or_reuse",
|
|
||||||
new=AsyncMock(side_effect=fake_create),
|
|
||||||
),
|
|
||||||
patch("strix.entry.session_manager.cleanup", new=AsyncMock()),
|
|
||||||
patch("strix.entry.Runner.run", new=AsyncMock(return_value=MagicMock())),
|
|
||||||
patch("strix.entry.build_strix_agent", return_value=MagicMock()),
|
|
||||||
):
|
|
||||||
await run_strix_scan(
|
|
||||||
scan_config=_scan_config(),
|
|
||||||
image="i",
|
|
||||||
sources_path=tmp_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(captured_scan_id) == 1
|
|
||||||
assert captured_scan_id[0].startswith("scan-")
|
|
||||||
assert len(captured_scan_id[0]) > len("scan-")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_strix_scan_passes_scan_level_config_into_factory(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""scan_mode / is_whitebox flow from scan_config into both the
|
|
||||||
root factory call and the child factory closure."""
|
|
||||||
bundle = _bundle_for_test()
|
|
||||||
factory_calls: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
def fake_factory(**kwargs: Any) -> Any:
|
|
||||||
factory_calls.append(kwargs)
|
|
||||||
return MagicMock()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"strix.entry.session_manager.create_or_reuse",
|
|
||||||
new=AsyncMock(return_value=bundle),
|
|
||||||
),
|
|
||||||
patch("strix.entry.session_manager.cleanup", new=AsyncMock()),
|
|
||||||
patch("strix.entry.Runner.run", new=AsyncMock(return_value=MagicMock())),
|
|
||||||
patch("strix.entry.build_strix_agent", side_effect=fake_factory),
|
|
||||||
):
|
|
||||||
await run_strix_scan(
|
|
||||||
scan_config=_scan_config(scan_mode="fast", is_whitebox=True),
|
|
||||||
scan_id="s",
|
|
||||||
image="i",
|
|
||||||
sources_path=tmp_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert factory_calls[0]["scan_mode"] == "fast"
|
|
||||||
assert factory_calls[0]["is_whitebox"] is True
|
|
||||||
assert factory_calls[0]["is_root"] is True
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
"""Smoke tests for make_run_config / make_agent_context."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from agents import RunConfig
|
|
||||||
from agents.model_settings import ModelSettings
|
|
||||||
from agents.retry import ModelRetryBackoffSettings
|
|
||||||
|
|
||||||
from strix.orchestration.bus import AgentMessageBus
|
|
||||||
from strix.run_config_factory import make_agent_context, make_run_config
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_run_config_returns_run_config() -> None:
|
|
||||||
cfg = make_run_config(sandbox_session=None)
|
|
||||||
assert isinstance(cfg, RunConfig)
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_parallel_tool_calls_is_false() -> None:
|
|
||||||
"""Default is sequential — the tool server serializes one task per agent."""
|
|
||||||
cfg = make_run_config(sandbox_session=None)
|
|
||||||
assert cfg.model_settings is not None
|
|
||||||
assert cfg.model_settings.parallel_tool_calls is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_tool_choice_is_required() -> None:
|
|
||||||
cfg = make_run_config(sandbox_session=None)
|
|
||||||
assert cfg.model_settings is not None
|
|
||||||
assert cfg.model_settings.tool_choice == "required"
|
|
||||||
|
|
||||||
|
|
||||||
def test_call_model_input_filter_is_wired() -> None:
|
|
||||||
cfg = make_run_config(sandbox_session=None)
|
|
||||||
assert cfg.call_model_input_filter is not None
|
|
||||||
# Wired to inject_messages_filter (validated by name to keep import light).
|
|
||||||
assert cfg.call_model_input_filter.__name__ == "inject_messages_filter"
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_settings_have_max_retries_5() -> None:
|
|
||||||
cfg = make_run_config(sandbox_session=None)
|
|
||||||
assert cfg.model_settings is not None
|
|
||||||
retry = cfg.model_settings.retry
|
|
||||||
assert retry is not None
|
|
||||||
assert retry.max_retries == 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_backoff_uses_strix_defaults() -> None:
|
|
||||||
"""min(90, 2*2^n) with initial 2s, max 90s, x2."""
|
|
||||||
cfg = make_run_config(sandbox_session=None)
|
|
||||||
assert cfg.model_settings is not None
|
|
||||||
retry = cfg.model_settings.retry
|
|
||||||
assert retry is not None
|
|
||||||
backoff = retry.backoff
|
|
||||||
assert isinstance(backoff, ModelRetryBackoffSettings)
|
|
||||||
assert backoff.initial_delay == 2.0
|
|
||||||
assert backoff.max_delay == 90.0
|
|
||||||
assert backoff.multiplier == 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_policy_is_set() -> None:
|
|
||||||
"""Retry policy is wired (auth/validation 4xx excluded by construction)."""
|
|
||||||
cfg = make_run_config(sandbox_session=None)
|
|
||||||
assert cfg.model_settings is not None
|
|
||||||
retry = cfg.model_settings.retry
|
|
||||||
assert retry is not None
|
|
||||||
assert retry.policy is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_trace_include_sensitive_data_is_false() -> None:
|
|
||||||
cfg = make_run_config(sandbox_session=None)
|
|
||||||
assert cfg.trace_include_sensitive_data is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_model_settings_override_merges() -> None:
|
|
||||||
"""Per-call override path."""
|
|
||||||
override = ModelSettings(tool_choice="auto", parallel_tool_calls=True)
|
|
||||||
cfg = make_run_config(sandbox_session=None, model_settings_override=override)
|
|
||||||
assert cfg.model_settings is not None
|
|
||||||
assert cfg.model_settings.tool_choice == "auto"
|
|
||||||
assert cfg.model_settings.parallel_tool_calls is True
|
|
||||||
# Retry settings (not in override) preserved from base.
|
|
||||||
assert cfg.model_settings.retry is not None
|
|
||||||
assert cfg.model_settings.retry.max_retries == 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_reasoning_effort_propagates() -> None:
|
|
||||||
cfg = make_run_config(sandbox_session=None, reasoning_effort="high")
|
|
||||||
assert cfg.model_settings is not None
|
|
||||||
assert cfg.model_settings.reasoning is not None
|
|
||||||
assert cfg.model_settings.reasoning.effort == "high"
|
|
||||||
|
|
||||||
|
|
||||||
def test_max_turns_default_is_300() -> None:
|
|
||||||
"""Default max_turns=300 in make_agent_context.
|
|
||||||
|
|
||||||
``max_turns`` itself is passed to ``Runner.run``; the context copy is
|
|
||||||
consumed by the budget-warning hook.
|
|
||||||
"""
|
|
||||||
bus = AgentMessageBus()
|
|
||||||
ctx = make_agent_context(
|
|
||||||
bus=bus,
|
|
||||||
sandbox_session=None,
|
|
||||||
sandbox_token=None,
|
|
||||||
tool_server_host_port=None,
|
|
||||||
caido_host_port=None,
|
|
||||||
agent_id="root",
|
|
||||||
parent_id=None,
|
|
||||||
tracer=None,
|
|
||||||
)
|
|
||||||
assert ctx["max_turns"] == 300
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_agent_context_full_shape() -> None:
|
|
||||||
"""The context dict carries every field tools/hooks reach for."""
|
|
||||||
bus = AgentMessageBus()
|
|
||||||
ctx = make_agent_context(
|
|
||||||
bus=bus,
|
|
||||||
sandbox_session=None,
|
|
||||||
sandbox_token="bearer-xyz",
|
|
||||||
tool_server_host_port=48081,
|
|
||||||
caido_host_port=48080,
|
|
||||||
agent_id="agent-1",
|
|
||||||
parent_id=None,
|
|
||||||
tracer="not-a-real-tracer",
|
|
||||||
is_whitebox=True,
|
|
||||||
diff_scope={"changed_files": ["src/app.py"]},
|
|
||||||
run_id="strix_runs/abc_def",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert ctx["bus"] is bus
|
|
||||||
assert ctx["agent_id"] == "agent-1"
|
|
||||||
assert ctx["parent_id"] is None
|
|
||||||
assert ctx["agent_finish_called"] is False
|
|
||||||
assert ctx["turn_count"] == 0
|
|
||||||
assert ctx["is_whitebox"] is True
|
|
||||||
assert ctx["diff_scope"] == {"changed_files": ["src/app.py"]}
|
|
||||||
assert ctx["run_id"] == "strix_runs/abc_def"
|
|
||||||
assert ctx["sandbox_token"] == "bearer-xyz"
|
|
||||||
assert ctx["tool_server_host_port"] == 48081
|
|
||||||
assert ctx["caido_host_port"] == 48080
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_agent_context_is_whitebox_defaults_false() -> None:
|
|
||||||
bus = AgentMessageBus()
|
|
||||||
ctx = make_agent_context(
|
|
||||||
bus=bus,
|
|
||||||
sandbox_session=None,
|
|
||||||
sandbox_token=None,
|
|
||||||
tool_server_host_port=None,
|
|
||||||
caido_host_port=None,
|
|
||||||
agent_id="r",
|
|
||||||
parent_id=None,
|
|
||||||
tracer=None,
|
|
||||||
)
|
|
||||||
assert ctx["is_whitebox"] is False
|
|
||||||
assert ctx["diff_scope"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_sandbox_config_omitted_when_no_session() -> None:
|
|
||||||
cfg = make_run_config(sandbox_session=None)
|
|
||||||
assert cfg.sandbox is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_model_default_is_strix_claude() -> None:
|
|
||||||
cfg = make_run_config(sandbox_session=None)
|
|
||||||
assert cfg.model == "anthropic/claude-sonnet-4-6"
|
|
||||||
|
|
||||||
|
|
||||||
def test_multi_provider_is_built() -> None:
|
|
||||||
"""Verify the factory wires our custom MultiProvider, not the SDK default."""
|
|
||||||
cfg = make_run_config(sandbox_session=None)
|
|
||||||
# MultiProvider is opaque, but our build_multi_provider returns
|
|
||||||
# an instance with our prefix routes installed.
|
|
||||||
assert cfg.model_provider is not None
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for strix.tools module."""
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""Fixtures for strix.tools tests."""
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_function_with_types() -> Callable[..., None]:
|
|
||||||
"""Create a sample function with type annotations for testing argument conversion."""
|
|
||||||
|
|
||||||
def func(
|
|
||||||
name: str,
|
|
||||||
count: int,
|
|
||||||
enabled: bool,
|
|
||||||
ratio: float,
|
|
||||||
items: list[Any],
|
|
||||||
config: dict[str, Any],
|
|
||||||
optional: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return func
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_function_no_annotations() -> Callable[..., None]:
|
|
||||||
"""Create a sample function without type annotations."""
|
|
||||||
|
|
||||||
def func(arg1, arg2, arg3): # type: ignore[no-untyped-def]
|
|
||||||
pass
|
|
||||||
|
|
||||||
return func
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
"""Phase 0 smoke tests for the strix_tool decorator factory.
|
|
||||||
|
|
||||||
The SDK's ``FunctionTool`` only honors ``timeout_seconds`` for ``async``
|
|
||||||
handlers (verified at ``agents.tool._validate_function_tool_timeout_config``):
|
|
||||||
sync function bodies cannot be cleanly cancelled, so the SDK refuses to
|
|
||||||
attach a timeout to them. Every Strix tool is therefore an ``async def``;
|
|
||||||
sync libraries (libtmux, IPython) get wrapped in ``asyncio.to_thread``
|
|
||||||
inside the async tool body.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from agents.tool import FunctionTool
|
|
||||||
|
|
||||||
from strix.tools._decorator import strix_tool
|
|
||||||
|
|
||||||
|
|
||||||
def test_strix_tool_returns_function_tool() -> None:
|
|
||||||
@strix_tool()
|
|
||||||
async def my_tool(x: int) -> str:
|
|
||||||
"""Do a thing."""
|
|
||||||
return str(x)
|
|
||||||
|
|
||||||
assert isinstance(my_tool, FunctionTool)
|
|
||||||
|
|
||||||
|
|
||||||
def test_strix_tool_default_timeout_is_120s() -> None:
|
|
||||||
@strix_tool()
|
|
||||||
async def my_tool(x: int) -> str:
|
|
||||||
"""Do a thing."""
|
|
||||||
return str(x)
|
|
||||||
|
|
||||||
assert my_tool.timeout_seconds == 120.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_strix_tool_default_timeout_behavior_is_error_as_result() -> None:
|
|
||||||
@strix_tool()
|
|
||||||
async def my_tool(x: int) -> str:
|
|
||||||
"""Do a thing."""
|
|
||||||
return str(x)
|
|
||||||
|
|
||||||
assert my_tool.timeout_behavior == "error_as_result"
|
|
||||||
|
|
||||||
|
|
||||||
def test_strix_tool_timeout_override() -> None:
|
|
||||||
@strix_tool(timeout=300)
|
|
||||||
async def my_tool(x: int) -> str:
|
|
||||||
"""Do a thing."""
|
|
||||||
return str(x)
|
|
||||||
|
|
||||||
assert my_tool.timeout_seconds == 300.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_strix_tool_critical_tool_can_raise() -> None:
|
|
||||||
"""C20 (AUDIT_R3): critical tools opt into raise_exception."""
|
|
||||||
|
|
||||||
@strix_tool(timeout=30, timeout_behavior="raise_exception")
|
|
||||||
async def critical_tool(x: int) -> str:
|
|
||||||
"""Do a critical thing."""
|
|
||||||
return str(x)
|
|
||||||
|
|
||||||
assert critical_tool.timeout_behavior == "raise_exception"
|
|
||||||
|
|
||||||
|
|
||||||
def test_strix_tool_sync_handlers_rejected_by_sdk() -> None:
|
|
||||||
"""SDK explicitly rejects timeout on sync handlers; documenting the constraint."""
|
|
||||||
with pytest.raises(ValueError, match="async @function_tool"):
|
|
||||||
|
|
||||||
@strix_tool()
|
|
||||||
def my_sync_tool(x: int) -> str:
|
|
||||||
"""Sync tools can't have a timeout in the SDK."""
|
|
||||||
return str(x)
|
|
||||||
@@ -1,514 +0,0 @@
|
|||||||
"""Phase 3 tests for the multi-agent graph SDK tools.
|
|
||||||
|
|
||||||
Six tools: view_agent_graph, agent_status, send_message_to_agent,
|
|
||||||
wait_for_message, create_agent, agent_finish.
|
|
||||||
|
|
||||||
Strategy:
|
|
||||||
|
|
||||||
- Build a real ``AgentMessageBus`` in each test and put it under
|
|
||||||
``ctx.context['bus']`` so the tools exercise the same code path
|
|
||||||
production runs do.
|
|
||||||
- ``create_agent`` is the only tool that touches ``Runner.run`` and
|
|
||||||
``asyncio.create_task``. Its test injects a stub agent factory and
|
|
||||||
patches the SDK's ``Runner.run`` to a sentinel coroutine — we verify
|
|
||||||
the spawn shape (bus.register called, task handle stored, identity
|
|
||||||
block in initial input) without spinning up a real LLM.
|
|
||||||
- ``wait_for_message`` is exercised on both branches: a message arrives
|
|
||||||
mid-poll, and the timeout path.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from agents.tool import FunctionTool
|
|
||||||
|
|
||||||
from strix.orchestration.bus import AgentMessageBus
|
|
||||||
from strix.tools.agents_graph.tools import (
|
|
||||||
agent_finish,
|
|
||||||
agent_status,
|
|
||||||
create_agent,
|
|
||||||
send_message_to_agent,
|
|
||||||
view_agent_graph,
|
|
||||||
wait_for_message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _Ctx:
|
|
||||||
context: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
async def _invoke(tool: FunctionTool, ctx: _Ctx, **kwargs: Any) -> dict[str, Any]:
|
|
||||||
from agents.tool_context import ToolContext
|
|
||||||
|
|
||||||
tool_ctx = ToolContext(
|
|
||||||
context=ctx.context,
|
|
||||||
usage=None,
|
|
||||||
tool_name=tool.name,
|
|
||||||
tool_call_id="test-call-id",
|
|
||||||
tool_arguments=json.dumps(kwargs),
|
|
||||||
)
|
|
||||||
result = await tool.on_invoke_tool(tool_ctx, json.dumps(kwargs))
|
|
||||||
assert isinstance(result, str)
|
|
||||||
decoded = json.loads(result)
|
|
||||||
assert isinstance(decoded, dict)
|
|
||||||
return decoded
|
|
||||||
|
|
||||||
|
|
||||||
async def _make_bus_with_agents() -> AgentMessageBus:
|
|
||||||
"""Bus prepopulated with a root + two registered children."""
|
|
||||||
bus = AgentMessageBus()
|
|
||||||
await bus.register("root-1", "root", parent_id=None)
|
|
||||||
await bus.register("child-A", "scanner", parent_id="root-1")
|
|
||||||
await bus.register("child-B", "exploiter", parent_id="root-1")
|
|
||||||
return bus
|
|
||||||
|
|
||||||
|
|
||||||
def _ctx_for(bus: AgentMessageBus, agent_id: str = "root-1") -> _Ctx:
|
|
||||||
return _Ctx(context={"bus": bus, "agent_id": agent_id, "parent_id": None})
|
|
||||||
|
|
||||||
|
|
||||||
# --- registration ---------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_graph_tools_are_function_tools() -> None:
|
|
||||||
for tool in (
|
|
||||||
view_agent_graph,
|
|
||||||
agent_status,
|
|
||||||
send_message_to_agent,
|
|
||||||
wait_for_message,
|
|
||||||
create_agent,
|
|
||||||
agent_finish,
|
|
||||||
):
|
|
||||||
assert isinstance(tool, FunctionTool)
|
|
||||||
|
|
||||||
|
|
||||||
# --- view_agent_graph -----------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_view_agent_graph_renders_tree() -> None:
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
out = await _invoke(view_agent_graph, _ctx_for(bus))
|
|
||||||
assert out["success"] is True
|
|
||||||
assert "root (root-1)" in out["graph_structure"]
|
|
||||||
assert "scanner (child-A)" in out["graph_structure"]
|
|
||||||
assert "exploiter (child-B)" in out["graph_structure"]
|
|
||||||
# The "you" marker should be on the calling agent's line.
|
|
||||||
you_lines = [line for line in out["graph_structure"].splitlines() if "← you" in line]
|
|
||||||
assert len(you_lines) == 1
|
|
||||||
assert "root-1" in you_lines[0]
|
|
||||||
assert out["summary"]["total"] == 3
|
|
||||||
assert out["summary"]["running"] == 3
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_view_agent_graph_handles_missing_bus() -> None:
|
|
||||||
out = await _invoke(view_agent_graph, _Ctx(context={}))
|
|
||||||
assert out["success"] is False
|
|
||||||
assert "Bus" in out["error"]
|
|
||||||
|
|
||||||
|
|
||||||
# --- agent_status ---------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_agent_status_returns_state() -> None:
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
out = await _invoke(agent_status, _ctx_for(bus), agent_id="child-A")
|
|
||||||
assert out["success"] is True
|
|
||||||
assert out["agent_id"] == "child-A"
|
|
||||||
assert out["name"] == "scanner"
|
|
||||||
assert out["status"] == "running"
|
|
||||||
assert out["parent_id"] == "root-1"
|
|
||||||
assert out["pending_messages"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_agent_status_unknown_id() -> None:
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
out = await _invoke(agent_status, _ctx_for(bus), agent_id="nope")
|
|
||||||
assert out["success"] is False
|
|
||||||
assert "Unknown" in out["error"]
|
|
||||||
|
|
||||||
|
|
||||||
# --- send_message_to_agent -----------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_message_queues_into_target_inbox() -> None:
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
out = await _invoke(
|
|
||||||
send_message_to_agent,
|
|
||||||
_ctx_for(bus, agent_id="child-A"),
|
|
||||||
target_agent_id="child-B",
|
|
||||||
message="hello sibling",
|
|
||||||
priority="high",
|
|
||||||
)
|
|
||||||
assert out["success"] is True
|
|
||||||
assert out["delivery_status"] == "queued"
|
|
||||||
# Drain via the same API the filter uses; confirm the message landed.
|
|
||||||
msgs = await bus.drain("child-B")
|
|
||||||
assert len(msgs) == 1
|
|
||||||
assert msgs[0]["from"] == "child-A"
|
|
||||||
assert msgs[0]["content"] == "hello sibling"
|
|
||||||
assert msgs[0]["priority"] == "high"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_message_unknown_target() -> None:
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
out = await _invoke(
|
|
||||||
send_message_to_agent,
|
|
||||||
_ctx_for(bus),
|
|
||||||
target_agent_id="ghost",
|
|
||||||
message="hi",
|
|
||||||
)
|
|
||||||
assert out["success"] is False
|
|
||||||
assert "not found" in out["error"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_message_to_finalized_agent_is_rejected() -> None:
|
|
||||||
"""A finalized target should not silently swallow messages."""
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
await bus.finalize("child-A", "completed")
|
|
||||||
out = await _invoke(
|
|
||||||
send_message_to_agent,
|
|
||||||
_ctx_for(bus),
|
|
||||||
target_agent_id="child-A",
|
|
||||||
message="too late",
|
|
||||||
)
|
|
||||||
assert out["success"] is False
|
|
||||||
# finalize() also clears parent_of/names, so the user-visible state is
|
|
||||||
# "completed" — confirm the wrapper treats finalized agents as
|
|
||||||
# undeliverable rather than queuing into a dropped inbox.
|
|
||||||
assert "completed" in out["error"] or "not found" in out["error"]
|
|
||||||
|
|
||||||
|
|
||||||
# --- wait_for_message ----------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wait_for_message_returns_when_message_arrives() -> None:
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
|
|
||||||
async def deliver_after_short_pause() -> None:
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
await bus.send("child-A", {"from": "child-B", "content": "ping", "type": "info"})
|
|
||||||
|
|
||||||
sender = asyncio.create_task(deliver_after_short_pause())
|
|
||||||
out = await _invoke(
|
|
||||||
wait_for_message,
|
|
||||||
_ctx_for(bus, agent_id="child-A"),
|
|
||||||
timeout_seconds=3,
|
|
||||||
)
|
|
||||||
await sender
|
|
||||||
assert out["success"] is True
|
|
||||||
assert out["status"] == "message_arrived"
|
|
||||||
assert out["pending_messages"] >= 1
|
|
||||||
# Status must be returned to "running" after the wait completes.
|
|
||||||
assert bus.statuses["child-A"] == "running"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wait_for_message_times_out() -> None:
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
out = await _invoke(
|
|
||||||
wait_for_message,
|
|
||||||
_ctx_for(bus, agent_id="child-A"),
|
|
||||||
timeout_seconds=1,
|
|
||||||
)
|
|
||||||
assert out["success"] is True
|
|
||||||
assert out["status"] == "timeout"
|
|
||||||
assert out["timeout_seconds"] == 1
|
|
||||||
assert bus.statuses["child-A"] == "running"
|
|
||||||
|
|
||||||
|
|
||||||
# --- create_agent --------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_agent_requires_factory_in_context() -> None:
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
out = await _invoke(
|
|
||||||
create_agent,
|
|
||||||
_ctx_for(bus),
|
|
||||||
name="recon-bot",
|
|
||||||
task="enumerate hosts",
|
|
||||||
)
|
|
||||||
assert out["success"] is False
|
|
||||||
assert "agent_factory" in out["error"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_agent_spawns_and_registers_child() -> None:
|
|
||||||
"""Verify the spawn shape without running a real LLM."""
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
|
|
||||||
factory_calls: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
def fake_factory(*, name: str, skills: list[str]) -> Any:
|
|
||||||
factory_calls.append({"name": name, "skills": list(skills)})
|
|
||||||
# The Runner.run patch below ignores this object; any sentinel
|
|
||||||
# works.
|
|
||||||
return object()
|
|
||||||
|
|
||||||
runner_calls: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def fake_runner_run(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
# Capture the input + max_turns so the test can assert the
|
|
||||||
# identity block + delegation envelope are present.
|
|
||||||
runner_calls.append({"args": args, "kwargs": kwargs})
|
|
||||||
await asyncio.sleep(0) # cooperate so create_task can return
|
|
||||||
return None
|
|
||||||
|
|
||||||
ctx = _Ctx(
|
|
||||||
context={
|
|
||||||
"bus": bus,
|
|
||||||
"agent_id": "root-1",
|
|
||||||
"parent_id": None,
|
|
||||||
"agent_factory": fake_factory,
|
|
||||||
"sandbox_session": None,
|
|
||||||
"sandbox_client": None,
|
|
||||||
"sandbox_token": "token",
|
|
||||||
"tool_server_host_port": 12345,
|
|
||||||
"caido_host_port": None,
|
|
||||||
"tracer": None,
|
|
||||||
"model": "anthropic/claude-sonnet-4-6",
|
|
||||||
"model_settings": None,
|
|
||||||
"max_turns": 300,
|
|
||||||
"is_whitebox": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"strix.tools.agents_graph.tools.Runner.run",
|
|
||||||
side_effect=fake_runner_run,
|
|
||||||
):
|
|
||||||
out = await _invoke(
|
|
||||||
create_agent,
|
|
||||||
ctx,
|
|
||||||
name="recon-bot",
|
|
||||||
task="enumerate hosts",
|
|
||||||
inherit_context=False,
|
|
||||||
skills=["recon"],
|
|
||||||
)
|
|
||||||
# The spawned task must be allowed to run so Runner.run side-effect
|
|
||||||
# records the call.
|
|
||||||
new_id = out["agent_id"]
|
|
||||||
await asyncio.gather(*(t for t in bus.tasks.values()), return_exceptions=True)
|
|
||||||
|
|
||||||
assert out["success"] is True
|
|
||||||
assert factory_calls == [{"name": "recon-bot", "skills": ["recon"]}]
|
|
||||||
assert len(runner_calls) == 1
|
|
||||||
|
|
||||||
# Bus state: child registered, task stored.
|
|
||||||
assert new_id in bus.statuses
|
|
||||||
assert bus.parent_of[new_id] == "root-1"
|
|
||||||
assert bus.names[new_id] == "recon-bot"
|
|
||||||
assert new_id in bus.tasks
|
|
||||||
|
|
||||||
# Initial input shape: identity preamble + task message at the end.
|
|
||||||
initial_input = runner_calls[0]["kwargs"]["input"]
|
|
||||||
assert any(
|
|
||||||
isinstance(item, dict) and "You are agent recon-bot" in item.get("content", "")
|
|
||||||
for item in initial_input
|
|
||||||
)
|
|
||||||
assert initial_input[-1]["content"] == "enumerate hosts"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_agent_inherits_parent_history() -> None:
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
|
|
||||||
def fake_factory(*, name: str, skills: list[str]) -> Any:
|
|
||||||
return object()
|
|
||||||
|
|
||||||
runner_calls: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def fake_runner_run(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
runner_calls.append(kwargs)
|
|
||||||
return None
|
|
||||||
|
|
||||||
parent_history = [
|
|
||||||
{"role": "user", "content": "scope: example.com"},
|
|
||||||
{"role": "assistant", "content": "I'll start with subdomain enum."},
|
|
||||||
]
|
|
||||||
ctx = _Ctx(
|
|
||||||
context={
|
|
||||||
"bus": bus,
|
|
||||||
"agent_id": "root-1",
|
|
||||||
"parent_id": None,
|
|
||||||
"agent_factory": fake_factory,
|
|
||||||
"parent_input_items": parent_history,
|
|
||||||
"sandbox_session": None,
|
|
||||||
"sandbox_client": None,
|
|
||||||
"sandbox_token": "token",
|
|
||||||
"tool_server_host_port": 12345,
|
|
||||||
"caido_host_port": None,
|
|
||||||
"tracer": None,
|
|
||||||
"model": "anthropic/claude-sonnet-4-6",
|
|
||||||
"model_settings": None,
|
|
||||||
"max_turns": 300,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"strix.tools.agents_graph.tools.Runner.run",
|
|
||||||
side_effect=fake_runner_run,
|
|
||||||
):
|
|
||||||
await _invoke(
|
|
||||||
create_agent,
|
|
||||||
ctx,
|
|
||||||
name="child",
|
|
||||||
task="do thing",
|
|
||||||
inherit_context=True,
|
|
||||||
)
|
|
||||||
await asyncio.gather(*(t for t in bus.tasks.values()), return_exceptions=True)
|
|
||||||
|
|
||||||
initial_input = runner_calls[0]["input"]
|
|
||||||
contents = [item.get("content", "") for item in initial_input]
|
|
||||||
assert any("Inherited context from parent" in c for c in contents)
|
|
||||||
assert any("End of inherited context" in c for c in contents)
|
|
||||||
# Parent's exact items should be in between.
|
|
||||||
assert any(c == "scope: example.com" for c in contents)
|
|
||||||
|
|
||||||
|
|
||||||
# --- agent_finish --------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_agent_finish_rejects_root() -> None:
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
out = await _invoke(
|
|
||||||
agent_finish,
|
|
||||||
_ctx_for(bus, agent_id="root-1"),
|
|
||||||
result_summary="done",
|
|
||||||
)
|
|
||||||
assert out["success"] is False
|
|
||||||
assert "agent_finish is for subagents" in out["error"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_agent_finish_posts_report_to_parent_inbox() -> None:
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
ctx = _Ctx(
|
|
||||||
context={
|
|
||||||
"bus": bus,
|
|
||||||
"agent_id": "child-A",
|
|
||||||
"parent_id": "root-1",
|
|
||||||
"agent_finish_called": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
out = await _invoke(
|
|
||||||
agent_finish,
|
|
||||||
ctx,
|
|
||||||
result_summary="found 3 issues",
|
|
||||||
findings=["xss in /search", "open redirect", "stored xss"],
|
|
||||||
final_recommendations=["sanitize search input"],
|
|
||||||
success=True,
|
|
||||||
)
|
|
||||||
assert out["success"] is True
|
|
||||||
assert out["agent_completed"] is True
|
|
||||||
assert out["parent_notified"] is True
|
|
||||||
|
|
||||||
# Side effects: agent_finish_called flipped (so on_agent_end records
|
|
||||||
# "completed", not "crashed"), and the parent's inbox got the report.
|
|
||||||
assert ctx.context["agent_finish_called"] is True
|
|
||||||
parent_msgs = await bus.drain("root-1")
|
|
||||||
assert len(parent_msgs) == 1
|
|
||||||
msg = parent_msgs[0]
|
|
||||||
assert msg["type"] == "completion"
|
|
||||||
assert msg["from"] == "child-A"
|
|
||||||
payload = json.loads(msg["content"])
|
|
||||||
assert payload["kind"] == "agent_completion_report"
|
|
||||||
assert payload["summary"] == "found 3 issues"
|
|
||||||
assert "xss in /search" in payload["findings"]
|
|
||||||
assert "sanitize search input" in payload["recommendations"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_agent_finish_skips_parent_when_report_to_parent_false() -> None:
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
ctx = _Ctx(
|
|
||||||
context={
|
|
||||||
"bus": bus,
|
|
||||||
"agent_id": "child-A",
|
|
||||||
"parent_id": "root-1",
|
|
||||||
"agent_finish_called": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
out = await _invoke(
|
|
||||||
agent_finish,
|
|
||||||
ctx,
|
|
||||||
result_summary="silent done",
|
|
||||||
report_to_parent=False,
|
|
||||||
)
|
|
||||||
assert out["success"] is True
|
|
||||||
assert out["parent_notified"] is False
|
|
||||||
assert ctx.context["agent_finish_called"] is True
|
|
||||||
parent_msgs = await bus.drain("root-1")
|
|
||||||
assert parent_msgs == []
|
|
||||||
|
|
||||||
|
|
||||||
# --- bus integration sanity ---------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_agent_spawn_is_cancelable_via_bus() -> None:
|
|
||||||
"""Verify bus.cancel_descendants reaches a child task we just spawned."""
|
|
||||||
bus = await _make_bus_with_agents()
|
|
||||||
|
|
||||||
def fake_factory(*, name: str, skills: list[str]) -> Any:
|
|
||||||
return object()
|
|
||||||
|
|
||||||
# Long-lived child that yields control so we can cancel it before it
|
|
||||||
# finishes naturally.
|
|
||||||
async def slow_runner_run(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
return None
|
|
||||||
|
|
||||||
ctx = _Ctx(
|
|
||||||
context={
|
|
||||||
"bus": bus,
|
|
||||||
"agent_id": "root-1",
|
|
||||||
"parent_id": None,
|
|
||||||
"agent_factory": fake_factory,
|
|
||||||
"sandbox_session": None,
|
|
||||||
"sandbox_client": None,
|
|
||||||
"sandbox_token": "t",
|
|
||||||
"tool_server_host_port": 12345,
|
|
||||||
"caido_host_port": None,
|
|
||||||
"tracer": None,
|
|
||||||
"model": "anthropic/claude-sonnet-4-6",
|
|
||||||
"model_settings": None,
|
|
||||||
"max_turns": 300,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
runner_mock = AsyncMock(side_effect=slow_runner_run)
|
|
||||||
with patch(
|
|
||||||
"strix.tools.agents_graph.tools.Runner.run",
|
|
||||||
new=runner_mock,
|
|
||||||
):
|
|
||||||
out = await _invoke(
|
|
||||||
create_agent,
|
|
||||||
ctx,
|
|
||||||
name="long-running",
|
|
||||||
task="do thing",
|
|
||||||
inherit_context=False,
|
|
||||||
)
|
|
||||||
child_id = out["agent_id"]
|
|
||||||
# Let the task actually start.
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
await bus.cancel_descendants("root-1")
|
|
||||||
|
|
||||||
# The cancel should have propagated; the task is done (cancelled).
|
|
||||||
assert bus.tasks[child_id].done()
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
"""Phase 2.3 smoke tests for the simplest SDK-wrapped local tools.
|
|
||||||
|
|
||||||
Validates the wrapping pattern (legacy implementation in, JSON string out)
|
|
||||||
on three tool families: think (trivial), todo (in-memory + agent_state
|
|
||||||
adapter), notes (in-memory + JSONL persistence).
|
|
||||||
|
|
||||||
If this slice works end-to-end the same pattern carries the rest of the
|
|
||||||
local tools (reporting, web_search, file_edit, finish_scan, load_skill).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from agents.tool import FunctionTool
|
|
||||||
|
|
||||||
from strix.tools.notes import tools as _notes_impl
|
|
||||||
from strix.tools.notes.tools import (
|
|
||||||
create_note,
|
|
||||||
delete_note,
|
|
||||||
get_note,
|
|
||||||
list_notes,
|
|
||||||
update_note,
|
|
||||||
)
|
|
||||||
from strix.tools.thinking.tool import think
|
|
||||||
from strix.tools.todo.tools import (
|
|
||||||
create_todo,
|
|
||||||
delete_todo,
|
|
||||||
list_todos,
|
|
||||||
mark_todo_done,
|
|
||||||
mark_todo_pending,
|
|
||||||
update_todo,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _Ctx:
|
|
||||||
"""Stand-in for ``RunContextWrapper``."""
|
|
||||||
|
|
||||||
context: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
def _ctx_for(agent_id: str = "test-agent") -> _Ctx:
|
|
||||||
return _Ctx(context={"agent_id": agent_id})
|
|
||||||
|
|
||||||
|
|
||||||
async def _invoke(tool: FunctionTool, ctx: _Ctx, **kwargs: Any) -> dict[str, Any]:
|
|
||||||
"""Invoke a function tool the way the SDK would and JSON-decode the result."""
|
|
||||||
from agents.tool_context import ToolContext
|
|
||||||
|
|
||||||
tool_ctx = ToolContext(
|
|
||||||
context=ctx.context,
|
|
||||||
usage=None,
|
|
||||||
tool_name=tool.name,
|
|
||||||
tool_call_id="test-call-id",
|
|
||||||
tool_arguments=json.dumps(kwargs),
|
|
||||||
)
|
|
||||||
result = await tool.on_invoke_tool(tool_ctx, json.dumps(kwargs))
|
|
||||||
assert isinstance(result, str)
|
|
||||||
decoded = json.loads(result)
|
|
||||||
assert isinstance(decoded, dict)
|
|
||||||
return decoded
|
|
||||||
|
|
||||||
|
|
||||||
# --- think ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_think_is_a_function_tool() -> None:
|
|
||||||
assert isinstance(think, FunctionTool)
|
|
||||||
assert think.name == "think"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_think_records_thought() -> None:
|
|
||||||
ctx = _ctx_for()
|
|
||||||
thought = "planning my next move"
|
|
||||||
out = await _invoke(think, ctx, thought=thought)
|
|
||||||
assert out["success"] is True
|
|
||||||
assert f"{len(thought)} characters" in out["message"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_think_rejects_empty() -> None:
|
|
||||||
ctx = _ctx_for()
|
|
||||||
out = await _invoke(think, ctx, thought=" ")
|
|
||||||
assert out["success"] is False
|
|
||||||
|
|
||||||
|
|
||||||
# --- todo -----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _isolate_todo_storage() -> None:
|
|
||||||
"""Each test starts with an empty todo store so tests don't bleed."""
|
|
||||||
from strix.tools.todo import tools as todo_module
|
|
||||||
|
|
||||||
todo_module._todos_storage.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def test_todo_tools_are_function_tools() -> None:
|
|
||||||
for tool in (
|
|
||||||
create_todo,
|
|
||||||
list_todos,
|
|
||||||
update_todo,
|
|
||||||
mark_todo_done,
|
|
||||||
mark_todo_pending,
|
|
||||||
delete_todo,
|
|
||||||
):
|
|
||||||
assert isinstance(tool, FunctionTool)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_todo_lifecycle() -> None:
|
|
||||||
ctx = _ctx_for("agent-A")
|
|
||||||
|
|
||||||
# Create
|
|
||||||
created = await _invoke(create_todo, ctx, title="audit endpoint", priority="high")
|
|
||||||
assert created["success"] is True
|
|
||||||
assert created["count"] == 1
|
|
||||||
todo_id = created["created"][0]["todo_id"]
|
|
||||||
|
|
||||||
# List
|
|
||||||
listed = await _invoke(list_todos, ctx)
|
|
||||||
assert listed["success"] is True
|
|
||||||
assert any(t["todo_id"] == todo_id for t in listed["todos"])
|
|
||||||
|
|
||||||
# Update
|
|
||||||
updated = await _invoke(update_todo, ctx, todo_id=todo_id, status="in_progress")
|
|
||||||
assert updated["success"] is True
|
|
||||||
|
|
||||||
# Mark done
|
|
||||||
done = await _invoke(mark_todo_done, ctx, todo_id=todo_id)
|
|
||||||
assert done["success"] is True
|
|
||||||
|
|
||||||
# Reset to pending
|
|
||||||
pending = await _invoke(mark_todo_pending, ctx, todo_id=todo_id)
|
|
||||||
assert pending["success"] is True
|
|
||||||
|
|
||||||
# Delete
|
|
||||||
deleted = await _invoke(delete_todo, ctx, todo_id=todo_id)
|
|
||||||
assert deleted["success"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_todos_are_per_agent_isolated() -> None:
|
|
||||||
"""Two agents should have independent todo stores."""
|
|
||||||
ctx_a = _ctx_for("agent-A")
|
|
||||||
ctx_b = _ctx_for("agent-B")
|
|
||||||
|
|
||||||
await _invoke(create_todo, ctx_a, title="A's task")
|
|
||||||
await _invoke(create_todo, ctx_b, title="B's task")
|
|
||||||
|
|
||||||
list_a = await _invoke(list_todos, ctx_a)
|
|
||||||
list_b = await _invoke(list_todos, ctx_b)
|
|
||||||
|
|
||||||
titles_a = [t["title"] for t in list_a["todos"]]
|
|
||||||
titles_b = [t["title"] for t in list_b["todos"]]
|
|
||||||
assert titles_a == ["A's task"]
|
|
||||||
assert titles_b == ["B's task"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_todo_bulk_via_json_string() -> None:
|
|
||||||
ctx = _ctx_for()
|
|
||||||
out = await _invoke(
|
|
||||||
create_todo,
|
|
||||||
ctx,
|
|
||||||
todos=json.dumps(
|
|
||||||
[
|
|
||||||
{"title": "t1", "priority": "high"},
|
|
||||||
{"title": "t2", "priority": "low"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert out["success"] is True
|
|
||||||
assert out["count"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
# --- notes ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def notes_run_dir(tmp_path: Path) -> Iterator[Path]:
|
|
||||||
"""Point the legacy notes module at a fresh run dir per test."""
|
|
||||||
run_dir = tmp_path / "strix_runs" / "test"
|
|
||||||
run_dir.mkdir(parents=True)
|
|
||||||
_notes_impl._notes_storage.clear()
|
|
||||||
_notes_impl._loaded_notes_run_dir = None
|
|
||||||
|
|
||||||
with patch.object(_notes_impl, "_get_run_dir", return_value=run_dir):
|
|
||||||
yield run_dir
|
|
||||||
|
|
||||||
|
|
||||||
def test_notes_tools_are_function_tools() -> None:
|
|
||||||
for tool in (create_note, list_notes, get_note, update_note, delete_note):
|
|
||||||
assert isinstance(tool, FunctionTool)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_note_lifecycle(notes_run_dir: Path) -> None:
|
|
||||||
ctx = _ctx_for()
|
|
||||||
|
|
||||||
created = await _invoke(
|
|
||||||
create_note,
|
|
||||||
ctx,
|
|
||||||
title="SQLi at /login",
|
|
||||||
content="Form param `email` reflects into the WHERE clause.",
|
|
||||||
category="findings",
|
|
||||||
tags=["sqli", "auth"],
|
|
||||||
)
|
|
||||||
assert created["success"] is True
|
|
||||||
note_id = created["note_id"]
|
|
||||||
|
|
||||||
listed = await _invoke(list_notes, ctx, category="findings")
|
|
||||||
assert listed["success"] is True
|
|
||||||
assert listed["total_count"] == 1
|
|
||||||
|
|
||||||
fetched = await _invoke(get_note, ctx, note_id=note_id)
|
|
||||||
assert fetched["success"] is True
|
|
||||||
assert "WHERE clause" in fetched["note"]["content"]
|
|
||||||
|
|
||||||
updated = await _invoke(
|
|
||||||
update_note,
|
|
||||||
ctx,
|
|
||||||
note_id=note_id,
|
|
||||||
content="Confirmed boolean-blind SQLi.",
|
|
||||||
)
|
|
||||||
assert updated["success"] is True
|
|
||||||
|
|
||||||
deleted = await _invoke(delete_note, ctx, note_id=note_id)
|
|
||||||
assert deleted["success"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_notes_jsonl_appended(notes_run_dir: Path) -> None:
|
|
||||||
"""Verify side effect: notes.jsonl receives one event per op."""
|
|
||||||
ctx = _ctx_for()
|
|
||||||
await _invoke(create_note, ctx, title="t", content="c", category="general")
|
|
||||||
|
|
||||||
jsonl = notes_run_dir / "notes" / "notes.jsonl"
|
|
||||||
assert jsonl.exists()
|
|
||||||
events = [json.loads(line) for line in jsonl.read_text().splitlines() if line]
|
|
||||||
assert events[0]["op"] == "create"
|
|
||||||
assert events[0]["note"]["title"] == "t"
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""C6 regression test — concurrent notes JSONL writes must produce valid JSONL.
|
|
||||||
|
|
||||||
This test would fail before the C6 fix (AUDIT_R2 §1.1, applied in Phase 2.2):
|
|
||||||
the legacy ``_append_note_event`` opened the file and called ``f.write``
|
|
||||||
without holding ``_notes_lock``, so two threads writing simultaneously
|
|
||||||
could interleave bytes mid-line and corrupt the JSONL.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from strix.tools.notes.tools import _append_note_event
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def notes_path(tmp_path: Path) -> Iterator[Path]:
|
|
||||||
"""Point ``_get_notes_jsonl_path`` at a tmp file for the test."""
|
|
||||||
target = tmp_path / "notes" / "notes.jsonl"
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"strix.tools.notes.tools._get_notes_jsonl_path",
|
|
||||||
return_value=target,
|
|
||||||
):
|
|
||||||
yield target
|
|
||||||
|
|
||||||
|
|
||||||
def test_concurrent_note_writes_yield_valid_jsonl(notes_path: Path) -> None:
|
|
||||||
"""C6 fix: 50 threads x 20 events = 1000 lines, all valid JSON.
|
|
||||||
|
|
||||||
Without the lock, byte-level interleaving on the file produces
|
|
||||||
fragments like ``{"timesta{"timestamp"...`` that fail json.loads.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def writer(thread_idx: int) -> None:
|
|
||||||
for i in range(20):
|
|
||||||
note: dict[str, Any] = {
|
|
||||||
"title": f"thread-{thread_idx}-note-{i}",
|
|
||||||
"content": "x" * 200, # non-trivial body to widen the race
|
|
||||||
"category": "general",
|
|
||||||
}
|
|
||||||
_append_note_event(
|
|
||||||
op="create",
|
|
||||||
note_id=f"t{thread_idx}-i{i}",
|
|
||||||
note=note,
|
|
||||||
)
|
|
||||||
|
|
||||||
threads = [threading.Thread(target=writer, args=(t,)) for t in range(50)]
|
|
||||||
for t in threads:
|
|
||||||
t.start()
|
|
||||||
for t in threads:
|
|
||||||
t.join()
|
|
||||||
|
|
||||||
lines = notes_path.read_text(encoding="utf-8").splitlines()
|
|
||||||
assert len(lines) == 1000, f"expected 1000 lines, got {len(lines)}"
|
|
||||||
for line in lines:
|
|
||||||
# raises if the line is malformed JSON
|
|
||||||
event = json.loads(line)
|
|
||||||
assert event["op"] == "create"
|
|
||||||
assert "note_id" in event
|
|
||||||
|
|
||||||
|
|
||||||
def test_single_writer_still_works(notes_path: Path) -> None:
|
|
||||||
"""Sanity: serial writes still produce a valid JSONL log."""
|
|
||||||
_append_note_event("create", "n1", {"title": "first"})
|
|
||||||
_append_note_event("update", "n1", {"title": "first updated"})
|
|
||||||
_append_note_event("delete", "n1")
|
|
||||||
|
|
||||||
events = [json.loads(line) for line in notes_path.read_text().splitlines()]
|
|
||||||
assert [e["op"] for e in events] == ["create", "update", "delete"]
|
|
||||||
assert all(e["note_id"] == "n1" for e in events)
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from strix.telemetry.tracer import Tracer, get_global_tracer, set_global_tracer
|
|
||||||
from strix.tools.notes import tools as notes_actions
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_notes_state() -> None:
|
|
||||||
notes_actions._notes_storage.clear()
|
|
||||||
notes_actions._loaded_notes_run_dir = None
|
|
||||||
|
|
||||||
|
|
||||||
def test_wiki_notes_are_persisted_and_removed(
|
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
_reset_notes_state()
|
|
||||||
|
|
||||||
previous_tracer = get_global_tracer()
|
|
||||||
tracer = Tracer("wiki-test-run")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
|
|
||||||
try:
|
|
||||||
created = notes_actions._create_note_impl(
|
|
||||||
title="Repo Map",
|
|
||||||
content="## Architecture\n- monolith",
|
|
||||||
category="wiki",
|
|
||||||
tags=["source-map"],
|
|
||||||
)
|
|
||||||
assert created["success"] is True
|
|
||||||
note_id = created["note_id"]
|
|
||||||
assert isinstance(note_id, str)
|
|
||||||
|
|
||||||
note = notes_actions._notes_storage[note_id]
|
|
||||||
wiki_filename = note.get("wiki_filename")
|
|
||||||
assert isinstance(wiki_filename, str)
|
|
||||||
|
|
||||||
wiki_path = tmp_path / "strix_runs" / "wiki-test-run" / "wiki" / wiki_filename
|
|
||||||
assert wiki_path.exists()
|
|
||||||
assert "## Architecture" in wiki_path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
updated = notes_actions._update_note_impl(
|
|
||||||
note_id=note_id,
|
|
||||||
content="## Architecture\n- service-oriented",
|
|
||||||
)
|
|
||||||
assert updated["success"] is True
|
|
||||||
assert "service-oriented" in wiki_path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
deleted = notes_actions._delete_note_impl(note_id=note_id)
|
|
||||||
assert deleted["success"] is True
|
|
||||||
assert wiki_path.exists() is False
|
|
||||||
finally:
|
|
||||||
_reset_notes_state()
|
|
||||||
set_global_tracer(previous_tracer)
|
|
||||||
|
|
||||||
|
|
||||||
def test_notes_jsonl_replay_survives_memory_reset(
|
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
_reset_notes_state()
|
|
||||||
|
|
||||||
previous_tracer = get_global_tracer()
|
|
||||||
tracer = Tracer("notes-replay-run")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
|
|
||||||
try:
|
|
||||||
created = notes_actions._create_note_impl(
|
|
||||||
title="Auth findings",
|
|
||||||
content="initial finding",
|
|
||||||
category="findings",
|
|
||||||
tags=["auth"],
|
|
||||||
)
|
|
||||||
assert created["success"] is True
|
|
||||||
note_id = created["note_id"]
|
|
||||||
assert isinstance(note_id, str)
|
|
||||||
|
|
||||||
notes_path = tmp_path / "strix_runs" / "notes-replay-run" / "notes" / "notes.jsonl"
|
|
||||||
assert notes_path.exists() is True
|
|
||||||
|
|
||||||
_reset_notes_state()
|
|
||||||
listed = notes_actions._list_notes_impl(category="findings")
|
|
||||||
assert listed["success"] is True
|
|
||||||
assert listed["total_count"] == 1
|
|
||||||
assert listed["notes"][0]["note_id"] == note_id
|
|
||||||
assert "content" not in listed["notes"][0]
|
|
||||||
assert "content_preview" in listed["notes"][0]
|
|
||||||
|
|
||||||
updated = notes_actions._update_note_impl(note_id=note_id, content="updated finding")
|
|
||||||
assert updated["success"] is True
|
|
||||||
|
|
||||||
_reset_notes_state()
|
|
||||||
listed_after_update = notes_actions._list_notes_impl(search="updated finding")
|
|
||||||
assert listed_after_update["success"] is True
|
|
||||||
assert listed_after_update["total_count"] == 1
|
|
||||||
assert listed_after_update["notes"][0]["note_id"] == note_id
|
|
||||||
assert listed_after_update["notes"][0]["content_preview"] == "updated finding"
|
|
||||||
|
|
||||||
listed_with_content = notes_actions._list_notes_impl(
|
|
||||||
category="findings",
|
|
||||||
include_content=True,
|
|
||||||
)
|
|
||||||
assert listed_with_content["success"] is True
|
|
||||||
assert listed_with_content["total_count"] == 1
|
|
||||||
assert listed_with_content["notes"][0]["content"] == "updated finding"
|
|
||||||
|
|
||||||
deleted = notes_actions._delete_note_impl(note_id=note_id)
|
|
||||||
assert deleted["success"] is True
|
|
||||||
|
|
||||||
_reset_notes_state()
|
|
||||||
listed_after_delete = notes_actions._list_notes_impl(category="findings")
|
|
||||||
assert listed_after_delete["success"] is True
|
|
||||||
assert listed_after_delete["total_count"] == 0
|
|
||||||
finally:
|
|
||||||
_reset_notes_state()
|
|
||||||
set_global_tracer(previous_tracer)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_note_returns_full_note(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
_reset_notes_state()
|
|
||||||
|
|
||||||
previous_tracer = get_global_tracer()
|
|
||||||
tracer = Tracer("get-note-run")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
|
|
||||||
try:
|
|
||||||
created = notes_actions._create_note_impl(
|
|
||||||
title="Repo wiki",
|
|
||||||
content="entrypoints and sinks",
|
|
||||||
category="wiki",
|
|
||||||
tags=["repo:appsmith"],
|
|
||||||
)
|
|
||||||
assert created["success"] is True
|
|
||||||
note_id = created["note_id"]
|
|
||||||
assert isinstance(note_id, str)
|
|
||||||
|
|
||||||
result = notes_actions._get_note_impl(note_id=note_id)
|
|
||||||
assert result["success"] is True
|
|
||||||
assert result["note"]["note_id"] == note_id
|
|
||||||
assert result["note"]["content"] == "entrypoints and sinks"
|
|
||||||
finally:
|
|
||||||
_reset_notes_state()
|
|
||||||
set_global_tracer(previous_tracer)
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_and_get_note_handle_wiki_repersist_oserror_gracefully(
|
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
_reset_notes_state()
|
|
||||||
|
|
||||||
previous_tracer = get_global_tracer()
|
|
||||||
tracer = Tracer("wiki-repersist-oserror-run")
|
|
||||||
set_global_tracer(tracer)
|
|
||||||
|
|
||||||
try:
|
|
||||||
created = notes_actions._create_note_impl(
|
|
||||||
title="Repo wiki",
|
|
||||||
content="initial wiki content",
|
|
||||||
category="wiki",
|
|
||||||
tags=["repo:demo"],
|
|
||||||
)
|
|
||||||
assert created["success"] is True
|
|
||||||
note_id = created["note_id"]
|
|
||||||
assert isinstance(note_id, str)
|
|
||||||
|
|
||||||
_reset_notes_state()
|
|
||||||
|
|
||||||
def _raise_oserror(*_args: object, **_kwargs: object) -> None:
|
|
||||||
raise OSError("disk full")
|
|
||||||
|
|
||||||
monkeypatch.setattr(notes_actions, "_persist_wiki_note", _raise_oserror)
|
|
||||||
|
|
||||||
listed = notes_actions._list_notes_impl(category="wiki")
|
|
||||||
assert listed["success"] is True
|
|
||||||
assert listed["total_count"] == 1
|
|
||||||
assert listed["notes"][0]["note_id"] == note_id
|
|
||||||
|
|
||||||
fetched = notes_actions._get_note_impl(note_id=note_id)
|
|
||||||
assert fetched["success"] is True
|
|
||||||
assert fetched["note"]["note_id"] == note_id
|
|
||||||
assert fetched["note"]["content"] == "initial wiki content"
|
|
||||||
finally:
|
|
||||||
_reset_notes_state()
|
|
||||||
set_global_tracer(previous_tracer)
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
"""Smoke tests for the remaining local SDK tool wrappers.
|
|
||||||
|
|
||||||
Covers: web_search, file_edit (str_replace_editor + list_files +
|
|
||||||
search_files), reporting (create_vulnerability_report), finish_scan.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from agents.tool import FunctionTool
|
|
||||||
|
|
||||||
from strix.tools.file_edit.tools import (
|
|
||||||
list_files,
|
|
||||||
search_files,
|
|
||||||
str_replace_editor,
|
|
||||||
)
|
|
||||||
from strix.tools.finish.tool import finish_scan
|
|
||||||
from strix.tools.reporting.tool import create_vulnerability_report
|
|
||||||
from strix.tools.web_search.tool import web_search
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _Ctx:
|
|
||||||
context: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
def _ctx_for(agent_id: str = "test-agent") -> _Ctx:
|
|
||||||
return _Ctx(
|
|
||||||
context={
|
|
||||||
"agent_id": agent_id,
|
|
||||||
"tool_server_host_port": 12345,
|
|
||||||
"sandbox_token": "test-token",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _invoke(tool: FunctionTool, ctx: _Ctx, **kwargs: Any) -> dict[str, Any]:
|
|
||||||
from agents.tool_context import ToolContext
|
|
||||||
|
|
||||||
tool_ctx = ToolContext(
|
|
||||||
context=ctx.context,
|
|
||||||
usage=None,
|
|
||||||
tool_name=tool.name,
|
|
||||||
tool_call_id="test-call-id",
|
|
||||||
tool_arguments=json.dumps(kwargs),
|
|
||||||
)
|
|
||||||
result = await tool.on_invoke_tool(tool_ctx, json.dumps(kwargs))
|
|
||||||
assert isinstance(result, str)
|
|
||||||
decoded = json.loads(result)
|
|
||||||
assert isinstance(decoded, dict)
|
|
||||||
return decoded
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_remaining_tools_are_function_tools() -> None:
|
|
||||||
for tool in (
|
|
||||||
web_search,
|
|
||||||
str_replace_editor,
|
|
||||||
list_files,
|
|
||||||
search_files,
|
|
||||||
create_vulnerability_report,
|
|
||||||
finish_scan,
|
|
||||||
):
|
|
||||||
assert isinstance(tool, FunctionTool)
|
|
||||||
|
|
||||||
|
|
||||||
# --- web_search -----------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_web_search_no_api_key_returns_structured_error(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""The legacy function returns a structured error when the env var is
|
|
||||||
missing — verify the wrapper passes that through verbatim."""
|
|
||||||
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
|
|
||||||
|
|
||||||
out = await _invoke(web_search, _ctx_for(), query="anything")
|
|
||||||
assert out["success"] is False
|
|
||||||
assert "PERPLEXITY_API_KEY" in out["message"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_web_search_delegates_to_perplexity(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""The wrapper invokes the Perplexity HTTP path on a thread."""
|
|
||||||
monkeypatch.setenv("PERPLEXITY_API_KEY", "fake-key")
|
|
||||||
|
|
||||||
fake_result = {
|
|
||||||
"success": True,
|
|
||||||
"query": "xss techniques",
|
|
||||||
"content": "Reflected XSS payload examples...",
|
|
||||||
"message": "Web search completed successfully",
|
|
||||||
}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.web_search.tool._do_search",
|
|
||||||
return_value=fake_result,
|
|
||||||
) as do_search:
|
|
||||||
out = await _invoke(web_search, _ctx_for(), query="xss techniques")
|
|
||||||
|
|
||||||
assert out == fake_result
|
|
||||||
do_search.assert_called_once_with("xss techniques")
|
|
||||||
|
|
||||||
|
|
||||||
# --- file_edit (sandbox-bound) -------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_str_replace_editor_routes_to_sandbox() -> None:
|
|
||||||
"""file_edit tools must POST to the in-sandbox tool server, not run locally."""
|
|
||||||
fake_response = {"result": {"content": "file viewed"}}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.file_edit.tools.post_to_sandbox",
|
|
||||||
return_value=fake_response,
|
|
||||||
) as dispatch:
|
|
||||||
out = await _invoke(
|
|
||||||
str_replace_editor,
|
|
||||||
_ctx_for(),
|
|
||||||
command="view",
|
|
||||||
path="src/foo.py",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert out == fake_response
|
|
||||||
assert dispatch.call_count == 1
|
|
||||||
# post_to_sandbox is called positionally as (ctx, tool_name, kwargs).
|
|
||||||
args, _ = dispatch.call_args
|
|
||||||
assert args[1] == "str_replace_editor"
|
|
||||||
assert args[2]["command"] == "view"
|
|
||||||
assert args[2]["path"] == "src/foo.py"
|
|
||||||
# All optional file-edit params are forwarded as None (parity with legacy schema).
|
|
||||||
assert args[2]["file_text"] is None
|
|
||||||
assert args[2]["old_str"] is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_files_routes_to_sandbox() -> None:
|
|
||||||
fake_response = {"result": {"files": ["a.py"], "directories": []}}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.file_edit.tools.post_to_sandbox",
|
|
||||||
return_value=fake_response,
|
|
||||||
) as dispatch:
|
|
||||||
out = await _invoke(list_files, _ctx_for(), path="src", recursive=True)
|
|
||||||
|
|
||||||
assert out == fake_response
|
|
||||||
args, _ = dispatch.call_args
|
|
||||||
assert args[1] == "list_files"
|
|
||||||
assert args[2] == {"path": "src", "recursive": True}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_files_routes_to_sandbox() -> None:
|
|
||||||
fake_response = {"result": {"output": "src/foo.py:1:match"}}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.file_edit.tools.post_to_sandbox",
|
|
||||||
return_value=fake_response,
|
|
||||||
) as dispatch:
|
|
||||||
out = await _invoke(
|
|
||||||
search_files,
|
|
||||||
_ctx_for(),
|
|
||||||
path="src",
|
|
||||||
regex="TODO",
|
|
||||||
file_pattern="*.py",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert out == fake_response
|
|
||||||
args, _ = dispatch.call_args
|
|
||||||
assert args[1] == "search_files"
|
|
||||||
assert args[2] == {"path": "src", "regex": "TODO", "file_pattern": "*.py"}
|
|
||||||
|
|
||||||
|
|
||||||
# --- reporting -----------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_vulnerability_report_validates_required_fields() -> None:
|
|
||||||
"""Empty required fields should be rejected by the validator."""
|
|
||||||
out = await _invoke(
|
|
||||||
create_vulnerability_report,
|
|
||||||
_ctx_for(),
|
|
||||||
title="", # empty -> validation error
|
|
||||||
description="d",
|
|
||||||
impact="i",
|
|
||||||
target="t",
|
|
||||||
technical_analysis="ta",
|
|
||||||
poc_description="pd",
|
|
||||||
poc_script_code="curl ...",
|
|
||||||
remediation_steps="rs",
|
|
||||||
cvss_breakdown={"attack_vector": "N"},
|
|
||||||
)
|
|
||||||
assert out["success"] is False
|
|
||||||
assert "errors" in out
|
|
||||||
assert any("Title" in e for e in out["errors"])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_vulnerability_report_delegates_to_impl() -> None:
|
|
||||||
"""Verify the wrapper threads its kwargs through to the implementation."""
|
|
||||||
fake_result = {
|
|
||||||
"success": True,
|
|
||||||
"message": "Vulnerability report 'X' created successfully",
|
|
||||||
"report_id": "abc123",
|
|
||||||
"severity": "high",
|
|
||||||
"cvss_score": 7.5,
|
|
||||||
}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.reporting.tool._do_create",
|
|
||||||
return_value=fake_result,
|
|
||||||
) as do_create:
|
|
||||||
out = await _invoke(
|
|
||||||
create_vulnerability_report,
|
|
||||||
_ctx_for(),
|
|
||||||
title="t",
|
|
||||||
description="d",
|
|
||||||
impact="i",
|
|
||||||
target="tg",
|
|
||||||
technical_analysis="ta",
|
|
||||||
poc_description="pd",
|
|
||||||
poc_script_code="pc",
|
|
||||||
remediation_steps="rs",
|
|
||||||
cvss_breakdown={"attack_vector": "N"},
|
|
||||||
cve="CVE-2024-12345",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert out == fake_result
|
|
||||||
kwargs = do_create.call_args.kwargs
|
|
||||||
assert kwargs["title"] == "t"
|
|
||||||
assert kwargs["cve"] == "CVE-2024-12345"
|
|
||||||
# Optional params we didn't pass should still be forwarded as None.
|
|
||||||
assert kwargs["endpoint"] is None
|
|
||||||
assert kwargs["method"] is None
|
|
||||||
|
|
||||||
|
|
||||||
# --- finish_scan ---------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_finish_scan_validates_empty_fields() -> None:
|
|
||||||
"""Legacy validation: every section must be non-empty."""
|
|
||||||
out = await _invoke(
|
|
||||||
finish_scan,
|
|
||||||
_ctx_for(),
|
|
||||||
executive_summary="",
|
|
||||||
methodology="m",
|
|
||||||
technical_analysis="ta",
|
|
||||||
recommendations="r",
|
|
||||||
)
|
|
||||||
assert out["success"] is False
|
|
||||||
assert any("Executive summary" in e for e in out["errors"])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_finish_scan_rejects_subagent() -> None:
|
|
||||||
"""A subagent (parent_id is set) must not be able to finish the scan."""
|
|
||||||
ctx = _Ctx(
|
|
||||||
context={
|
|
||||||
"agent_id": "child-1",
|
|
||||||
"parent_id": "root-1",
|
|
||||||
"tool_server_host_port": 12345,
|
|
||||||
"sandbox_token": "test-token",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
out = await _invoke(
|
|
||||||
finish_scan,
|
|
||||||
ctx,
|
|
||||||
executive_summary="es",
|
|
||||||
methodology="m",
|
|
||||||
technical_analysis="ta",
|
|
||||||
recommendations="r",
|
|
||||||
)
|
|
||||||
assert out["success"] is False
|
|
||||||
assert out["error"] == "finish_scan_wrong_agent"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_finish_scan_persists_via_tracer() -> None:
|
|
||||||
"""When a global tracer exists, finish_scan should write the four sections."""
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
fake_tracer = MagicMock()
|
|
||||||
fake_tracer.vulnerability_reports = [{}, {}, {}]
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"strix.telemetry.tracer.get_global_tracer",
|
|
||||||
return_value=fake_tracer,
|
|
||||||
):
|
|
||||||
out = await _invoke(
|
|
||||||
finish_scan,
|
|
||||||
_ctx_for("root-agent"),
|
|
||||||
executive_summary="es",
|
|
||||||
methodology="m",
|
|
||||||
technical_analysis="ta",
|
|
||||||
recommendations="r",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert out["success"] is True
|
|
||||||
assert out["vulnerabilities_found"] == 3
|
|
||||||
fake_tracer.update_scan_final_fields.assert_called_once_with(
|
|
||||||
executive_summary="es",
|
|
||||||
methodology="m",
|
|
||||||
technical_analysis="ta",
|
|
||||||
recommendations="r",
|
|
||||||
)
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
"""Phase 2.1 smoke tests for the sandbox dispatch helper."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from strix.tools._sandbox_dispatch import post_to_sandbox
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _Ctx:
|
|
||||||
"""Stand-in for ``RunContextWrapper``. Only ``.context`` is touched."""
|
|
||||||
|
|
||||||
context: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
def _ok_ctx(**overrides: Any) -> _Ctx:
|
|
||||||
base: dict[str, Any] = {
|
|
||||||
"tool_server_host_port": 48081,
|
|
||||||
"sandbox_token": "test-bearer",
|
|
||||||
"agent_id": "agent-1",
|
|
||||||
}
|
|
||||||
base.update(overrides)
|
|
||||||
return _Ctx(context=base)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_missing_context_returns_error() -> None:
|
|
||||||
"""If ``ctx.context`` isn't a dict, return error — never raise."""
|
|
||||||
ctx = _Ctx(context="not a dict")
|
|
||||||
result = await post_to_sandbox(ctx, "browser_action", {"action": "launch"})
|
|
||||||
assert "error" in result
|
|
||||||
assert "context" in result["error"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_missing_port_or_token_returns_error() -> None:
|
|
||||||
ctx = _Ctx(context={"sandbox_token": "tok"}) # no port
|
|
||||||
result = await post_to_sandbox(ctx, "x", {})
|
|
||||||
assert "error" in result
|
|
||||||
assert "tool server port" in result["error"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_successful_response_returned_as_dict(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
captured: dict[str, Any] = {}
|
|
||||||
|
|
||||||
async def fake_post(
|
|
||||||
self: httpx.AsyncClient,
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
json: dict[str, Any] | None = None,
|
|
||||||
headers: dict[str, str] | None = None,
|
|
||||||
) -> httpx.Response:
|
|
||||||
captured["url"] = url
|
|
||||||
captured["json"] = json
|
|
||||||
captured["headers"] = headers
|
|
||||||
return httpx.Response(
|
|
||||||
status_code=200,
|
|
||||||
json={"result": "ok"},
|
|
||||||
request=httpx.Request("POST", url),
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
||||||
|
|
||||||
result = await post_to_sandbox(_ok_ctx(), "terminal_execute", {"command": "ls"})
|
|
||||||
assert result == {"result": "ok"}
|
|
||||||
assert captured["url"] == "http://127.0.0.1:48081/execute"
|
|
||||||
assert captured["json"] == {
|
|
||||||
"agent_id": "agent-1",
|
|
||||||
"tool_name": "terminal_execute",
|
|
||||||
"kwargs": {"command": "ls"},
|
|
||||||
}
|
|
||||||
assert captured["headers"]["Authorization"] == "Bearer test-bearer"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_401_returns_auth_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
async def fake_post(
|
|
||||||
self: httpx.AsyncClient,
|
|
||||||
url: str,
|
|
||||||
**_: Any,
|
|
||||||
) -> httpx.Response:
|
|
||||||
return httpx.Response(
|
|
||||||
status_code=401,
|
|
||||||
text="forbidden",
|
|
||||||
request=httpx.Request("POST", url),
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
||||||
result = await post_to_sandbox(_ok_ctx(), "x", {})
|
|
||||||
assert "error" in result
|
|
||||||
assert "authorization" in result["error"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_5xx_returns_http_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
async def fake_post(
|
|
||||||
self: httpx.AsyncClient,
|
|
||||||
url: str,
|
|
||||||
**_: Any,
|
|
||||||
) -> httpx.Response:
|
|
||||||
return httpx.Response(
|
|
||||||
status_code=503,
|
|
||||||
text="server fell over",
|
|
||||||
request=httpx.Request("POST", url),
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
||||||
result = await post_to_sandbox(_ok_ctx(), "browser_action", {})
|
|
||||||
assert "error" in result
|
|
||||||
assert "503" in result["error"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_timeout_returns_timeout_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
async def fake_post(*_args: Any, **_kwargs: Any) -> httpx.Response:
|
|
||||||
raise httpx.ReadTimeout("read timeout")
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
||||||
result = await post_to_sandbox(_ok_ctx(), "python_action", {})
|
|
||||||
assert "error" in result
|
|
||||||
assert "timed out" in result["error"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connection_error_returns_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
async def fake_post(*_args: Any, **_kwargs: Any) -> httpx.Response:
|
|
||||||
raise httpx.ConnectError("refused")
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
||||||
result = await post_to_sandbox(_ok_ctx(), "x", {})
|
|
||||||
assert "error" in result
|
|
||||||
assert "connection" in result["error"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_response_too_large_capped(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""C18 (AUDIT_R3): response > 50MB returns error, doesn't OOM."""
|
|
||||||
|
|
||||||
async def fake_post(
|
|
||||||
self: httpx.AsyncClient,
|
|
||||||
url: str,
|
|
||||||
**_: Any,
|
|
||||||
) -> httpx.Response:
|
|
||||||
# Construct a response with a >50MB body. Use a string trick —
|
|
||||||
# httpx.Response stores .content directly; we make it look huge.
|
|
||||||
big = b"x" * (51 * 1024 * 1024)
|
|
||||||
return httpx.Response(
|
|
||||||
status_code=200,
|
|
||||||
content=big,
|
|
||||||
request=httpx.Request("POST", url),
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
||||||
result = await post_to_sandbox(_ok_ctx(), "browser_action", {})
|
|
||||||
assert "error" in result
|
|
||||||
assert "too large" in result["error"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_non_json_response_returns_error(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
async def fake_post(
|
|
||||||
self: httpx.AsyncClient,
|
|
||||||
url: str,
|
|
||||||
**_: Any,
|
|
||||||
) -> httpx.Response:
|
|
||||||
return httpx.Response(
|
|
||||||
status_code=200,
|
|
||||||
text="hello not json",
|
|
||||||
request=httpx.Request("POST", url),
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
||||||
result = await post_to_sandbox(_ok_ctx(), "x", {})
|
|
||||||
assert "error" in result
|
|
||||||
assert "non-json" in result["error"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_non_object_json_returns_error(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
async def fake_post(
|
|
||||||
self: httpx.AsyncClient,
|
|
||||||
url: str,
|
|
||||||
**_: Any,
|
|
||||||
) -> httpx.Response:
|
|
||||||
return httpx.Response(
|
|
||||||
status_code=200,
|
|
||||||
json=["a", "list", "not", "an", "object"],
|
|
||||||
request=httpx.Request("POST", url),
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
||||||
result = await post_to_sandbox(_ok_ctx(), "x", {})
|
|
||||||
assert "error" in result
|
|
||||||
assert "non-object" in result["error"].lower()
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
"""Smoke tests for the sandbox-bound SDK tool wrappers.
|
|
||||||
|
|
||||||
Covers: browser_action, terminal_execute, python_action, file_edit
|
|
||||||
helpers, and the seven Caido proxy tools.
|
|
||||||
|
|
||||||
These wrappers are pure pass-throughs to ``post_to_sandbox`` — there's
|
|
||||||
no per-tool logic to assert, so the tests focus on:
|
|
||||||
|
|
||||||
- ``FunctionTool`` registration succeeds (which proves the SDK could
|
|
||||||
derive a JSON schema from the type hints — a non-trivial check given
|
|
||||||
Literal types, ``dict[str, str]``, and strict-mode opt-outs).
|
|
||||||
- The dispatch payload to ``post_to_sandbox`` carries the right
|
|
||||||
``kwargs`` shape so the in-container tool server can dispatch to the
|
|
||||||
matching action.
|
|
||||||
- The ``send_request`` / ``repeat_request`` tools opt out of strict
|
|
||||||
schema mode (their ``headers`` / ``modifications`` dicts are
|
|
||||||
free-form and would otherwise fail registration).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from agents.tool import FunctionTool
|
|
||||||
|
|
||||||
from strix.tools.browser.tool import browser_action
|
|
||||||
from strix.tools.proxy.tools import (
|
|
||||||
list_requests,
|
|
||||||
list_sitemap,
|
|
||||||
repeat_request,
|
|
||||||
scope_rules,
|
|
||||||
send_request,
|
|
||||||
view_request,
|
|
||||||
view_sitemap_entry,
|
|
||||||
)
|
|
||||||
from strix.tools.python.tool import python_action
|
|
||||||
from strix.tools.terminal.tool import terminal_execute
|
|
||||||
|
|
||||||
|
|
||||||
_ALL_SANDBOX_TOOLS = (
|
|
||||||
browser_action,
|
|
||||||
terminal_execute,
|
|
||||||
python_action,
|
|
||||||
list_requests,
|
|
||||||
view_request,
|
|
||||||
send_request,
|
|
||||||
repeat_request,
|
|
||||||
scope_rules,
|
|
||||||
list_sitemap,
|
|
||||||
view_sitemap_entry,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _Ctx:
|
|
||||||
context: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
def _ctx_for(agent_id: str = "test-agent") -> _Ctx:
|
|
||||||
return _Ctx(
|
|
||||||
context={
|
|
||||||
"agent_id": agent_id,
|
|
||||||
"tool_server_host_port": 12345,
|
|
||||||
"sandbox_token": "test-token",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _invoke(tool: FunctionTool, ctx: _Ctx, **kwargs: Any) -> dict[str, Any]:
|
|
||||||
from agents.tool_context import ToolContext
|
|
||||||
|
|
||||||
tool_ctx = ToolContext(
|
|
||||||
context=ctx.context,
|
|
||||||
usage=None,
|
|
||||||
tool_name=tool.name,
|
|
||||||
tool_call_id="test-call-id",
|
|
||||||
tool_arguments=json.dumps(kwargs),
|
|
||||||
)
|
|
||||||
result = await tool.on_invoke_tool(tool_ctx, json.dumps(kwargs))
|
|
||||||
assert isinstance(result, str)
|
|
||||||
decoded = json.loads(result)
|
|
||||||
assert isinstance(decoded, dict)
|
|
||||||
return decoded
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_sandbox_tools_register() -> None:
|
|
||||||
for tool in _ALL_SANDBOX_TOOLS:
|
|
||||||
assert isinstance(tool, FunctionTool)
|
|
||||||
|
|
||||||
|
|
||||||
def test_send_and_repeat_request_opt_out_of_strict_mode() -> None:
|
|
||||||
"""The two free-form-dict tools must turn off strict JSON schema."""
|
|
||||||
assert send_request.strict_json_schema is False
|
|
||||||
assert repeat_request.strict_json_schema is False
|
|
||||||
# All other tools should keep strict mode on (the SDK default).
|
|
||||||
for tool in (
|
|
||||||
browser_action,
|
|
||||||
terminal_execute,
|
|
||||||
python_action,
|
|
||||||
list_requests,
|
|
||||||
view_request,
|
|
||||||
scope_rules,
|
|
||||||
list_sitemap,
|
|
||||||
view_sitemap_entry,
|
|
||||||
):
|
|
||||||
assert tool.strict_json_schema is True, tool.name
|
|
||||||
|
|
||||||
|
|
||||||
# --- browser ---------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_browser_action_dispatches_full_payload() -> None:
|
|
||||||
"""All optional browser_action params must forward as None when unset
|
|
||||||
(the in-container handler distinguishes ``None`` from missing)."""
|
|
||||||
fake = {"result": {"screenshot": "data:image/png;base64,..."}}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.browser.tool.post_to_sandbox",
|
|
||||||
return_value=fake,
|
|
||||||
) as dispatch:
|
|
||||||
out = await _invoke(
|
|
||||||
browser_action,
|
|
||||||
_ctx_for(),
|
|
||||||
action="goto",
|
|
||||||
url="https://example.com",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert out == fake
|
|
||||||
args, _ = dispatch.call_args
|
|
||||||
assert args[1] == "browser_action"
|
|
||||||
payload = args[2]
|
|
||||||
assert payload["action"] == "goto"
|
|
||||||
assert payload["url"] == "https://example.com"
|
|
||||||
# All optional params are present in the payload (as None / defaults)
|
|
||||||
# so the legacy in-container handler sees the full kwarg surface.
|
|
||||||
for key in (
|
|
||||||
"coordinate",
|
|
||||||
"text",
|
|
||||||
"tab_id",
|
|
||||||
"js_code",
|
|
||||||
"duration",
|
|
||||||
"key",
|
|
||||||
"file_path",
|
|
||||||
):
|
|
||||||
assert key in payload, key
|
|
||||||
assert payload[key] is None
|
|
||||||
assert payload["clear"] is False
|
|
||||||
|
|
||||||
|
|
||||||
# --- terminal --------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_terminal_execute_dispatches() -> None:
|
|
||||||
fake = {"result": {"content": "hello\n", "exit_code": 0}}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.terminal.tool.post_to_sandbox",
|
|
||||||
return_value=fake,
|
|
||||||
) as dispatch:
|
|
||||||
out = await _invoke(
|
|
||||||
terminal_execute,
|
|
||||||
_ctx_for(),
|
|
||||||
command="echo hello",
|
|
||||||
terminal_id="term-1",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert out == fake
|
|
||||||
args, _ = dispatch.call_args
|
|
||||||
assert args[1] == "terminal_execute"
|
|
||||||
assert args[2]["command"] == "echo hello"
|
|
||||||
assert args[2]["terminal_id"] == "term-1"
|
|
||||||
assert args[2]["is_input"] is False
|
|
||||||
assert args[2]["no_enter"] is False
|
|
||||||
assert args[2]["timeout"] is None
|
|
||||||
|
|
||||||
|
|
||||||
# --- python ---------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_python_action_dispatches() -> None:
|
|
||||||
fake = {"result": {"stdout": "42\n", "is_running": False}}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.python.tool.post_to_sandbox",
|
|
||||||
return_value=fake,
|
|
||||||
) as dispatch:
|
|
||||||
out = await _invoke(
|
|
||||||
python_action,
|
|
||||||
_ctx_for(),
|
|
||||||
action="execute",
|
|
||||||
code="print(6*7)",
|
|
||||||
session_id="sess-1",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert out == fake
|
|
||||||
args, _ = dispatch.call_args
|
|
||||||
assert args[1] == "python_action"
|
|
||||||
assert args[2] == {
|
|
||||||
"action": "execute",
|
|
||||||
"code": "print(6*7)",
|
|
||||||
"timeout": 30,
|
|
||||||
"session_id": "sess-1",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# --- proxy / Caido --------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_requests_forwards_full_query() -> None:
|
|
||||||
fake: dict[str, Any] = {"result": {"requests": []}}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.proxy.tools.post_to_sandbox",
|
|
||||||
return_value=fake,
|
|
||||||
) as dispatch:
|
|
||||||
await _invoke(
|
|
||||||
list_requests,
|
|
||||||
_ctx_for(),
|
|
||||||
httpql_filter="resp.code:eq:500",
|
|
||||||
page_size=20,
|
|
||||||
sort_by="response_time",
|
|
||||||
sort_order="asc",
|
|
||||||
)
|
|
||||||
|
|
||||||
args, _ = dispatch.call_args
|
|
||||||
assert args[1] == "list_requests"
|
|
||||||
assert args[2]["httpql_filter"] == "resp.code:eq:500"
|
|
||||||
assert args[2]["page_size"] == 20
|
|
||||||
assert args[2]["sort_by"] == "response_time"
|
|
||||||
assert args[2]["sort_order"] == "asc"
|
|
||||||
# Defaults preserved.
|
|
||||||
assert args[2]["start_page"] == 1
|
|
||||||
assert args[2]["end_page"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_view_request_dispatches() -> None:
|
|
||||||
fake = {"result": {"raw": "GET / HTTP/1.1..."}}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.proxy.tools.post_to_sandbox",
|
|
||||||
return_value=fake,
|
|
||||||
) as dispatch:
|
|
||||||
await _invoke(
|
|
||||||
view_request,
|
|
||||||
_ctx_for(),
|
|
||||||
request_id="req-9",
|
|
||||||
part="response",
|
|
||||||
search_pattern="Set-Cookie",
|
|
||||||
)
|
|
||||||
|
|
||||||
args, _ = dispatch.call_args
|
|
||||||
assert args[1] == "view_request"
|
|
||||||
assert args[2]["request_id"] == "req-9"
|
|
||||||
assert args[2]["part"] == "response"
|
|
||||||
assert args[2]["search_pattern"] == "Set-Cookie"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_request_normalizes_missing_headers() -> None:
|
|
||||||
"""Legacy schema treats omitted ``headers`` as ``{}``; the wrapper must too."""
|
|
||||||
fake = {"result": {"status": 200}}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.proxy.tools.post_to_sandbox",
|
|
||||||
return_value=fake,
|
|
||||||
) as dispatch:
|
|
||||||
await _invoke(
|
|
||||||
send_request,
|
|
||||||
_ctx_for(),
|
|
||||||
method="POST",
|
|
||||||
url="https://api.example.com/login",
|
|
||||||
body='{"u":"x"}',
|
|
||||||
)
|
|
||||||
|
|
||||||
args, _ = dispatch.call_args
|
|
||||||
assert args[1] == "send_request"
|
|
||||||
assert args[2]["headers"] == {} # not None
|
|
||||||
assert args[2]["method"] == "POST"
|
|
||||||
assert args[2]["body"] == '{"u":"x"}'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_repeat_request_normalizes_missing_modifications() -> None:
|
|
||||||
fake = {"result": {"status": 200}}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.proxy.tools.post_to_sandbox",
|
|
||||||
return_value=fake,
|
|
||||||
) as dispatch:
|
|
||||||
await _invoke(repeat_request, _ctx_for(), request_id="req-1")
|
|
||||||
|
|
||||||
args, _ = dispatch.call_args
|
|
||||||
assert args[1] == "repeat_request"
|
|
||||||
assert args[2]["modifications"] == {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_scope_rules_dispatches() -> None:
|
|
||||||
fake = {"result": {"scope_id": "s-1"}}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.proxy.tools.post_to_sandbox",
|
|
||||||
return_value=fake,
|
|
||||||
) as dispatch:
|
|
||||||
await _invoke(
|
|
||||||
scope_rules,
|
|
||||||
_ctx_for(),
|
|
||||||
action="create",
|
|
||||||
scope_name="prod",
|
|
||||||
allowlist=["*.example.com"],
|
|
||||||
)
|
|
||||||
|
|
||||||
args, _ = dispatch.call_args
|
|
||||||
assert args[1] == "scope_rules"
|
|
||||||
assert args[2]["action"] == "create"
|
|
||||||
assert args[2]["scope_name"] == "prod"
|
|
||||||
assert args[2]["allowlist"] == ["*.example.com"]
|
|
||||||
assert args[2]["denylist"] is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_sitemap_defaults() -> None:
|
|
||||||
fake: dict[str, Any] = {"result": {"entries": []}}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.proxy.tools.post_to_sandbox",
|
|
||||||
return_value=fake,
|
|
||||||
) as dispatch:
|
|
||||||
await _invoke(list_sitemap, _ctx_for())
|
|
||||||
|
|
||||||
args, _ = dispatch.call_args
|
|
||||||
assert args[1] == "list_sitemap"
|
|
||||||
assert args[2]["depth"] == "DIRECT"
|
|
||||||
assert args[2]["page"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_view_sitemap_entry_dispatches() -> None:
|
|
||||||
fake = {"result": {"entry_id": "e-1"}}
|
|
||||||
with patch(
|
|
||||||
"strix.tools.proxy.tools.post_to_sandbox",
|
|
||||||
return_value=fake,
|
|
||||||
) as dispatch:
|
|
||||||
await _invoke(view_sitemap_entry, _ctx_for(), entry_id="e-1")
|
|
||||||
|
|
||||||
args, _ = dispatch.call_args
|
|
||||||
assert args[1] == "view_sitemap_entry"
|
|
||||||
assert args[2] == {"entry_id": "e-1"}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import importlib
|
|
||||||
import sys
|
|
||||||
from types import ModuleType
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from strix.config import Config
|
|
||||||
from strix.tools.registry import clear_registry
|
|
||||||
|
|
||||||
|
|
||||||
def _empty_config_load(_cls: type[Config]) -> dict[str, dict[str, str]]:
|
|
||||||
return {"env": {}}
|
|
||||||
|
|
||||||
|
|
||||||
def _reload_tools_module() -> ModuleType:
|
|
||||||
clear_registry()
|
|
||||||
|
|
||||||
for name in list(sys.modules):
|
|
||||||
if name == "strix.tools" or name.startswith("strix.tools."):
|
|
||||||
sys.modules.pop(name, None)
|
|
||||||
|
|
||||||
return importlib.import_module("strix.tools")
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_sandbox_skips_browser_and_web_search_when_disabled(
|
|
||||||
monkeypatch: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Browser registration is gated on STRIX_DISABLE_BROWSER and
|
|
||||||
web_search on PERPLEXITY_API_KEY; both should stay out of the
|
|
||||||
in-container ``register_tool`` registry when their gates are off.
|
|
||||||
Agents_graph is no longer in this registry — those tools are SDK
|
|
||||||
function tools (host-side only), not in-container tools.
|
|
||||||
"""
|
|
||||||
monkeypatch.setenv("STRIX_SANDBOX_MODE", "false")
|
|
||||||
monkeypatch.setenv("STRIX_DISABLE_BROWSER", "true")
|
|
||||||
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
|
|
||||||
monkeypatch.setattr(Config, "load", classmethod(_empty_config_load))
|
|
||||||
|
|
||||||
tools = _reload_tools_module()
|
|
||||||
names = set(tools.get_tool_names())
|
|
||||||
|
|
||||||
assert "browser_action" not in names
|
|
||||||
assert "web_search" not in names
|
|
||||||
|
|
||||||
|
|
||||||
def test_sandbox_registers_sandbox_tools_but_not_non_sandbox_tools(
|
|
||||||
monkeypatch: Any,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setenv("STRIX_SANDBOX_MODE", "true")
|
|
||||||
monkeypatch.setenv("STRIX_DISABLE_BROWSER", "true")
|
|
||||||
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
|
|
||||||
monkeypatch.setattr(Config, "load", classmethod(_empty_config_load))
|
|
||||||
|
|
||||||
tools = _reload_tools_module()
|
|
||||||
names = set(tools.get_tool_names())
|
|
||||||
|
|
||||||
assert "terminal_execute" in names
|
|
||||||
assert "python_action" in names
|
|
||||||
assert "list_requests" in names
|
|
||||||
assert "create_agent" not in names
|
|
||||||
assert "finish_scan" not in names
|
|
||||||
assert "browser_action" not in names
|
|
||||||
assert "web_search" not in names
|
|
||||||
@@ -731,90 +731,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
|
{ url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "coverage"
|
|
||||||
version = "7.13.5"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "croniter"
|
name = "croniter"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -1816,15 +1732,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "iniconfig"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipython"
|
name = "ipython"
|
||||||
version = "9.11.0"
|
version = "9.11.0"
|
||||||
@@ -3917,15 +3824,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pluggy"
|
|
||||||
version = "1.6.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polars"
|
name = "polars"
|
||||||
version = "1.39.3"
|
version = "1.39.3"
|
||||||
@@ -4468,61 +4366,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/59/d0/bb522283b90853afbf506cd5b71c650cf708829914efd0003d615cf426cd/pyte-0.8.2-py3-none-any.whl", hash = "sha256:85db42a35798a5aafa96ac4d8da78b090b2c933248819157fc0e6f78876a0135", size = 31627, upload-time = "2023-11-12T09:33:41.096Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/d0/bb522283b90853afbf506cd5b71c650cf708829914efd0003d615cf426cd/pyte-0.8.2-py3-none-any.whl", hash = "sha256:85db42a35798a5aafa96ac4d8da78b090b2c933248819157fc0e6f78876a0135", size = 31627, upload-time = "2023-11-12T09:33:41.096Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pytest"
|
|
||||||
version = "9.0.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
||||||
{ name = "iniconfig" },
|
|
||||||
{ name = "packaging" },
|
|
||||||
{ name = "pluggy" },
|
|
||||||
{ name = "pygments" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pytest-asyncio"
|
|
||||||
version = "1.3.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "pytest" },
|
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pytest-cov"
|
|
||||||
version = "7.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "coverage" },
|
|
||||||
{ name = "pluggy" },
|
|
||||||
{ name = "pytest" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pytest-mock"
|
|
||||||
version = "3.15.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "pytest" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
@@ -5471,10 +5314,6 @@ dev = [
|
|||||||
{ name = "pyinstaller", marker = "python_full_version < '3.15'" },
|
{ name = "pyinstaller", marker = "python_full_version < '3.15'" },
|
||||||
{ name = "pylint" },
|
{ name = "pylint" },
|
||||||
{ name = "pyright" },
|
{ name = "pyright" },
|
||||||
{ name = "pytest" },
|
|
||||||
{ name = "pytest-asyncio" },
|
|
||||||
{ name = "pytest-cov" },
|
|
||||||
{ name = "pytest-mock" },
|
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5515,10 +5354,6 @@ dev = [
|
|||||||
{ name = "pyinstaller", marker = "python_full_version >= '3.12' and python_full_version < '3.15'", specifier = ">=6.17.0" },
|
{ name = "pyinstaller", marker = "python_full_version >= '3.12' and python_full_version < '3.15'", specifier = ">=6.17.0" },
|
||||||
{ name = "pylint", specifier = ">=3.3.7" },
|
{ name = "pylint", specifier = ">=3.3.7" },
|
||||||
{ name = "pyright", specifier = ">=1.1.401" },
|
{ name = "pyright", specifier = ">=1.1.401" },
|
||||||
{ name = "pytest", specifier = ">=8.4.0" },
|
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
|
|
||||||
{ name = "pytest-cov", specifier = ">=6.1.1" },
|
|
||||||
{ name = "pytest-mock", specifier = ">=3.14.1" },
|
|
||||||
{ name = "ruff", specifier = ">=0.11.13" },
|
{ name = "ruff", specifier = ">=0.11.13" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user